java中的volatile详解

参考资料: 《深入理解java虚拟机》(周志明)

java中的volatile详解

  • 1. JAVA为什么要有一个volatile修饰符?
  • 2. 代码不符合预期的问题
    • 2.1 工作内存带来的`可见性`问题
      • 2.1.1 java内存模型
      • 2.1.2 主内存和工作内存的区分带来了什么问题?
    • 2.2 各种优化策略带来的`指令重排`问题
      • 2.2.1 什么是指令重排序
      • 2.2.2 指令重排序带来了什么问题?
  • 3. 怎么解决上面的问题?
    • 3.1 解决可见性的问题
    • 3.2 解决指令重排序带来的问题
  • 4. 为什么`volatile`能解决 可见性和指令重排序造成的问题
    • 4.1 详解`volatile`原理
      • 4.1.1 实现可见性的原理:
      • 4.1.2 禁止指令重排序的原理:

1. JAVA为什么要有一个volatile修饰符?

要说清楚volatile就是要说清楚下面3个问题:

    1. 没有volatile的程序会出现什么问题?
    1. volatile解决了什么问题?
    1. 为什么volatile能解决这些问题?

2. 代码不符合预期的问题

2.1 工作内存带来的可见性问题

2.1.1 java内存模型

预备知识: java内存模型JSR-133https://jcp.org/en/jsr/detail?id=133

java中的volatile详解_第1张图片

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中 (物理上对应L3缓存或内存或硬盘(虚拟内存))

每条线程还有自己的工作内存 (物理上可能是L1,L2高速缓存或者寄存器)

线程的工作内存中保存了被该线程使用的变量的主内存副本

线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。

不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成

  • 实际上JVM在操作变量的时候经常会有如下步骤
    1. 将变量的值从主内存加载到工作内存
    1. 再对工作内存中的变量进行操作,比如加减乘除
    1. 当我们操作结束之后,某个时刻写回到主内存
    • 也就是我们的赋值操作被拆分成了多条字节码指令,基本无法保证原子性和可见性
    • 修改的结果,其他线程可能不能立刻观测到

2.1.2 主内存和工作内存的区分带来了什么问题?

  • 每个线程有自己的工作内存
  • 导致了一个问题: 如果多个线程之间,是依赖于某个变量作为状态位来影响其他线程,那么状态位的更新不能保证立刻被其他线程观测到

直接代码举例:

  • 问题例子1:
private boolean isOpening = true;//成员变量

//线程1:
isOpening = false;
closeResources();//释放资源操作

//线程2:
if(isOpening){
    readResources();//读取资源操作
}
  • 此段代码,预期 在释放资源之前,用isOpening作为状态位来控制其他线程,禁止其他线程访问此资源readResources(),然后安全的释放资源closeResources()
  • 但是实际上程序可能是按照以下步骤进行的:
    1. 线程1 将isOpening变量拷贝到 线程1的工作线程中
    1. 线程1 将 工作线程中的 isOpening 修改为false
    1. 线程1 执行释放资源操作closeResources()
    1. 线程2 从主内存中读取 isOpening 变量,此时线程1的工作内存还没有写回到主内存中
    1. 线程2 判断isOpening == true,则开始执行readResources()
    1. 最终: 由于资源已经被释放,所以线程2读取失败报错,程序出现bug
    • 由于变量可见性导致的问题会非常的隐蔽,所以我们需要重视这个问题

2.2 各种优化策略带来的指令重排问题

2.2.1 什么是指令重排序

  • 为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化
  • 计算之后,将乱序执行的结果重组,保证该结果与顺序执行结果是一致的
  • 但不保证程序中各个语句计算的先后顺序与代码的顺序一致
  • Java虚拟机的即时编译器中也有指令重排序(Instruction Recorder)优化

下面举个例子:

private int a = 0;//成员变量a
private int b = 0;//成员变量b

//线程1
a = 1;
b = 2;
  • 在这里是a先被赋值还是b先被赋值,我们是不知道的,因为CPU会对指令进行重排序,以提高代码的执行效率

2.2.2 指令重排序带来了什么问题?

在单个线程中看似好像没有问题,但是在多线程环境下,就会出现问题了

举个例子:

  • 问题例子2:
private int a;//成员变量a
private int b;//成员变量b

//线程1
//假设这里的逻辑是先赋值a,再赋值b,当b赋值完成也就表示赋值操作完成
a = 1;
b = 2;

//线程2
if(b==2){
    //假设这里有个业务,认为赋值操作完成之后,要计算一个分数=a*10
    score = a * 10;
}
  • 这里出现了一个问题
  • 线程2,认为 给a,b的赋值操作是有顺序的,b=2会在最后完成
  • 但是事实上不一定,我们不能保证,a和b的赋值顺序
  • 线程2预期的是,b=2的时候a=1,score=10
  • 由于指令被重排序了,线程2依赖b==2做判断可能得不到正确的结果
  • 实际可能执行的过程是这样的:
    1. 线程1指令重排了,先执行了b=2
    1. 这时候线程1让出CPU时间片,改由线程2执行
    1. 线程2检测到b==2, 取出a=0 乘以10,得到score=0
    1. 线程2让出CPU时间片,线程1执行
    1. 线程1,给a赋值 a=1
    1. 最终: a=1,b=2,score=0

3. 怎么解决上面的问题?

3.1 解决可见性的问题

  • 解决2.1.2的问题:
private volatile boolean isOpening = true;//成员变量,是volatile变量

//线程1:
isOpening = false;
closeResources();//释放资源操作

//线程2:
if(isOpening){
    readResources();//读取资源操作
}
  • 代码和 2.1.2 中的例子几乎一模一样,只是isOpeningvolatile修饰了
  • JVM保证了,在写volatile修饰的变量的时候,会立刻同步到主内存中,并且将其他工作线程中的副本作废
  • 具体硬件上的实现,可以查一查MESI协议等缓存一致性协议
  • 由于isOpening被设置为volatile,状态位保证了线程之间的可见性;
  • 我们的代码可以保证在线程1执行到closeResources()的时候, 线程2是绝对不可能执行readResources()

3.2 解决指令重排序带来的问题

  • 解决 2.2.2 的问题:
private int a;//成员变量a
private volatile int b;//成员变量b,被设置成了volatile

//线程1
//假设这里的逻辑是先赋值a,再赋值b,当b赋值完成也就表示赋值操作完成
a = 1;
b = 2;//指令重排序屏障

//线程2
if(b==2){
    //假设这里有个业务,认为赋值操作完成之后,要计算一个分数=a*10
    score = a * 10;
}
  • 这里b被设置为了,volatile变量
  • volatile在这里存在的意义是:
  • volatileb = 2;这步操作的位置设置了一个指令重排序的屏障,他的意义是:
  • b = 2;这行代码之前的代码,随便重排序,但是一定不会影响到 b = 2;这行代码及其之后的代码
  • b = 2;这行代码之后的代码,随便重排序,但是一定不会影响到 b = 2;这行代码及其之前的代码
  • 这样就保证了 b = 2;这条语句一定发生在a = 1;之后
  • 也就解决了score的值可能算不准的问题

4. 为什么volatile能解决 可见性和指令重排序造成的问题

  • java内存模型为volatile专门定义了一些特殊的访问规则
  • 当一个变量被定义成volatile之后,它将具备两项特性:
    1. 保证此变量对所有线程的可见性,当一条线程修改了变量的值,新值对于其他线程来说是可以立即得知的
    1. volatile变量的操作,禁止指令重排序

4.1 详解volatile原理

4.1.1 实现可见性的原理:

  • 在对votalite修饰的变量进行赋值之后会立即执行一条指令lock addl $0x0, (%esp)
  • lock addl $0x0, (%esp): 把ESP计算器的值加0; 是一个空操作
  • 这里的关键在于lock前缀
  • 它的作用是将本地处理器的缓存写入内存,该动作会引起别的处理器或者别的内核的无效化其缓存(硬件上CPU可能使用了MESI之类的协议来实现)

4.1.2 禁止指令重排序的原理:

先聊一下,硬件层面上的指令重排序是什么:

以下引用《深入理解java虚拟机》(周志明)

指令重排序是 指处理器采用了允许 将多条指令 不按程序规定的顺序 分开发送给各个相应的电路单元 进行处理

但并不是说指令任意重排,处理器必须能正确处理指令依赖情况,保障程序能得出正确的执行结果

如: 指令1地址A 中的值加10,指令2地址A 中的值乘以2,指令3地址B 中的值减去3

这时指令1和指令2是有依赖的,他们之间的顺序不能重排

(A+10)*2A*2+10肯定不相等

指令3可以重排到指令1,2之前或者中间,因为指令3操作的是地址B,与地址A上的计算无关
只要保证处理器执行到后面,其他操作依赖到A、B值的操作时 能获取正确的A、B的值即可

所以在同一个处理器中,重排序过的代码,看起来依然是有序的

之前分析可见性的时候,我们知道了:

  • 在对votalite修饰的变量进行赋值之后会立即执行一条指令lock addl $0x0, (%esp)

关键来了:

lock addl $0x0, (%esp)指令把修改同步到内存时,意味着所有之前的操作已经完成了!

为了达到这个效果,CPU不会去将lock addl $0x0, (%esp)之前的指令重排到lock addl $0x0, (%esp)之后

这样便形成了指令重排序无法越过的内存屏障效果

总结:

  • 这里其实CPU要分析的是各个指令的互相依赖关系
  • 由于需要将工作内存的计算值,写入到主内存
  • CPU为了保证写入到主内存的值是正确的
  • 那么意味着lock addl $0x0, (%esp)执行的时候,之前的指令必须执行完毕
  • 自然的就会形成一个指令重排序的屏障
  • 防止了屏障前的指令,重排到屏障之后
  • 也防止了屏障后的指令,重排到屏障之前

你可能感兴趣的:(JVM学习)