volatile关键字的作用以及原理

物理计算机的并发问题

CPU在摩尔定律的指导下以每18个月翻一番的速度在发展,然而内存和硬盘的发展速度远远不及CPU。内存和硬盘的运算速度和CPU查了几个数量级。为了解决这个问题,CPU厂商在CPU中内置了少量的高速缓存以解决I/O速度和CPU运算速度之间的不匹配问题。

所谓高速缓存也就是,当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。

但是高速缓存引入了一个新的问题:缓存一致性(Cache Coherence)。在多核CPU系统中,每个CPU都有自己的高速缓存,而它们又公用一块主内存(Main Memory)。当多个CPU的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存不一致。如果真发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?

解决缓存一致性问题

一般来说,有两种方式解决缓存一致性问题

  • 通过在总线加LOCK#锁的方式

  • 通过缓存一致性协议

这2种方式都是硬件层面上提供的方式。

在早期的CPU当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。比如上面例子中 如果一个线程在执行 i = i +1,如果在执行这段代码的过程中,在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。

但是上面的方式会有一个问题,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。

缓存一致性协议

由于总线加Lock锁的方式效率低下,后来便出现了缓存一致性协议。最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取

CPU乱序执行优化

除了增加高速缓存外,为了使得处理器内部的运算单元能够尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行是一致的,但不保证程序中各个语句执行的先后顺序和输入的顺序一致。Java虚拟机的即时编译器也有类似的指令重排序(Instrution Reorder)优化。

Java内存模型

Java内存模型和上面处理器、高速缓存、主内存间的交互关系有很高的可比性。Java虚拟机规范视图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽调各种硬件和操作系统的内存访问差异,以实现让Java程序在各个平台下都能达到一致的内存访问效果。

Java内存模型规定了所有的变量(注意这里的变量包括了实例字段、静态字段和构成数组对象的元素,但不包括局部变量于方法参数,后者是线程私有的,不会被共享,自然就不会存在竞争问题。)都存储在主内存中。每条线程还有自己的工作内存(Working Memory,可与前面讲得处理器高速缓存类比),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存完成。

并发中的原子性、可见性、有序性问题

原子性

所谓原子性或原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch (切换到另一个线程)

在java中,可以大致认为对基本数据类型的访问读写具备原子性

例如:

1
int a = 10;

但是下面这行代码便不是原子操作了

1
2
int a =10;
a++;

a++ 实际上包含了三个操作:

  1. 读取变量a的值;
  2. 对a进行加一的操作;
  3. 将计算后的值再赋值给变量a

这三个操作无法构成原子性。

如何保证原子性

由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write,我们大致可以认为基本数据类型的访问读写是具备原子性的(例 外就是long和double的非原子性协定,读者只要知道这件事情就可以了,无须太过在意这些几乎不会发生的例外情况)。

但是在某些业务场景,需要更大范围的原子性保证。Java内存模型提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更髙展次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块一ynchronized关键字,因此在synchronized块之间的操作也具备原子性。

内存间的交互操作

此处扩展下内存间交互操作,Java内存模型中定义了以下8种操作来完成。虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。

  • lock(锁定):作用于主内存中的变量,它把一个变量标识为一个线程独占的状态;
  • unlock(解锁):作用于主内存中的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便后面的load动作使用;
  • load(载入):作用于工作内存中的变量,它把read操作从主内存中得到的变量值放入工作内存中的变量副本
  • use(使用):作用于工作内存中的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作;
  • assign(赋值):作用于工作内存中的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作;
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送给主内存中以便随后的write操作使用;
  • write(操作):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中

如果要把一个变量从主内存复制到工作内存。那就要顺序地执行read和load操作,如果要把变量从工作内存同步回主内存,就要顺序地执行store和write操作。注意,Java内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行。也就是说,read与load之间、store与write之间是可插人其他指令的,如对主内存中的变量a、b进行访问时,一种可能出现顺序是read a、read b, load b, load a。

volatile不能保证原子性

volatile不能保证原子性

由于volatile不能保证原子性,在使用volatile时要注意在不符合以下两条规则的运算场景中,我们仍然要
通过加锁(使用synchronized或java.util.concurrent中的原子类)来保证原子性。

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
  • 变量不需要与其他的状态变量共同参与不变约束。

可见性

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通
过在变景修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。

如何保证可见性

synchronized

同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行
store、write操作)”这条规则获得的。

让你彻底理解Synchronized

final

final关键字的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看见final字段的值。

volatile

被volatile关键字修饰的变量则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。当一个变量被声明为valatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。

下面是一个在双检索单例中,使用volatile关键字的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Singleton {
/**
* 声明单例对象
*/
private static volatile Singleton instance;
/**
* 私有化构造器
*/
private Singleton() {

}
/**
* 双重检查加锁
*
* @return Singleton
*/
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

有序性

所谓有序性是指:程序执行的顺序按照代码的先后顺序执行,禁止进行指令重排序。

Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的; 如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”(Within-ThreadAs-IfSerial Semantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。

如何保证有序性

Java语言提供了 volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的。这条规则决定了持有同一个锁的两个同步块只能串行地进入。

除了这两个关键字外,Java 内存模型还具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从 happens-before 原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

如下是 happens-before 的8条原则,摘自 《深入理解Java虚拟机》。

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
  • 锁定规则:一个 unLock 操作先行发生于后面对同一个锁的 lock 操作;
  • volatile 变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
  • 对象终结规则:一个对象的初始化完成先行发生于他的 finalize() 方法的开始;

在上面双检索单例的代码中,instance = new Singleton(); 这一行代码并不是原子性的,它的底层其实分为三个步骤:

  1. 为 Singleton 对象分配内存 memory = allocate();
  2. 初始化对象
  3. 设置 instance 指向对象的内存空间

因为步骤 2 和步骤 3 需要依赖步骤 1,而步骤 2 和 步骤 3 并没有依赖关系,所以这两条语句有可能会发生指令重排,也就是或有可能步骤 3 在步骤 2 的之前执行。

在这种情况下,步骤 3 执行了,但是步骤 2 还没有执行,也就是说 instance 实例还没有初始化完毕,正好,在此刻,线程 2 判断 instance 不为 null,所以就直接返回了 instance 实例,但是,这个时候 instance 其实是一个不完全的对象,所以,在使用的时候就会出现问题。

而使用 volatile 关键字,也就是使用了 “对一个 volatile修饰的变量的写,happens-before于任意后续对该变量的读” 这一原则,对应到上面的初始化过程,步骤2 和 3 都是对 instance 的写,所以一定发生于后面对 instance 的读,也就是不会出现返回不完全初始化的 instance 这种可能。

volatile保证有序性的原理是通过添加内存屏障实现的。

# 让你彻底理解volatile