面试——volatile
1. 神奇的现象
Volatile是面试里面几乎必问的一个话题,很多朋友仅限于会用阶段,今天我们换个角度去了解Volatile。
先来看一个例子:
package com.qf.test;
public class Demo1 {
public static void main(String[] args) {
MyThread myThread = new MyThread();
myThread.start();
while(true){
if(myThread.isFlag()){
System.out.println("here.....");
}
}
}
}
class MyThread extends Thread {
private boolean flag = false;
public boolean isFlag() {
return flag;
}
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("flag=" + flag);
}
}
虽然两个线程同时运行,第一个线程一直在循环,第二个线程把标记flag改成了true,但是你会发现,控制台永远打印不了“here…”。
这是为什么呢?首先我们先来了解下JMM
2. Java Memory Model
Java内存模型是Java虚拟机规范中定义的一种内存模型规范,也就是说JMM只是一种规范,即标准化。不同的虚拟机厂商依据这套规范,来做底层具体的实现。了解这套规范,先从计算机内存模型开始聊起。
2.1 计算机的内存模型
从图上可以看到,CPU和内存之间加入了一个高速缓存的角色。我们来分析下原因。在目前的计算机中,CPU的计算速度远远大于计算机存储数据的速度。为了提升整体性能,在CPU和内存之间加入了高速缓存。
CPU将计算需要用到的数据暂存进缓存中。当计算结束后再将缓存中的数据存入到内存中。这样CPU的运算可以在缓存中高速进行。
但是这种情况在多核CPU中会存在一个问题,多个CPU使用各自的高速缓存,但多个高速缓存在共享同一个内存,此时就有可能一个CPU更新了数据,但另一个CPU还在操作老数据。导致脏数据的读写问题,此时就需要缓存一致性协议来解决这个数据一致性的问题。
2.2 JMM
计算机的内存模型帮我们简单梳理了下思路,接下来我们回到JMM。JMM做了一些约定和规范。
一段代码中的多线程,操作的共享变量,即成员变量或类变量。线程在操作共享变量时,先从主内存中将变量拷贝到工作内存中,然后线程在自己的工作内存中操作。线程不能访问别人工作内存中的内容。线程间对变量值的传递是通过主内存进行中转。这个操作就会导致可见性问题,即一个线程更新了共享变量,但另一个已经加载了数据到自己工作内存的线程,是没办法看到最新的变量的值。这也是文章开始的那个demo出现的问题。
3. 可见性的解决方案
3.1 给代码加锁
public class Demo2 {
public static void main(String[] args) {
MyThread myThread = new MyThread();
myThread.start();
while(true){
synchronized (myThread) {
if(myThread.isFlag()){
System.out.println("here.....");
}
}
}
}
}
为什么给代码加锁就能解决可见性问题呢?
3.2 JMM数据同步
-
lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态 (触发总线锁)
-
unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
-
read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
-
load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
-
use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
-
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量
-
store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作
-
write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中
程序会按照上面的流程,在使用synchronized的代码前后,线程会获得锁,清空工作内存。read将数据读到工作内存并load成为最新的副本,再通过store和write将数据写会主内存。而获取不到锁的线程会阻塞等待,所以变量的值一直都是最新的。
3.3 使用Volatile保证可见性
除了Synchronized外,Volatile也能保证可见性。
package com.qf.test;
public class VisibilityVolatileDemo3 {
public static void main(String[] args) {
MyVolatileThread myThread = new MyVolatileThread();
myThread.start();
while (true) {
if (myThread.isFlag()) {
System.out.println("here.....");
}
}
}
}
class MyVolatileThread extends Thread {
private volatile boolean flag = false;
public boolean isFlag() {
return flag;
}
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("flag=" + flag);
}
}
使用了volatile后,操作数据的线程先从主内存中把数据读到自己的工作内存中。如果有线程对volatile修饰的变量进行操作并且写回了主内存,则其他已读取该变量的线程中,该变量副本将会失效。其他线程需要从主内存中重新加载一份最新的变量值。
Volatile保证了共享变量的可见性。当有的线程修改了Volatile修饰的变量值并写回到主内存后,其他线程能立即看到最新的值。
但是Volatile不能保证原子性。
4. Volatile不能保证原子性
先看下面这个例子。
package com.qf.atomicity;
import java.util.concurrent.CountDownLatch;
public class AtomicityDemo1 {
private static volatile int count = 0;
public static void main(String[] args) {
CountDownLatch countDownLatch = new CountDownLatch(1);
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(() -> {
try {
countDownLatch.await();
for (int i1 = 0; i1 < 1000; i1++) {
count++;
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
thread.start();
}
try {
Thread.sleep(500);
countDownLatch.countDown();
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(count);
}
}
在这个例子中,并不会每次count的结果是10000,有的时候不足10000。于是,做如下调整。
private static volatile int count = 0;
当给变量count前加上了volatile修饰后,发现结果依然有可能不足10000。为什么会这样,我们先来看下count++的执行过程。
count++在执行引擎中被分成了两步操作:
- count = 0,先将count值初始化为0
- count=count+1,再执行+1操作
这两步操作在左边的线程执行完第一步,但还没执行第二步时右边的线程抢过CPU控制权开始完成+1的操作后写入到主内存,于是左边的线程工作内存中的count副本失效了,相当于左边这一次+1的操作就被覆盖掉了。
因此,Volatile不能保证原子性。
该如何保证原子性呢?——加锁。
package com.qf.atomicity;
import java.util.concurrent.CountDownLatch;
public class AtomicityDemo1 {
private static volatile int count = 0;
static Object object = new Object();
public static void main(String[] args) {
CountDownLatch countDownLatch = new CountDownLatch(1);
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(() -> {
try {
countDownLatch.await();
for (int i1 = 0; i1 < 1000; i1++) {
synchronized (object) {
count++;
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
thread.start();
}
try {
Thread.sleep(500);
countDownLatch.countDown();
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(count);
}
}
5. Volatile保证有序性
5.1 指令重排
我们先来看这个例子来了解什么是指令重排。
package com.qf.reorder;
public class ReorderDemo {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
int i=0;
for(;;){
i++;
x=0;
y=0;
a=0;
b=0;
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
shortWait(10000);
a = 1;
x = b;
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
y = a;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
String result = "第" + i + "次 :" + x + "," + y ;
System.out.println(result);
if(x == 0 && y == 0) {
break;
}
}
}
public static void shortWait(long interval) {
long start = System.nanoTime();
long end;
do {
end = System.nanoTime();
} while (start + interval >= end);
}
}
在这个例子中,x和y的值只会有三种情况:
- x=1 y=1
- x=0 y=1
- x=1 y=0
如果发生指令重排,才会出现第四种:
- x=0 y=0
为了提高性能,编译器和处理器常常会对既定代码的执行顺序进行指令重排序。
系统为了提升执行效率,在不影响最终结果的前提下,系统会对要执行的指令进行重排序。
重排序分为以下几种:
- 编译器优化的重排序:编译器在不改变单线程程序语义的前提下重新安排语句的执行顺序。
- 指令级并行的重排序:如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序:由于数据读写过程中涉及到多个缓冲区,这使得加载和存储的操作看上去可能是乱序执行,于是需要内存系统的重排序。
5.2 as-if-serial语义
不管怎么重排序,单线程程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守
“as-if-serial语义”。
也就是说,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
5.3 使用Volatile禁止指令重排
使用Volatile可以禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象。Volatile通过设置内存屏障(Memory Barrier)来解决指令重排优化。
5.4 内存屏障
Java编译器会在生成指令系列时在适当的位置会插入“内存屏障指令”来禁止特定类型的处理器重排序。下面是内存屏障指令:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1;LoadLoad;Load2 | 保证Load1操作在Load2之前执行 |
StoreStore | Store1;StoreStore;Store2 | 在store2及其后的写操作执行前,保证store1的写操作已刷新到主内存 |
LoadStore | Load1;LoadStore;Store2 | 在stroe2及其后的写操作执行前,保证load1的读操作已结束 |
StoreLoad | Store1;StoreLoad;Load2 | 保证store1的写操作已刷新到主内存之后,load2及其后的读操作才能执行 |
如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。
接下来看一个经典的懒汉式单例模式,可能被指令重排而导致错误的结果。
package com.qf.reorder;
public class Singleton {
private static volatile Singleton instance;
//私有的构造器
private Singleton() {
}
public static Singleton getInstance() {
//第一重检查锁定
if (instance == null) {
//同步锁定代码块
synchronized (Singleton.class) {
//第二重检查锁定
if(instance==null){
//注意:这里是非原子操作
instance = new Singleton();
}
}
}
return instance;
}
}
如果在高并发场景下,因为instance = new Singleton();
是非原子操作,这个对象的创建要经历这么几个步骤:
- 分配内存空间
- 调用构造器来初始化实例
- 返回地址给引用。
如果此时发生了指令重排,先执行了分配内存空间后直接返回地址给引用,再进行初始化。此时在这个过程中另一个线程抢占,虽然引用不为空,但对象还没有被实例化,于是报空指针异常。
可以通过加入volatile来防止指令重排。
package com.qf.reorder;
/**
* @author Thor
* @公众号 Java架构栈
*/
public class Singleton {
//防止指令重排
private static volatile Singleton instance;
//私有的构造器
private Singleton() {
}
public static Singleton getInstance() {
//第一重检查锁定
if (instance == null) {
//同步锁定代码块
synchronized (Singleton.class) {
//第二重检查锁定
if(instance==null){
//注意:这里是非原子操作
instance = new Singleton();
}
}
}
return instance;
}
}
那么Volatile是怎么禁止指令重排?
5.5 Volatile指令重排语义
为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制定
volatile重排序规则表:
第一个操作 | 第二个操作:普通读写 | 第二个操作:volatile读 | 第二个操作:volatile写 |
---|---|---|---|
普通读写 | 可以重排 | 可以重排 | 不可以重排 |
volatile读 | 不可以重排 | 不可以重排 | 不可以重排 |
volatile写 | 可以重排 | 不可以重排 | 不可以重排 |
这个规则在代码中体现:
package com.qf.reorder;
public class MemoryBarrierDemo {
int a;
public volatile int m1 = 1;
public volatile int m2 = 2;
public void readAndWrite() {
int i = m1; // 第一个volatile读
int j = m2; // 第二个volatile读
a = i + j; // 普通写
int i = m1; // 第一个volatile读
m1 = i + 1; // 第一个volatile写
m2 = j * 2; // 第二个volatile写
a = i + j; // 普通写
}
}
6. MESI缓存一致性协议
在介绍Volatie保证可见性时,我们说到当两个线程在操作一个volatile修饰的变量时,操作数据的线程先从主内存中把数据读到自己的工作内存中。如果有线程对volatile修饰的变量进行操作并且写回了主内存,则其他已读取该变量的线程中,该变量副本将会失效。其他线程需要从主内存中重新加载一份最新的变量值。
那么被迫更新变量的线程是怎么知道操作的数据已被其他线程更新了呢?这就跟MESI缓存一致性协议有关系。
早期技术较为落后,对总线上锁直接使用总线锁,也就是说CPU1访问到,CPU2一定不能操作,总线锁并发性较差。MESI方式上锁是目前较为和谐的总线上锁的方式。
MESI协议缓存状态是四个单词的首字母:
- M(Modified修改):当cpu2对变量进行修改时,现在cpu内的缓存行中上锁,并向总线发信号,此时cpu2中的变量状态为M
- E(Exclusive独享):当cpu1读取一个变量时,该变量在工作内存中的状态是E
- S(Shared共享):当cpu2读取该变量时,两个cpu中该变量的状态由E转为S。
- I(Invalid无效):cpu1嗅探到变量被其他cpu修改的信号,于是将自己缓存行中的变量状态设置为i,即失效。则cpu1再从内存中获取最新数据。
6.1 总线风暴
由于Volatile的MESI缓存⼀致性协议,需要不断的从主内存嗅探和cas不断循环,⽆效交互会导致总线带宽达到峰值。所以不要⼤量使⽤Volatile,⾄于什么时候去使⽤Volatile,什么时候使⽤锁,根据场景区分。
7. 总结
7.1 Volatile
volatile修饰符适⽤于以下场景:某个属性被多个线程共享,其中有⼀个线程修改了此属性,其他线程可以⽴即得到修改后的值,⽐如booleanflag;或者作为触发器,实现轻量级同步。
volatile属性的读写操作都是⽆锁的,它不能替代synchronized,因为它没有提供原⼦性和互斥性。因为⽆锁,不需要花费时间在获取锁和释放锁_上,所以说它是低成本的。
volatile只能作⽤于属性,我们⽤volatile修饰属性,这样compilers就不会对这个属性做指令重排序。
volatile提供了可⻅性,任何⼀个线程对其的修改将⽴⻢对其他线程可⻅,volatile属性不会被线程缓存,始终从主 存中读取。
volatile可以在单例双重检查中实现可⻅性和禁⽌指令重排序,从⽽保证安全性。
7.2 Volatile和Synchronized区别
volatile只能修饰实例变量和类变量,⽽synchronized可以修饰⽅法,以及代码块。
volatile保证数据的可⻅性,但是不保证原⼦性(多线程进⾏写操作,不保证线程安全);⽽synchronized是⼀种排他(互斥)的机制。 volatile⽤于禁⽌指令重排序:可以解决单例双重检查对象初始化代码执⾏乱序问题。
volatile可以看做是轻量版的synchronized,volatile不保证原⼦性,但是如果是对⼀个共享变量进⾏多个线程的赋值,⽽没有其他的操作,那么就可以⽤volatile来代替synchronized,因为赋值本身是有原⼦性的,⽽volatile⼜保证了可⻅性,所以就可以保证线程安全了。