java内存模型与volatile详解

Posted by 梧桐和风的博客 on February 5, 2017

由于各种硬件及操作系统的内存访问差异,java虚拟机使用java内存模型(java Memory Model,JMM)来规范java对内存的访问。这套模型在jdk 1.2中开始建立,经jdk 1.5的修订,现已逐步完善起来。

###什么是java内存模型

什么是java内存模型,为什么会有这个模型?关于这个问题,就不得不从并发的问题讲起。在多核系统中,处理器一般设置缓存来加速数据的读取,缓存大大提升了程序性能,却也带来了“缓存一致性”的新问题。比如,当多个处理器写同一块主内存时,以谁的缓存数据为准?读取、写入内存的变量需遵循怎样保证线程安全?针对这些问题,java设计了一套内存模型以用来定义程序中各个变量的访问规则。

java的内存模型采用的是共享内存的线程通信机制。线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存存储了共享变量的副本。 这里写图片描述

图片来自《深入理解java虚拟机 第2版》

关于共享变量,可以对应为存储在堆内存的实例变量、类变量及数组元素(堆内存是线程共享的)。私有变量可对应虚拟机栈中的局部变量。事实上,他们是java内存不同层次的划分,并没有一定联系。

内存间的交互操作

要完成主内存与工作内存的交互操作,需遵守一定的规则。java内存模型定义了相当严谨而复杂的访问规则。主要有8种原子性的操作。分别是:lock(锁定)、unlock(解锁)、read(读取)、load(载入)、use(使用)、assign(赋值)、store(存储)、write(写入)

内存交互时,必须使用以上几种操作搭配完成,且这8种操作要满足一定规则。如read和load,store和write必须成对出现;对变量实施use、store时,必须先执行assign和load操作。

幸好,这些难以记忆的规则有一个等效判定的原则,即先行发生原则。

  1. 程序次序规则:在一个线程中,程序控制流前面的操作先行发生于后面的操作。
  2. 监视器锁规则:一个unlock操作先行发生于对同一个锁的lock操作。
  3. volatile变量规则:对于一个volatile变量,写操作先行发生于对这个变量的读操作。
  4. 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,则操作A先行发生于操作C。

一个内存交互的例子

我们知道java的多线程通信采用共享内存的方式。线程对变量的所有操作都要在工作内存中进行,不能直接访问主内存。线程间变量传递均需主内存间接完成。

这里写图片描述

则,线程A要与线程B通信(比如B线程要读取A线程经操作后的值),需要:

  1. 线程A修改本地内存A的值,并将其写入主内存的共享变量。
  2. 线程B到主内存读取线程A修改后的值。

内存模型的3个重要特征

原子性

前面我们提到的8种原子操作都是原子性的,这样可以保证对基本数据类型的访问读写是原子性的。这里有个例外是JVM没有强制规定long、double一定是原子操作。但几乎所有的商业JVM都实现了long、double的原子操作。

可见性

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

这里需要引出本文第二个关键点:volatile。volatile有两个语义。这里用其可见性语义。经volatile修饰的变量保证新值能立即同步到主内存中,每次使用前立即从主内存刷新。保证了多线程操作时变量的可见性。后面会有更详细解释。

除volatile外,synchronized和final也能实现可见性。 synchronized的可见性由“对一个变量执行unlock前,必须先把此变量同步回主内存”。获得。

final关键字的可见性指:被final修饰的字段在构造器中初始完成,则其他线程就能看到final的值。

有序性

java程序本身具有的有序性可以总结为:如果在同一线程观察,所有操作都是有序的。而如果在一个线程观察另一线程,所有操作都是无序的。前部分指在单线程环境中程序的顺序性,后部分说的无序是指“指令的重排序”和“工作内存与主内存的同步延迟”。

指令重排序

编译器能够自由的以优化的名义去改变指令顺序。在特定的环境下,处理器可能会次序颠倒的执行指令。是为指令的重排序。在单线程环境中,程序执行结果不会受到指令重排序的影响。

但有时,我们在多线程情况下,并不希望发生指令重排序来影响并发结果。

java提供了volatile和synchronized来保证线程之间操作的有序性。volatile含有禁止指令重排序的语义(即它的第二个语义),synchronized规定一个变量在同一时刻只允许一条线程对其lock操作,也就是说同一个锁的两个同步块只能串行进入。禁止了指令的重排序。

关于指令重排序,下文还有更多解释。

volatile语义

介绍完java内存模型的3个特征,现在来详细介绍volatile及它代表的语义。

准确来说,volatile是java提供的轻量的同步机制。它有两个特性:

  1. 保证修饰的变量对所有线程的可见性。
  2. 禁止指令的重排序优化。

根据上面的介绍,我们对可见性及禁止重排序背后的顺序性都不陌生。下面我们来详细说明下。

验证volatile具有可见性

volatile变量对所有线程是立即可见的,对volatile变量的写操作都能立即反应到其他线程中。

volatile boolean flag;

public void shundown(){
   flag = true;
}

public void doWork(){
   while(!flag){
     doSomething();
   }   
}

上面的例子即是volatile的典型应用。任一线程调用了shundown()方法,都能保证所有线程执行doWork()时doSomething()方法不执行。

假设flag 不是由volatile修饰,则不能保证内存可见性,当某个线程修改了flag的值后,其他线程不一定会马上看到或根本看不到,就会引起错误。

需注意的是,volatile变量保证可见性时,需满足以下规则:

  1. 运算结果不依赖变量的当前值,或保证只有单一线程修改变量值。(如i++,运算依赖当前值,就不满足)
  2. 变量不需要与其他状态变量共同参与不变约束。
public class TestThread2 {
    public static volatile int race = 0;

    public static void increase(){
        race++;
    }
    private static final int THREADS_COUNT =20;

    public static void main(String[] args) {
        Thread[] threads = new Thread[THREADS_COUNT];

        for(int i=0;i<THREADS_COUNT;i++){
            threads[i] = new Thread(()->{
                for(int j=0;j<1000;j++){
                    increase();
                }
            });
            threads[i].start();
        }
        System.out.println(race);
    }
}

如上例,若正确并发,则最后应输出20*1000=20000,可结果总输出小于20000的结果,且每次都不相同。原因就在于volatile不能保证 race++的可见性。race++ 操作实际上有1.读取race的值;2.对race加1;3.修改race的值3步操作,而volatile显然不能保证这些操作的原子性。

volatile禁止指令重排序

指令重排序的语句需遵守一个规则,即as-if-serial语义:

所有操作都可以为了优化而重排序,但必须保证重排序的结果和程序执行结果一致。

这里给出重排序的例子

public class Test {
    private static int x = 0, y = 0;
    private static int a = 0, b =0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        while(true) {
            x = 0; y = 0;
            a = 0; b = 0;
            i++;
            Thread first = new Thread(()->{a = 1;x = b;});
            Thread second = new Thread(()->{b = 1;y = a;});
            first.start();second.start();
            first.join();second.join();
            String result = "第" + i + "次 (" + x + "," + y + ")";
            if(x == 0 && y == 0) {
                System.err.println(result);
                break;
            } else {
                System.out.println(result);
            }
        }
    }
}

一个线程执行a = 1;x = b;,另一个线程执行b = 1;y = a;,由于a、x,b、y不存在依赖关系,所以有可能发生先执行x=b,然后a=1的指令重排序,经试验,在多次循环后出现x=b;b=1;y=a;a=1;的线程交替执行结果。即x=0;y=0

这里写图片描述

这说明发生了指令重排序,将a,b,x,y用volatile修饰后,运行多次也没有出现重排序情况。 这里写图片描述

一个单例模式的例子

单例模式中的“双重检查加锁”模式如下所示

public class SingletonTest {
    private volatile static SingletonTest instance = null;
    private SingletonTest() { }
    public static SingletonTest getInstance() {
        if(instance == null) {
            synchronized (SingletonTest.class){
                if(instance == null) {
                    instance = new SingletonTest();  //非原子操作
                }
            }
        }
        return instance;
    }
}

上面代码大家都不陌生,可为什么instance一定要volatile修饰呢?这是由于instance = new SingletonTest();并不是一个原子操作。可分解为:

  1. memory =allocate(); //分配对象的内存空间
  2. ctorInstance(memory); //初始化对象
  3. instance =memory; //设置instance指向刚分配的内存地址

2操作依赖1操作,但3操作并不依赖2操作,也就是说,上述操作的顺序可能为1-2-3,也可能为1-3-2,若是后者,当instance不为空时也可能没有正确初始化对象,而导致错误。

参考

  1. 《深入理解java虚拟机 第2版》
  2. java内存模型FAQ
  3. 深入理解Java内存模型(一)——基础
  4. Java内存访问重排序的研究