前言需要
咱们来看看不同大厂间接波及到的一些无关 volatile 的面试题
蚂蚁花呗:请你谈谈 volatile 的工作原理
今日头条:Volatile 的禁止指令重排序有什么意义?synchronied 怎么用?
蚂蚁金服:volatile 的原子性问题? 为什么 i ++ 这种不反对原子性? 从计算机原理的设计来讲下不能保障原子性的起因
一、volatile 是什么?
Java 语言标准第三版中对 volatile 的定义如下:java 编程语言 容许线程访问共享变量,为了确保共享变量能被精确和统一的更新,线程应确保通过排他锁独自取得这个变量
。
Java 语言提供了 volatile,在某些状况下比锁更加不便。
如果一个字段被申明为 volatile,java 线程内存模型确保所有线程看到这个变量的值是统一的
从下面的官网定义咱们可简略一句话阐明:volatile 是轻量级的同步机制次要有三大个性:保障可见性、不保障原子性、禁止指令重排
那么仅接问题就来了:什么是可见性?什么是不保障原子性?指令重排请你说说?
二、volatile 的可见性特色
在说可见性之前,咱们须要从 JMM 开始说起,不然怎么讲?
咱们晓得 JVM 是 Java 虚拟机,JMM 是什么?答:Java 内存模型
那么 JMM 与 volatile 有什么关系?
别急,咱们先来理解一下 JMM 是个什么玩意先
JMM(Java 内存模型 Java Memory Model, 简称 JMM)自身 是一种形象的概念并不实在存在,它形容的是一组规定或标准
。
通过这组标准定义了 程序中各个变量 (包含实例字段,动态字段和形成数组对象的元素) 的拜访形式
。
简略来说就像中国的十二生肖,其中有一龙,但你能在动物园里牵一头进去吗?
这龙其实就是十二生肖之一,是一种标准,占位,约定,有一个地位属龙
JMM 对于对于同步的规定
1. 线程 解锁前
,必须把 共享变量的值刷新回主内存
2. 线程 加锁前
,必须 读取主内存的最新值
到本人的 工作内存
3. 加锁与解锁 要同一把锁
什么玩意?又多两个知识点,什么是主内存、工作内存?
对于咱们工作中的数据存储大略是这样:硬盘 < 内存 <CPU
比如说当咱们的小明同学存储在主内存中
这时有三个线程须要批改小明的年龄,那么会怎么操作呢?
如果线程 t1,将小明的年龄批改为:37,这时会怎么样呢?
咱们须要一种机制,能晓得某线程操作完后写回主内存及时告诉其余线程
简略的来说:比方下一节咱们班的语文课批改为数学课,须要马上告诉给咱们班所有同学,下节课改为数学课了
论断:只有有变动,立刻收到最新消息
JMM 的主内存与工作内存形容
因为 JVM 运行程序的实体是线程
,而每个线程创立时 JVM 都会为其创立 一个工作内存 (有些中央称为栈空间)
,工作内存是 每个线程的公有数据区域
。
而 Java 内存模型
中规定 所有变量都存储在主内存
,主内存是 共享内存区域
, 所有线程都能够拜访
但线程对 变量的操作 (读取赋值等) 必须在工作内存中进行
,首先要将 变量从主内存拷贝的本人的工作内存空间
,而后对变量进行操作
操作实现后再 将变量写回主内存
, 不能间接操作主内存中的变量
,各个线程中的 工作内存中存储着主内存中的变量正本拷贝
。
因而 不同的线程间
无奈 拜访对方的工作内存
,线程间的通信(传值) 必须通过主内存来实现
,其简要拜访过程如下图:
论断:当某线程批改完后并写回主内存后,其余线程第一工夫就能看见,这种状况称:可见性
示例代码来意识可见性
class TestData{
int number = 0;
// 当办法调用的时候,number 值改为 60
public void addNum(){this.number = 60;}
}
public static void main(String[] args) {TestData data = new TestData();
new Thread(() -> {System.out.println(Thread.currentThread(). getName()+"\t come in");
try {
// 暂停一会
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {e.printStackTrace();
}
// 调用办法从新赋值
data.addNum();
System.out.println(Thread.currentThread(). getName()+"\t updated number value:" +data.number);
},"AAA"). start();
while(data.number == 0){ }
System.out.println(Thread.currentThread(). getName()+"\t getMessage number value:"+data.number);
}
运行后果如下:AAA come in
AAA updated number value: 60
然而有没有发现,当将 number 批改为 60 的时候,main 主线程并不知道
所以咱们的 main 线程须要被告诉,须要被第一工夫看见批改的状况
class TestData{
volatile int number = 0;
// 当办法调用的时候,number 值改为 60
public void addNum(){this.number = 60;}
}
public static void main(String[] args) {TestData data = new TestData();
new Thread(() -> {System.out.println(Thread.currentThread(). getName()+"\t come in");
try {
// 暂停一会
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {e.printStackTrace();
}
// 调用办法从新赋值
data.addNum();
System.out.println(Thread.currentThread(). getName()+"\t updated number value:" +data.number);
},"AAA"). start();
while(data.number == 0){ }
System.out.println(Thread.currentThread(). getName()+"\t getMessage number value:"+data.number);
}
运行后果如下:AAA come in
AAA updated number value: 60
main getMessage number value: 60
这时咱们的 main 接管到最新状况,并没有进入 while 循环,没有像刚刚那样始终傻傻的期待,所以间接 getMessage 输入最新的值
三、volatile 的原子性特色
首先咱们来看看什么是原子性?
指的是:不可分割,完整性,也即某个线程正在做某个具体业务时,两头不能够被加塞或者被宰割。须要整体残缺要么同时胜利,要么同时失败
。
比如说:来上课同学在黑板上签到本人的名字,是不能被打断或者批改的
那么咱们下面依据官网的定义总结一句话阐明,提到过不保障原子性
那么为什么会呈现不保障原子性呢?
咱们后面说到应用 volatile 能够让其余线程第一工夫看到最新状况
然而这也是不好的中央,咱们用案例来说说这种状况是怎么回事
class TestData{
volatile int number = 0;
// 当办法调用的时候,number 值 ++
public void addNumPlus(){number ++;}
}
咱们采纳 for 循环来模仿二十个线程,每个线程做 1000 次的调用形式
public static void main(String[] args) {TestData data = new TestData();
for (int i = 1; i<= 20; i++){new Thread(() -> {for (int j= 1; j<= 1000; j++){
// 调用办法从新赋值
data.addNumPlus();}
},String.valueOf(i)). start();}
// 须要期待下面 20 个线程都全副计算实现后,再 main 线程获得最终的后果值看是多少?
while(Thread . activeCount() > 2){Thread.yield();
}
System.out.println(Thread.currentThread().getName()+"\t finally number value"+ data.number);
运行后果如下:main finally number value19853
咱们应用 volatile 来保障可见性,按理来说 20 个线程每个做 1000 次
咱们的到的后果应该是 20000 才对,为什么是 19853 呢??why!
图解为什么不保障原子性
还记得咱们的 JMM 规定 所有变量都存储在主内存
,而线程对 变量的操作 (读取赋值等) 必须在工作内存中进行
吗?
首先要将 变量从主内存拷贝的本人的工作内存空间
,而后对变量进行操作
所以咱们以后的 t1、t2、t3 初始值为 0
当咱们的线程调用办法进行 ++ 的时候,拷贝正本到本人的内存空间
比如说 t1、t2、t3 线程各自在本人的空间 ++ 完后,将变量写回主内存
这时因为线程之间交织,在某一时间段内呈现了一些问题
导致被 t2 线程写入主内存,刷新数据写回主内存
咱们 volatile 保障了可见性,这时应该是第一工夫告诉其余线程
这也就是为什么不与咱们想的一样,是 20000,反而是 19853
图解解读字节码 ++ 操作做了哪些事件
这里应用新的类 T1,抽取进去但等同代码是一样的
咱们依据 add 办法的事件,先看看它做了哪些事件
噢,看了剖析图,是否理解了理论 n ++ 分了三步骤
当咱们的线程 t1、t2、t3 执行第一步拷贝正本到本人空间
当咱们的线程 t1、t2、t3 执行第二步在本人空间操作变量
当咱们的线程 t1、t2、t3 执行第三步将值写回给主内存时
volatile 怎么解决原子性问题
1. 增加 synchronized 的形式
2. 应用 AtomicInteger
class TestData{
volatile int number = 0;
// 当办法调用的时候,number 值改为 60
public void addNum(){this.number = 60;}
public void addNumPlus(){number ++;}
AtomicInteger atomicInteger = new AtomicInteger();
public void addAtomic(){atomicInteger.getAndIncrement();
}
}
public static void main(String[] args) {TestData data = new TestData();
for (int i = 1; i<= 20; i++){new Thread(() -> {for (int j= 1; j<= 1000; j++){
// 调用办法从新赋值
data.addNumPlus();
data.addAtomic();}
},String.valueOf(i)). start();}
// 须要期待下面 20 个线程都全副计算实现后,再 main 线程获得最终的后果值看是多少?
while(Thread . activeCount() > 2){Thread.yield();
}
System.out.println(Thread.currentThread().getName()+"\t int finally number value:"+ data.number);
System.out.println(Thread.currentThread().getName()+"\t AtomicInteger finally number value:"+ data.atomicInteger);
}
运行后果如下:main int finally number value:19966
main AtomicInteger finally number value:20000
为什么应用 AtomicInteger 能够解决这个问题呢?(小编程度不够,前面再补)
四、volatile 的指令重排
那么咱们来聊聊什么是指令重排,什么是指令重排?
其实就是有序性,简略的来说程序员个别写的代码长这样
然而在咱们的电脑机器眼里,咱们的代码长这样
换句话说,什么是指令重排的呢?
为了保障快、准、稳,会做一些指令重排进步性能
单线程环境外面确保程序最终执行后果和代码程序执行的后果统一。
处理器在进行重排序时必须要思考指令之间的 数据依赖性
多线程环境中载程交替执行, 因为编泽器优化重排的存在,两个线程中应用的变量是否保障一致性是无奈确定的后果无奈预测
示例一:班上同学答题
咱们有五道题
当只有一个同学的时候,咱们能够轻易抢,都是一题一题有执行程序
当有多个同学的时候,咱们无法控制程序,抢到哪一题就是哪一题
示例二:代码块执行程序
public void mySort()
{
int x=11; // 语句 1
int y=12; // 语句 2
x= x + 5; // 语句 3
y= x * x; // 语句 4
}
当咱们单线程的时候,他的程序是 1234
当咱们多线程的时候,他有可能程序就是:2134、1234 了
那么请问:执行程序能够是 4132、4123 呢?
答案是不能够的,因为必须要思考指令之间的 数据依赖性
示例三:代码执行程序
请问 x y 是多少?答:x = 0 y = 0
如果编译器对这段代码进行从新优化后,可能会呈现以下状况
请问 x y 是多少?答:x = 2 y = 1
示例四:代码块执行程序
public class ReSortSeqDemo{
int a=0;
boolean flag = false;
public void method01(){
a =1; // 语句 1
flag = true;// 语句 2
}
public void method02(){if(flag){a=a+5; // 语句 3}
System.out.println("*****retValue:"+a);
}
}
如果示例代码呈现指令重排的状况,语句 1,语句 2 的程序便从 1 -2,变成 2 -1,这个时候 flag = true
当两个线程有一个线程抢到 flag = true 就会执行上面的 if 判断
这时就会有两个后果:a = 6、a =5
volatile 禁止实现指令重排优化
volatile 禁止实现指令重排优化,从而防止多线程下程序呈现乱序执行的景象
先理解一个概念,内存屏障(Memory Barrier)又称内存栅栏
,是一个 CPU 指令,他的作用有两个作用:
1.保障特定操作的执行程序
2. 保障 某些变量的内存可见性(利用该个性实现 volatile 的内存可见性)
因为 编译器和处理器都能执行指令重排优化
。如果在指令间插入一条 MemoryBarrier 则会通知编译器和 CPU, 不论什么指令都不能让这条 Memory Barrier 指令重排序
。
也就是说 通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化
。
内存屏障另外一个作用是 强制刷出各种 CPU 的缓存数据,因而任何 CPU 上的线程都能读取到这些数据的最新版本
。
五、单例模式下的 volatile
咱们都晓得单例模式下懒汉有非线程平安的状况产生,常见的形式下采纳 DCL(Double Check Lock)
public static Singleton getInstance() {if(instance == null) {synchronized (Singleton.class) {if(instance == null) {instance = new Singleton();
}
}
}
return instance;
}
那么多线程状况下会指令重排导致读取的对象是半初始化状态状况
其实 DCL 机制也不肯定是线程平安的状况,起因是 有指令重排
起因在于某一个线程执行第一次检测的时候有以下状况
1. 读取到 instance!=null
2.instance 的援用对象没有实现初始化
简略来说:分座位,有一个叫张三的人一个小时候才来坐这个地位
实践上座位调配进来了,但实际上人并没有到,有名无实
咱们来剖析一下 instance = new Singleton(); 这一步代码
1.memory = allocate(); //1. 调配对象内存空间
2.instance(memory);//2. 初始化对象
3.instance = memory; //3. 设置 instance 指向刚调配的内存地址,此时 instance! =null
简略来说对应的步骤是
1. 有个张三的须要调配座位,我为给他留一个地位
2. 给张三的地位调配好网线,电脑,擦洁净桌子
3. 一个小时后张三到了把地位给他坐下,进行上课
尽管说这是实践上来说是这样的,然而很道歉 步骤 2 和步骤 3 不存在数据依赖关系
,而且 无论重排前还是重排后程序的执行后果在单线程中并没有扭转
,因而这种重排优化是容许的。
指令重后变成了以下的执行程序
1.memory = allocate(); //1. 调配对象内存空间
2.instance = memory; //3. 设置 instance 指向刚调配的内存地址,此时 instance! =null
3.instance(memory);//2. 初始化对象
然而指令重排只会保障串行语义的执行的一致性(单线程),但并不会关怀多线程间的语义一致性。
所以当一条线程拜访 instance 不为 nul 时,因为 instance 实例未必已初始化实现,也就造成了线程平安问题。
即示例未初始化实现,保留的是默认值,这样也是出问题
所以应用 volatile 禁止指令重排,老老实实按程序来
参考资料
尚硅谷:Java 大厂面试题选集(周阳主讲):volatile