Java 提供了一种稍弱的同步机制,即 volatile 变量,用来确保将变量的更新操作通知到其他线程。可以将 volatile 看做一个轻量级的锁,它可以保证线程可见性,防止指令重排序,但不能保证变量状态的“原子性操作”。
保证线程可见性
在下面的例子中,如果不加volatile关键字修饰flag变量,程序不会输出“end”。
1 | public class TestVolatile { |
Java内存模型(JMM)
在JVM的内存模型中,每个线程有自己的工作内存,实际上JAVA线程借助了底层操作系统线程实现,一个JVM线程对应一个操作系统线程,线程的工作内存其实是cpu寄存器和高速缓存的抽象。
现代处理器的缓存一般分为三级,由每一个核心独享的L1、L2 Cache,以及所有的核心共享L3 Cache组成,具体每个cache,实际上是有很多缓存行组成:

缓存一致性和MESI
缓存一致性协议给缓存行(通常为64字节)定义了个状态:独占(exclusive)、共享(share)、修改(modified)、失效(invalid),用来描述该缓存行是否被多处理器共享、是否修改。所以缓存一致性协议也称MESI协议(Modified, Exclusive, Shared, Invalid)。
- 独占(Exclusive):仅当前处理器拥有该缓存行,并且没有修改过,是最新的值。
- 共享(Shared):有多个处理器拥有该缓存行,每个处理器都没有修改过缓存,是最新的值。
- 修改(Modified):仅当前处理器拥有该缓存行,并且缓存行被修改过了,一定时间内会写回主存,会写成功状态会变为S。
- 失效(Invalid):缓存行被其他处理器修改过,该值不是最新的值,需要读取主存上最新的值。
协议协作如下:
- 一个处于M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回CPU。
- 一个处于S状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I。
- 一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为S。
- 当CPU需要读取数据时,如果其缓存行的状态是I的,则需要从内存中读取,并把自己状态变成S,如果不是I,则可以直接读取缓存中的值,但在此之前,必须要等待其他CPU的监听结果,如其他CPU也有该数据的缓存且状态是M,则需要等待其把缓存更新到内存之后,再读取。
- 当CPU需要写数据时,只有在其缓存行是M或者E的时候才能执行,否则需要发出特殊的RFO指令(Read Or Ownership,这是一种总线事务),通知其他CPU置缓存无效(I),这种情况下会性能开销是相对较大的。在写入完成后,修改其缓存状态为M。
关系如下图:

这个图的含义就是:地址 0x00010000 对应的cacheline在core0上为状态M, 则其它所有的core对应于0x00010000的cacheline都必须为I , 0x00010000 对应的cacheline在core0上为状态S, 则其它所有的core对应于0x00010000的cacheline 可以是S或者I 。
MESI 协议为了提高性能,引入了存储缓存/写缓存器(Store Buffer/Write Buffer)和失效队列(Invalidate Queues),但还是有可能会引起缓存不一致,还会再引入内存屏障来确保一致性。
解释一下这里为什么还可能引起缓存不一致:
是编译器的优化带来的问题。我们都知道,从寄存器里面取一个数要比从内存中取快的多,所以有时候编译器为了编译出优化度更高的程序,就会把一些常用变量放到寄存器中,下次使用该变量的时候就直接从寄存器中取,而不再访问内存,就有会出现问题。
write buffer和invalidate queue带来的问题,下面会介绍。
写缓存器、失效队列
MESI 协议解决了缓存一致性问题, 但是其自身也存在一个性能弱点——处理器执行写内存操作时,必须等待其他所有处理器将其高速缓存中的相应副本数据删除并接收到这些处理器所回复的 Invalidate Acknowledge/Read Response消息之后才能将数据写入高速缓存。无论a之前的值为何,在该条指令执行后都会被覆盖,因此这段等待的开销是完全没有必要的。
写缓冲器和无效化队列的引入又会带来一些新的问题——内存重排序和可见性问题。
存储缓存/写缓存器(Store Buffer/Write Buffer)
也就是常说的写缓存器,当处理器修改缓存时,把新值放到存储缓存中,处理器就可以去干别的事了,把剩下的事交给存储缓存(因为对变量a进行写操作时,无论a之前的值为何,在该条指令执行后都会被覆盖,因此这段等待的开销是完全没有必要的)。但这么做会有两个风险:
由于store buffer的存在,在CPU中同一个变量可能存在两份拷贝(当缓存行到达CPU时,缓存和store buffer中存在同一个变量的两份拷贝),这无疑破坏了缓存的一致性,若CPU在store buffer写入缓存之前load数据,就会拿到旧的数据。(为了解决这个问题,CPU设计者又加入了store forwarding机制,简单的讲就是CPU会优先从store buffer中取变量,保证同一时刻一个变量在单个CPU中的一致性。)
保存什么时候会完成,这个并没有任何保证。这样可能会导致内存重排序:
在下面这个程序中,假设CPU A执行”exeToCPUA“方法,CPU B执行”exeToCPUB“方法。
1
2
3
4
5
6
7
8
9
10
11
12value = 3;
void exeToCPUA(){
value = 10;
isFinsh = true;
}
void exeToCPUB(){
if(isFinsh){
//value一定等于10?!
assert value == 10;
}
}试想一下开始执行前,CPU A保存着finished在E(独享)状态,而value并没有保存在它的缓存中(例如Invalid)。在这种情况下,value会比finished更迟地抛弃存储缓存。完全有可能CPU B读取finished的值为true,而value的值不等于10。
即isFinsh的赋值在value赋值之前。这种在可识别的行为中发生的变化称为内存重排序(reordings)。注意:这里和发生在编译器中的指令重排序不一样。
从上面的例子可以看出,一致性问题的出现来源于数据之间的隐式依赖,也就是说必须保证某个操作在另外一个操作之前完成。但是CPU是无法探测到这种隐式相关性的,必须由程序员自己来进行控制。因此CPU提供了内存屏障指令,该指令使得屏障之前的写操作都在屏障之后的写操作之前完成。
失效队列(Invalidate Queues)
处理失效的缓存也不是简单的,需要读取主存。并且存储缓存也不是无限大的,那么当存储缓存满的时候,处理器还是要等待失效响应的。为了解决上面两个问题,引进了失效队列(invalidate queue)。处理失效的工作如下:
- 收到失效消息时,放到失效队列中去。
- 为了不让处理器久等失效响应,收到失效消息需要马上回复失效响应。
- 为了不频繁阻塞处理器,不会马上读主存以及设置缓存为invlid,合适的时候再一块处理失效队列。
内存屏障
为了禁止编译器重排序和CPU 重排序,在编译器和CPU 层面都有对应的指令,也就是内存屏障(Memory Barrier)。这也正是 JMM 和 happen-before 规则的底层实现原理。
编译器的内存屏障,只是为了告诉编译器不要对指令进行重排序。当编译完成之后,这种内存屏障就消失了,CPU并不会感知到编译器中内存屏障的存在。
而CPU的内存屏障是CPU提供的指令,可以由开发者显示调用。
CPU的内存屏障
CPU提供了两种内存屏障指令:Load Barrier(读屏障) 和 Store Barrier(写屏障)。
Load Barrier:保证读屏障之后的读操作从主内存中读取数据,再更新至高速缓存,高速缓存和主内存一致。
Store Barrier:保证写屏障之前的写操作不仅会将其写入高速缓存,还会将其更新至主内存,让其他线程可见。
JDK的内存屏障
在JSR规范中定义了4种内存屏障:
- LoadLoad:在屏障后的读取操作要读取的数据被访问前,保证屏障前的要读取的数据被读取完毕。
- LoadStore:在屏障后的写入操作的数据更新至主内存前,保证屏障前要读取的数据被读取完毕。
- StoreStore:在屏障后的写入操作的数据更新至主内存前,保证屏障前的写入操作的数据更新至主内存。
- StoreLoad:在屏障后的读取操作要读取的数据被访问前,保证屏障前的写入操作的数据更新至主内存。
StoreLoad 的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
Unsafe
1 | public native void loadFence(); |
根据注释可知:
- loadFence = LoadLoad + LoadStore
- storeFence = StoreStore+LoadStore
- fullFence = loadFence + storeFence + StoreLoad
volatile

从上图可以看出:
- 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
- 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
- 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
对于volatile关键字,按照规范会有下面的操作:
- 在每个volatile写入之前,插入一个StoreStore,写入之后,插入一个StoreLoad
- 在每个volatile读取之前,插入LoadLoad,之后插入LoadStore
缓存行对齐
缓存行(通常是64个字节)是CPU同步的基本单位,缓存行隔离会比伪共享(False Sharing)效率要高。
伪共享(False Sharing)
如果多个核的线程在操作同一个缓存行中的不同变量数据,那么就会出现频繁的缓存失效,即使在代码层面看这两个线程操作的数据之间完全没有关系。
这种不合理的资源竞争情况学名伪共享(False Sharing),会严重影响机器的并发执行效率。
下面这个程序中,T继承Padding类程序的执行时间是不继承Padding类程序的执行时间的3~4倍。
1 | public class T02_CacheLinePadding { |
java 8中引入了一个更加简单的解决方案:@Contended
注解,比如ConcurrentHashMap中的CounterCell类就用到了这个注解。
更多资料
- Disruptor消息队列(推荐)
- 高性能队列——Disruptor(推荐)
- 写缓冲器与无效化
- CPU缓存一致性协议MESI,memory barrier和java volatile(这篇不错)
- 内存屏障
- JMM和底层实现原理
防止指令重排序
这里解释一下为什么通常只是说volatile可以防止指令重排序(指的是编译器优化导致的重排序),而不提指令级的重排序和内存层级的重排序。那是因为volatile是jmm对底层的封装,而指令级的重排序和内存层级的重排序由CPU指令级和缓存一致性协议实现方式决定,或者说缓存一致性协议不仅有MESI,换到其他协议可能就没有类似问题。
重排序类型

- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
证明指令重排序存在
在下面的例子中,x, y, a, b初始值为0。线程“one”执行 a = 1, x = b,线程“other”执行 b = 1, y = a。理论上来说,不可能会发生x = 0, y = 0的情况,除非线程“one”在执行 a = 1 前先执行了 x = b,并且线程“other”在执行 b = 1 前先执行了 y = a。如果发生了 x = 0, y = 0 的情况,就可以证明指令重排序确实存在。
1 | public class Test_Disorder { |

as-if-serial
不管怎么重排序,单线程下的执行结果不能被改变。编译器、和处理器都必须遵守as-if-serial语义。
但多个线程会互相读取和写入共享的变量,对于这种相互影响,编译器和CPU 不会考虑。
Happens-Before
单线程场景下有 as-if-serial语义保证执行结果不能被改变。
为了明确定义在多线程场景下,什么时候可以重排序,什么时候不能重排序,Java引入了JMM(Java Memory Model),也就是Java内存模型。这个模型就是一套规范,对上,是 JVM 和开发者之间的协定;对下,是JVM和编译器、CPU之间的协定。
定义这套规范,其实是要在开发者写程序的方便性和系统运行的效率之间找到一个平衡点。一方面,要让编译器和CPU可以灵活地重排序;另一方面,要对开发者做一些承诺,明确告知开发者不需要感知什么样的重排序,需要感知什么样的重排序。然后,根据需要决定这种重排序对程序是否有影响。如果有影响,就需要开发者显示地通过volatile、synchronized等线程同步机制来禁止重排序。
为了描述这个规范,JMM引入了happen-before,使用happen-before描述两个操作之间的内存可见性。那么,happen-before是什么呢?
如果A happen-before B,意味着A的执行结果必须对B可见,也就是保证跨线程的内存可见性。A happen before B不代表A一定在B之前执行。因为,对于多线程程序而言,两个操作的执行顺序是不确定的。happen-before只确保如果A在B之前执行,则A的执行结果必须对B可见。定义了内存可见性的约束,也就定义了一系列重排序的约束。
基于happen-before的这种描述方法,JMM对开发者做出了一系列承诺:
- 单线程中的每个操作,happen-before 对应该线程中任意后续操作(也就是as-if-serial语义保证)。
- 对volatile变量的写入,happen-before对应后续对这个变量的读取。
- 对synchronized的解锁,happen-before对应后续对这个锁的加锁。
- 对final变量的写,happen-before于final域对象的读,happen-before于后续对final变量的读。
- …
volatile 和 synchronized 都具有Happens-Before语义(不是只有它们才具有Happens-Before语义)。
对于非volatile变量的写入和读取,不在这个承诺之列。通俗来讲,就是JMM对编译器和CPU 来说,volatile 变量不能重排序;非volatile 变量可以任意重排序(保证as-if-serial语义的前提)。
如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。
从底向上看volatile背后的原理:

Happens-Before的传递性
除了这些基本的 happen-before 规则,happen-before还具有传递性,即若A happen-before B,B happen-before C,则A happen-before C。
总线风暴
由于 volatile 的 mesi 缓存一致性协议需要不断的从主内存嗅探 和 cas 不断循环无效交互导致总线带宽达到峰值。
解决办法:部分volatile和cas使用synchronized
问题:DCL单例需不需要加volatile?
需要,正确的写法如下:
1 | public class Singleton { |
查看创建对象的字节码指令(这里简单的以new Object为例):

1 | 1 new #2 <java/lang/Object> // 分配对象内存空间 |
因为3、4没有依赖关系,在单线程的情况下,无论3、4哪个先执行,都不会影响最终的结果。但是, CPU和编译器在指令重排时, 并不会关心是否影响多线程的执行结果。在不加volatile关键字时, 如果有多个线程访问getInstance方法, 此时正好发生了指令重排, 那么可能出现如下情况:
当第一个线程拿到锁并且进入到第二个if方法后, 先分配对象内存空间, 然后再instance指向刚刚分配的内存地址, instance 已经不等于null, 但此时instance还没有初始化完成。如果这个时候又有一个线程来调用getInstance方法, 在第一个if的判断结果就为false, 于是直接返回还没有初始化完成的instance, 那么就很有可能产生异常。
C++中的volatile关键字
Java中的 volatile 关键字不仅具有内存可见性,还会禁止volatile变量写入和非volatile变量写入的重排序,但C++中 的volatile关键字不会禁止这种重排序。
JSR-133对volatile语义的增强
Java的volatile比C++多出的这点特性,正是JSR-133对volatile语义的增强。下面这段话摘自JSR-133的原文:
What was wrong with the old memory model?
The old memory model allowed for volatile writes to be reordered withnonvolatile reads and writes,which was not consistent with most developersintuitions about volatile and therefore caused confusion.
也就是说,在旧的JMM模型中,volatile变量的写入会和非volatile变量的读取或写入重排序,正如C++中所做的。但新的模型不会,这也正体现了Java对happen-before规则的严格遵守。
不能保证原子性
值得一提的是,volatile还能够用来保证对long/double型变量的写操作具有原子性。在Java语言中,对long类型和double类型以外的任何类型的变量的写操作都是原子操作(在某些32位Java虚拟机上,对long/double类型变量的写操作可能不具备原子性)。
但 volatile 不能保证变量状态的“原子性操作”。
例如:不能保证 i++的原子性,因为本质上 i++是读、写两次操作。
实现原理
语法层面
用volatile关键字修饰变量:valatile boolean flag = false;
字节码层面
变量多了一个ACC_VOLATILE标签,运行时处理。


JVM层面
bytecodeinterpreter.cpp中的一个片段
1 | // |
我们可以看到第三行有一个判断is_volatile(),就是在查看变量是否加了 volatile 修饰。接着后面就有一个调用OrderAccess::fence()。这里的 fence 就是屏障的意思。
当然不同的系统和硬件会有不同实现的方式,我们以 Linux 系统为例,在orderAccess_linux_x86.inline.hpp文件中可以找到 fence()函数。
1 | inline void OrderAccess::fence() { |
可以看出,屏障的实现就是依赖于这样一条汇编指令:
1 | lock; addl |
汇编码层面
汇编指令:
1 | lock; addl |