java内存模型与volatile

1,356 阅读4分钟

前言

在计算机硬件结构中,为了平衡cpu和内存之间由于速度带来的差距,cpu中引入了cache作为处理器与内存之间的缓冲。在多核的处理器中,每个核都有属于自己的cache,这就带来了cache一致性的问题。前面提到的MESI协议就是用于处理cache一致性问题的一个协议,它将cache的内容分成几个状态,并要求每个核监听总线上传来的其他核发出的事件,根据这些外部事件以及自身操作cache的内部事件来维护cache的内容和状态,以达到cache一致性。但MESI协议中特定的优化有时会导致cache中存在临时的不一致的数据,所以引入了内存屏障来规避这个问题。

即使有cache的存在,当处理器等待cache的载入时仍然会浪费时间。所以处理器会在当前指令因等待数据阻塞时尝试执行其他不依赖这个数据的指令,来尽可能提高处理速度,这称为乱序执行。处理器会保证乱序执行的结果与顺序执行的结果一致,但仅在当前处理器范围内。如果有其他任务的计算依赖当前任务的中间结果,就有可能出现不符合预期的结果,这个问题同样可以通过内存屏障来规避。

java的内存模型

java虚拟机规范中定义了java自身的内存模型,通过这个内存模型来屏蔽不同的操作系统和硬件带来的差异,达到各个平台运行效果一致的目标。java内存模型规定所有的变量都存储在主内存中,每个线程有自己的工作内存,线程在访问变量时都直接从工作内存中访问,而不能访问主内存。一个线程不能访问其他的线程的工作内存,线程之间的变量传递都需要经过主内存来完成。这里的线程、工作内存和主内存有有点类似计算机硬件结构中的处理器、cache和内存的关系。此外,java虚拟机中的即时编译中也有类似指令重排序的优化。

volatile变量

在java中有一个用于实现单例模式的方式,叫做“双成例检查”。双成例检查利用了synchronized和volatile关键词保证了在并发执行的情况下单例模式的正确性。但是在jdk1.5以前(不包括1.5)的版本是存在问题的,其中具体的原因就是volatile关键词底层实现在jdk1.5才完全正确。

根据volatile的特性,如果一个变量被标记为volatile,那么它将获得两个额外的属性:

  1. 在一个线程中对于volatile变量的修改会立即被其他线程感知到,也就是可见性。前面提到,在java内存模型中,各个线程之间的变量传递都需要先经过主内存,所以为了性能考虑,线程不会总是从主内存获取最新的变量的值,而是在特定的时机才从主内存同步最新的内容。而volatile关键词则能够强制触发其他线程同步主内存的内容。
  2. 禁止指令重排序。对于一个普通的变量,只会保证所有依赖这个变量的地方都能获得正确的结果,而并不会保证对这个变量赋值的顺序和实际的代码执行顺序一致,比如不依赖这个变量的代码可能会被挪到之前或者之后执行,也就是“看起来就像是顺序执行一样”。而volatile关键词能够禁止指令重排序。

在jdk1.5之前的版本,volatile并没有禁止指令重排序的作用,所以即使把变量声明为volatile也会存在volatile变量前后的代码重排序的情况,这也是在jdk1.5之前不能使用双成例检查来实现单例的原因。

volatile的实现

前面提到内存屏障能够避免cache中存在过期数据以及避免乱序执行,而volatile自身也是通过内存屏障来实现上述的2个特性的。

内存屏障通常分为几个级别:读写(保证屏障前的读写操作都早于屏障后的读写操作)、读(只保证读操作)以及写(只保证写操作)。不同体系结构的硬件对内存屏障的实现都不一样,比如在x86中内存屏障的指令是:

  • lfence 读操作屏障
  • sfence 写操作屏障
  • mfence 读写操作屏障

而当我们把实际的java字节码反汇编成汇编指令时,可以看到并没有这几个屏障,而是在写入volatile变量之后添加一条lock addl $0, 0 (%esp)指令。lock指令的作用是可以使当前处理器的cache内容被写入内存,同时使其他处理器的cache失效,这种操作相当于将本线程的工作内存的内容同步到主内存,也就保证了可见性。而在指令重排序的角度,由于lock指令之前的操作的结果都同步到了内存,也就相当于lock之前的操作都已经完成,这样就相当于“屏障后边的操作无法穿越到屏障前面”的效果。

lock实际的作用

可以看到,lock实际上具备了内存屏障的语义,那lock具体的作用是什么呢。lock是一个指令前缀,在它后面的指令会保证原子执行。其实现方式就是在指令执行期间设置处理器的LOCK#信号,这样就能确保处理器能够互斥的操作内存(通过锁定总线来实现),当指令执行完毕之后LOCK#信号会自动取消。从intel奔腾Pro处理器开始,当要锁定的内存地址已经被加载到cache时,会直接锁定对应的cache而不是设置LOCK#信号

也就是说,volatile的实现中通过lock前缀+一条空的指令来锁定cache,实现了可见性和禁止重排序的功能。至于为什么要用addl $0, 0 (%esp)配合lock前缀是因为lock前缀只支持内存操作类的指令,所以不能直接用lock前缀加空指令nop。