本文参考来源 海子的博客
volatile在Java 5之前一直是个备受争议的关键字,直到Java 5之后才焕发新机。虽然volatile的含义如此简洁,但是深入理解它则需要了解Java的内存模型。
内存模型
高速缓存
在计算机中,执行程序时每条指令都是最终在CPU执行的,而指令在执行过程中,必然会和数据的读取和写入相关。由于程序运行时的临时数据是储存在主内存(RAM物理内存)中的,CPU的执行速度很快,而从内存中读取和写入相对CPU速度慢的多,于是CPU就有自己的存储结构——高速缓存。
CPU一般的结构由控制单元、执行单元、存储单元组成。
也就是说,在程序运行的过程中,CPU运算所需的数据需要从主存中复制一份到高速缓存中,然后通过读取-计算-写入,CPU完成计算写入后再将数据刷新到主存中。举个例子:
int i = i + 1;
当线程执行这段代码时,会先从主存中读取i的值,复制一份到高速缓存中,然后CPU执行对i+1的操作,再将数据写入高速缓存,最后将高速缓存中的i的值刷新到主存中。
在这个过程中,如果是单线程执行是没有问题的,但是在多线程中,会出现 缓存不一致 的问题。
缓存不一致
在多核心CPU中,每个线程独立运行于自己的CPU中,因此线程都会有属于自己的高速缓存。对于单核CPU而言,也会有这个问题,通过线程调度执行产生。这边以多核CPU为例:
int i = i + 1;
比如有2个线程同时执行这段代码,初始值为0,希望结果是i=2。大多数情况下是OK的,但是可能也会出现这种情况:初始时,两个线程分别将i的值从主存读取复制到各自的高速缓存中,然后线程1进行+1操作,i为1写入高速缓存并刷新内存,此时线程2的高速缓存仍然是0,进行+1操作后,i为1同样写入高速缓存并刷新内存。出现了最终结果是1而不是2的问题。
通常这样多个线程访问的变量被称为共享变量。
为了解决缓存不一致的问题,一般有以下两个方法:
- 通过在总线加LOCK#锁。
- 通过缓存一致性协议。
- 以上都是在硬件层面解决问题。
早期是通过在总线加LOCK#锁解决缓存不一致的问题。因为CPU和外部的通信是通过总线进行的,于是加锁则能保证同步一致,阻塞了其他CPU对部件通信(访问内存),虽然可以解决,但是效率太低了,因为访问其他部件的操作相对CPU的执行速度而言效率太低。于是就出现了缓存一致性协议。
缓存一致性协议:最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。
它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为 无效状态 ,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
并发编程
并发编程中,经常要解决以下问题:原子性问题、可见性问题、有序性问题。
原子性
- 原子性是指一个操作或多个操作,要么全部执行并且不会被打断,要么不执行。
反应这个概念最简单的模型就是“转账取款”的问题。
如果反映到并发编程中,以java举例,i = 999;
,i是int为32位,赋值过程中,前16位写入后,中断,此时另一个线程读取i,则值会错乱。
可见性
- 可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得见修改后的值。
1 | //thread 1 |
假若线程1是CPU1,线程2是CPU2,当线程1执行到语句2时,此时CPU1高速缓存变为10,但是未刷新到主存中。则此时线程2执行,读取主存中的i仍然是0,使得j也变为0,出错。
可见性问题是在线程1修改变量后,线程2没有立刻看到线程1中的值。
有序性
- 有序性是指程序执行的顺序按照代码的先后顺序执行。
1 | int i = 0; //语句1 |
按照正常的程序执行顺序,应该是1234顺序执行语句指令,但是在JVM中,会对指令优化处理,发生 指令重排 ,顺序可能变为2134。
虽然指令重排会使程序执行顺序发生变化,但是还是会有依赖顺序,不会出现语句4在语句3(或语句2)之前执行。因为在执行语句时,类似拓扑排序,会检查调用到的其他参数,保证其他参数执行完毕返回结果后,才会执行此语句。
对于单线程而言,不会产生问题。但是对于多线程,则会出现问题。比如:
1 | //thread1 |
此时语句1语句2无依赖关系,则线程1中,很有可能会语句2先于语句1执行,此时线程2则会错误的认为context已经初始化ok,然后跳出循环执行语句4,实际context要在语句2才执行,进而导致异常。
因此可以看出,JVM指令重排对于单线程没有影响,但是多线程会导致可能出现错误。
在并发编程中,若未保证以上三原则中的任何一个,都会产生不可预知的问题。
Java内存模型
在JVM虚拟机中,试图定义一种Java内存模型(Java Memory Model,JMM),以此解决跨硬件和不同操作系统上的内存访问差异,完善跨平台性。其中,JMM主要定义了程序中访问变量的访问规则,即程序的执行顺序(有序性)。
但是,为了更好的性能,JMM并没有禁止指令重排,也没有限制CPU在指令的处理上使用寄存器和高速缓存。所以,JMM也会产生缓存一致性问题和指令重排的问题。
JMM规定所有变量都是存在于主存中(物理内存),所有线程都有自己的工作内存(类似CPU的高速缓存),线程对变量的操作必须在工作内存中完成,无法直接对主存操作,并且不能访问其他线程的工作内存。
例如i = 10
,规定线程从主存中复制i到自己的工作内存中,然后在工作内存中赋值i的值为10,最后再刷新主存中的i的值。注意,线程不是直接操作主存中的变量。
原子性
- 在Java中,对基本数据类型的变量进行简单的读取和赋值是原子性的,不可被中断的。
1 | x = 1; //语句1 |
以上4个语句,只有语句1变量赋值是原子操作。语句2涉及读取x、赋值y的两步操作。虽然单步是原子操作,但是两步合起来就不是了。同样语句3和语句4都是读取x、执行x+1、赋值给x,三步。
不过这里有一点需要注意:在32位平台下,对64位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。但是好像在最新的JDK中,JVM已经保证对64位数据的读取和赋值也是原子性操作了。
- 对于其他代码块的原子性问题,可以通过
synchronized
和Lock
来锁块。
可见性
对于可见性,Java使用volatile来保证。也就是本章的主题。
当一个共享变量被volatile修饰时,会保证在被修改的时候立刻存入主存,当有其他线程需要读取时,会重新到主存中读取新的值。
而未修饰的共享变量则无法保证可见性,因为写入主存的时间不固定。
另外,通过synchronized
和Lock
也可以保证可见性,因为它们可以保证同一时刻只有一个线程操作变量(对象),执行代码直至释放锁。在释放锁之前,会将对变量的修改写入主存中。
有序性
之前提到过JVM的指令重排问题。这里可以用volatile保证一定的有序性(原因见下)。当然,synchronized
和Lock
一样可以,原因不再赘述。
Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
happens-before 原则
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
- 单线程内程序的运行结果和代码的书写顺序一致。这里并不是指程序执行过程一致,因为JVM有重排机制,但是输出的结果是肯定一致的。
- 也就是说,不论单线程还是多线程,都会等待线程锁释放后再执行获取锁的操作。
- volatile的关键特性,简单的说就是,如果一个线程先去写一个变量,另一个线程进行读取,那么volatile保证写入一定在读取之前。
- happens-before具有传递性。
后面4条比较简单,就不赘述了。
剖析volatile关键字
两层语义
一旦一个共享变量被volatile修饰后,则具备以下两个语义:
- 保证了不同线程对这个变量操作的可见性,即一个线程修改了变量的值,在对另一个线程是立即可见的,读取变量时会重新进入主存中读取。
- 禁止了指令重排。
1 | //thread 1 |
这是一个常见的带有中断的多线程任务流程。按照代码执行,线程2一定会使线程1中断吗?
不一定。由于之前所说的JMM规则(可见性)导致的这一问题。这种bug概率虽极低,但是一旦出现,不好排查且后果严重,会造成无限循环、死锁等。
具体描述是,线程1有自己的工作线程,读取stop值后复制到自己的工作内存中,此时执行工作。如果此时线程2读取主存的stop值,然后在自己的工作内存中改变stop,在写入主存之前,被中断或进行其他工作。此时线程1则会无法知道stop已经被改变,进而引发问题。
但是如果被volatile修饰后,则:
- 强制修改变量的值立刻写入主存。
- 当线程2进行修改时,会导致线程1工作内存的stop缓存无效(映射到硬件层的话,就是CPU的L1或L2缓存中的对应缓存行无效)。
- 由于线程1中stop缓存无效,则线程1会去主存中重新读取stop的值。程序运行正常。
保证原子性
- volatile无法保证对变量的任何操作的原子性。
当然,基于JMM,原子操作的原子性肯定是可以保证的。
此时可以采用synchronized
和Lock
来解决代码块的原子性问题。对于基础数据结构,Java还提供了原子数据结构(比如AtomicInteger
),对自增、自减、加法、减法进行封装,保证这些操作的原子性。
AtomicInteger
利用CAS(Compare And Swap)来实现原子操作的,CAS实际是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG是原子操作。
保证有序性
- volatile禁止指令重排,所以一定程度上可以保证有序性。
这里有两层含义:
- 当程序执行到volatile修饰的变量(读写)时,在其之前的操作保证一定完成,且结果对后续程序可见(写入主存),在其后面的语句还未执行。
- JVM将指令优化时,无法将volatile修饰的变量进行重排(无法改变变量执行时的顺序)
1 | //thread1 |
比如之前的代码,若volatile对init修饰,则不会出现语句2在语句1之前执行,程序正确执行。
原理和实现机制
“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”
lock前缀指令相当于内存屏障(内存栅栏),提供3个功能:
- 执行指令时,前面的操作已经全部完成。
- 强制缓存修改参数立刻写入主存。
- 如果是写操作,会导致其他CPU中对于的缓存无效(可见性)。
volatile使用场景
synchronized
可以防止多个线程执行一段代码块,但是影响程序执行效率,而volatile在某些情况下的性能要优于synchronized
。但是之前说过,volatile无法保证操作的原子性,所以无法替代synchronized
。一般来说,使用volatile必须具备以下条件:
- 对变量的写操作不依赖当前值。
- 该变量没有包含在具有其他变量的不变式中。
以上表明,可以被写入volatile变量的有效值是独立于程序的任何状态,包括变量的当前状态。
其实主要上述是为了保证操作是原子操作,否则在并发时无法得到正确执行。
以下举例常用场景:
1 | volatile boolean flag = false; |
这个之前讨论过了,就不赘述了。
1 | class Singleton{ |
双重检查源自Java的Singleton单例模式(同步延迟加载)。关于单例模式,有空再总结一下。
参考文献:《Java编程思想》《深入理解Java虚拟机》