JMM 简介及 volatile 的说明

2017-02-08  本文已影响0人  yuhuanxi

个人博客地址:https://huanxi.pub

为了屏蔽各个操作系统和硬件的差异,使得 Java 程序在所有平台下都能达到一致的内存访问效果,所以 Java 虚拟机定义了一种 Java 内存模型。

Java 内存模型的作用

我们写代码,说到底就是在操作内存。

Java 内存模型主要定义了程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量的底层细节。(这里的变量不包括局部变量和方法参数,因为那是线程私有的,不会产生竞争)

Java 虚拟机规定所有的变量都存储在主内存(Main Memory),每个线程都有自己的工作线程(Work Memory)。

线程的工作内存中保存了使用到的变量的主内存副本拷贝,线程对变量的操作是在自己的工作内存中,而不能直接对主内存的变量进行读取赋值。

不同线程之间无法直接访问对方工作内存中的变量,需要通过主内存来进行传递。

线程、主内存、工作内存之间的关系如下图所示


main_work_memory

volatile 关键字

volatile 变量具备两种特性,其一是保证该变量对所有线程可见,这里的可见性指的是当一个线程修改了变量的值,那么新的值对于其他线程是可以立即获取的。其二 volatile 禁止了指令重排。

虽然 volatile 变量具有可见性和禁止指令重排序,但是并不能说 volatile 变量能确保并发安全。

public class VolatileTest {

    public static volatile int a = 0;
    public static final int THREAD_COUNT = 20;

    public static void increase() {
        a++;
    }

    public static void main(String[] args) throws InterruptedException {

        Thread[] threads = new Thread[THREAD_COUNT];

        for (int i = 0; i < THREAD_COUNT; i++) {
            threads[i] = new Thread(new Runnable() {
                public void run() {
                    for (int i = 0; i < 1000; i++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }

        while (Thread.activeCount() > 2) {
            Thread.yield();
        }

        System.out.println(a);
    }
}

按照我们的预期,它应该返回 20000 ,但是很可惜,该程序的返回结果几乎每次都不一样。

问题主要出在 a++ 上,复合操作并不具备原子性, 虽然这里利用 volatile 定义了 a ,但是在做 a++ 时, 先获取到最新的 a 值,比如这时候最新的可能是 50,然后再让 a 增加,但是在增加的过程中,其他线程很可能已经将 a 的值改变了,或许已经成为 52、53 ,但是该线程作自增时,还是使用的旧值,所以会出现结果往往小于预期的 2000。如果要解决这个问题,可以对 increase() 方法加锁。

volatile 适用场景

volatile 适用于程序运算结果不依赖于变量的当前值,也相当于说,上述程序的 a 不要自增,或者说仅仅是赋值运算,例如 boolean flag = true 这样的操作。

    volatile boolean shutDown = false;

    public void shutDown() {
        shutDown = true;
    }

    public void doWork() {
        while (!shutDown) {
            System.out.println("Do work " + Thread.currentThread().getId());
        }
    }

当调用 shutDown() 方法时,能保证所有的线程都停止工作。

指令重排

int a = 1;
int b = 2;
int c = a * b;

CPU 为了提升效率,允许将多条指令进行重新排序。
比如上述 就有可能先执行
int b = 2;
然后执行
int a = 1;
但是 int c = a * b 是不会进行重排的,它必须在 a、b 之后,因为 c 对 a、b 有所依赖。
上面的代码,在单线程中,即使经过指令重排,但是并不会影响最终的结果,所以是不会出问题的,但是在多线程中,就会引发问题。

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

}

看似简单的一段赋值语句:instance= new Singleton(),但是很不幸它并不是一个原子操作,其实际上可以抽象为下面几条JVM指令:

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

上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM是可以针对它们进行指令的优化重排序的,经过重排序后如下:

memory =allocate(); //1:分配对象的内存空间
instance =memory; //3:instance指向刚分配的内存地址,此时对象还未初始化
ctorInstance(memory); //2:初始化对象

可以看到指令重排之后,instance指向分配好的内存放在了前面,而这段内存的初始化被排在了后面。

在线程A执行这段赋值语句,在初始化分配对象之前就已经将其赋值给instance引用,恰好另一个线程进入方法判断instance引用不为null,然后就将其返回使用,导致出错。

instance 加上 volatile 即可防止指令重排。

本文参考:《深入理解 Java 虚拟机第二版

最后一个例子及说明摘自:Java并发:volatile内存可见性和指令重排

上一篇下一篇

猜你喜欢

热点阅读