真正理解Java Volatile的妙用

787 阅读19分钟

前言

线程并发系列文章:

Java 线程基础
Java 线程状态
Java “优雅”地中断线程-实践篇
Java “优雅”地中断线程-原理篇
真正理解Java Volatile的妙用
Java ThreadLocal你之前了解的可能有误
Java Unsafe/CAS/LockSupport 应用与原理
Java 并发"锁"的本质(一步步实现锁)
Java Synchronized实现互斥之应用与源码初探
Java 对象头分析与使用(Synchronized相关)
Java Synchronized 偏向锁/轻量级锁/重量级锁的演变过程
Java Synchronized 重量级锁原理深入剖析上(互斥篇)
Java Synchronized 重量级锁原理深入剖析下(同步篇)
Java并发之 AQS 深入解析(上)
Java并发之 AQS 深入解析(下)
Java Thread.sleep/Thread.join/Thread.yield/Object.wait/Condition.await 详解
Java 并发之 ReentrantLock 深入分析(与Synchronized区别)
Java 并发之 ReentrantReadWriteLock 深入分析
Java Semaphore/CountDownLatch/CyclicBarrier 深入解析(原理篇)
Java Semaphore/CountDownLatch/CyclicBarrier 深入解析(应用篇)
最详细的图文解析Java各种锁(终极篇)
线程池必懂系列
通过这篇文章你将知道: 1、什么是线程同步以及为什么需要线程同步
2、加一条打印语句可退出循环原因
3、volatile为什么不能保证原子性
4、cpu 缓存一致性协议(MESI)
5、内存屏障的作用
6、有了MESI,为啥还需要volatile
7、volatile可见性、顺序性原理
8、volatile运用在哪些场景

概念解析

看到volatile就会想到线程同步,那么volatile能够实现线程同步吗?
两个前提:什么是线程同步?为什么要进行线程同步?

  1. 我们知道,在现代计算机原理中,中央处理器(cpu)负责计算,内存(mem)负责存储数据,两者交互可以简单理解为程序执行的核心(当然实际比这复杂得多)。

image.png

线程则可认为是cpu上运行的一段代码,在单核cpu中,任何时刻只有一个线程被执行

image.png

如上图所示,有5个线程在等待cpu轮转执行,mem里有个变量x,每个线程的功能是使x增1,假设x初始值x=1,那么线程执行结果如下:

线程1从mem取出x=1 送到cpu运算x = x+1 -> x = 2;
线程2从mem取出x=2 送到cpu运算x = x+1 -> x= 3;
...
线程5从mem取出x=4 送到cpu运算x = x+1 -> x= 5;

可以看出,在单核cpu中,多个线程共享同一个变量x,并且都对其进行了操作,当其中一个线程修改了x,其它线程能够知道该值已经发生了变化。我们可以理解为对于变量x的,线程间达到了线程同步。

2、我们知道cpu运算速度远远高于内存读写速度,当线程1计算x完成并且写入内存时,此时cpu没事干了。为了充分利用cpu资源,在cpu和mem之间加了个存储器,速度比mem快很多,称之为cache。当从mem中读取x后,变将x更新到cache里,下次再读取x时,先去cache里查找x,找到就不用到mem里找了,显著提高了读写效率。然而单核cpu同一时间只能执行一个线程,为了能够实现真正的并行,多核cpu应运而生,如下图。

image.png

线程1运行在cpu1上,线程2运行在cpu2上,x变量存储在mem中。
可以看出,cpu与mem之间多了一层cache,那么现在线程1、线程2如何更新变量x呢?

1、假设x的初始值x=1
2、线程1和线程2同时对x进行自增操作x=x+1
3、线程1将x值加载到cache1,线程2将x值加载到cache2
4、线程1修改x=x+1->x = 2,并写入cache1
5、线程2修改x=x+1->x = 2,并写入cache2
6、将cache1里x写入mem,此时x=2。将cache2里x写入mem,此时x=2
7、最终mem里的x=2

由于是线程1和线程2同时修改x,因此就会存在一个风险:当线程2修改x时并不知道线程1在修改x,当然线程1也知道线程2在修改x,两个线程自顾自的干自己的活,最终反馈到mem里的x=2,并不是预期的x=3。

举个通俗的例子:爸爸(线程1)妈妈(线程2)规定小明1天只能有1元的零花钱,某一天爸爸给了小明1元,而妈妈并不知道爸爸已经给了1元,于是也给了小明1元,至此小明拥有了2元,就与爸爸妈妈的要求不相符了。这就是需要进行线程同步的现实需求。

上面解释了什么是线程同步以及为什么要线程同步,接下来我们来看看volatile能否进行线程同步。

    private volatile static int x = 0;

    public static void main(String args[]) {

        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 1000; i++) {
                    x++;
                }
            }
        };

        Thread thread1 = new Thread(runnable);
        Thread thread2 = new Thread(runnable);

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
            System.out.println("x=" + x);
        } catch (Exception e) {

        }

    }

thread1和thead2分别对x自增,x使用volatile修饰,预期结果x = 2000,实际结果:

image.png

image.png

可以看出结果是随机的,不符合预期,说明此种场景下,volatile并不能保证线程同步。

线程并发三要素

原子性

一个或者多个操作,要么全部执行并且中途不能被打断,要么都不执行。

可见性

同一个线程里,先执行的代码结果对后执行的代码可见,不同线程里任意线程对某个变量修改后,其它线程能够及时知道修改后的结果。

有序性

同一线程里,程序的执行顺序按照代码的先后顺序执行。

可以看出,如果不作其它的操作,在多线程环境下可能就会遇到原子性、可见性的问题,因为各个线程之间并不知道其中某个线程正在对共享变量进行操作,也不能立即知道操作后的结果。要保证多线程安全,就需要满足上述三个条件,那么volatile满足哪些条件呢?

验证volatile可见性

看下边网络上摘抄一个例子:
【注1】juejin.im/post/5c6b99…

    private static int sharedVariable = 0;
    private static final int MAX = 10;

    public static void main(String[] args) {

        new Thread(() -> {
            int oldValue = sharedVariable;
            while (sharedVariable < MAX) {
                if (sharedVariable != oldValue) {
                    System.out.println(Thread.currentThread().getName() + " watched the change : " + oldValue + "->" + sharedVariable);
                    oldValue = sharedVariable;
                }
            }
            System.out.println(Thread.currentThread().getName() + " stop run");
        }, "t1").start();

        new Thread(() -> {
            int oldValue = sharedVariable;
            while (sharedVariable < MAX) {
                System.out.println(Thread.currentThread().getName() + " do the change : " + sharedVariable + "->" + (++oldValue));
                sharedVariable = oldValue;
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(Thread.currentThread().getName() + " stop run");
        }, "t2").start();

    }

上面代码目的是:有个共享变量sharedVariable,分别启动两个线程t1,t2。t2每次对sharedVariable自增操作,t1不停检测sharedVariable变化,如果发生变化则打印。根据之前的了解,我们很快就能猜出t1可能无法实时获知sharedVariable的变化,运行代码来验证一下我们的猜测。

image.png

上图传达出两个信息:

1、t2将sharedVariable改变为10,t1只有一次打印
2、t2线程停止,t1未停止,程序没有停止运行

和我们预想的一致,t2对共享变量的修改,t1没能够及时获知,导致t1一直在检测。好了,现在我们加上volatile修饰sharedVariable,结果会如何呢?

image.png

上图传达出两个信息:

1、t1能够及时获知t2每次对sharedVariable的修改。
2、t1、t2均已停止,程序停止运行。

通过上面的正反例子(有无volatile的情况下),似乎能够证明volatile修饰的变量能在各个线程间可见,但此种证明方式合适吗?我们再来看看t1的代码

while (sharedVariable < MAX) {
                if (sharedVariable != oldValue) {
                    System.out.println(Thread.currentThread().getName() + " watched the change : " + oldValue + "->" + sharedVariable);
                    oldValue = sharedVariable;
                }
            }

当sharedVariable != oldValue条件成立时才会打印sharedVariable的值,那我们想直接知道sharedVariable值的变化呢,因此在改造将代码做两个改动:
1、将volatile修饰符去掉。 2、在t1循环里增加一行打印。

        new Thread(() -> {
            int oldValue = sharedVariable;
            while (sharedVariable < MAX) {
                System.out.println("sharedVariable:" + sharedVariable + "  oldValue:" + oldValue);
                if (sharedVariable != oldValue) {
                    System.out.println(Thread.currentThread().getName() + " watched the change : " + oldValue + "->" + sharedVariable);
                    oldValue = sharedVariable;
                }
            }
            System.out.println(Thread.currentThread().getName() + " stop run");
        }, "t1").start();

结果:

image.png

结果出乎我们的意料,在没有volatile修饰sharedVariable变量的情况下,t1仍然能够监测到sharedVariable的变化,而我们仅仅只增加了1行打印语句而已。
既然t1能够监测到sharedVariable变化,那为啥之前if (sharedVariable != oldValue)条件没成立呢,因此我们想在else里打印验证一下:

        new Thread(() -> {
            int oldValue = sharedVariable;
            while (sharedVariable < MAX) {
                if (sharedVariable != oldValue) {
                    System.out.println(Thread.currentThread().getName() + " watched the change : " + oldValue + "->" + sharedVariable);
                    oldValue = sharedVariable;
                } else {
                    System.out.println("sharedVariable:" + sharedVariable + "  oldValue:" + oldValue);
                }
            }
            System.out.println(Thread.currentThread().getName() + " stop run");
        }, "t1").start();

结果如下:

image.png

t1还是能够监测到sharedVariable变化。为了能够看清t1里的全部打印,我们加sleep(200),再来看看打印结果:

image.png

我们看到t1能够感知到t2每次对sharedVariable的改变。这里面的可变因素是增加了一行打印,因此我们现在将else里的打印去掉

        new Thread(() -> {
            int oldValue = sharedVariable;
            while (sharedVariable < MAX) {
                if (sharedVariable != oldValue) {
                    System.out.println(Thread.currentThread().getName() + " watched the change : " + oldValue + "->" + sharedVariable);
                    oldValue = sharedVariable;
                } else {
                    try {
//                        System.out.println("sharedVariable:" + sharedVariable + "  oldValue:" + oldValue);
                        Thread.sleep(200);
                    } catch (Exception e) {

                    }
                }
            }
            System.out.println(Thread.currentThread().getName() + " stop run");
        }, "t1").start();

结果:

image.png

结果依旧,这下可变因素只有一句sleep(200)代码了,我们将此行代码注释
结果:

image.png

打印结果回到了我们最开始时的表现,t1不能够监测到sharedVariable的变化。
由现象推导出的结论:

在t1里增加打印或者sleep,能够让t1监测到sharedVariable的变化

也许你会说:“既然如此,我还需要volatile干嘛”。println和sleep真能达到可见性吗?查看了println和sleep方法,本身没什么特殊的。既然确定我们代码没啥问题,不禁会想到是不是编译器和JVM在搞事呢?看看没有加printltn和sleep时,它们对代码做了怎样的优化。
【注2】www.cnblogs.com/dzhou/p/954… 引用此文章一张图

image.png

有可能对代码进行优化部分包括:
1、一是编译器编译为.class文件
2、解释器&JIT代码生成器部分

查看.class文件
编译前 image.png

编译后 image.png

可以看出,编译前后没啥区别。
查看JVM优化
解释器是将字节码解释为机器语言;JIT是为了让重复执行的代码(热点代码)避免重复解释,将之编译为本地机器码,用到时直接执行机器码,达到了节约时间的目的。
既然编译器没有做优化,那么猜测就是JVM做了优化,实际上还真是JIT做了这事。
【注3】hllvm-group.iteye.com/group/topic… 这篇文章解释了JIT对循环做了怎样的优化。
这里简单说一下:
依然是以t1的代码为例,我们的初衷是如果sharedVariable<10,那么不断监测sharedVariable的变化,如果发生了变化则打印出来。

            while(sharedVariable < 10) {
                if (sharedVariable != oldValue) {
                    System.out.println(Thread.currentThread().getName() + " watched the change : " + oldValue + "->" + sharedVariable);
                    oldValue = sharedVariable;
                }
            }

JIT发现这里是重复判断“sharedVariable != oldValue”,因此优化了代码:只在第一次取sharedVariable的值,后续不再取最新值,然后一直死循环,最终导致程序没退出。而增加了println或者sleep后,JIT取消了优化。(注:此处结论根据链接里的例子进行猜测,没有去了解汇编后的代码,若有不同看法,请评论指正)。

缓存一致性协议

上面我们发现了一个现象:没有volatile修饰共享变量,增加println/sleep取消JIT优化,使得t1能够监测到sharedVariable的变化,这是怎么做到的呢?记得我们最开始时提到的多核cpu下,每个cpu有多级cache缓存,cpu每次优先从cache里取数据,为了能够让不同cpu cache之间数据尽量一致,cpu实现了缓存一致性协议(Cache-Coherence Protocols

image.png

cache基本数据单位称之为cache line(大小可能为64byte 128byte等,不同cache设计不一样),cache与mem和cache之间的数据交换都是以cache line为基本单位。既然cache1、mem、cache2之间要保证数据一致性,那么需要有效的协同。cache line设计了四种状态:

Modified(M),Exclusive(E),Shared(S)和Invalid(I)

因此缓存一致性协议也称作MESI协议。那么cache1、mem、cache2是怎么通信的呢?它们之间定义了6种消息:

1、Read (包含要读取变量在mem里的地址)
2、Read Response (对Read消息的回复,包含对应变量的cache line)
3、Invalidate (包含待失效的变量地址)
4、Invalidate Acknowledge (对Invalidate消息的回复)
5、Read Invalidate (Read消息和Invalidate消息的结合)
6、WriteBack (将cache 刷新到mem里)

简单化的通信过程

我们分别从读和写一个共享变量x来观察MESI如何工作的。
读写x:
x存在mem里,不在cache1、cache2里,初始值x=0
cpu1读x:

1、cpu1发出Read消息,mem和cache2收到该消息,此时mem发送Read Response(包含x)
2、cpu1收到Read Response后,将数据放入对应的cache line

cpu2读取x:

1、cpu2发出Read消息,mem和cache1收到该消息,此时cache1发送Read Response(包含x)
2、cpu2收到Read Response后,将数据放入对应的cache line

此时,cache1 和cache2都拥有了x,状态为S
现在cpu1要修改x=1

1、cpu1发送Invalidate消息
2、cpu2收到后,找到对应的cache line,将之移除(置为I状态),并发送Invalidate Acknowledge
3、cpu1收到Invalidate Acknowledge,将x=1更新到cache line,此时状态为(M)

cpu2读取x=1

1、cpu2读取x时,发现cache line状态为I,因此会发送Read消息
2、cpu1收到Read消息,发现此时处在M状态,发出WriteBack将cache line刷新到mem,并发送Read Response给cpu2
3、cpu2收到Read Response,更新对应cache line

此时x=1已经会写到mem,并且cpu2 cache里 x =1,cache1 、mem、cache2 三者x=1,完成一致性通信。

榨取cpu性能

如果cpu参照上述协议运行,那么可想而知效率是比较低的。
Store Buffer
假设x在cpu2里,不在cpu1里,x初始值x=0
cpu1执行x=1

1、cpu1发送Read Invalidate消息
2、cpu2收到后先移除对应的cache line,并将Read Response和Invalidate Acknowledge返回给cpu1
3、cpu1收到后放入对应的cache line
4、cpu1将x=1写入对应的cache line

从上面步骤可以看出,cpu1需要等待cpu2的Response才能进行下一步操作。等待过程比较耗时,因此cpu1和cache 之间增加了Store Buffer暂存数据,流程变为如下:

1、cpu1发送Read Invalidate消息
2、cpu1将x=1放入Store Buffer
3、cpu2收到后先移除对应的cache line,并将Read Response和Invalidate Acknowledge返回给cpu1
4、cpu1收到后放入对应的cache line
5、cpu1将Store Buffer写入对应的cache line

增加了Store Buffer,看似没啥用处。但想象一下,如果cpu1同时需要更新多个变量如x、y、z的值,那么先将x、y、z写入到Store Buffer,当某个时机成熟时再一并写入cache,这个时候效率就体现出来了。
Invalidate Queue
cpu1 发送Read Invalidate,需要等待cpu2发送Invalidate Acknowledge,假设此时cpu2忙于将Store Buffer写入cache line,没有及时将cache line移除,那么就不会发送Invalidate Acknowledge,cpu1就需要等待。此时就引入了Invalidate Queue,cpu2收到Invalidate消息后,将之放入Invalidate Queue,并立即发送Invalidate Acknowledge,这个时候cpu1及时收到确认消息,并没有被耽搁。
加入Store Buffer和Invalidate Queue后示意图

image.png

内存屏障

通过Store Buffer 和Invalidate Queue成功提升了cpu效率,会有副作用吗?
先来看看Store Buffer引入
x在cache2里,y在cache1里,cpu1执行performByCpu1(),cpu2执行performByCpu2

        int x = 0;
        boolean y = false;
        
        void performByCpu1() {
            x = 1;
            y = true;
        }
        
        void performByCpu2() {
            while(!y)
                continue;
            assert x == 1;
        }

1、cpu1将x=1放入Store Buffer
2、cpu2发现y=false,则一直循环
3、cpu1将y=true放入写入cache
4、cpu2发现y失效,则重新读取发现y=true
5、cpu2从cpu1获取x,发现x=0,assert fail
6、cpu1将Store Buffer里的x=1 刷新到cache line

问题出现了,x=1并没有被cpu1及时写入cache line,导致cpu2无法及时获取x的值。为什么会出现此种问题呢?

  • 当cpu1发送Read Invalidate之后,将x=1放入Store Buffer,此时继续执行y=1操作,而cpu2发送Read请求y的值,cpu2可能会先收到cpu1的Response,因此执行assert x== 1操作,但是此时cpu1还没收到Read Response和Invalidate Acknowledge,因此Store Buffer里的数据无法写入cache,cpu2收到的是更改之前的数据。

如何解决此种问题?
通过加入内存屏障告诉CPU在进行下一步之前保证Store Buffer已刷新进cache

        int x = 0;
        boolean y = false;

        void performByCpu1() {
            x = 1;
            smp_mb
            y = true;
        }

        void performByCpu2() {
            while(!y)
                continue;
            assert x == 1;
        }
  • cpu1执行到smp_mb指令时,发现是内存屏障指令,先将x=1从Store Buffer写入cache,当cpu2获取y值时,收到的Read Response里x=1,因此asser x==1 为true。成功解决了引入Store Buffer的问题。

再来看看Invalidate Queue引入

1、cpu1发送Read Invalidate消息
2、cpu2收到后将之放入Invalidate Queue,并发送InvalidateAcknowledge消息
3、cpu1将x=1、y=true更新到cache里
4、cpu2将y更新到cache line并跳出循环
5、cpu2读取x,发现x就在自己的cache line,x=0 assert fail
6、cpu2将Invalidate Queue取出,失效自身的cache

问题出现了,x=1并没有被cpu2获取到。出现该问题原因如下:

  • cpu2执行Invalidate Queue里的消息时间不确定

如何解决此种问题?
通过加入内存屏障告诉CPU在进行下一步之前保证Invalidate Queue里的消息已经执行

        int x = 0;
        boolean y = false;

        void performByCpu1() {
            x = 1;
            smp_mb
            y = true;
        }

        void performByCpu2() {
            while(!y)
                continue;
            smp_mb
            assert x == 1;
        }
  • 当cpu2执行到smp_mb时,强制将Invalidate Queue消息取出,将x所在的cache line移除,然后cpu2访问x时,会从cpu1获取,此时cpu1的x=1,cpu2收到将cache line 写入x=1,assert x== 1 为true。成功解决了引入Invalidate Queue的问题。

【注4】内存屏障消息流转可参考:zhuanlan.zhihu.com/p/55767485

小结
cpu实现了缓存一致性协议,尽可能保证cache之间数据一致性。但是为了充分利用cpu性能,增加了Store Buffer和Invalidate Queue缓存,导致cpu可能产生指令顺序不一致问题,看起来像是指令重排了,通过增加内存读写屏障来规避此问题。
回到我们之前的问题:“没有volatile修饰共享变量,增加println/sleep取消JIT优化,使得t1能够监测到sharedVariable的变化”。这个能够实现是因为cpu实现了缓存一致性协议。 再回到我们最初的问题,怎样验证volatile修饰的变量具有线程间可见性呢?我们原本想通过t1的死循环来证明,发现死循环并不是因为t1监测不到sharedVariable变化,而是JIT对代码进行了优化。因此我们增加了打印语句,取消JIT优化,此时无法有效的观测到t1监测不到sharedVariable变化现象,因为缓存一致性尽可能保证cache数据一致性。

volatile作用

可见性

MESI通过加入内存屏障指令来确保数据顺序一致性,那么这个内存屏障是什么时候插入呢?实际上在编译为汇编语句的时候,当JVM发现有变量被volatile修饰时,会加入LOCK前缀指令,这个指令的作用就是增加内存屏障。这就是为什么有了MESI协议,我们还需要volatile的原因之一。因为volatile变量读写前后插入内存屏障指令,所以cache之间的数据能够及时感知到,共享变量就达到了线程间可见性,也就是说volatile修饰的变量具有线程间可见。

有序性

我们知道单个线程里,程序执行的顺序按照代码的先后顺序执行。引用上面的例子。

        int x = 0;
        boolean y = false;

        void performByCpu1() {
            x = 1;
            y = true;
        }

        void performByCpu2() {
            while(!y)
                continue;
            assert x == 1;
        }

先来看performByCpu1()方法,x、y没有什么关系,编译器为了优化,可能进行指令重排,将y=true 排在x=1前面执行,在单线程里没有影响,但当我们再看performByCpu2()方法时,因为指令重排的问题,导致多线程下程序执行结果不正确了。这是编译器层面的指令重排,加了volatile修饰后,取消这种编译优化。这就是为什么有了MESI协议,我们还需要volatile的原因之二。

volatile能保证原子性吗

前面的例子我们有验证过,volatile不能保证原子性,为什么不能保证呢?
t1、t2线程同时对x执行x++操作,假设t1在cpu1上执行,t2在cpu2上执行。x++是复合操作,其包含如下三步骤:

1、读取x的值
2、计算x+1的值
3、用x+1的结果给x赋值
指令执行简单化如下: 1、cpu1读取x的值,初始值x=0
2、cpu2读取x的值,初始值x=0
3、cpu1执行x+1,此时tmp=1
4、cpu1将x=1写入cache
5、cpu2收到Invalidate消息,失效其cache,并使用cpu1 cache的值,此时cpu2 cache x=1
6、cpu2执行x+1,因为之前x=0已经在cpu2 寄存器里边,因此直接执行x+1,并将x=1写入cache
7、最终写入mem x=1 与预期结果x=2不符

因此volatile不能保证原子性。

volatile使用场景

综上所述,volatile能够保证可见性、有序性、不能保证原子性,那么volatile在哪些场景下会用到呢?那就针对其可见性、有序性特点来分析。
针对有序性,我们可以通过volatile禁止指令重排达到有序性。典型用处是在单例双重检查锁 DCL(double-checked locking)

    static volatile CheckManager instance;
    public static CheckManager getInstance() {
        if (instance == null) {
            synchronized (CheckManager.class) {
                if (instance == null) {
                    instance = new CheckManager();
                }
            }
        }
        
        return instance;
    }

instance = new CheckManager() 是复合运算,正常步骤分解如下:

1、分配对象内存空间
2、初始化CheckManager
3、返回对象内存空间首地址

由于存在指令重排优化,3可能在2之前执行,当另一个线程获取到instance时,由于没有正常初始化完成,可能会导致问题。通过使用volatile 修饰instance,禁止指令重排,避免此种情况。
针对可见性,当一个线程在修改共享变量,而其它线程只是读取变量时,使用volatile修饰共享变量,读线程能够及时获知共享变量的变化。例子可参考开篇的t1、t2读写x做法。
实际上我们发现CAS、Lock等锁机制内部使用了volatile保证可见性,有兴趣的可以去看看源码。 有疑惑、指正的请评论留言~

参考链接

juejin.im/post/5c6b99… www.cnblogs.com/dzhou/p/954… hllvm-group.iteye.com/group/topic… zhuanlan.zhihu.com/p/55767485

本文基于JDK 1.8,运行环境也是jdk1.8。

您若喜欢,请点赞、关注,您的鼓励是我前进的动力

持续更新中,和我一起步步为营系统、深入学习Android/Java

更多干货,公众号【小鱼人爱编程】