【进阶之路】深入了解volatile、内存屏障与happens-before规则

1,380 阅读16分钟

大家好,我是练习java两年半时间的南橘,从一名连java有几种数据结构都不懂超级小白,到现在懂了一点点的进阶小白,学到了不少的东西。知识越分享越值钱,我这段时间总结(包括从别的大佬那边学习,引用)了一些平常学习和工作中的重点(自我认为),希望给大家带来一些帮助

这是之前的三篇关于JVM的文章,没看过的同学可以一起看一下

有需要的同学可以加我的公众号,以后的最新的文章第一时间都在里面,也可以找我要思维导图

大家都知道,在阿里巴巴泰山版开发手册中有这一段,在并发情况下使用延迟初始化的方法实现单例模式时,需要将目标属性声明为volatile。

volatile关键字在 Java 中的作用是保证变量的可见性防止指令重排

一、保证变量的可见性

在知道volatile是如何保证变量的可见性之前,我们先要知道内存不可见的两个原因:

1、CPU的运行速度是远远高于内存的读写速度的,为了不让CPU等待读写内存数据,现代CPU和内存之间都存在一个高速缓存cache(实际上是一个多级寄存器),如下图:

线程在运行的过程中会把主内存的数据拷贝一份到线程内部cache中,其实就是访问自己的内部cache。如果线程B把数据加载进内部缓存cache中,线程A再修改了数据。即使重新写入主内存,但是线程B不会重新从主内存加载变量,看到的还是自己cache中的变量,所以线程B是读取不到线程A更新后的值。

在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。volatile变量通过这样的机制就使得每个线程都能获得该变量的最新值。 但是,我们也都知道volatile只能保证可见性,不能保证原子性。多个线程同时读取这个共享变量的值,就算保证其他线程修改的可见性,也不能保证线程之间读取到同样的值然后相互覆盖对方的值的情况。

二、防止指令重排

我们再来看指令重排。

1、定义

指令重排是指在程序执行过程中, 为了性能考虑, 编译器和CPU可能会对指令重新排序

介绍指令重排之前,首先介绍一下内存交互操作的8种指令吧。虚拟机实现必须保证每一个操作都是原子的,不可再分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台上允许例外)

指令内容
lock (锁定)作用于主内存的变量,把一个变量标识为线程独占状态
read (读取)作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
load (载入)作用于工作内存的变量,它把read操作从主存中得到变量放入工作内存的变量副本中
use (使用)作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作
assign (赋值)作用于工作内存中的变量,它把一个从执行引擎中接受到的值赋值给工作内存的变量副本中,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
store (存储)作用于工作内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用
write  (写入)作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中
unlock (解锁)作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
如图所示:

既然操作可以被分解为很多步骤, 那么多条操作指令就不一定依次序执行,因为每次只执行一条指令, 依次执行效率太低了。就像小时候学习的煮饭烧水任务时间分配一样,内存也会很聪明的分配时间。

本来想给大家整一个指令重排序的例子的,但是不管是我自己写还是用别人的代码,我的电脑都没办法让它重排序。但是我们都知道,指令重排是确实存在的(CPU确实会进行重排序,但是这种重排序是无法被我们观测到和控制的)。

一般重排序可以分为如下三种:

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

2、原理

我们来看加了volatile前后的代码,用的就是阿里规约提供给我们的双重检查锁的代码。我们分别编译了两次,第一个是没有使用volatile关键字修饰的,第二个是使用volatile关键字来修饰,然后取出他们的的汇编代码(实在是设计的地方太底层,其实这里算是用到了策略模式了)

未使用volatile修饰

  0x000000010d29e93b: mov    %rax,%r10
  0x000000010d29e93e: shr    $0x3,%r10
  0x000000010d29e942: mov    %r10d,0x68(%rsi)
  0x000000010d29e946: shr    $0x9,%rsi
  0x000000010d29e94a: movabs $0xfe403000,%rax
  0x000000010d29e954: movb   $0x0,(%rsi,%rax,1) 
                                                

使用volatile修饰

  0x0000000114353959: mov    %rax,%r10
  0x000000011435395c: shr    $0x3,%r10
  0x0000000114353960: mov    %r10d,0x68(%rsi)
  0x0000000114353964: shr    $0x9,%rsi
  0x0000000114353968: movabs $0x10db6e000,%rax
  0x0000000114353972: movb   $0x0,(%rsi,%rax,1)
  0x0000000114353976: lock addl $0x0,(%rsp)    

很明显,在movb操作后,加了volatile修饰的汇编代码后面多了一条汇编指令lock addl $0x0,(%rsp),这个操作相当于一个内存屏障,指令重排时不能把后面的指令重排序到内存屏障之前的位置。lock前缀会强制执行原子操作,它的作用是是的本CPU的cache写入了内存,该写入动作会引起别的CPU无效化其cache。所以通过这样一个空操作,可让前面volatile变量的便是对其他CPU可见

从硬件架构上讲,指令重排序是指CPU将多条指令不按程序规定的顺序分开发送给各相应的点,但并不是指令任意重排,CPU需要能正确处理指令,以保障程序能得出正确的执行结果。lock addl $0x0,(%rsp) 指令把修改同步到内存时,意味着所有值钱的操作都已经执行完成,这样便形成了指令重排序无法越过内存屏障的效果。

三、内存屏障

既然指令重排和可见性都依赖了lock,同时lock指令引出了内存屏障,我们就来学习一下什么是内存屏障。

1、定义

内存屏障:保证屏障前的读写指令必须在屏障后的读写指令之前执行,通知被Volatile修饰的值,每次读取都从主存中读取,每次写入都同步写入主存。

内存屏障具体又分为写屏障和读屏障 写屏障(Store Memory Barrier):强制将缓存中的内容写入到缓存中或者将该指令之后的写操作写入缓存直到之前的内容被刷入到缓存中,也被称之为smp_wmb 读屏障(Load Memory Barrier):强制将无效队列(volatile写操作之后失其作废)中的内容处理完毕,也被称之为smp_rmb

屏障类型指令示例说明
LoadLoadBarriersLoad1;LoadLoad;Load2该屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作
StoreStoreBarriersStore1;StoreStore;Store2该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作
LoadStoreBarriersLoad1;LoadStore;Store2确保Load1的数据装载先于Store2及其后所有的存储指令刷新数据到内存的操作
StoreLoadBarriersStore1;StoreLoad;Load1该屏障确保Store1立刻刷新数据到内存的操作先于Load2及其后所有装载装载指令的操作.它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令

2、原理

内存屏障在Java中的体现

  • 1、volatile读之后,所有变量读写操作都不会重排序到其前面。
  • 2、volatile读之前,所有volatile读写操作都已完成。
  • 3、volatile写之后,volatile变量读写操作都不会重排序到其前面。
  • 4、volatile写之前,所有变量的读写操作都已完成。

根据JMM规则,结合内存屏障的相关分析得出以下结论

  • 1、在每一个volatile写操作前面插入一个StoreStore屏障。这确保了在进行volatile写之前前面的所有普通的写操作都已经刷新到了内存。
  • 2、在每一个volatile写操作后面插入一个StoreLoad屏障。这样可以避免volatile写操作与后面可能存在的volatile读写操作发生重排序。
  • 3、在每一个volatile读操作后面插入一个LoadLoad屏障。这样可以避免volatile读操作和后面普通的读操作进行重排序。
  • 4、在每一个volatile读操作后面插入一个LoadStore屏障。这样可以避免volatile读操作和后面普通的写操作进行重排序。

如下图所示:

3、as-if-serial语义

但是用了volatile关键字,程序的运行速度必然会受到影响,那么除了volatile关键字以外什么时候不会发生重排序呢?这里就要引入as-if-serial语义。

as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提供并行度),(单线程)程序的执行结果不能被改变。

如果两个操作访问同一个变量,且这两个操作有一个为写操作,此时这两个操作就存在数据依赖性这里就存在三种情况:1. 读后写;2.写后写;3. 写后读,者三种操作都是存在数据依赖性的。如果重排序会对最终执行结果会存在影响,编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序。

int a=1;
int b=2;
int c =a+b;

as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。比如上面计算的代码,在单线程中,会让人感觉代码是一行一行顺序执行上,实际上a,b两行不存在数据依赖性可能会进行重排序,即a,b不是顺序执行的。as-if-serial语义使程序员不必担心单线程中重排序的问题干扰他们,也无需担心内存可见性问题。

说到底,as-if-serial语义不过是一种最基础的架构定义,可以类比地球上氧气的比例约为21%。

重排序可以分为两类:

会改变程序执行结果的重排序。

不会改变程序执行结果的重排序。

JMM对这两种不同性质的重排序,采取了不同的策略。

  • 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
  • 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许进行优化重排序)

volatile就是通过对内存语义的封装实现了对volatile关键字读写时的顺序和可见。保证了我们所谓的多线程下的可见性,但是还是没办法保证多线程下修改数据的同步,因为同步除了有序和可见还需要满足原子性。

四、happens-before规则

在Java内存模型中,如果要确保有序性可以靠volatile和synchronized来实现,但是如果所有的有序性都仅仅依靠这两个关键字来完成,那么有一些操作将会变得很繁琐,但是我们在编写Java代码的时候并没有感觉到这一点,这是因为Java语言中有一个“先行发生(happens-before)”的原则。那么happens-before到底是什么呢?

happens-before的概念最初由Leslie Lamport在其一篇影响深远的论文(《Time,Clocks and the Ordering of Events in a Distributed System》)中提出。JSR-133(即JavaTM内存模型与线程规范,由JSR-133专家组开发)使用happens-before的概念来指定两个操作之间的执行顺序。

1、定义

happens-before表示的是前一个操作的结果对于后续操作是可见的,它是一种表达多个线程之间对于内存的可见性。所以我们可以认为在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作必须要存在happens-before关系。

具体的定义为:

  • 1、如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

  • 2、两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。

2、happens-before的8条规则

8条规则定义:

  • 1、程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。(在一个线程内一段代码的执行结果是有序的)
  • 3、监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。(先加锁后解锁)
  • 3、volatile变量规则:对于volatile修饰的变量的写的操作,一定happen-before后续对于volatile变量的读操作。(读写不会重排序,写操作的结果一定对读的这个线程可见)
  • 4、传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  • 5、start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  • 6、Join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
  • 7、程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。
  • 8、对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。

JMM对编译器和处理器的束缚已经尽可能少。从上面的分析可以看出,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。例如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再如,如果编译器经过细致的分析后,认定一个volatile变量只会被单个线程访问,那么编译器可以把这个volatile变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。

3、happens-before与JMM的关系

一个happens-before规则对应于一个或多个编译器和处理器重排序规则。对于Java程序员来说,happens-before规则简单易懂。

结语

这段时间为了写这篇文章也是看了很多的文章和视频,很多文章都互相有冲突,很多点我也没有办法保证完全正确,只能自己拿着JSR133的文档去看,看完了以后我推荐大家不要去看orz,真的是学的越多懂得越多不懂的也就更多了。不过学习如逆水行舟,不进则退,如果没有跳出舒适区(包括学一些很难的东西)的勇气,那学习这一条路也就到头了吧~一起加油吧!