volatile涉及较多操作系统相关知识,本文仅简单讲述。如有兴趣请去阅读学习操作系统相关书籍。

Volatile简述

Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。

当一个变量被定义为volatile后,它将具备两项特性:
1、保证此变量对所有线程的可见性:这里的可见性是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
2、防止指令重排续:被volatile修饰的变量,在JVM中的赋值操作的顺序会与程序代码中的执行顺序一致。

背景知识描述

计算机原理性知识

什么是原子性

原子性就是一个操作或一系列操作在执行过程中不可中断。

由Java内存模型来保证的原子性变量操作包括:read、load、assign、use、store和write这六个。我们大致可以认为,基本数据类型的访问、读写都是具备原子性的(例外就是long和double的非原子性协定)

如果需要更大范围的原子性保证,Java内存提供了lock和unlock操作来满足这种需求。尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter、monitorexit来隐式的使用这两个操作。这两个字节码指令反应到java代码块中就是同步块-synchronized关键字。因此,在synchronized块之间的操作也具备原子性。

  1. public void test(){
  2. int a = 100;
  3. a = a ++;
  4. }
  5. // 反编译结果:
  6. 0 bipush 100 // push byte
  7. 2 istore_1 // Store int into local variable
  8. 3 iload_1 // Load int from local variable()
  9. 4 iinc 1 by 1 // increment local variable by constant
  10. 7 istore_1 // Store int into local variable(同理long对应lstore,float对应fstore)
  11. 8 return

反编译指令含义详解:The Java Virtual Machine Instruction Set
int a = 100;这句操作就是原子性的。(istore_1)
int a = a++这个操作不是原子性的,它在执行时会被拆分成三个动作:
1、读取a变量的值;(iload_1)
2、执行a++操作;(iinc 1 by 1)
3、将计算后的结果赋值给a变量。(istore_1)

修正一个经常被理解错误的概念:volatile变量的运算在并发下是线程安全的。举个例子:
1、A线程修改完数据后,同步到了主存。int a = 0;
2、B、C线程同时从主存获取到了该数据的最新值。
3、B、C线程同时对该数据进行+1运算。a = a + 1;
4、B、C线程同时将该值写入到主存中。
结果可想而知:该值最终结果有可能为1。所以我们可以得到结论:volatile变量的运算在并发下一样是不安全的。

什么是可见性

可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。

Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。无论是普通变量还是volatile变量都是如此。普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此我们可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。

除了volatile之外,Java还有两个关键字也可以实现可见性:synchronized和final。

  • volatile的可见性则像上面所说,写后同步到主存,读前去主存刷新最新值。
  • 同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主存中“这条规则保证。
  • final可见性是指被final修饰的变量在构造器完成,且构造器没有把”this”的引用传递出去,那么其他线程也能看见final变量的值。

什么是有序性

Java内存模型的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内似表现为串行的语句”,后半句是指“指令重排续”现象和“工作内存与主内存同步延迟”现象。

Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排续的语义,而synchronized则是由“一个变量在同一时刻只允许一个线程对其进行lock操作”这个规则获得。而这个规则决定了持有同一个锁的两个同步块只能串行的进入。

程序的有序性或是靠synchronized、volatile这种源于保证,或是通过happens-before原则进行保证。

什么是指令重排序

Java语言规范JVM线程内部维持顺序花语义,即只要程序的最终结果与它顺序化情况的结果相等那么指令的顺序可以与代码逻辑顺序不一致,这个过程就叫做指令重排续。它的意义在于:使指令更加符合CPU的执行特性,最大限度的发挥机器的性能,提高程序的执行效率。

指令重排续主要分为三种:
1、编译器重排续:JVM中进行
2、指令级并行重排续;
3、处理器重排续:CPU中进行

as-if-serial语义的意思是:不管怎么进行重排续,单线程内程序的执行结果不能被改变。编译器、处理器进行指令冲排序都必须要遵守as-if-serial语义规则。为了遵循这个语义规则,编译器和处理器对存在依赖关系的操作,都不会对其进行重排续,因为这样的重排续很可能会改变执行的结果。但是对于不存在依赖关系的操作,就有可能进行重排续。

我们创建一个对象时,JVM中具体是怎么操作的?Object o = new Object()
1、通过new Object()方法创建对象,在堆区域为其分配存储空间。
2、为该对象的成员变量设置默认值(0或null)。
3、建立堆栈的链接。
4、如果该类有继承,去调用父类的构造器。
5、执行成员变量的初始化方法。
6、调用构造器,完成类的完整初始化。

其中,2和3是两个没有关联的操作,并且不会影响最终结果,所以是可以乱序执行的。

什么是先行发生原则(happens-before)

如果Java内存模型中所有的有序性都靠volatile和synchronized来完成,那么很多操作都会变得很啰嗦。但是我们在编写Java并发代码时并没有察觉,是因为Java语言有一个先行发生(happens-before)原则。这个原则是判断数据是否存在竞争、线程是否安全的非常有用的手段。

happens-before原则是:Java内存模型中,定义两项操作之间的偏序关系。其实就是说:在发生操作B之前,操作A产生的影响能被操作B观察到。“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。

下面是Java内存模型下一些“天然的”happens-before原则。这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出,则他们没有顺序性保障,虚拟机可对他们随意进行重排续。

  • 程序次序规则(Program Order Rule):同一个流程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。
  • 管程锁定规则(Monitor Lock Rule):一个unlock操作发生于后面对一个锁的lock操作。这里必须强调是同一个锁,而后面是指时间上的先后。
  • volatile变量规则(Volatile Variable Rule):对一个变量的写操作先行发生于后面对这个变量的读操作。这个“后面”同样是时间上的先后。
  • 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
  • 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止监测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段监测线程是否已经终止执行。
  • 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码监测到中断事件的发生,可以通过Thread::interrupted()方法监测到是否有中断发生。
  • 对象终结规则(Finalizer Rule):一个对象的初始化过程(构造函数执行结束)先行发生于它的finalize()方法的开始。
  • 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那么可以得出操作A先行发生于操作C的结论。

操作系统相关知识

相关CPU术语

相关CPU术语定义如下所示:
Volitile原理剖析 - 图1

CPU与主存和IO之间的交互如下图:
Volitile原理剖析 - 图2

CPU(多核、多颗、超线程)

单CPU结构

Volitile原理剖析 - 图3

我们从左往右,从上往下依次介绍:
PC:程序计数器Program Counter。存放下一条要执行的指令地址。CPU执行完当前指令后会根据指令地址获取指令信息。
Registers:寄存器。CPU暂时存放数据的地方,里面保存等待处理的数据或已处理过的数据。
ALU:运算器Arithmetic Logic Unit。数据放到寄存器后,使用ALU来运算数据。运算结束后将数据写回到寄存器,寄存器再将数据写回到内存。
L1:一级缓存。位于CPU内。如果Registers内未命中所需数据,CPU会去L1缓存查询。准确的说每个核上有两个L1 Cache,一个存数据L1 d-cache,一个存指令L1 i-cache)
L2:二级缓存。如果L1一级缓存未命中,去会L2二级缓存进行查询。
L3:三级缓存。如果L2二级缓存未命中,会去L3三级缓存进行查询。若还未命中,则去主存加载数据。
BUS:总线。包括数据总线DB(Data Bus)、地址总线AB(Address Bus)、控制总线CB(Control Bus)。数据总线用来传输数据信息,地址总线用来传输CPU发出的地址信息,控制总线用来传送控制信号、时序信号、状态信息等。

CPU的工作原理指令指针通知CPU将要执行的指令放置在内存中的存储位置。将指令取出后,通过地址总线将指令送到控制单元中指令译码器从指令寄存器中获取到指令,翻译成CPU可以执行的形式,然后决定完成该指令需要哪些必要的操作,通知ALU什么时候计算,通知指令读取器什么时候获取数据,通知指令译码器什么时候翻译指令。数据将会执行指令中规定的算术运算和其他运算。当数据处理完毕后,将回到寄存器中,通过不同的指令将数据继续运行或通过DB总线送到数据缓存器中。

多个CPU结构(性能较差,成本较低)

多个CPU就是将多个CPU物理的安装在主板或其他设备上。各个CPU拥有自己独立的运算结构和缓存。当并发执行两个线程,并且两个线程间需要共享数据时,那么数据的同步流程大概会是这样:

A-CPU -> A-缓存 -> 主板芯片(总线) -> B-缓存 -> B-CPU

相同数据会在两个CPU的各级缓存中同时存在,而且需要通过总线进行数据的共享,存在较大的性能开销。

多核CPU结构(性能最好,成本最高)

我们大概了解了CPU简单几个组件的功能。现在我们看下单CPU的多核架构,即单个CPU内,集成了多个核心Core。
Volitile原理剖析 - 图4
如图所示,每一个核心都是可以独立运行指令的单元,每个独立单元包含了PC(程序计数器)Registers(寄存器)ALU(运算器)L1(一级缓存)。至于L2二级缓存,有的CPU选择将L2二级缓存集成在核心Core内,有的则是多个核心Core共享一个L2二级缓存。同时它们共享了三级缓存和总线等资源。

相比于多个CPU,多核CPU共享缓存,减少了线程间数据同步的总线开销,提高了执行效率。

CPU的超线程

超线程(hyper-threading)就是同时多线程(simultaneous multi-threading),是一项允许一个CPU执行多个控制流的技术。他的原理很简单,就是把一颗CPU当成两颗来用,将一颗具有超线程功能的物理CPU变成两颗逻辑CPU,而逻辑CPU对操作系统来说,跟物理CPU没有区别。因此,操作系统会把工作线程分派给两颗(逻辑)CPU上执行,让应用程序的多个线程能够同时在同一颗CPU上被执行。注意:两颗逻辑CPU共享单颗物理CPU的所有执行资源。因此,我们可以认为,超线程技术就是对CPU的虚拟化。超线程技术的实现是通过在单个CPU中继承两个逻辑处理单元实现的。

超线程状态下,CPU虚拟化出多个PC(程序计数器)、Registers(寄存器)。计算流程模拟如下:

线程A -> Registers-A存储数据 ->PC-A获取指令地址 -> 通过ALU计算 -> PC获取并存储下条指令地址 -> Registers-A存储计算后的数据 -> 切换线程B -> Registers-B存储数据 -> PC-B获取指令地址 ->通过ALU计算 … ->切换回线程A ….

这样,不需要数据同步,仅仅需要切换寄存器和程序计数器就可进行快速的运算。同时,缺点在于可能会造成多级缓存的命中率降低,频繁的过期旧数据写入新数据。

计算机存储器的层级结构

Volitile原理剖析 - 图5

CPU在缓存中找到有用的数据被称为命中。当缓存中没有CPU所需要的数据时(此时称为未命中),CPU才访问内存。理论上,在一颗拥有二级缓存的CPU中,读取一级缓存的命中率为80%,也就是说CPU以及缓存中找到的有用数据占数据总量的80%左右(从二级缓存读到有用数据占总数据的16%)。那么还有的数据不得不从内存调用。目前较高端的CPU中还会带有三级缓存。它是为了读取二级缓存后未命中的数据设计的一种缓存,在拥有三级缓存的CPU中,只有约5%的数据需要从内存中调用。进一步提高了CPU的效率。

为了保证CPU访问时有较高的命中率,缓存中的内容应该按一定的算法替换。一种较常用的算法是“最近最少使用算法”(LRU算法),它是将最近一段时间内最少被访问过的行淘汰出局。因此需要为每行设置一个计数器,LRU算法是把命中行的计数器清零,其他各行计数器加1当需要替换时淘汰行计数器计数值最大的数据行出局。这是一种高效、科学的算法,其计数器清零过程可以把一些频繁调用后再不需要的数据淘汰出缓存,提高缓存的利用率。

线程间内存的相互操作

Java内存模型规定,所有变量存储在主内存中。每条线程有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的内存副本,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方内存中的变量,线程间变量值的传递需要通过主内存来完成,线程、主内存、工作内存三者的交互如图:

Volitile原理剖析 - 图6

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如果从工作内存同步回主内存这一类的实现细节,Java内存模型中定义了以下8种操作来完成。Java内存模型实现时,必须保证下面提及的每一种操作都是原子的、不可再分的。(对于double和long类型的变量,load、store、read、write操作在某些平台上允许有例外。)

Volitile原理剖析 - 图7

如果要把一个变量从主内存拷贝到工作内存,那就要按照顺序执行read和laod操作,如果要把变量从工作内存同步回主内存,就要按顺序执行store和write操作。Java内存模型只要求上述两个操作必须按顺序执行,但是不要求连续执行。也就是说read和load之间、store和write之间可以插入其他指令。此外,Java内存模型还规定了执行上述8种操作时必须满足以下规则:

  • 不允许read和load、store、write操作之一单独出现,即不允许一个变量从主内存读取了单工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现;
  • 不允许一个线程丢弃它最近的assign操作。即变量在工作内存中改变了之后必须把该变化同步回主内存。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。换句话说就是对一个变量实施use、store操作之前,必须先执行assign和load操作。
  • 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值。
  • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。

什么是缓存行

缓存行(cache line)是CPU操作缓存的基本(最小)单位,大小为2的整数幂个连续字节,是常见的是64字节大小。

Volitile原理剖析 - 图8

如图所示,CPU处理器为了提高处理效率,不会与主存直接通讯,而是通过将需要的数据读取至缓存。而每次读取时,都是已缓存行纬度进行。读取数据后,对数据进行计算。操作成功后,再将数据写入缓存行同时写回主存。

缓存行的伪共享问题

如上图所示。X、Y存在于同一缓存行。此时,一个线程要修改数据X,进入CPU1,另一个线程要修改Y,进入CPU2。

CPU1和CPU2同时获取到了该缓存行的信息。当CPU1对X完成修改写入内存后,缓存子系统(???)会使CPU2对应的缓存行失效,致使CPU2跨越L1、L2、L3、总线重新获取该缓存行数据。CPU2获取成功后重新修改Y,那CPU1对应的缓存行又失效了。这样,CPU1又要跨越多层缓存去获取最新的缓存行数据。大大影响了性能。

伪共享问题解决:
在Java中,会看到Disruptor消除这个问题。

  1. public long p1, p2, p3, p4, p5, p6, p7; // cache line padding
  2. private volatile long cursor = INITIAL_CURSOR_VALUE;
  3. public long p8, p9, p10, p11, p12, p13, p14; // cache line padding

什么是缓存一致性协议

MESI简述

MESI(Modified Exclusive Shared Or Invalid):一种广泛使用的支持写回策略的缓存一致性协议。

协议规定,内存的每个缓存行都有四种状态(使用额外的两位(bit)标识):M(Modified被修改)E(Exclusive独享)S(Shared共享)I(Invalid失效)

M(Modified被修改):该缓存行只会存在于当前CPU的缓存中。该缓存行从主存中读取后,被修改操作。在未来的某个节点写回主存并被标识为E(Exclusive独享)状态。
E(Exclusive独享):该缓存行只会存在于当前CPU的缓存中。该缓存行从主存中读取后未被修改。当有其他CPU读取该缓存时,缓存行状态会被标识为S(Shared共享)状态。
S(Shared共享):该缓存行存在于多个CPU缓存中,并且各个缓存中的数据与主存一致。当有一个CPU修改该缓存行时,其他CPU中该缓存行的状态被标识为I(Invalid失效)
I(Invalid失效):该缓存行时无效的。
Volitile原理剖析 - 图9

CPU相关消息机制(请求动作):
Volitile原理剖析 - 图10

综上所述,在发送命令后,不光需要等待最新数据的返回或其他CPU将数据置为无效,还要等待其他CPU返回ack才可继续执行其他指令。这种同步行为是对CPU资源的极大浪费。

MESI优化升级 - Store Bufferes

我们重新梳理下上述执行流程:
1、修改数据:如果需要修改本地缓存的数据,就必须将无效状态(I)同步至其他CPU缓存中,并且等待确认;
2、读取数据:在读取数据时,会主动向其他CPU或内存发送指令请求获取数据。在获取到ack后,会对当前缓存行的数据进行状态标记;

引入Store Bufferes后,处理器将想要写回缓存的值暂存到Store Buffers中,然后继续执行其他业务。当其他CPU回复ACK后,再将Store Buffer中的数据提交至缓存。

但是,Store Buffers作为CPU的缓存前的暂存,空间容量有限。而且如果后续逻辑涉及到cache miss,需要更新缓存中的数据,就需要等待Store Buffers清空后才能继续处理。

尤其是执行了内存屏障后,不管本地缓存是否cache miss,只要Store Buffer中还有数据,所有的写入变更都要进入Store Buffer。会在成CPU的空等(stall)现象。[这块儿我没太理解]

只能再引入了失效队列Invalidate Queue的概念。Invalidate Queue的作用时把需要失效的数据物理地址存储起来。根据这个物理地址,我们可以对缓存行的失效进行延后执行

Volitile原理剖析 - 图11

整理梳理下我对这块儿的理解。
前提:CPU会一直监听总线数据状态变更(MESI)的消息。
1、CPU修改数据后,会将数据写入到Store Buffer,同时发送Invalidate消息到总线。
2、其他CPU监听到消息后,将数据内存地址放入Invalidate Queue并返回Invalidate Acknowledge。(注意,并没有cas修改缓存行的状态为I)
3、CPU收到Invalidate Ack后,会将数据由Store Buffer刷新回缓存。然后等待Invalidate Queue被执行后,相关失效数据所对应的内存行状态才会真正被CAS修改为Invalidate状态。

这样,就产生了一个问题:在Invalidate Queue被执行前,其他CPU读取该数据的顺序是先扫描Store Buffer,再读取缓存。这样就产生了脏读问题。MESI协议可以保证缓存的一致性,但是无法保证实时性。

详细可参考引用文章1【CPU缓存和volatile】

什么是内存屏障

添加Invalidate Queue后引起的数据不一致的问题我们已经了解了。解决办法就是通过内存屏障来解决,简言之就是通过CPU指令实现对内存操作的顺序限制。
smp_mb:内存屏障指令。一旦CPU执行到此命令,CPU首先将本地已存在的Invalidate Queue全部标记,强制要求CPU随后的所有读操作必须等待已被标记的Invalidate Queue真正应用至缓存后,才能执行后续逻辑。这样会带来一定程度的性能损耗。
smp_mb的语义相比来说较重。即包含了Store Buffer的flush,又包含了Invalidate Queue等待环节。但是现实情况下,我们可能只需要与其中一个过程交互。于是CPU设计者将smp_mb屏障分拆为两个:smp_rmb:为读屏障,smp_wmb:为写屏障。

smp_rmb:执行后需等待 Store Buffer 中的写入变更 flush 完全到缓存后,后续的写操作才能继续执行,保证执行前后的写操作对其他 CPU 而言是顺序执行的;

smp_wmb:执行后需等待 Invalidate Queue 完全应用到缓存后,后续的读操作才能继续执行,保证执行前后的读操作对其他 CPU 而言是顺序执行的;

JVM 是如何实现自己的内存屏障的?抽象上看 JVM 涉及到的内存屏障有四种:
Volitile原理剖析 - 图12

JVM 是如何分别插入上面四种内存屏障到指令序列之中的呢?这里的设计相当巧妙。对于 volatile 读 or monitor enter

  1. int t = x; // x 是 volatile 变量
  2. [LoadLoad]
  3. [LoadStore]
  4. <other ops>

对于 volatile 写 or monitor exit

  1. <other ops>
  2. [StoreStore]
  3. [LoadStore]
  4. x = 1; // x 是 volatile 变量
  5. [StoreLoad] // 这里带了个尾巴

详细可参考引用文章1【CPU缓存和volatile】

Volatile实现原理

volatile是如何保证可见性?让我们在x86处理器下通过工具获取JIT编译器生成的汇编指令来查看对volatile进行写操作时,CPU会做什么事情。

  1. // instance是volatile变量
  2. instance = new Singleton();

转换成汇编代码如下:

  1. 0x01a3deld: movb $0x0,0x1104800(%esi);0x01a3de24: lock add1 $0x0,(%esp);

对Lock指令的描述,有如下两个版本:

V1:有volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,通过查IA-32架构软件开发者手册得知,Lock前缀的指令在多核处理器下会引发两件事: 1、将当前处理器缓存行的数据写回到系统内存; 2、这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。

V2:lock用于在多处理器中执行指令时对共享内存的独占使用。它的副作用是能够将当前处理器对应缓存的内容刷新到内存,并使其他处理器对应的缓存失效。另外还提供了有序的指令无法越过这个内存屏障的作用。 简单来说,这句指令的作用就是保证了可见性以及内存屏障。

  • 执行 a 的写操作后执行到 StoreLoad 内存屏障;
  • 发出 Lock 指令,锁总线 或 a 的缓存行,那么其他 CPU 不能对已上锁的缓存行有任何操作;
  • 让其他 CPU 持有的 a 的缓存行失效;
  • 将 a 的变更写回主内存,保证全局可见;

上面执行完后,该 CPU 方可执行后续操作。

综上所述,volatile实现全局可见的底层,就是通过内存屏障。
但是,volatile不具有原子性,所以线程安全还需要通过锁来进行保证。锁能保证CPU在同一时间独占某个缓存行,只有在锁被释放后,其他CPU才能访问该缓存行。

其他引用:

1、cpu缓存和volatile
2、内存屏障的来历
3、面试打怪升升级-被问烂的volatile关键字,这次我要搞懂它(深入到操作系统层面理解,超多图片示意图
4、并发编程-(4)-JMM基础(总线锁、缓存锁、MESI缓存一致性协议、CPU 层面的内存屏障