并发的三个特性
首先说我们如果要使用 volatile 了,那肯定是在多线程并发的环境下。我们常说的并发场景下有三个重要特性:原子性、可见性、有序性。只有在满足了这三个特性,才能保证并发程序正确执行,否则就会出现各种各样的问题。
原子性,上篇文章说到的 CAS 和 Atomic* 类,可以保证简单操作的原子性,对于一些负责的操作,可以使用synchronized 或各种锁来实现。
可见性,指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
有序性,程序执行的顺序按照代码的先后顺序执行,禁止进行指令重排序。看似理所当然的事情,其实并不是这样,指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。但是在多线程环境下,有些代码的顺序改变,有可能引发逻辑上的不正确。
而 volatile 做实现了两个特性,可见性和有序性。所以说在多线程环境中,需要保证这两个特性的功能,可以使用 volatile 关键字。
volatile 是如何保证可见性的
说到可见性,就要了解一下计算机的处理器和主存了。因为多线程,不管有多少个线程,最后还是要在计算机处理器中进行的,现在的计算机基本都是多核的,甚至有的机器是多处理器的。我们看一下多处理器的结构图:
这是两个处理器,四核的 CPU。一个处理器对应一个物理插槽,多处理器间通过QPI总线相连。一个处理器包含多个核,一个处理器间的多核共享L3 Cache。一个核包含寄存器、L1 Cache、L2 Cache。
在程序执行的过程中,一定要涉及到数据的读和写。而我们都知道,虽然内存的访问速度已经很快了,但是比起CPU执行指令的速度来,还是差的很远的,因此,在内核中,增加了L1、L2、L3 三级缓存,这样一来,当程序运行的时候,先将所需要的数据从主存复制一份到所在核的缓存中,运算完成后,再写入主存中。下图是 CPU 访问数据的示意图,由寄存器到高速缓存再到主存甚至硬盘的速度是越来越慢的。
了解了 CPU 结构之后,我们来看一下程序执行的具体过程,拿一个简单的自增操作举例。
i=i+1;
执行这条语句的时候,在某个核上运行的某线程将 i 的值拷贝一个副本到此核所在的缓存中,当运算执行完成后,再回写到主存中去。如果是多线程环境下,每一个线程都会在所运行的核上的高速缓存区有一个对应的工作内存,也就是每一个线程都有自己的私有工作缓存区,用来存放运算需要的副本数据。那么,我们再来看这个 i+1 的问题,假设 i 的初始值为0,有两个线程同时执行这条语句,每个线程执行都需要三个步骤:
1、从主存读取 i 值到线程工作内存,也就是对应的内核高速缓存区;
2、计算 i+1 的值;
3、将结果值写回主存中;
建设两个线程各执行 10,000 次后,我们预期的值应该是 20,000 才对,可惜很遗憾,i 的值总是小于 20,000 的 。导致这个问题的其中一个原因就是缓存一致性问题,对于这个例子来说,一旦某个线程的缓存副本做了修改,其他线程的缓存副本应该立即失效才对。
而使用了 volatile 关键字后,会有如下效果:
1、每次对变量的修改,都会引起处理器缓存(工作内存)写回到主存;
2、一个工作内存回写到主存会导致其他线程的处理器缓存(工作内存)无效。
因为 volatile 保证内存可见性,其实是用到了 CPU 保证缓存一致性的 MESI 协议。MESI 协议内容较多,这里就不做说明,请各位同学自己去查询一下吧。总之用了 volatile 关键字,当某线程对 volatile 变量的修改会立即回写到主存中,并且导致其他线程的缓存行失效,强制其他线程再使用变量时,需要从主存中读取。
那么我们把上面的 i 变量用 volatile 修饰后,再次执行,每个线程执行 10,000 次。很遗憾,还是小于 20,000 的。这是为什么呢?
volatile 利用 CPU 的 MESI 协议确实保证了可见性。但是,注意了,volatile 并没有保证操作的原子性,因为这个自增操作是分三步的,假设线程 1 从主存中读取了 i 值,假设是 10 ,并且此时发生了阻塞,但是还没有对i进行修改,此时线程 2 也从主存中读取了 i 值,这时这两个线程读取的 i 值是一样的,都是 10 ,然后线程 2 对 i 进行了加 1 操作,并立即写回主存中。此时,根据 MESI 协议,线程 1 的工作内存对应的缓存行会被置为无效状态,没错。但是,请注意,线程 1 早已经将 i 值从主存中拷贝过了,现在只要执行加 1 操作和写回主存的操作了。而这两个线程都是在 10 的基础上加 1 ,然后又写回主存中,所以最后主存的值只是 11 ,而不是预期的 12 。
所以说,使用 volatile 可以保证内存可见性,但无法保证原子性,如果还需要原子性,可以参考,之前的这篇文章。
volatile 是如何保证有序性的
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() 方法的开始;
这里主要说一下 volatile 关键字的规则,举一个著名的单例模式中的双重检查的例子:
class Singleton{
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) { // step 1
synchronized (Singleton.class) {
if(instance==null) // step 2
instance = new Singleton(); //step 3
}
}
return instance;
}
}
如果 instance 不用 volatile 修饰,可能产生什么结果呢,假设有两个线程在调用 getInstance() 方法,线程 1 执行步骤 step1 ,发现 instance 为 null ,然后同步锁住 Singleton 类,接着再次判断 instance 是否为 null ,发现仍然是 null,然后执行 step 3 ,开始实例化 Singleton 。而在实例化的过程中,线程 2 走到 step 1,有可能发现 instance 不为空,但是此时 instance 有可能还没有完全初始化。
什么意思呢,对象在初始化的时候分三个步骤,用下面的伪代码表示:
memory = allocate(); //1. 分配对象的内存空间
ctorInstance(memory); //2. 初始化对象
instance = memory; //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 这种可能。
JVM 底层是通过一个叫做“内存屏障”的东西来完成。内存屏障,也叫做内存栅栏,是一组处理器指令,用于实现对内存操作的顺序限制。
最后
通过 volatile 关键字,我们了解了一下并发编程中的可见性和有序性,当然只是简单的了解。更深入的了解,还得靠各位同学自己去钻研。如果感觉还是有点作用的话,欢迎点个推荐。