volatile的实现原理
1. 简介
上一篇博客中,我们了解了synchronized
是一个重量级的锁,虽然JVM
对它做了很多优化,而下面介绍的volatile
则是轻量级的synchronized
。如果一个变量使用volatile
,则它比使用synchronized
的成本更加低,因为它不会引起线程上下文的切换和调度。
Java
语言规范对volatile
的定义如下:
Java
编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。
通俗点讲就是说一个变量如果用volatile
修饰了,则Java
可以确保所有线程看到这个变量的值是一致的,如果某个线程对volatile
修饰的共享变量进行更新,那么其他线程可以立马看到这个更新,这就是所谓的线程可见性。
2. 内存模型相关概念
2.1 操作系统语义
计算机在运行程序时,每条指令都是在CPU
中执行的,在执行过程中势必会涉及到数据的读写。我们知道程序运行的数据是存储在主存中,这时就会有一个问题,读写主存中的数据没有CPU
中执行指令的速度快,如果任何的交互都需要与主存打交道则会大大影响效率,所以就有了CPU
高速缓存。
CPU
高速缓存为某个CPU
独有,只与在该CPU
运行的线程有关。有了CPU
高速缓存虽然解决了效率问题,但是它会带来一个新的问题:数据一致性。在程序运行中,会将运行所需要的数据复制一份到CPU
高速缓存中,在进行运算时CPU
不再也主存打交道,而是直接从高速缓存中读写数据,只有当运行结束后才会将数据刷新到主存中。举一个简单的例子:
i++
当线程运行这段代码时,首先会从主存中读取i (i=1)
,然后复制一份到CPU
高速缓存中,然后CPU
执行 +1 (2)
的操作,然后将数据(2)
写入到告诉缓存中,最后刷新到主存中。
其实这样做在单线程中是没有问题的,有问题的是在多线程中。如下:假如有两个线程A
、B
都执行这个操作(i++)
,按照我们正常的逻辑思维主存中的i
值应该为3
,但事实是这样么?分析如下:两个线程从主存中读取i
的值(1)
到各自的高速缓存中,然后线程A
执行+1
操作并将结果写入高速缓存中,最后写入主存中,此时主存i=2
,线程B
做同样的操作,主存中的i
仍为2
。所以最终结果为2
并不是3
。这种现象就是缓存一致性问题。
解决缓存一致性方案有两种:
- 通过在总线加
LOCK#
锁的方式 - 通过缓存一致性协议
但是方案1
存在一个问题,它是采用一种独占的方式来实现的,即总线加LOCK#
锁的话,只能有一个CPU
能够运行,其他CPU
都得阻塞,效率较为低下。
第二种方案,缓存一致性协议它确保每个缓存中使用的共享变量的副本是一致的。其核心思想如下:当某个CPU
在写数据时,如果发现操作的变量是共享变量,则会通知其他CPU
告知该变量的缓存行是无效的,因此其他CPU
在读取该变量时,发现其无效会重新从主存中加载数据。
2.2 Java 内存模型
上面从操作系统层次阐述了如何保证数据一致性,下面我们来看一下Java
内存模型,稍微研究一下Java
内存模型为我们提供了哪些保证以及在Java
中提供了哪些方法和机制来让我们在进行多线程编程时能够保证程序执行的正确性。在并发编程中我们一般都会遇到这三个基本概念:原子性、可见性、有序性。
2.2.1 原子性
即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
i = 0; ---1
j = i ; ---2
i++; ---3
i = j + 1; ---4
上面四个操作,只有1
才是原子操作,其余均不是。
- 1 — 在Java中,对基本数据类型变量的读取和赋值操作都是原子性操作;
- 2 — 包含了两个操作:读取
i
,将i
值赋值给j
- 3 — 包含了三个操作:读取
i
值、i + 1
、将+1
结果赋值给i; - 4 — 同
3
一样
在单线程环境下我们可以认为整个步骤都是原子性操作,但是在多线程环境下则不同,Java只保证了基本数据类型的变量和赋值操作才是原子性的(注:在32
位的JDK环境下,对64
位数据的读取不是原子性操作,如long
、double
)。要想在多线程环境下保证原子性,则可以通过锁、synchronized
来确保。
volatile
是无法保证复合操作的原子性。
2.2.2 可见性
指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
在上面已经分析了,在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。Java提供了volatile来保证可见性,当一个变量被volatile
修饰后,如果一个线程修改共享变量后会立即被更新到主内存中,同时会使其他线程该变量的本地缓存无效,当其他线程读取共享变量时,它会直接从主内存中读取。当然,synchronize
和锁都可以保证可见性。
2.2.3 有序性
即程序执行的顺序按照代码的先后顺序执行。
在Java
内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序它不会影响单线程的运行结果,但是对多线程会有影响。Java
提供volatile
来保证一定的有序性。最著名的例子就是单例模式里面的DCL
(双重检查锁)。
3. volatile 原理
volatile
可以保证线程可见性且提供了一定的有序性,但是无法保证原子性(复合操作)。在JVM
底层volatile
的有序性是采用“内存屏障”来实现的。
在执行程序时为了提高性能,编译器和处理器通常会对指令做重排序:
-
编译器重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
-
处理器重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
指令重排序对单线程没有什么影响,他不会影响程序的运行结果,但是会影响多线程的正确性。既然指令重排序会影响到多线程执行的正确性,那么我们就需要禁止重排序。那么JVM
是如何禁止重排序的呢?
happen-before
原则保证了程序的“有序性”,它规定如果两个操作的执行顺序无法从happens-before
原则中推到出来,那么他们就不能保证有序性,可以随意进行重排序。其定义如下:
-
同一个线程中的,前面的操作
happen-before
后续的操作。(即单线程内按代码顺序执行。但是,在不影响在单线程环境执行结果的前提下,编译器和处理器可以进行重排序,这是合法的。换句话说,这一是规则无法保证编译重排和指令重排)。 -
监视器上的解锁操作
happen-before
其后续的加锁操作。(Synchronized 规则) -
对volatile变量的写操作
happen-before
后续的读操作。(volatile 规则) -
线程的
start()
方法happen-before
该线程所有的后续操作。(线程启动规则) -
线程所有的操作
happen-before
其他线程在该线程上调用join
返回成功后的操作。 -
如果
a happen-before b
,b happen-before c
,则a happen-before c
(传递性)。
volatile
是如何保证可见性的?
instance = new Singleton();
转变成汇编代码如下:
0x01a3de1d: movb $0X0,0X1104800(%esi);0x01a3de24: lock addl $0X0,(%esp)
Lock
前缀指令的作用如下:
1)将当前处理器缓存行的数据写回到系统内存
2)这个写回内存的操作会使其他CPU
里缓存了该内存地址的数据无效。
4. 总结
volatile
看起来简单,但是要想理解它还是比较难的,这里只是对其进行基本的了解。volatile
相对于synchronized
稍微轻量些,在某些场合它可以替代synchronized
,但是又不能完全取代synchronized
,只有在某些场合才能够使用volatile
。使用它必须满足如下两个条件:
- 对变量的写操作不依赖当前值;
- 该变量没有包含在具有其他变量的不变式中。
volatile经常用于两个两个场景:状态标记、double check
。