Java volatile关键字内存原语

雨点打透心脏的1/2处 2021-06-10 20:38 526阅读 0赞

一、简述

  1. volatile特性:实现最轻量级的同步。
  2. volatile关键字的内存原语主要包含2个:

1、保证volatile修饰的变量对所有线程的可见性。

2、禁止指令重排序优化。

二、案例代码

  1. 先给一个经典的错误案例:
  2. package com.hy.current;
  3. public class VolatileTest {
  4. private static volatile int race = 0;
  5. public static void increase() {
  6. race++;
  7. }
  8. public static void main(String[] args) {
  9. Thread[] threads = new Thread[20];
  10. for (int i = 0; i < threads.length; i++) {
  11. threads[i] = new Thread(new Runnable() {
  12. @Override
  13. public void run() {
  14. for (int j = 0; j < 10000; j++) {
  15. increase();
  16. }
  17. }
  18. });
  19. threads[i].start();
  20. }
  21. while(Thread.activeCount() > 1) {
  22. Thread.yield();
  23. }
  24. System.out.println(race);
  25. }
  26. }

正确的结果应该是200000,但程序运行的值均比200000小,为何?

javap -v com.hy.current.VolatileTest 得到字节码:

  1. public static void increase();
  2. flags: ACC_PUBLIC, ACC_STATIC
  3. Code:
  4. stack=2, locals=0, args_size=0
  5. 0: getstatic #10 // Field race:I
  6. 3: iconst_1
  7. 4: iadd
  8. 5: putstatic #10 // Field race:I
  9. 8: return
  10. LineNumberTable:
  11. line 8: 0
  12. line 9: 8
  13. LocalVariableTable:
  14. Start Length Slot Name Signature

race++不是原子性操作,虽然volatile修改的变量能够保证线程拿到的值在当前时刻是最新的(getstatic),但其他线程可能执行到了iadd等操作,导致拿到的值已经过期,这样就会出现最终结果比预期的200000要小的原因。

这个是单独使用volatile同步失败的典型案例,此案例的正确做法是在increase()方法增加同步锁(synchronized或Lock都行)。

再来看一个正确的案例:

  1. package com.hy.current;
  2. public class VolatileTest2 {
  3. private static volatile boolean isShutdown = false;
  4. public void shutdown() {
  5. isShutdown = true;
  6. }
  7. public void execute() {
  8. while(!isShutdown) {
  9. // TODO
  10. // 执行业务代码
  11. }
  12. }
  13. }

字节码如下:

  1. public void shutdown();
  2. flags: ACC_PUBLIC
  3. Code:
  4. stack=1, locals=1, args_size=1
  5. 0: iconst_1
  6. 1: putstatic #10 // Field isShutdown:Z
  7. 4: return
  8. LineNumberTable:
  9. line 8: 0
  10. line 9: 4
  11. LocalVariableTable:
  12. Start Length Slot Name Signature
  13. 0 5 0 this Lcom/hy/current/VolatileTest2;

针对volatile变量只有一个赋值操作,并且是原子的,这样就能达到预期的同步结果。

我们简单总结一下用volatile做同步的场景:

1、对volatile变量的操作,必须是原子性的。或者可以保证volatile变量只能由一个线程来修改,其他线程只是使用此volatile变量。

2、单独由这一个volatile变量控制同步,不能与其他变量一起参与。

三、Java内存模型

  1. 要理解volatile关键字的作用,就先要了解Java内存模型,Java内存模型(Java Memory Model, JMM)指的是由Java虚拟机规范定义的,减少不同的操作系统平台内存访问的差异,实现跨平台一致性的内存访问效果,主要的目标就是定义程序中各个变量的访问规则,这里的变量指的是实例字段,静态字段和构成数组对象的元素,但不包括局部变量与方法参数,后者是线程私有的。
  2. JMM的内存有主内存和工作内存之分,可以狭义地认为,主内存主要对应Java堆中的对象实例,工作内存则对应虚拟机栈中的部分区域。主内存存储所有的变量,工作内存保存该线程使用到的变量 的主内存副本拷贝,线程对变量的所有操作(读取,赋值等)都必须在工作内存中进行,而不能直接使用主内存的变量,不同的线程之间也无法直接访问对方工作内存中的变量,线程、工作内存、主内存三者关系图如下所示:

watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2h1YW5neWluZzIxMjQ_size_16_color_FFFFFF_t_70

四、内存间交互操作

  1. 此节主要描述主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存的实现细节,是本篇的重点。
  2. 共有8种操作,每一种担任 都是原子的,如下图所示:

watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2h1YW5neWluZzIxMjQ_size_16_color_FFFFFF_t_70 1

1、Lock:只在主内存中操作,将一个变量标识为被一条线程独占的状态。

2、unLock:与Lock对应,释放变量的锁定状态。

3、read:从主内存中读取一个变量传输到线程的工作内存中。

4、load:紧接着read操作之后,在工作内存开辟一个变量副本,装载read回来的变量。

5、use:把工作内存中的变量传递给执行引擎。

6、assign:与use对应,执行引擎向工作内存传输变量值(一般是变量的赋值操作)。

7、store:对应read操作,从工作内存读取一个变量传输到主内存。

8、write:对应load操作,紧接着store之后,在主内存中开辟一段空间,保存store回来的变量。

看手绘图可知,看似8个操作挺复杂,整理一下可以知道,是相对应的4对操作。

JMM规定这8个操作必须满足的条件:

1、read/load或是store/write这两组操作,必须成对出现,保证值一定可以正常读取、写入。

2、assign操作后面必须会有store/write这组操作,不允许出现工作内存有变量更新,不刷新回主内存的现象存在。

3、没有assign就不会平白无故地出现store/write这组操作。

4、一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化的变量,即有use/store操作出现,前面肯定会有assign/load操作。

5、一个变量在同一个时刻只能被一个线程Lock,Lock操作可以执行多次,想到解锁,就必须unLock相同的次数。

6、线程对一个变量进行Lock操作,将会清掉线程此变量在工作内存中的值,想用这个变量,就要重新load->assign操作。

7、没Lock的变量是不能执行unLock的。

8、unLock执行前,必须先执行store->write将此变量刷新到主内存中。

五、禁止指令重排序

  1. 变量加了volatile修饰后,汇编代码里会多出一个"lock addl $0x0,(%esp)"操作,这种操作相当于一个内存屏障(Memory BarrierMemory Fence,指重排序时不能把后面的指令重排序到内存屏障之前的位置),这样就可以保证volatile修饰的变量,是先赋值,再使用。
  2. 如果没有volatile修饰,指令重排序操作(属于硬件架构CPU优化的范畴,简单来说就是CPU在保证正确处理指令依赖、执行结果正确的前提下,会对一些指令进行顺序的优化,这样指令和实际的代码顺序就会有一些差别)会影响线程之间共享变量的使用。

举个例子:

  1. package com.hy.current;
  2. import java.io.IOException;
  3. import java.util.Properties;
  4. /**
  5. * @author Administrator
  6. *
  7. */
  8. public class VolatileTest3 {
  9. private static boolean isInitial = false;
  10. /**
  11. * 加载配置信息,加载完成后isInitial设置为true
  12. * 由线程A调用
  13. */
  14. public void init() throws IOException {
  15. Properties prop = new Properties();
  16. prop.load(VolatileTest3.class.getClassLoader().getResourceAsStream("test.properties"));
  17. isInitial = true;
  18. }
  19. /**
  20. * 如果配置信息加载完成后,执行相关业务代码
  21. * 由线程B调用
  22. */
  23. public void execute() {
  24. if (isInitial) {
  25. // TODO 执行业务代码
  26. }
  27. }
  28. }

乍一看,好像没问题,运行起来也能得到预期结果,但是请思考一下这行代码的位置:

isInitial = true;

一定能保证配置信息加载完后才执行吗?

我们看看这个方法,isInitial在方法里跟其他变量没有任何的依赖,CPU完全有优化的可能将isInitial的赋值操作放在最前面,此致”isInitial = true;”被提前执行,这样就会导致配置信息没加载完,线程B就已经开始干活了。

  1. isInitial变量增加volatile修饰后,就可以避免这种排序情况发生,从而保证了线程B的起始条件的正确的。

六、volatile变量定义在Java内存模型中的特殊规则

  1. 前面我们讲到JMM中内存的交互操作,如果变量用volatile修饰,JMM中有三条特殊的规则:
  2. 1、普通变量只要求loadread动作相关联,volatile变量要求useloadread三个动作相关联,意思就是想要用的变量,每次都是从主内存取的。
  3. 2、普通变量只要求storewrite动作相关联,volatile变量要求assignstorewrite三个动作相关联,意思就是有变化的变量,立即刷新到主内存中。
  4. 3use/assignload/storeread/ write这三组我们先认为是三段操作,假定一个线程T、两个变量VW,如果线程T对变量Vuse/assign先于对变量Wuse/assign操作,那么线程T对变量Vload/store也先于对变量Wload/store操作,意思就是线程对volatile变量的读操作(useloadread)或写操作(assignstorewrite)都会整套执行完,再去执行另一个volatile的整套读写操作,这条规则体现出来的特性就是禁止指令重新排序优化,保证代码的执行顺序与程序的顺序相同。

七、总结

  1. 1volatile是最轻量的同步方案最佳实践,但要注意使用的场景。
  2. 2JMM内存交互操作,以及volatile后的特殊规则是如何达到变量对所有线程可见性的实现原理。

来源《深入理解Java虚拟机》

发表评论

表情:
评论列表 (有 0 条评论,526人围观)

还没有评论,来说两句吧...

相关阅读

    相关 Java内存模型与volatile关键字

    Java内存模型(Java Memory Model,简称JMM)是Java程序中多线程并发访问共享内存的规范。它定义了线程如何与内存交互以及线程之间如何通过内存进行通信。其中