volatile底层实现原理详解

小伙伴都知道可以使用 volatile 达到保证可见性和指令重排的目的,对他的认知很多小伙伴也仅限于会用阶段,但是对其实现原理并不是很清楚,为了加深学习和理解今天写了这篇总结一下。

下面我们来段代码看一下:

  final static int MAX = 50;
	  static int value = 0;
	    
	    public static void main(String[] args) {
	    	//读数据线程
	    	 new Thread(()->{
	                int localValue=value;
	                while(localValue<MAX){
	                    if(localValue!=value){
	                        System.out.println("读取数据:"+value);
	                        localValue=value;
	                    }
	                }
	        }).start();
	    	//改数据线程
	        new Thread(()->{
	            int localValue=value;
	            while(localValue<MAX){
	                System.out.println("修改数据:"+(++localValue));
	                value=localValue;
	                try {
	                    TimeUnit.SECONDS.sleep(2);
	                } catch (InterruptedException e) {
	                    e.printStackTrace();
	                }
	            }
	        }).start();
	    }

从代码云校我们可以看到,当写线程将共享变量修改以后,读线程仍然进行死循环,没有输出,为什么会这样?

想知道 volatile 实现原理首先需要了下解JMM,JMM定义了Java 虚拟机(JVM)在计算机内存中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的

JMM 内存模型

java 内存模型规定所有变量都存储在主内存中,此处所指的变量是实例字段、静态字段等,不包含局部变量和函数参数,因为这两种是线程私有无法共享。

每条线程还有自己的工作内存,工作内存保存了该线程使用到的变量到主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在自己的工作内存中进行而不能直接读写主内存的变量,不同线程之间无法相互直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成。

工作内存和主内存的关系:
在这里插入图片描述
下面我们看一个变量的值改变需要经过哪些步骤:

  • 首先会执行一个read操作将主内存中的值读取出来
  • 执行load操作将值副本写入到工作内存中
  • 当前线程执行user操作将工作内存中的值拿出在经过执行引擎运算
  • 将运算后的值进行assign操作写会到工作内存
  • 线程将当前工作内存中新的值存储回主内存,注意只是此处还没有写入到主内存的共享变量,主内存中的值还没有改变
  • 最后一步执行write操作将主内存中的值刷新到共享变量,到此处一个简单的流程就走完了

工作内存和主内存交互的8大操作:

在这里插入图片描述

所以,开始的时候我们的代码是这样的:
在这里插入图片描述
代码优化以后是这样的:

在这里插入图片描述
那么 volatile 做了那些事呢?

每个线程操作数据的时候会把数据从主内存读取到自己的工作内存,如果他操作了数据并且写入了,那其他已经读取的线程的变量副本就会失效了,需要数据进行操作又要再次去主内存中读取。

volatile 保证不同线程对共享变量操作的可见性,也就是说一个线程修改了 volatile 修饰的变量,当修改写回主内存时,另外一个线程立即看到最新的值。

是不是看着加了 volatile 很简单,实际上它在背后做了很多的事,我们知道,CPU 的速度要比 CPU 访问内存的速度快几个数量级,为了解决两者速度不匹配问题,现代计算机系统架构在 CPU 与内存间加入了(多级)缓存。缓存的引入,提高了 CPU 与内存交互性能的同时,对于多核共享内存系统也带来了缓存一致性问题。

为了解决一致性的问题,前辈们制定了多种缓存一致性协议,需要各个处理器访问缓存时都遵循协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol等。

缓存一致性协议有MSI,MESI,MOSI,Synapse,Firefly及DragonProtocol等等,接下来我们主要介绍MESI协议。

MESI(缓存一致性协议)

缓存一致性协议用于管理多个 CPU 缓存之间数据的一致性,这里我们仅讨论 MESI 协议的四种状态:

在这里插入图片描述

MESI工作原理(单核 CPU)

1、CPU1 从内存中将变量 a 加载到缓存中,并将变量 a 的状态改为E(独占的),并通过总线嗅探机制对内存中变量 a 的操作进行嗅探

在这里插入图片描述

2、此时,CPU2 读取变量 a,总线嗅探机制会将 CPU1 中的变量 a的状态置为S(共享),并将变量 a 加载到 CPU2 的缓存中,状态为 S

在这里插入图片描述
3、CPU1 对变量 a 进行修改操作,此时 CPU1 中的变量 a 会被置为 M(修改)状态,而 CPU2 中的变量 a 会被通知,改为 I(无效)状态,此时 CPU2 中的变量 a 做的任何修改都不会被写回内存中(高并发情况下可能出现两个 CPU 同时修改变量 a,并同时向总线发出将各自的缓存行更改为 M 状态的情况,此时总线会采用相应的裁决机制进行裁决,将其中一个置为 M 状态,另一个置为 I 状态,且 I 状态的缓存行修改无效)

在这里插入图片描述
4、CPU1 将修改后的数据写回内存,并将变量 a 置为E(独占)状态

在这里插入图片描述
5、此时,CPU2 通过总线嗅探机制得知变量 a 已被修改,会重新去内存中加载变量 a,同时 CPU1 和 CPU2 中的变量 a 都改为 S 状态

在这里插入图片描述
在上述过程第 3 步中,CPU2 的变量 a 被置为 I(无效)状态后,只是保证变量 a 的修改不会被写回内存,但 CPU2有可能会在 CPU1 将变量 a 置为 E(独占)状态之前重新读取内存中的变量 a,这个取决于汇编指令是否要求 CPU2重新加载内存。

禁止指令重排序

什么是重排序?

在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,在单线程下有着一个 as-if-serial 的概念 。

as-if-serial 的概念就是: 不管怎么重排序(编译器和处理器为了提高并行度),单线程下的执行结果不能被改变。编译器、runtime和处理器都必须遵守 as-if-serial 语义。为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。

重排序的类型有哪些呢?

在这里插入图片描述

重排序分为如下 3 种:

  • 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  • 指令级并行的重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
  • 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的

那么在什么情况下会进行重排序呢?

public void sort() {
	int a = 8;// 1
	int b = 5;// 2
	a = a + 5;// 3
	b = a * a;// 4
}

在正常单线程环境,执行顺序是 1 2 3 4

但是在多线程环境下,可能出现以下的顺序:

2 1 3 4

1 3 2 4

上述的过程就可以当做是指令的重排,即内部执行顺序,和我们的代码顺序不一样。

这里需要说明指令重排也是有限制的,不会出现4 3 2 1,因为处理器在进行重排时候,必须考虑到指令之间的数据依赖性,因为步骤 4:需要依赖于 b 的申明,以及 a 的申明,故因为存在数据依赖,无法首先执行。

那么 JMM 是如何禁止重排序从而保证可见性的呢?

  • 对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)
  • 对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel称之为memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)

内存屏障

跟据上面的描述,为了保证内存可见性,java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。内存屏障是一组处理指令,用来实现对内存操作的顺序限制。

为了实现 volatile 的内存语义,JMM 会限制特定类型的编译器和处理器重排序,JMM 会针对编译器制定 volatile 重排序规则表:
在这里插入图片描述
注:volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障

volatile写

在这里插入图片描述
volatile读

在这里插入图片描述
为了提高处理速度,JVM 会对代码进行编译优化,也就是指令重排序优化,并发编程下指令重排序会带来一些安全隐患:如指令重排序导致的多个线程操作之间的不可见性。

如果让程序员再去了解这些底层的实现以及具体规则,那么程序员的负担就太重了,严重影响了并发编程的效率。

从 JDK5 开始,提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。

happens-before

如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

volatile域规则:对一个volatile域的写操作,happens-before于任意线程后续对这个volatile域的读。

happens-before 与 JMM 的关系如下图所示:
在这里插入图片描述

如上图所示,一个 happens-before 规则通常对应于多个编译器重排序规则和处理器重排序规则。对于 java 程序员来说,happens-before 规则简单易懂,它避免程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现。

  • 4
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值