【Java进阶营】Java技术专题-针对volatile可见性和有序性的思考

【Java进阶营】Java技术专题-针对volatile可见性和有序性的思考,第1张

前提概要

Java内存从逻辑上可划分主内存与工作内存,这类划分不同于JVM中堆、线程栈及PC计数器等这类划分,如果非要等同,可以认为主内存指的是堆,工作内存指的是线程栈。工作线程使用主内存中变量采用的是创建变量副本及回写主存的方式,经历的步骤有:

read 读取主内存变量值到工作内存中
load 加载至工作内存参数上进行复制工作内存变量
use 执行处理器在工作内存参数变量
assign 对变量重新赋值到工作内存变量
store 保存变量值到主存
write 更新主内存中变量值

以上每个步骤属于原子交易。

volatile关键词修饰的变量,能做到一个工作线程对变量的修改能在其它工作线程立即可见,如何做到这点的呢,采用的内存屏障机制

内存屏障机制

1. 主要在上面步骤3后面多加了read和load *** 作,也就是工作线程每次正要使用变量时重新从主内存快速读取一次(相当于刷新工作内存变量值)

2. 主要在上面步骤6后面多加了assign和store *** 作,保证了每次回显到主内存中的是最新变量值。

但由于整个流程并非一个原子性交易,所以不能完全保证线程安全。
可以从下面的例子体现中非线程安全:

public class VoliatieTest {

  static volatile int i = 0;

  public static void main(String[] args) {

   for(int m =0; m<2000; m++) {

     new Thread(){

      public void run(){

        for(int n=0; n<100;n++)

           i++;

            System.out.println(i);

         }

     }.start();

   }

 }

}

上面 i 的结果值不一定是200000,每次运行结果都可能不一样,这就是因为该交易非原子性交易带来的结果。

通过上面的描述,可以了解到了,内存屏障基本控制住了程序的可见性和一定范围的有序性,防止读写操作的冲突,接下来就要将重排序的控制原则,防止进行重排序和提高有序性的机制HB原则和As-If-Serial语义机制。

Happen-Before原则

JMM(Java 内存模型) 中的 happen-before(简称 hb)规则,该规则定义了Java多线程 *** 作的有序性和可见性,防止了编译器重排序对程序结果的影响当一个变量被多个线程读取并且至少被一个线程写入时,如果读 *** 作和写 *** 作没有HB关系,则会产生数据竞争问题。要想保证 *** 作B的线程看到 *** 作A的结果(无论A和B是否在一个线程),那么在A和B之间必须满足HB原则,如果没有,将有可能导致重排序。
HB 有哪些规则?

程序次序规则:一个线程内,按照代码顺序,书写在前面的 *** 作先行发生于书写在后面的 *** 作;
锁定规则:在监视器锁上的解锁 *** 作必须在同一个监视器上的加锁 *** 作之前执行。
volatile变量规则:对一个变量的写 *** 作先行发生于后面对这个变量的读 *** 作;
传递规则:如果 *** 作A先行发生于 *** 作B, *** 作B又先行发生于 *** 作C,则得出 *** 作A先行发生于 *** 作C;
线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作;
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
线程终结规则:线程中所有的 *** 作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

其中,传递规则我加粗了,这个规则至关重要。如何熟练的使用传递规则是实现同步的关键。在此我向大家推荐一个架构学习交流圈。交流学习指导伪鑫:1253431195(里面有大量的面试题及答案)里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化、分布式架构等这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多
然后,再换个角度解释HB:当一个 *** 作A HB *** 作 B,那么, *** 作A对共享变量的 *** 作结果对 *** 作B都是可见的。
同时,如果  *** 作 B HB  *** 作 C,那么, *** 作 A 对共享变量的 *** 作结果对 *** 作C都是可见的。

实现可见性的原理则是MESI Protocol和Memory Barrier。缓存一致性协议和内存屏障实现可见性。

Volatile可以做到什么?

volatile关键字修饰的变量可以保证该变量 *** 作的有序性和可见性。

Volatile不可以做到什么?

在讨论原子性 *** 作时,我们经常会听到一个说法:任意单个volatile变量的读写具有原子性,但是volatile++这种 *** 作除外。所以问题就是:为什么volatile++不是原子性的?

volatile关键字的原子 *** 作的介绍中大多用的是对int类型的变量进行自增 *** 作的例子介绍的, 那我们也索性就用这个例子来继续阐述吧。

private volatile int i = 0;
i++;

上边的代码中的变量i是被volatile修饰的,他在下边做i++ *** 作的时候在多线程环境中并不安全,不安全的原因就是i++这个 *** 作是非原子性的 *** 作。

如果在起比较多的线程,比如起了500条线程并发地去执行i++这个 *** 作最后的结果i是小于500的,解释是:

i++ *** 作可以被拆分为三步:

1,线程读取i的值
2、i进行自增计算
3、刷新回i的值

假设某一时刻i=5,此时有两个线程同时从主存中读取了i的值,那么此时两个线程保存的i的值都是5, 此时A线程对i进行了自增计算,然后B也对i进行自增计算,此时两条线程最后刷新回主存的i的值都是6(本来两条线程计算完应当是7)所以说volatile保证不了原子性。

看完以上的解释我的不解之处在于,既然i是被volatile修饰的变量,那么对于i的 *** 作应该是线程之间是可见的啊,就算A,B两个线程都同时读到i的值是5,但是如果A线程执行完i的 *** 作以后应该会把B线程读到的i的值置为无效并强制B重新读入i的新值也就是6然后才会进行自增 *** 作才对啊。
问题的自我解释

所以问题就是:为什么volatile++不是原子性的?

因为它实际上是三个 *** 作组成的一个符合 *** 作。

首先获取volatile变量的值
将该变量的值加1
将该volatile变量的值写会到对应的主存地址

例子:

如果两个线程在volatile读阶段都拿到的是a=1,那么后续在线程对应的CPU核心上进行自增当然都得到的是a=2,最后两个写 *** 作不管怎么保证原子性,结果最终都是a=2。每个 *** 作本身都没啥问题,但是合在一起,从整体上看就是一个线程不安全的 *** 作:发生了两次自增 *** 作,然而最终结果却不是3。在此我向大家推荐一个架构学习交流圈。交流学习指导伪鑫:1253431195(里面有大量的面试题及答案)里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化、分布式架构等这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多

结合内存屏障这个概念对volatile的读写 *** 作深入理解的话:
第一步

*** 作的指令后,会增加两个内存屏障:

在Volatile读 *** 作后插入LoadLoad屏障,防止前面的Volatile读与后面的普通读重排序
在Volatile读 *** 作后插入LoadStore屏障,防止前面的Volatile读与后面的普通写重排序

因此第一个指令和它后续的普通读写 *** 作会被保证没有重排序来捣乱。通常是去内存中去读。

那么问题又来了,为什么通常去内存中读?

其实这个问题要说细的话可以很细,大概就两个关键点吧:

volatile的写 *** 作的缓存失效机制
最后一个对volatile变量执行写 *** 作的CPU,由于在它对应的缓存中保有最新的值,因此可以不用再去主存里面获取

具体看下面第三步的分析。
第二步:自增

这个步骤没什么特别的,就是在CPU自身的高速缓存(寄存器,L1-L3 Cache)中完成。不涉及到缓存和内存的交互。
第三步:写

volatile写算是一个重点。

根据JMM对于volatile变量类型的语义规范:volatile在编译之后,会在变量写 *** 作时添加LOCK前缀指令。

这个LOCK前缀指令在多核处理器的环境中,有这样的作用:MESI的具体实现机制

通知CPU将当前处理器缓存行的数据写回到系统主存中。
该写回 *** 作将使其他CPU缓存了该内存地址的数据无效。

另外,内存屏障在volatile的写 *** 作中起到了很大的作用,来保证上面两点能够实现:

StoreStore 或者 StoreLoad 等那些指令主要木点是建立HB和As If Serial语义,防止指令重排,其实哪些指令主要就是帮助通过建立变量之间的关联以来关系从而达到“HB原则“和”As If Serial语义“。

在Volatile写 *** 作前插入StoreStore屏障,防止前面其他写与本次Volatile写重排序
在Volatile写 *** 作后插入StoreLoad屏障,防止本次的Volatile写与后面的读 *** 作重排序

经验总结
HB原则的实现方式主要由JMM中定义的内存屏障进行实现:

有序性:建立内存屏障指令从而建立参数关系从而防止指令重重排(防止与其他变量进行重排)。
可见性:建立内存屏障机制强制回写机制,比如在use指令之后自动填充(read、load),此外在write指令之后自动填充assgin和store指令获取最新的结果值。

Lock指令更多的是实现了MESI协议,重点在与多CPU处理器的情况下,

可见性:更多的是实现了相关的缓存锁的概念,从而促使多个CPU之间可以数据同步以及状态的同步(作了很多情况下的CPU之间的通信规则和状态更新规则),此部分其实也配合了内存屏障的强制回写内存机制做呼应。

最后梳理

MESI做为多CPU情况下的LOCK机制从而控制有序性和可见性(一般会在多CPU下运作)。

内存屏障实现的HB原则更多无论单CPU还是多CPU的情况下,都会运作保证有序性和可见性的特性存在。

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/langs/725622.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-04-26
下一篇 2022-04-26

发表评论

登录后才能评论

评论列表(0条)

保存