什么是Java内存模型?JMM (Java Memory Model,Java 内存模型),它定义了多线程访问Java内存的规范。
简单的说有以下几部分内容:
- Java 内存模型将内存分为主内存和工作内存
- 定义了几个原子操作,用于操作主内存和工作内存中的变量
- 定义了volatile变量的使用规则
- happens-before,即,定义了操作A必然先行发生于操作B的一些规则,比如在同一个线程内控制流前面的代码一定先行发生于控制流后面的代码、一个释放锁unlock的动作一定先行发生于后面对于同一个锁进行锁定lock的动作等等,只要符合这些规则,则不需要额外做同步措施,如果某段代码不符合所有的happens-before规则,则这段代码一定是线程非安全的。
Java 为了保证其平台性,使Java应用程序与操作系统内存模型隔离开,需要定义自己的内存模型。在JMM中,内存分为主内存和工作内存(working memory)两个部分,其中主内存是所有线程所共享的,而工作内存则是每个线程分配一份,各线程的工作内存间彼此独立、互不可见(线程内共享,线程外独立)。线程对于变量的所有读写操作都必须在工作内存中进行,而不能直接读写主内存中的变量,同时不同线程之间无法直接访问,线程间值的传递均需要通过主内存来完成。某个线程运行时,内存中的一份数据会存在于该线程的工作内存中,并在某个特定时间(比如线程执行完毕后)回写到主内存中去。在线程启动的时候,虚拟机为每个内存分配一块工作内存,不仅包含了线程内部定义的局部变量,也包含了线程所需要使用的共享变量(非线程内构造的对象)的副本,即为了提高执行效率,读取副本比直接读取主内存更快(这里可以简单地将主内存理解为虚拟机中的堆,而工作内存理解为栈,栈是连续的小空间、顺序入栈出栈,而堆是不连续的大空间,所以在栈中寻址的速度比堆要快很多)。同时,JMM 还定义了一系列工作内存和主内存之间交互的操作及操作之间的顺序的规则。
需要知道的是,“主内存”、“工作内存”是cache和寄存器的一个抽象,有不少人觉得 working memory 是内存的一部分,但其实并不是。在涉及JSR-133的几个规范中,都不存在本地内存的概念,本地内存的概念以及它的描述,是Brian Goetz写的一篇关于JSR-133内存模型的文章《Java theory and practice: Fixing the Java Memory Model, Part 2》提出来的,Brian Goetz通过使用本地内存这个概念来抽象CPU、内存系统和编译器的优化。
虚拟机会为每个线程开辟一块虚拟机栈,默认大小1M,这是实实在在占用java进程内存的每个线程专属的本地内存。本地内存占用主要有3个方面:
- 一个线程就是一个对象,对象本身会占用内存空间;
- 创建线程(即Thread实例)的时候,JVM会为每个这样的对象额外分配两个线程调用栈(callstack)所需的内存空间,一个用于跟踪Java方法调用,另一个用于跟踪本地(native)方法调用);
- 线程运行过程中可能持有的对其它对象的引用。只要这些线程没有终止,那么这些被引用的对象就无法被垃圾回收。
一、Happens-Before
Java代码底层的执行并不像我们看到的高级语言----Java程序那么直观,它执行的是从Java代码-->编译成字节码-->根据字节码执行对应的C/C++代码-->被编译成汇编语言-->和硬件电路交互。现实中,为了获取更好的性能JVM大多会对指令进行重排序。为什么会这样呢?
相比于工作内存的存取,CPU的计算的速度还是要快很多,那么CPU在数据存取过程中意味着CPU将一直空置,这是一种极大的浪费。现代的CPU设计了很多寄存器、多级cache,它们是置于CPU内部的高速存储,比内存的存取速度要高很多。
在执行程序时为了性能,编译器和处理器会对指令做重排序。重排序分两个层面:
- 在虚拟机层面,为了尽可能减少内存操作速度远慢于CPU运行速度所带来的CPU空置的影响,虚拟机会按照自己的一些规则将程序编写顺序打乱——即写在后面的代码在时间顺序上可能会先执行,而写在前面的代码会后执行——以尽可能充分地利用CPU计算资源。举个例子来说:
int a=new byte[1024*1024]; // 分配1M空间
boolean flag=false;
第一条语句它会运行地很慢,此时CPU是等待其执行结束呢,还是先执行后面的语句呢?显然,先执行后面的语句可以提前使用CPU,加快整体效率,而且这样的前提是不会产生错误。虽然这里有两种情况:后面的代码先于前面的代码开始执行;前面的代码先开始执行,但当效率较慢的时候,后面的代码开始执行并先于前面的代码执行结束。不管谁先开始,总之后面的代码在一些情况下存在先结束的可能。
- 在硬件层面,CPU会将接收到的一批指令按照其规则重排序,同样是因为CPU速度比缓存速度快的原因,和上一点的目的类似,只是硬件处理的话,每次只能在接收到的有限指令范围内重排序,而虚拟机可以在更大层面、更多指令范围内重排序。
基于上面这两个层面,重排序是编译器或运行时环境(主要是处理器)为了优化程序性能而采取的对指令进行重新排序执行的一种手段。通常,JMM将需要的重排序分为两类:会改变程序执行结果的重排序,不会改变程序执行结果的重排序。对应这两种情况,JMM又采用了不同的策略:对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序,对于不会改变程序执行结果的重排序,JMM对编译器和和处理器不做要求。对上述两个策略,我们可以简单理解为:只要不改变程序的执行结果,编译器和处理器可以随意优化。
我们分三种类型来理解:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下的重排序;
- 指令级并行的重排序。现代处理器采用了指令级并行技术(ILP)来将多条指令重叠执行。如果不存在数据依赖性,允许处理器对机器指令进行重排序。
- 内存指令的重排序。处理器使用缓存和读/写缓冲区,基于处理器的流水线优化,CPU会再次对指令(加载和存储操作)乱序执行。
※ 指令级并行(Instruction-Level Parallelism, ILP)是指处理器将多条指令重叠执行以提高性能,这种重叠称之为指令级并行。一个处理器支持的指令和指令的字节级编码称之为指令集体系结构(Instruction Set Architecture, ISA)
从Java源代码到最终实际执行的指令序列,会经历下面的重排序:
这些重排序可能会导致多线程程序出现内存可见性问题。从JDK5开始,Java使用新的JSR-133内存模型。JSR-133提出了happens-before的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。
happens-before 偏序关系,也叫先行发生原则,它定义了操作A必然先行发生于操作B的一些规则,比如在同一个线程内控制流前面的代码一定先行发生于控制流后面的代码,一个释放锁 unlock 的动作一定先行发生于后面对于同一个锁进行锁定 lock 的动作等,只要符合这些规则,则不需要额外做同步措施,如果某段代码不符合所有的 happens-before 规则,则这段代码一定是非线程安全的。
happens-before 的图形化表示:
上图中,黑色箭头表示程序顺序规则,橙色箭头表示监视器锁规则,蓝色箭头表示组合这些规则后提供的happens before保证。上图表示,线程A在释放锁之前所有可见的共享变量,在线程B获取同一个锁之后,将立刻变得对B线程可见。也就是编号2 happens before 编号5。
happens-before 与 JMM 的关系:
具体的 happen-before 定义如下:
1)如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前 。
2)如果两个操作之间存在 happens-before 关系,这并不意味着 Java 平台的具体实现必须要按照happens-before 关系指定的顺序来执行。
happens-before 总共有六条规则:
- 程序次序规则(Program Order Rule):在一个线程内,按照代码顺序,书写在前面的操作 happens-before 于书写在后面的操作;
- 监视器锁定规则(Monitor Lock Rule):一个unlock操作 happens-before 于随后对这个对象锁的lock操作;
- volatile变量规则(Volatile Variable Rule):对一个 volatile 域的写 happens-before 于对这个域的读;
- 线程启动规则(Thread Start Rule):线程的start方法 happens-before 于此线程的每一个动作;
- 线程终止规则(Thread Termination Rule):线程中的每个操作都 happens-before 于对此线程的终止检测;
- 线程中断规则(Thread Interruption Rule):对线程interrupte()方法的调用优先于被中断线程的代码检测到中断事件的发生;
- 对象终结原则(Finalizer Rule):一个对象的初始化完成 happens-before 于它的finalize()方法的开始;
- 传递性(Transitivity):如果A happens-before B,B happens-before C,那么A happens-before C;
- 如果线程A执行线程B的join方法,那么线程B的任意操作 happens-before 于线程A从TreadB.join()方法成功返回。
※ 对于Thread Start Rule规则,你可以理解为:如果线程A执行线程B的start(),那么线程A的写先行发生于线程B的任意操作。对于Thread Termination Rule规则,你可以理解为:如果线程A执行线程B的join(),那么线程B的写操作先行发生于线程A从TreadB.join()方法成功返回。
重排序的发生是不确定的(有可能发生,也有可能不发生)。只要是单线程程序或正确同步的多线程程序,程序员就不需要担心会受到重排序的干扰。
“as-if-serial”语义:无论如何重排序,单线程内的程序执行结果不会被改变。编译器和处理器都必须遵守“as-if-serial”语义。与“as-if-serial”语义不同,happens-before 是保证正确同步的多线程内的程序执行结果不会发生改变。
比较容易误解的是,对于一个java的具体实现来说,当两个操作之间具有happens-before关系时,java的具体实现可以不按照这个happens-before关系指定的顺序来执行。如果重排序之后执行产生的结果,与程序按happens-before指定的顺序执行产生的结果一致,那么,这种重排序行为在JMM看来并不非法。简而言之,JSR-133规范中的happens-before关系指定的是两个操作之间的指令执行顺序,但java的具体实现在保证程序正确性(程序的执行结果不被改变)的前提下,可以不按照这个指定的顺序来执行这两个操作。这恰恰说明:JSR-133内存模型从表面上来看,关注的是操作之间的执行顺序(happens-before指定的是操作之间的执行顺序);但实际上,它关注的是程序执行时语义的正确性(即程序的执行结果不能被改变)。这个差异极其关键,JSR-133内存模型的设计者通过这个差异,在程序员与编译器和处理器之间取得了近乎完美的平衡:一方面通过happens-before向程序员提供足够强的内存可见保证,另一方面又尽可能少的束缚编译器和处理器。如果A happens-before B,那么JMM将向程序员保证:A操作的结果将对B可见,且A的执行顺序排在B之前。这仅仅是JMM向程序员做出的保证!如果重排序A和B的执行顺序后,程序的结果不被改变,那么JMM就允许编译器和处理器对这两个操作做重排序。因此,happens-before关系其实本质上和as-if-serial语义是一回事!
- as-if-serial语义保证单线程内程序的执行结果不被改变;
- happens-before关系保证正确同步的多线程程序的执行结果不被改变;
- as-if-serial语义给编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的;
- happens-before关系给编写正确同步的多线程程序的程序员创建了一个幻觉:正确同步的多线程程序是按happens-before指定的顺序来执行的。
as-if-serial语义和happens-before关系这么做的目的是为了尽可能的开发并行度。在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能的开发并行度。编译器和处理器遵从这一目标,从happens- before的定义我们可以看出,JMM同样遵从这一目标。
二、顺序一致性内存模型
当程序未正确同步时,就存在数据竞争。Java语言规范对数据竞争的定义如下:在一个线程中写一个变量,另一个线程读同一个变量,而且写和读没有通过同步来排序。当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。
JMM对正确同步的多线程程序的内存一致性做了如下保证:
- 如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consistent)--即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。这里的同步是指广义上的同步,包括对常用同步原语(lock,volatile和final)的正确使用。
顺序一致性内存模型是一个被理想化了的理论参考模型(并不真实存在),它为程序员提供了极强的内存可见性保证。语言级的内存模型(如JMM)和处理器内存模型在设计时,通常会以顺序一致性内存模型为参照。顺序一致性内存模型有两大特性:
- 一个线程中的所有操作必须按照程序的顺序来执行。
- (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。
在概念上,顺序一致性模型有一个单一的全局内存,每一个线程必须按程序的顺序来执行内存读/写操作。在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,所有线程的所有读/写操作必须串行化访问内存。
对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false),JMM保证线程读操作读取到的值不会无中生有(out of thin air)的冒出来。为了实现最小安全性,JVM在堆上分配对象时,首先会清零内存空间,然后才会在上面分配对象(JVM内部会同步这两个操作)。因此,在以清零的内存空间(pre-zeroed memory)分配对象时,域的默认初始化已经完成了。概况来说:
- 最小安全性保证对象默认初始化之后(设置成员域为0,null或false),才会被任意线程使用;
- 最小安全性“发生”在对象被任意线程使用之前;
- 最小安全性保证线程读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false)。
- 最小安全性保证线程读取到的值不会无中生有的冒出来,但并不保证线程读取到的值一定是正确的。
JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为未同步程序在顺序一致性模型中执行时,整体上是无序的,其执行结果无法预知。保证未同步程序在两个模型中的执行结果一致毫无意义。
JMM不保证对64位的long型和double型变量的读/写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作都具有原子性。这与cpu总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(bus transaction)。总线事务包括读事务(read transaction)和写事务(write transaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字。这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其它所有的处理器和I/O设备执行内存的读/写。我们通过一个示意图来说明:
假设处理器A/B/C同时向总线发起总线事务,这时总线仲裁(bus arbitration)会对竞争作出裁决,我们假设总线在仲裁后判定处理器A在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存)。此时处理器A继续它的总线事务,而其它两个处理器则要等待处理器A的总线事务完成后才能开始再次执行内存访问。如果在处理器A执行总线事务期间(不管这个总线事务是读事务还是写事务),处理器D向总线发起了总线事务,此时处理器D的这个请求会被总线禁止。cpu总线的这个工作机制可以把所有处理器对内存的访问以串行化的方式来执行;在任意时间点,最多只能有一个处理器能访问内存。这个特性确保了单个总线事务之中的内存读/写操作具有原子性。
在一些32位的处理器上,如果要求对64位数据的读/写操作具有原子性,会有比较大的开销。为了照顾这种处理器,Java语言规范鼓励但不强求JVM对64位的long型变量和double型变量的读/写具有原子性。当JVM在这种处理器上运行时,会把一个64位long/ double型变量的读/写操作拆分为两个32位的读/写操作来执行。这两个32位的读/写操作可能会被分配到不同的总线事务中执行,此时对这个64位变量的读/写将不具有原子性。当单个内存操作不具有原子性,将可能会产生意想不到后果。
如图所示,假设处理器A写一个long型变量,同时处理器B要读这个long型变量。处理器A中64位的写操作被拆分为两个32位的写操作,且这两个32位的写操作被分配到不同的写事务中执行。同时处理器B中64位的读操作被拆分为两个32位的读操作,且这两个32位的读操作被分配到同一个的读事务中执行。当处理器A和B按上图的时序来执行时,处理器B将看到仅仅被处理器A“写了一半“的无效值。对于这种问题,JSR-133规范鼓励程序员,把这个long/double变量声明为volatile,或者对这个long/double变量的读/写,使用同步原语(lock,volatile和final)来正确同步。需要声明的是,这种情况不会每次都有,属低概率事件,但“多线程程序 + 在32位的处理器上运行 + 64位共享变量(long/double)”确实存在上述风险。
三、内存屏障
JMM并不保证一个线程可以一直以程序执行的顺序看到另一个线程对变量的修改,除非两个线程都跨越了同一个内存屏障。内存屏障:也称内存栅栏、屏障指令等, 它是一类同步屏障指令,使得CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。大多数现代计算机为了提高性能而采取乱序执行,这使得内存屏障成为必须。语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。
由于重排序分为编译器重排序和处理器重排序,因此需要两种不同的方式来禁止重排序。为了保证内存可见性,对于编译器,JMM会禁止特定类型的编译器重排序;对于处理器,JMM会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令(memory barriers,Intel称之为memory fence),通过内存屏障指定来禁止特定类型的处理器重排序。
现代处理器通过缓冲区的写来临时保存内存的数据。写缓冲区可以保证指令流水线持续的运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的时间延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线(计算机各部件之间传送信息的公共通信干线,总线的速度等同于CPU的外频)的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!
下面是常见处理器允许的重排序类型列表:
Load-Load
Load-Store
Store-Store
Store-Load
数据依赖
sparc-TSO
N
N
N
Y
N
x86
N
N
N
Y
N
ia64
Y
Y
Y
Y
N
PowerPC
Y
Y
Y
Y
N
从上表我们可以看到:常见的处理器都允许Store-Load重排序,都不允许对存在数据依赖的操作做重排序。sparc和x86拥有较强的处理器内存模型(sparc以TSO内存模型运行,这是它的特性),它们仅允许对写-读操作做重排序。
JMM把内存屏障指令分为下列四类:
屏障类型
说明
LoadLoad Barriers
确保Load1数据的装载,之前于Load2及所有后续装载指令的装载。
StoreStore Barriers
确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。
LoadStore Barriers
确保Load1数据装载,之前于Store2及所有后续的存储指令刷新到内存。
StoreLoad Barriers
确保Store1数据对其他处理器变得可见,之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。
StoreLoad Barriers是一个“全能型”的屏障,它同时具有其它三个屏障的效果。现代的处理器大都支持该屏障(其它类型的屏障所有处理器不一定支持)。执行该屏障代价会很高,因为当前处理器要把写缓冲区中的数据全部刷新到内存中。
四、MESI协议
现代处理器可能会有几个缓存(多核处理器,每个核心都会有自己的缓存,而且也会有多级缓存,一级缓存是和核关联,L2或L3缓存是多核共享)共享主存总线,每个相应的CPU会发出读写请求,而缓存的目的是为了减少CPU读写共享主存的次数。因为造价的原因
MESI协议是一种广泛使用的支持写回策略的缓存一致性协议,用来保证高速缓存之间以及高速缓存与内存之间的缓存一致性。
在CPU的cache中,写内存操作存在三种模式:
1) write through:写直达,当cpu向cache写入数据时,同时向memory也写一份,使cache和memory的数据保持一致。优点是简单,缺点是每次都要访问memory,效率较低。
2) write back:回写,当cpu更新cache时,只是把更新的cache区标记一下,并不同步更新memory。只是在cache区要被新进入的数据取代时,才更新memory。这样做的原因是考虑到很多时候cache存入的是中间结果,没有必要同步更新到memory。优点是CPU执行的效率提高,缺点是实现起来比较复杂。
3) post write:后写,当cpu更新cache时,把更新的数据写入到一个更新缓冲区,在合适的时候才对memory进行更新。这样可以提高cache访问速度。但是,在数据连续被更新两次以上的时候,缓冲区将不够使用,被迫同时更新memory。
单核Cache中每个缓存行(caceh line)有2个标志:dirty和valid标志,它们很好的描述了Cache和Memory之间的数据关系(数据是否有效,数据是否被修改),而在多核处理器中,多个核会共享一些数据,MESI协议就包含了描述共享的状态。
在MESI协议中,每个cache-line使用4种状态进行标记(使用额外的两位 bit 表示):
- Modified 被修改:
该cache-line只被缓存在该CPU的cache中,并且是被修改过的dirty数据,即与主存中的数据不一致。该cache-line中的内存需要在未来的某个时间点(允许其它CPU读取主存中相应内存之前)写回(write back)主存。当被写回主存之后,该cache-line的状态会变成exclusive状态。
- Exclusive 独享的:
该cache-line只被缓存在该CPU的cache中,它是未被修改过的clean数据,与主存中数据一致。该状态可以在任何时刻当有其它CPU读取该内存时变成shared状态。同样地,当CPU修改该cache-line中内容时,该状态可以变成Modified状态。
- Shared 共享的:
这意味着该cache-line可能被多个CPU缓存,并且各个cache中的数据与主存数据一致。只有clean的数据才能被多个cache共享。当有一个CPU修改该cache-line,其它CPU中该cache-line可以被作废(变成Invalid状态)。
- Invalid 无效的:
该cache-line是无效的(可能有其它CPU修改了该cache-line)。
在MESI协议中,每个cache的Cache控制器不仅知道自己的读写操作,而且也监听(snoop)其它cache的读写操作。每个cache line所处的状态根据本核和其它核的读写操作在4个状态间进行迁移。MESI状态转换图:
在图中,箭头表示本cache line状态的迁移,环形箭头表示状态不变。
当内核需要访问的数据不在本cache中而其它cache有这份数据的备份时,本cache既可以从内存中导入数据,也可以从其它cache中导入数据,不同的处理器会有不同的选择。
对于M和E状态而言操作总是精确的,他们在和该cache-line的真正状态是一致的。而S状态可能是非一致的,如果一个cache将处于S状态的cache-line作废了,而另一个cache实际上可能已经独享了该cache-line,但是该cache却不会将该cache-line升迁为E状态,这是因为其它cache不会广播他们作废掉该cache-line的通知。同样由于cache并没有保存该cache-line的copy的数量,因此(即使有这种通知)也没有办法确定自己是否已经独享了该cache-line。从这里的意义上来看,E状态是一种投机性的优化:如果一个CPU想修改一个处于S状态的cache-line,总线事务需要将所有该cache-line的copy变成invalid状态,而修改E状态的cache不需要使用总线事务。
※ AMD Opteron处理器使用从MESI协议演化出的MOESI协议,O(Owned)是MESI中S和M的一个合体,表示本cache-line被修改,和内存中的数据不一致,不过其它的核可以有这份数据的拷贝,状态为S。Intel core i7处理器使用从MESI演化出的MESIF协议,F(Forward)从S中演化而来,一个cache-line如果是F状态,它可以把数据直接传给其它内核的cache,而S状态则不能。
五、Volatile
volatile是个很古老的关键字,伴随着JDK诞生而诞生,我们在JDK及开源框架中随处可见这个关键字,但在synchronized关键字的性能被大幅优化之后的今天,几乎没有使用它的场景。但这仍然是个值得研究的关键字,研究它的意义不在于去使用它,而在于理解它。
在JMM中,线程彼此共享堆内存并保有他们自己独自的栈空间。为了性能,一个线程会在自己的栈空间中保持要访问的变量的副本。这就会出现同一个变量在某个瞬间,在线程的栈空间中的值可能与堆内存中的值不一致的情况。volatile就是用来避免这种情况的。
对于共享变量来说,约定了变量在工作内存中发生变化之后,需要回写到主内存(模拟cache一致性),但对于volatile变量则要求工作内存中发生变化之后,必须马上回写到主内存,而线程读取volatile变量的时候,也必须马上到主内存中去取最新值(而不是读取本地工作内存的副本),此规则保证了可见性,即“当线程A对变量X进行了修改后,在线程A后面执行的其他线程能看到变量X的变动”。更详细地说是要符合以下两个规则:线程对变量进行修改之后,要立刻回写到主内存;线程对变量读取的时候,要从主内存中读,而不是缓存。工作内存可以说是主内存的一份缓存,为了避免缓存的不一致性,所以volatile需要废弃此缓存,volatile所修饰的变量不保留拷贝,直接访问主内存中的。它提供了一种免锁的机制,使用这个关键字修饰的域相当于告诉虚拟机,这个域可能会被其他的线程更新,因此每次读取这个域的时候都需要重新计算(直接访问主内存中),并且不保留拷贝。换句话说,变量经volatile修饰后在所有线程中必须是同步的。但除了内存缓存之外,在CPU硬件级别也是有缓存的,即寄存器。假如线程A将变量X由0修改为1的时候,CPU 是在其缓存内操作,没有及时回写到内存,那么JVM是无法将X=1及时被之后执行的线程B看到的。所以,JVM在处理volatile变量的时候,也同样用到了硬件级别的缓存一致性原则(MESI协议)。
理所当然的,volatile修饰的变量存取时比一般变量消耗的资源要多一点,因为线程有它自己的变量拷贝更为高效。但是,还因为它们作用于内存的方式不同:首先,synchronized获得并释放监视器——如果两个线程使用了同一个对象锁,监视器能保证代码块同时只被一个线程所执行。事实上,synchronized也同步内存,synchronized在主内存区域同步整个线程的内存(在线程释放监视器之前,对于变量的任何修改会安全地写到主内存区域中)。因此volatile只是在线程内存和“主”内存间同步某个变量的值,而synchronized通过锁定和解锁某个监视器同步所有变量的值。显然synchronized要比volatile消耗更多资源。
volatile一般情况下不能代替sychronized,因为volatile能够实现可见性,但是无法保证操作的原子性。可见性是指一个线程修改了某个变量的值,新值对于其他线程来说是可以立即得知的,而普通变量是不能做到这一点的,变量值在线程间传递均需要通过主内存完成。volatile的变量在各个线程的工作内存中不存在一致性问题,但是java里面的运算并非原子操作的,导致volatile变量运算在并发下一样是不安全的。这是与volatile的使命相关的。创造它的背景是以前在某些情况下可以代替synchronized实现可见性的目的,规避synchronized带来的线程挂起、调度的开销。也正基于上面的原因,随着synchronized性能逐渐提高,volatile逐渐退出历史舞台。volatile缩短了普通变量在不同线程之间执行的时间差,但仍然存有漏洞,依然不能保证原子性。volatile不能保证操作的原子性,即使只是i++。实际上这种运算也是由多个原子操作组成:
- read i
- i+1
- write i
假如多个线程同时执行i++,volatile只能保证他们操作的i是同一块内存,但依然可能出现写入脏数据的情况。如果配合Java 5增加的atomic wrapper classes,对它们的increase类操作就不需要sychronized。
值得注意的是,在本章开头提到的“在线程启动的时候,虚拟机为每个内存分配一块工作内存,不仅包含了线程内部定义的局部变量,也包含了线程所需要使用的共享变量(非线程内构造的对象)的副本,为了提高执行效率”这句话并不准确。在JDK1.2以后,非volatile的共享变量,只有在对变量读取频率很高的情况下,虚拟机才不会及时回写主内存,而当频率没有达到虚拟机认为的高频率时,普通变量和volatile是同样的处理逻辑。比如在每个循环中执行System.out.println()加大了读取变量的时间间隔,使虚拟机认为读取频率并不那么高时,和volatile修饰的共享变量有同样的效果了。
volatile关键字的另一个作用:对禁止语义重排序,这当然一定程度上也降低了代码执行效率。在JSR-133以后增强了volatile的内存语义:严格限制编译器(在编译器)和处理器(在运行期)对volatile变量与普通变量的重排序,确保volatile的写-读和监视器的释放-获取一样,具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile变量与普通变量之间的重排序可能会破坏volatile的内存语意,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。
最后,JMM如何实现volatile写/读的内存语义?上面我们提到过重排序分为编译器重排序和处理器重排序,为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。下面是JMM针对编译器制定的volatile重排序规则表:
是否能重排序
第二个操作
第一个操作
普通读/写
volatile读
volatile写
普通读/写
NO
volatile读
NO
NO
NO
volatile写
NO
NO
从上表我们可以看出:
- 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后;
- 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前;
- 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。内存屏障(也称内存栅栏)的作用一般是:
1)确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面。即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
2)强制将对副本的修改立即写入主主存;
3)如果是写操作,它会导致其他CPU中对应的副本行无效。
对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
volatile内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。volatile写插入内存屏障后生成的指令序列示意图:
上图中的StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。这里比较有意思的是volatile写后面的StoreLoad屏障。这个屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面,是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)。为了保证能正确实现volatile的内存语义,JMM在这里采取了保守策略:在每个volatile写的后面或在每个volatile读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑,JMM选择了在每个volatile写的后面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里我们可以看到JMM在实现上的一个特点:首先确保正确性,然后再去追求执行效率。
volatile读插入内存屏障后生成的指令序列示意图:
如上所述,volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。举个例子来说:
int a; volatile int v1 = 1; volatile int v2 = 2;
void readAndWrite() {
int i = v1; //第一个volatile读
int j = v2; // 第二个volatile读
a = i + j; //普通写
v1 = i + 1; // 第一个volatile写
v2 = j \* 2; //第二个 volatile写
}
针对readAndWrite()方法,编译器在生成字节码时可以做如下的优化:
注意,最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器常常会在这里插入一个StoreLoad屏障。上面的优化是针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。比如在x86处理器,上图中除最后的StoreLoad屏障外,其它的屏障都会被省略。x86处理器仅会对写-读操作做重排序,不会对读-读、读-写和写-写操作做重排序,因此在x86处理器中会省略掉这三种操作类型对应的内存屏障。在x86中,JMM仅需在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。这意味着在x86处理器中,volatile写的开销比volatile读的开销大很多(因为执行StoreLoad屏障开销会比较大)。
这里比较一下volatile和synchronized的区别:
- volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住;
- volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的;
- volatile仅能实现变量的修改可见性,并不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性;
- volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞;
- volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。
这里只列举出一种volatile的使用场景,即作为标识位的时候,用专业点更广泛的说法就是“对变量的写操作不依赖于当前值且该变量没有包含在其他具体变量的不变式中”。
六、CAS
参考自: