在阅读本文前,请思考以下的面试题?
- volatile是什么?
- volatile的特性
- volatile是如何保证可见性的?
- volatile是如何保证有序性的?
- volatile可以保证原子性吗?
- 使用volatile变量的条件是什么?
- volatile和synchronized的区别
- volatile和atomic原子类的区别是什么?
这一章主要是讲解volatile的原理,在开始本文前,我们来看一张volatile的思维导图,先有个直观的认识。
目前的操作系统大多数都是多CPU,当多线程对一个共享变量进行操作时,会出现数据一致性
问题
Java编程语言允许线程访问共享变量,那么为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量,或者把这个变量声明成volatile,可以理解volatile是轻量级的synchronized
。
使用volatile可以在Java线程内存模型确保所有线程看到这个变量的值是一致的,在多个处理器中保证了共享变量的“可见性”
。
两大核心:JMM内存模型(主内存和工作内存)以及happens-before
三条性质:原子性,可见性,有序性
可见性
,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(实现可见性
)实现有序性
)不能实现原子性
)总结:volatile保证了可见性和有序性,同时可以保证单次读/写的原子性
一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
我们先来一个12306抢票的例子.
public class Ticket {
private long sum = 20000;
public void sendTicket(){
for(int i = 0;i<10000;i++){
//卖出一张票
sum-=1;
}
}
public static void main(String[] args) throws Exception{
Ticket ticket = new Ticket();
//创建售票窗线程1
Thread thread1 = new Thread(new Work(ticket));
thread1.setName("售票窗线程-" + 1);
//创建售票窗口线程2
Thread thread2 = new Thread(new Work(ticket));
thread2.setName("售票窗线程-" + 2);
thread1.start();
thread2.start();
// 等待两个线程执行结束
thread1.join();
thread2.join();
System.out.println("sum="+ticket.sum);
}
}
class Work implements Runnable {
private final Ticket ticket;
public Work( Ticket ticket) {
this.ticket = ticket;
}
@Override
public void run() {
try {
ticket.sendTicket();
System.out.println(Thread.currentThread().getName() + "启动时间是" + System.currentTimeMillis());
} catch (Exception e1) {
e1.printStackTrace();
}
}
}
第一次执行结果:
售票窗线程-2启动时间是1574601485313
售票窗线程-1启动时间是1574601485317
sum=2396
再执行一个结果:
售票窗线程-2启动时间是1574601475353
售票窗线程-1启动时间是1574601475355
sum=8029
直觉告诉我们应该是0,因为两个售票窗都卖了10000张票,sum 的值就是0,但实际上的执行结果是个0 到 1000之间的随机数。啥情况啊这是?为啥和我们想象中的不太一样呢?
我们假设线程A和线程B同时开始执行,那么第一次都会将 sum=2000读到各自的CPU缓存里,执行完sum-=1之后,各自CPU缓存里的值都是1,同时写入内存后,我们会发内存中是19999,而不是我们期望的19998。
之后由于各自的CPU缓存里都有了sum的值,两个线程都是基于 CPU缓存里的sum值来计算。这就是缓存的可见性问题。
这里可能又同学会提出疑问。那如果是都使用的上面的缓存中的值,那应该最后的结果也应该是10000才对呀。没错,理想状态下是这样的。但是我们知道两个线程不是同时启动的,有一个时差。
循环10000 次 sum-=1操作如果改为循环1亿张,总票数改为2亿张,并且使用CyclicBarrier让线程能在同一时刻触发,你会发现效果更明显,最终sum的值接近1亿,而不是 0。
改造之后的代码:
public class Ticket {
private long sum = 200000000;
public void sendTicket(){
for(int i = 0;i<100000000;i++){
//卖出一张票
sum-=1;
}
}
public static void main(String[] args) throws Exception{
Ticket ticket = new Ticket();
//创建线程1抢票
CyclicBarrier cyclicBarrier = new CyclicBarrier(2);
//创建售票窗线程1
Thread thread1 = new Thread(new Work(cyclicBarrier,ticket));
thread1.setName("售票窗线程-" + 1);
//创建售票窗口线程2
Thread thread2 = new Thread(new Work(cyclicBarrier,ticket));
thread2.setName("售票窗线程-" + 2);
thread1.start();
thread2.start();
// 等待两个线程执行结束
thread1.join();
thread2.join();
System.out.println("sum="+ticket.sum);
}
}
class Work implements Runnable {
private final CyclicBarrier cyclicBarrier;
private final Ticket ticket;
public Work(CyclicBarrier cyclicBarrier, Ticket ticket) {
this.cyclicBarrier = cyclicBarrier;
this.ticket = ticket;
}
@Override
public void run() {
try {
/**
* CyclicBarrier类的await()方法对当前线程(运行cyclicBarrier.await()代码的线程)进行加锁,然后进入await状态;
* 当进入CyclicBarrier类的线程数(也就是调用cyclicBarrier.await()方法的线程)等于初始化CyclicBarrier类时配置的线程数时;
* 然后通过signalAll()方法唤醒所有的线程。
*/
cyclicBarrier.await();
ticket.sendTicket();
System.out.println(Thread.currentThread().getName() + "启动时间是" + System.currentTimeMillis());
} catch (InterruptedException | BrokenBarrierException e1) {
e1.printStackTrace();
}
}
}
执行结果:
线程-2启动时间是1574601120948
线程-1启动时间是1574601120948
sum=99637289
在单核cpu的石器时代,我们所有的线程都是在一颗CPU上执行,CPU缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个CPU的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。
例如在下面的图中,线程A和线程B都是操作同一个CPU里面的缓存,所以线程A更新了变量a的值,那么线程B之后再访问变量 a,得到的一定是 a 的最新值(线程 A 写过的值)。
在多核CPU的时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存。比如下图中,线程A操作的是CPU-1上的缓存,而线程B操作的是CPU-2上的缓存,很明显,这个时候线程A对变量a的操作对于线程B而言就不具备可见性了。这个就属于硬件程序员给软件程序员挖的“坑”。
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的值读到内部缓存(L1,L2或者其他)后再进行操作,但是操作完不知道何时再写回内存。
从上面的分析,我们可以知道,多核的CPU缓存会导致的可见性问题。
instance = new Singleton();//instance是volatile变量
让我们来看看在处理器下通过工具获取JIT编译器生成的汇编指令来查看对volatile进行写操作的时候,cpu会做什么事?
转换成汇编代码如下:
有volatile修饰的共享变量进行写操作的时候会多出第二行汇编代码,也就是jvm会向处理器发送一条Lock前缀的指令,Lock前缀的指令在多核处理器下会引发两件事情:
一致性协:每个处理器通过嗅探在总线上传播的数据来检查自己的缓存的值是否过期了,当处理器发现自己的缓存行对应的内存过期,在下次访问相同内存地址时,强制执行缓存填充,从系统内存中读取。
简单理解:volatile在其修饰的变量被线程修改时,会强制其他线程在下一次访问该变量时刷新缓存区。
Look 前缀指令会引起处理器缓存回写到内存。Lock 前缀指令导致在执行指令期间,声言处理器的LOCK#信号。在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以独占任何共享内存。但是,在最近的处理器里,LOCK#信号一般不锁总线,而是锁缓存,毕竟锁总线开销的比较大,对干intel486和Pentiuln处理器,在锁操作时,总是在总线上声言LOCK#信号。但在P6和目前的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。
一个处理器的缓存回写到内存会导致其他处理器的缓存无效。 IA-32 处理器和 Iniel 64 处理器使用 MESI (修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候, IA-32和Intel64处理器能嗅探其他处理器访问系统内存和它们的内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如,在 Pentium 和 P6famaly 处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处干共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充
小结
- Lock前缀的指令会引起处理器缓存写回内存;
- 一个处理器的缓存回写到内存会导致其他处理器的缓存失效;
- 当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。
问题:代码编译优化之后会发生什么?
有序性指的是程序按照代码的先后顺序执行,和某一行代码执行的指令的顺序。
我们平时都以为代码都是从上到下一条一条执行的,然后再计算机实际的执行过程中不是这样子的,编译器为了优化性能,有时候会改变程序中语句的先后顺序或者是指令的顺序
在解释有序性前,我们先来看看什么是指令重排?
导致程序有序性的原因是编译优化,指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。但是在多线程环境下,有些代码的顺序改变,有可能引发逻辑上的不正确。有序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们的程序的性能就堪忧了,所以合理的方案是按需禁用缓存或者编译优化。
接下来我们来看一个著名的单例模式双重检查锁的实现
单例模式的特点
单例模式有9种实现模式,因为本编文章主要是介绍编译优化带来的有序性问题,我们这里举一个懒汉式单例模式的例子来说明,如果不知道其他几种方式的话请查查详解9种Java单例模式的文章
代码示例:
class Singleton {
private static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) { //步骤1
synchronized (Singleton.class) {
if (instance == null) //步骤2
instance = new Singleton(); //步骤3
}
}
return instance;
}
}
在以上代码中,instance不用volatile修饰时,输出的结果会是什么呢?我们的预期中代码是这样子执行的:线程A和B同时在调用getInstance()方法,线程A执行步骤1,发现instance为 null,然后同步锁住Singleton类,接着执行步骤2再次判断instance是否为null,发现仍然是null,然后执行步骤3,开始实例化Singleton。这样看好像没啥毛病,可是仔细一想,发现事情并不简单。
这时候,我们来我们先了解一下对象是怎么初始化的?
m = allocate(); //1、分配对象的内存空间
ctorInstance(m); //2、初始化对象
instance = m; //3、使instance指向对象的内存空间
程序为了优化性能,会将2和3进行重排序,此时执行的顺序是1、3、2,在单线程中,对结果是不会有影响的,可是在多线程程序下,问题就暴露出来了。这时候我们回到刚刚的单例模式中,在实例化的过程中,线程B走到步骤1,发现instance不为空,但是有可能因为指令重排了,导致instance还没有完全初始化,程序就出问题了
为了禁止实例化过程中的重排序,我们用volatile对instance修饰
下面我们来改进一下,使用懒汉式双重检查终极版(面试手写推荐)
与第六种方法不同的是,此方法给singleton的声明上加了关键字volatile,进而解决了低概率的线程不安全问题。
volatile 关键字,有两个作用,一是可以禁止指令重排序,二是保证了程序的可见性,在它赋值完成之前,就不会调用读操作(singleton==null)。
class Singleton {
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) { //步骤1
synchronized (Singleton.class) {
if (instance == null) //步骤2
instance = new Singleton(); //步骤3
}
}
return instance;
}
}
对于一般的变量则会被重排序(重排序分析编译器重排序和处理器重排序),而对于volatile则不能,这样会影响其内存语义,所以为了实现volatile的内存语义JMM会限制重排序
。
如果第一个操作为volatile读,则不管第二个操作是啥,都不能重排序。这个操作确保volatile读之后的操作不会被编译器重排序到volatile读之前,其前面的所有普通写操作都已经刷新到主内存中;
如果第一个操作volatile写,不管第二个操作是volatile读/写,禁止重排序。
如果第二个操作为volatile写时,则不管第一个操作是啥,都不能重排序。这个操作确保volatile写之前的操作不会被编译器重排序到volatile写之后;
如果第二个操作为volatile读时,不管第二个操作是volatile读/写,禁止重排序
volatile的底层实现是通过插入内存屏障
,但是对于编译器来说,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,所以,JMM采用了保守策略。如下:
在每一个volatile读操作后面插入一个LoadLoad屏障,用来禁止处理器把上面的volatile读与后面任意操作重排序
在每一个volatile写操作前面插入一个StoreStore屏障,用来禁止volatile写与前面任意操作重排序
在每一个volatile写操作后面插入一个StoreLoad屏障,用来禁止volatile写与后面可能有的volatile读/写操作重排序
在每一个volatile读操作前面插入一个LoadStore屏障,用来禁止volatile写与后面可能有的volatile读/写操作重排序
保守策略下,volatile的写插入屏障后生成的指令示意图:
Storestore 屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了,Storestore 屏障将保障上面所有的普通写在volatile写之前刷新到主内存。
这里比较有意思的是, volatite 写后面的 StoreLoad 屏障的作用是避免volatile写与后面可能有的volatile 读/写操作重排序。
因为编译器常常无法准确判断在一个volatile写的后面是否需要插入一个StoreLoad屏障。为保证能正确实现volatile的内存语义,JMM在采取了保守策略,在每个volatile写的后面,或者在每个 volatile读的前面插入一个StoreLoad屏障。
保守策略下,volatile的读插入屏障后生成的指令示意图:
上面的内存屏障插入策略非常保守,在实际执行中,只要不改变volatile写-读的内存语义,编译器可根据情况省略不必要的屏障
举个例子:
public class Test {
int a ;
volatile int v1 = 1;
volatile int v2 = 2;
public void readWrite(){
int i = v1;//第一个volatile读
int j = v2;//第二个volatile读
a = i+j://普通读
v1 = i+1;//第一个volatile写
v2 =j+2;//第二个volatile写
}
public synchronized void read(){
if(flag){
System.out.println("---i = " + i);
}
}}
针对readWrite方法,编译器在生成字节码的时候可以做到如下的优化:
注意:最后一个storeLoad屏障不能省略。因为第二个volatile写之后,方法立即return,此时编译器无法精准判断后面是否会有vaolatile读或者写。
我们知道在物理世界中,原子是不可以再分的,那么在mydql中也有事务原子性的概念,说的是一个事务里面的所有操作要么全部执行,要么全部不执行,他们是一个整体,不存在只执行了一部分这种情况。
我们知道IO和CPU的速度的差异是指数级别的,所以早期的操作系统就发明了多进程,用来减小这两者之间速度的巨大差异,所以我们在单核的CPU上我们也可以一边听着歌,一边写 Bug,这个就是多进程的功劳。
在操作系统中,通常会允许某个进程执行一小段时间,例如 50 毫秒,过了50毫秒操作系统就会重新选择一个进程来执行(我们称为“任务切换”),这个50毫秒称为“时间片”。
在一个时间片内,如果一个进程进行一个IO操作,例如读个文件,这个时候该进程可以把自己标记为“休眠状态”并出让CPU的使用权,待文件读进内存,操作系统会把这个休眠的进程唤醒,唤醒后的进程就有机会重新获得 CPU 的使用权了。
这里的进程在等待IO时之所以会释放CPU使用权,是为了让CPU在这段等待时间里可以做别的事情,这样一来CPU的使用率就上来了。
此外,如果这时有另外一个进程也读文件,读文件的操作就会排队,磁盘驱动在完成一个进程的读操作后,发现有排队的任务,就会立即启动下一个读操作,这样IO的使用率也上来了。
但是,虽然看似简单,支持多进程分时复用在操作系统的发展史上却具有里程碑意义,Unix就是因为解决了这个问题而名噪天下的。早期的操作系统基于进程来调度CPU,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了。现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”。
Java 并发程序都是基于多线程的,自然也会涉及到任务切换,也许你想不到,任务切换竟然也是并发编程里诡异 Bug 的源头之一。任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条CPU指令完成,例如上面代码中的count += 1,至少需要三条 CPU 指令。
操作系统做任务切换,可以发生在任何一条CPU指令执行完,是的,是CPU指令,而不是高级语言里的一条语句。对于上面的三条指令来说,我们假设count=0,如果线程A在指令1执行完后做线程切换,线程A和线程B按照下图的序列执行,那么我们会发现两个线程都执行了count+=1的操作,但是得到的结果不是我们期望的 2,而是1。
我们潜意识里面觉得count+=1这个操作是一个不可分割的整体,就像一个原子一样,线程的切换可以发生在count+=1之前,也可以发生在count+=1之后,但就是不会发生在中间。我们把一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性。CPU 能保证的原子操作是 CPU指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。
在某些情况下,如果读操作远远大于写操作
,volatile 变量可以提供优于锁的性能优势。
可是volatile变量不是说用就能用的,它必须满足两个约束条件:
第一个条件的限制使volatile变量不能用作线程安全计数器。虽然 i++ 看上去类似一个单独操作,实际上它是一个读取-修改-写入三个步骤的组合操作,必须以原子方式执行,而 volatile不能保证这种情况下的原子操作。正确的操作需要使i的值在操作期间保持不变,而volatile 变量无法做到这一点。
不会引起线程上下文的切换和调度
单次读/写
的原子性;而synchronized则可以变量的可见性,禁止重排序和原子性。Volatile变量可以确保先行关系,即写操作会发生在后续的读操作之前
但是Volatile对复合操作
不能保证原子性。例如用volatile修饰i变量,那么i++操作就不是原子性的。
atomic原子类提供的atomic方法可以让i++这种操作具有原子性,如getAndIncrement()方法会原子性的进行增量操作把当前值加一,其它数据类型和引用变量也可以进行相似操作,但是atomic原子类一次只能操作一个共享变量,不能同时操作多个共享变量
。
总结一下volatile的特性``
最终的写
“内存屏障”
来实现volatile语义单个读/写具有原子性
(32位Long、Double),但是复合操作除外,例如i++如果你觉得文章还不错,你的转发、分享、赞赏、点赞、留言就是对我最大的鼓励。 感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。