Java内存模型总结

来源:互联网 发布:数据库免费账号 编辑:程序博客网 时间:2024/05/22 02:29

参考资料:Java并发编程艺术,深入理解Java虚拟机

参考博客:http://blog.csdn.net/vking_wang/article/details/8574376

概述

并发编程模型中需要解决两个问题:线程间通信线程之间如何同步

通信:在共享内存模型中,线程之间共享内存程序的内存状态,通过写-读内存的公共状态进行隐式通信。 在消息传递模型中,线程之间没有公共状态,线程之间通过发送消息来显示进行通信。

同步:同步是指程序用于控制不同线程操作发生相对顺序的机制。在共享内存模型中,程序员必须显式指定某个方法或某段代码之间互斥执行。

Java的并发采用的是共享内存模型。

1.内存模型基础

1.1内存模型

在Java中,所有实例域、静态域和数组元素都存在堆内存中,堆内存在线程之间共享。局部变量,方法定义参数和异常处理器参数不会在线程之间共享。

Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。

从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化Java内存模型的抽象示意图如下:


线程间通信的步骤:

  1. 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
  2. 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。
  • 本地内存A和B有主内存中共享变量x的副本。
  • 假设初始时,这三个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。
  • 当线程A和线程B需要通信时(如何激发?--隐式),线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。
  • 随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。

从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。

1.2原子性、可见性、有序性

java内存模型主要是围绕在并发过程中如何处理原子性、可见性、有序性这三个特征建立的。

原子性Atomicity):由Java内存模型来直接保证的原子操作有read 、load 、use、 assign、  store、 write,我们可以认为基本数据类型的读写是具有原子性的。对于64位

的long/double JSR133新的内存模型,可以进行32位的操作,只允许进行32位写操作,不允许进行32读。

当需要更大范围的原子操作:

java内存模型提供了字节码指令monitorenter、monitorexit,java代码中就是synchronized关键字,两个字节码指令是成对出现的,正常代码退出和抛出异常

都会有monitorexit指令,因此抛出异常会释放锁。

可见性(Visibility):可见性是指当一个线程修改了共享变量的值,其他线程能立即得知这个修改。

Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存中刷新变量的值。普通变量和volatile都是这样的,

但区别是:volatile的特殊规则保证了新值能立即同步到主内存,每次使用前立即从主内存刷新,可以说volatile保证了多线程操作时变量的可见性,而普通变量不可以。

synchronized也可以保证可见性:对一个变量执行unlock之前,必须把此变量同步回主内存中。

final关键字的可见性是指:被final修饰的字段一旦初始化完成,并且没有把“this”的引用传递出去,那么在其他线程就能看见final字段的值。

有序性(Ordering):如果在本线程内观察,所有的操作都是有序的,如果在一个线程内观察另一个线程,所有的操作都是无序的。前半句是指指令会按照一种叫“串行”(as-if-serial)的方式执行,后半句是指“指令重排序”现象和“工作内存与主内存延迟”现象。

volatile和synchronized可以保证线程间的操作的有序性。

volatile禁止指令重排,synchronized则是由“一个变量一个时刻只允许一个线程进行lock操作”保证有序性。

1.3 synchronized、volatile

synchronized

一个线程执行互斥代码过程如下:

  1. 获得同步锁(其他尝试获得此线程的锁会阻塞);
  2. 清空工作内存;
  3. 从主内存拷贝对象副本到工作内存;
  4.  执行代码(计算或者输出等);
  5. 刷新主内存数据;
  6. 释放同步锁。

所以,synchronized代码块内包围的代码具有原子性,既保证了多线程的并发有序性,又保证了多线程的内存可见性。


volatile

volatile是java虚拟机提供的轻量级同步机制。

1.volatile保证了内存的可见性,修改完值后会立即同步到主内存,获取值时会立即从主内存中获取。但并不保证并发有序性,也就是非线程不安全的。

例子:

public class VolatileTest {    public static volatile int race=0;    public static void increase(){        race++;    }    public static final int THREAD_COUNT=20;    public  static void main(String[] args){        Thread[] threads=new Thread[THREAD_COUNT];        for(int i=0;i<THREAD_COUNT;i++){            threads[i]=new Thread(                    new Runnable() {                        @Override                        public void run() {                            for (int i=0;i<10000;i++){                                increase();                            }                        }                    }            );            threads[i].start();        }        while (Thread.activeCount()>2)//等待所有线程都结束? >1停止不了,除了主线程还有一个守护线程?            Thread.yield();        System.out.println(Thread.activeCount());        System.out.println(race);    }}
我们发现这个程序每次运行的结果都不一样,都是一个小于200000的数。

原因:

race++并不是一个原子操作,其中要经历了几下几个步骤:

1.把主存中的变量拷贝到工作内存中的变量副本

2.把变量取到操作栈顶(java并没有处理器,而是操作数栈) 而这时的数据volatile保证是正确的

3.但在进行++的操作时,其他线程可能把这个值增大了,而在操作栈顶的值是过期的数据,在计算结束之后就会把不正确的数据同步回主存中。因此,得到的值小于理论值。


valotile变量只能保证可见性,在不符合以下两条规则时,我们仍需要(synchronized或原子类)来保证原子性。

1.运算结果不依赖变量当前的值,或者能够确保只有单一的线程修改变量的值。

2.变量不需要与其他的状态变量共同参与不变性约束。

例子:

    private volatile boolean shutdownRequest;    public void shutdown(){        shutdownRequest=true;    }    public void doWord(){        while(!shutdownRequest){            //do stuff        }    }

2.volatile的第二个语义是禁止指令重排序,普通的变量仅仅保证在该方法的执行过程中所有依赖赋值结果的地方都能获得正确的结果,但不能保证变量赋值的操作的顺序和程序代码中的顺序一致。

//线程1:context = loadContext();   //语句1inited = true;             //语句2 //线程2:while(!inited ){  sleep()}doSomethingwithconfig(context);

如果inited没有使用volatile修饰,就可能由于指令的重排序的优化,导致线程A的语句2在语句1之前执行(因为二者没有数据一致性,不影响程序的结果,可以进行重排序),

这样在线程B的代码可能出现错误,context未进行初始化。

在有volatile修饰修饰的变量,赋值后 在生成的汇编代码中(或本地代码)会多执行" lock xxx" 操作,这个操作相当于一个内存屏障,指重排序时不能把后面的指令排到屏障之前的位置,只有一个CPU访问内存时,并不需要这个指令。

Lock前缀的两个作用:

1.使得本CPU的Cache写入内存(以为着指令之前的操作都已经至执行完成,这样便形成了“指令重排序无法越过的内存屏障”)

2.使得其他的CPU或者别的内核无效化其Cache


2、重排序

JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

对于编译器排序,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。

对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel称之为memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。

引申:

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题

2.1 数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:

名称代码示例说明写后读a = 1;b = a;写一个变量之后,再读这个位置。写后写a = 1;a = 2;写一个变量之后,再写这个变量。读后写a = b;b = 1;读一个变量之后,再写这个变量。

上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。


前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑

2.2 as-if-serial语义

as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。

【例】
  1. double pi  = 3.14;    //A  
  2. double r   = 1.0;     //B  
  3. double area = pi * r * r; //C  

上面三个操作的数据依赖关系如下图所示:

如上图所示,A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。下图是该程序的两种执行顺序:


as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题

2.3 重排序对多线程的影响

现在让我们来看看,重排序是否会改变多线程程序的执行结果。

class ReorderExample { int a = 0; boolean flag = false; public void writer() { a = 1; //1 flag = true; //2 } Public void reader() { if (flag) { //3 int i = a * a; //4 …… } } }

flag变量是个标记,用来标识变量a是否已被写入。这里假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入?

答案是:不一定能看到。

由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系(存在的是控制依赖关系),编译器和处理器也可以对这两个操作重排序。让我们先来看看,当操作1和操作2重排序时,可能会产生什么效果?请看下面的程序执行时序图:


如上图所示,操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还根本没有被线程A写入,在这里多线程程序的语义被重排序破坏了!

下面再让我们看看,当操作3和操作4重排序时会产生什么效果(借助这个重排序,可以顺便说明控制依赖性)。下面是操作3和操作4重排序后,程序的执行时序图:


在程序中,操作3和操作4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作3的条件判断为真时,就把该计算结果写入变量i中。

从图中我们可以看出,猜测执行实质上对操作3和4做了重排序。重排序在这里破坏了多线程程序的语义!

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果

所以我们可以使用Volatile来禁止指令重排序。

3.happens-before

仅靠synchronized volatile来保证内存可见性是不够的,"先行发生原则",是判读数据是否存在竞争、线程是否安全的主要依据。通过这个这个规则我们可以解决

并发环境下两个操作是否存在冲突的所有问题。

从JDK5开始,java使用新的JSR -133内存模型。JSR-133提出了happens-before的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

定义:

1.如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。(JMM对程序员的承诺)

2.两个操作之前存在happens-before关系,并不意味着java平台的具体实现必须按照happens-before的关系指定的顺序来执行。如果重排序之后的结果,

与按happens-before的结果一致,那么这种排序并不非法。(JMM对编译器和处理器重排序的约束原则)

as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程的执行结果不会改变。

 与程序员密切相关的happens-before规则如下:

    程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。

    监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。

    volatile变量规则:对一个volatile域的写,happens- before 于任意后续对这个volatile域的读。

    传递性:如果A happens- before B,且B happens- before C,那么A happens- before C。

    start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start() 操作happens-before与线程B中的任意操作。

    join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

注意,两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。

未完.......





1 0
原创粉丝点击