volatile和Java内存模型

本文是在读了周志明老师的《深入理解Java虚拟机》之后写得,算是读书笔记吧,部分讲解思路和图均出自此书,因为我想不出比这种讲解思路更容易理解的讲法。

在接触volatile之前,我们只知道“原子性”,原子性很好理解,这里也不再啰嗦。但当volatile引出“可见性”的时候,就不像原子性那样可以一两句说清楚说明白了。上网一查还总是让我们先学一下Java的内存模型,难道就不能像原子性那样简单直白的说明白吗?其实我们可以回想一下第一次接触“原子性”的时候是怎么去理解的,原子性就是它的字面意思,一个操作像原子(虽然物理上的原子还是可分的,引用东哥的话说,不要在意这些细节)那样是细不可分的,最小的操作。但我们真正理解和明白原子性,是通过知道了汇编语言和机器语言之后的事。因为正是由于高级语言要编译成汇编语言,最后对应于机器语言才能执行的这个过程,造成了高级语言存在非原子性操作的问题。同样的,可以说是Java的内存模型造成了可见性这个问题,所以如果想从原理上,而不只是字面上去理解“可见性”,确实需要先学一下Java的内存模型,其实是很简单的一个东西。

Java内存模型

为了更容易地理解Java内存模型,我们可以参考一下本科学过的硬件上的缓存设计。为了协调存储设备和处理器之间的速度差距,所以引入了高速缓存Cache:将运算需要使用到的数据复制到Cache中,在运算过程中CPU只在Cache上进行存取,在运算结束以后再把Cache中的数据同步写回到内存中,这样避免了CPU等待相对缓慢的内存读写,交互关系如下图:
处理器、高速缓存、主内存间的交互关系
上图中的缓存一致性协议不是重点,重点是这种结构设计。
类似的,Java内存模型规定,所有的变量(不包括局部变量和方法参数这种线程私有的,不会被共享的变量)都存储在主内存中(此处的主内存是虚拟机内存的一部分),每条线程还有自己的工作内存(可与上边的Cache类比),线程的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间也无法直接访问对方的工作内存,线程间的变量传递均通过主内存完成,交互关系如下图:
线程、工作内存、主内存间的交互关系
同样的,上图中的交互操作不是重点,重点是这种结构设计。
需要注意的是,这里所讲的主内存、工作内存,均是虚拟机内存的一部分,它们和JVM内存区域中的Java堆、栈、方法区等不是同一个层次的内存划分,前者更偏向逻辑上的划分,后者更偏向物理上的划分,如果要强行对应的话,主内存主要对应于Java堆中的对象实例数据部分,工作内存主要对应于栈中的部分区域。

可见性

在了解了Java内存模型以后,我们再去理解“可见性”时,就清楚明白多了。正因为工作内存的存在,所以某些线程正在操作的共享变量可能不是最新值,这就造成了“不可见性”问题。所以

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

那要怎么保证可见性呢?很简单,只要消除造成“不可见性”的原因————工作内存就好了,是的,如果能够跳过工作内存,直接操作主内存,那就一定是可见的,但这样就破坏了这种结构设计。所以正常的,也是Java volatile关键字采用的,用以下两条规则保证可见性:

  1. 在每次修改该变量后,立即从工作内存同步写回到主内存中;
  2. 在每次使用该变量前,立即从主内存中再读取到工作内存中;

要注意的是,volatile变量也是有工作内存副本的,它并没有破环这种结构设计,它与普通变量的区别仅仅是以上两条规则,即读取和写回的及时性。

Java中保证可见性的关键字

在Java中,一共有三个关键字可以保证可见性:

  1. volatile:通过上边两条规则保证;
  2. final:初始化后不可变,不存在修改的情况;
  3. synchronized:通过内存交互规则之一保证,“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中”,注意此处说的并不是同步块中的操作,而单单是指被synchronized修饰的变量本身。

volatile

对volatile的误解

至于具体的误解是什么,我就不说了,因为在我身上经常发生本来记得很对的概念,看了一眼反例就再也分不清了的蠢事。所以这里只把结论放出来,并解释为什么会这样。

volatile只能保证被修饰变量的可见性,不保证对其操作的原子性,对其所有的操作都和普通变量一样是非线程安全的。

volatile对变量的影响仅仅只是可见性,这是我们一直强调的一点,如果你已经理解了什么是可见性,但当可见性和原子性这两个概念放在一起时有点晕的话,看了下边的例子可能就清楚多了。

1
2
static volatile int num;
num++;

如果volatile能保证变量操作的原子性,那上边num++这个操作应该是原子操作,即它应该是一个细不可分的、不会被“中途打断”的操作,但稍微清醒一点的你也会意识到这是不可能的。这个操作最起码要分成三个原子操作才能完成:

  1. 读取num当前值;
  2. 对num当前值进行加1运算;
  3. 回写运算后的num值;

在多线程环境下,任一个正在进行num++操作的线程,都有可能在上边三个原子操作之间的任一点被切换上下文。这也是可以通过Javap反汇编印证的,下边是num++反汇编对应的字节码指令:

1
2
3
4
getstatic
iconst_1
iadd
pustatic

可以清楚的看到num++操作被编译成了四条字节码指令,所以可见性和原子性之间并没有什么必然联系,而volatile只保证变量的可见性,对于num++这个操作而言,volatile的唯一作用是,当且仅当putstatic指令执行完以后,任一线程通过getstatic指令读取到的num值都是最新的。(这里用字节码来说明原子性不是很严谨,因为即使编译出来只有一条字节码指令,也并不意味着执行这条指令就是一个原子操作,因为一条字节码指令在真正被执行时可能会转化为多条本地机器码指令,所以通过汇编代码而非字节码指令会更严谨一些,但此处使用字节码就已经能说明问题了)。

volatile的两个作用

前边我们一直在讲volatile能保证可见性,其实这只是它的作用之一,它还有另外一个作用就是禁止指令重排序优化。

从硬件架构上讲,指令重排序是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。但并不是说指令任意重排,CPU需要能正确处理指令依赖情况以保障程序能得出正确的执行结果。

可以结合CPU流水线理解上边的概念。那指令重排序对程序的并发执行又有什么影响呢?为什么volatile要禁止指令重排序呢?下边通过DCL单例模式的代码来说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Singleton {
private volatile static Singleton instance;
public static Singleton getInstance() {
if(instance == null) {
synchronized (Singleton.class) {
if(instance == null)
instance = new Singleton();
}
}
return instance;
}
private Singleton(){}
public void doSomething() {
//do something
}
}

如果instance没有使用volatile修饰,那么instance = new Singleton()对应的指令就有可能被重排序。该行代码起码有三个步骤需要完成:

  1. 在Java堆中分配一块内存用来存储Singleton实例;
  2. 在该内存块上初始化Singleton实例;
  3. 将该内存块对应的地址引用赋值给instance;

现在假设有线程A调用了Singleton.getInstance(),当执行到instance = new Singleton()这行代码时,经过指令重排序,上边第3个步骤被重排序到第2个步骤之前,并在执行了第3个步骤以后,将要执行第2个步骤之前,进行上下文切换至线程B,线程B再调用Singleton.getInstance()方法时,instance已经不等于null了,这时再继续调用doSomething()方法时,由于还没有初始化,所以会出现异常。虽然这种情况非常极端,但不是没有可能,所以只有使用volatile修饰instance的DCL单例模式才是线程安全的(在JDK1.5之前volatile还不能完全屏蔽指令重排序,所以即使使用volatile也不能保证DCL单例模式的线程安全,但在JDK1.5修复此问题之后完全是线程安全的)。

volatile的底层实现原理

这里所说的实现原理,即JVM是怎么实现volatile的可见性和禁止指令重排序这两个特性的。这时我们可以对比观察使用volatile和不使用volatile关键字时所生成汇编代码的差别,具体的代码不再贴出(纯汇编,贴出来意义不大不是重点),感兴趣可以自行实验。其中一个明显的差别是,在volatile变量每次赋值指令之后,都会紧随一条“lock add1 $0x0,(%esp)”指令(不一定是ESP寄存器,也可能是其他寄存器,取决于栈指针的指向)。这个操作就相当于传说中的内存屏障,以下引用《深入理解Java虚拟机》中的原话来说明该指令是如何保证可见性的:

当只有一个CPU访问内存时,并不需要内存屏障,但如果有两个或更多CPU访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证一致性了。这条指令中的“add1 $0x0,(%esp)”(把ESP寄存器的值加0)显然是一个空操作(采用这个空操作而不是空操作指令nop是因为IA32手册规定lock前缀不允许配合nop指令使用),关键在于lock前缀,查询IA32手册,它的作用是使得本CPU的Cache写入了内存,该写入动作也会引起别的CPU或者别的内核无效化其Cache,所以通过这样一个空操作,可让前面volatile变量的修改对其他CPU立即可见。

这段引用的重点在于后半句,关于lock前缀的解释,也就是volatile是使用硬件指令来保证可见性的。那lock前缀又是怎么保证禁止指令重排序呢?依然通过引用《深入理解Java虚拟机》中的原话来说明:

从硬件架构上讲,指令重排序是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。但并不是说指令任意重排,CPU需要能正确处理指令依赖情况以保障程序能得出正确的执行结果。譬如指令1把地址A中的值加10,指令2把地址A中的值乘以2,指令3把地址B中的值减去3,这时指令1和指令2是有依赖的,它们之间的顺序不能重排————(A+10)*2与A*2+10显然不相等,但指令3可以重排到指令1、2之前或者中间,只要保证CPU执行后面依赖到A、B值的操作时能获取到正确的A和B值即可。所以在本内CPU中,重排序看起来依然是有序的。因此,lock add1 $0x0,(%esp)指令把修改同步到内存时,意味着所有之前的操作都已经执行完成,这样便形成了“指令重排序无法越过内存屏障”的效果。

看了上边的引用,你可能还是一头雾水,这个解释给人的感觉像是只可意会不可言传一样,而且关于内存屏障,《深入理解Java虚拟机》一书并没有过多介绍,只说“重排序时不能把后面的指令重排序到内存屏障之前的位置”,但这个限制显然是不够的,难道屏障之前的代码就可以重排序了?那DCL单例模式依然是有问题的。应该有更严格的限制才对,比如绝对禁止屏障前后的代码重排序。经过进一步google,也找到一些说明,但由于太偏向底层,我也没有太深究,引用一篇博文中的话简单说明一下内存屏障:

with lock prefix, memory related instructions are processed specially to act as a memory barrier, similar to mfence instruction. Dave Dice explains in his blog that they benchmarked the 2 kinds of barrier, and the lock add seems the most efficient one on today’s architecture. So this barrier ensures that there is no reordering before and after this instruction and also drains all instructions pending into Store Buffer. After executing these instructions, all writes are visible to all other threads through cache subsystem or main memory. This costs some latency to wait for this drain.

后记

之前刚接触volatile时,看到过一个说法,volatile只适合简单的get和set方法,当时不太懂。这次在查内存屏障时,不知道在哪看到一句话,意思是对于volatile变量,单独的读操作和单独的写操作是原子操作,但任意的读写操作的组合不再是原子操作,这句话像是戳破了最后一张纸一样,一句话解释清楚了之前num++不是原子操作的原因,并给出了言简意赅的结论。这时再回头去查之前那个说法时才恍然大悟,看下面代码

1
2
3
4
5
6
7
private volatile int i = 0;
public int get() {
return i;
}
public void set(int i) {
this.i = i;
}

如果我说,上边的get和set方法对于变量i的操作,不但是原子操作,而且两个方法之间还是同步的,即此处volatile的作用完全等同于synchronized的作用,你是不是需要思考一会?这么神奇吗?

先来解释为什么是同步的,我们可以先用熟悉的synchronized想一下同步效果是什么样。如果两个方法是synchronized修饰的,那多线程环境下,任一时刻只有一个线程在执行get和set方法,其他线程都处于阻塞排队状态,则,任一时刻,任一线程通过get方法得到的,一定是变量i此刻的最新值。这就是synchronized在此处达到的同步效果,是不是很熟悉?这不就是保证了变量i的可见性嘛。再来看此处volatile达到的同步效果,由于volatile语义使然,同样的,任一时刻,任一线程通过get方法得到的,一定是变量i此刻的最新值。

再来解释原子性,这时我们可以考虑一下如果变量i没有使用volatile修饰,结合我们上边说的内存模型,当一个线程A执行this.i=i时,其实至少是有两步的:1.对工作内存中的i进行写操作;2.把工作内存中的i同步回写到主内存中;那么当在这两步之间进行上下文切换至其他线程时,通过get方法将看不到i的最新值。而volatile可以保证this.i=i这行代码的原子性。此处的原子性可能和我们通常理解的原子性不太一样,因为我们之前遇到的原子性都是在机器码的层面上,而这里是由于JAVA的内存模型导致的。

这时我们不妨回头想一下当看到“两个方法之间还是同步的,即此处volatile的作用完全等同于synchronized的作用”这句话时为什么会有点诧异呢?因为我们通常理解的“同步”,更多的关注点在于“原子性”上,因为我们通常需要同步的操作不像上边的代码只有一行,而是一系列相关的操作。而此处的“同步”,更多的关注点在于“可见性”上,所以只能说我们对于“同步”的认识还不够深。

关于volatile的正确用法,还有很多需要学习,这里推荐一篇很好的博文《正确使用 Volatile 变量》,值得一看。最后通过引用《深入理解Java虚拟机》书中的话来结束此文,既作为对内存模型的总结,更作为对“同步”的正确认识和理解。

Java内存模型是围绕着并发过程中如何处理原子性、可见性和有序性这3个特征来建立的。
有序性:Java程序中天然的有序性可以总结为一句话:如果在线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”,后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。