【Java EE】多线程初阶-volatile 关键字


本节⽬标
• 掌握 volatile 关键字

6. volatile 关键字

volatile 也是 java 中经典的面试题
有没有整理好的面试题集合??
有,又没有.
面试题都是贯穿在课程中的,我认为,整篇文章就是。面试绝对不是背两个题目, 就能搞定的,背后的前因后果, 来龙去脉都得交代清楚。

volatile 能保证内存可⻅性

volatile 修饰的变量, 能够保证 “内存可⻅性”.
在这里插入图片描述
代码在写⼊ volatile 修饰的变量的时候,
• 改变线程⼯作内存中volatile变量副本的值
• 将改变后的副本的值从⼯作内存刷新到主内存
代码在读取 volatile 修饰的变量的时候,
• 从主内存中读取volatile变量的最新值到线程的⼯作内存中
• 从⼯作内存中读取volatile变量的副本

前⾯我们讨论内存可⻅性时说了, 直接访问⼯作内存(实际是 CPU 的寄存器或者 CPU 的缓存), 速度⾮
常快, 但是可能出现数据不⼀致的情况.
加上 volatile , 强制读写内存. 速度是慢了, 但是数据变的更准确了.

代码⽰例
在这个代码中
• 创建两个线程 t1 和 t2
• t1 中包含⼀个循环, 这个循环以 flag = = 0 为循环条件.
• t2 中从键盘读⼊⼀个整数, 并把这个整数赋值给 flag
• 预期当⽤⼾输⼊⾮ 0 的值的时候, t1 线程结束.

static class Counter {
 public int flag = 0;
}
public static void main(String[] args) {
 Counter counter = new Counter();
 Thread t1 = new Thread(() -> {
	 while (counter.flag == 0) {
	 	// do nothing
	 }
	 System.out.println("循环结束!");
 });
 Thread t2 = new Thread(() -> {
	 Scanner scanner = new Scanner(System.in);
	 System.out.println("输⼊⼀个整数:");
	 counter.flag = scanner.nextInt();
 });
 t1.start();
 t2.start();
}
// 执⾏效果
// 当⽤⼾输⼊⾮0值时, t1 线程循环不会结束. (这显然是⼀个 bug)

while(flag == 0){

}
核心指令2条:
(1)load 从内存读取数据到 cpu 寄存器
(2)cmp(比较,同时会产生跳转)条件成立,继续顺序执行;条件不成立,就跳转到另外一个地址来执行。
由于上述代码,循环体是空着的.后续就没有别的指令. 当前循环旋转速度很快,短时间内出现大量的load 和 cmp 反复执行的效果~~load 执行消耗的时间,会比 cmp 多很多!!多个几干倍,上万倍!!cpu 寄存器的访问速度,也比内存速度快好几个数量级(内存访问速度比硬盘快好几个数量级)
这个执行过程中有两个关键要点:
(1)上述执行过程中,load 速度非常慢,load 操作开销远远超过 条件跳转 !! 执行 一次 load 消耗的时间,顶几干次,上万次 cmp 执行的时间
(2)另外,JVM 还发现每次 load 执行的结果,其实是一样的(要想输入, 过几秒才能输入,在这几秒之内,已经执行了不知道多少次循环(上百亿) )
干脆,JVM 就把上述 load 操作优化掉了:只是第一次真正进行 load,后续再执行到对应的代码,就不再真正 load 了,而是直接读取刚才已经 load 过的寄存器中的值了。把速度慢的给优化掉了,使程序执行速度更快了。

编译器优化
主流编程语言, 编译器的设计者 (对于 Java 来说,谈到的编译器包括 javac 和 jvm)考虑到一个问题: 实际上写 代码的程序员,水平是参差不齐的(差距很大的)。虽然有的程序员水平不高,写的代码效率比较低,编译器在编译执行的时候,分析理解现有代码的意图和效果,然后自动对这个代码进行调整和优化,在确保程序执行逻辑不变的前提下,提高程序的效率。
编译器优化 的效果是很明显~~服务器开启优化,启动时间可能 10min 左右,如果不开启优化,启动时间可能 1h 以上(这个服务器,启动好了要从硬盘上加载 100 多个 G 的数据,cpu 和 IO都是密集的)。
但是大前提是"程序的逻辑不变”。大多数情况下,编译器优化, 都可以做到"逻辑不变"前提,但是在有些特定场景下,编译器优化可能出现"误判"导致逻辑发生改变(想让编译器正确保持,没那么容易。如果是单线程下还好,如果是多线程下,很容易出现误判的!!!)。

优化固然挺好, 是提高效率了.但是因为优化引入 bug,也不合适。(某某公司进行"优化”,其实就是裁员。裁员裁到大动脉了。)
t1 读的是⾃⼰⼯作内存中的内容.上述把 load 优化掉, 导致后续当 t2 对 flag 变量进⾏修改, 此时 t1 感知不到 flag 的变化.(就没有后续 load)

小结: 上述问题本质上还是编译器优化引起的.t1 读的是⾃⼰⼯作内存中的内容.优化掉 load 操作之后,使 t1 线程感知不到 t2 线程的修改。"内存可见性"问题

内存可见性,高度依赖编译器的优化的具体实现,编译器啥时候触发优化,啥时候不触发优化,不好说!!!
上述代码如果稍微改动一点,就可能截然不同了:
如果上述代码中,循环体内存在 IO 操作或者 阻塞操作(sleep),这就会使循环的旋转速度大幅度降低了。
IO 操作:
(1)load,cmp,I0操作 中 I0操作占大头!!此时就没有优化 load 的必要了。(2)另外, IO 操作是不能被优化掉的!!刚才 load 被优化的前提是反复 load 的结果相同,IO 操作,注定是反复执行的结果是不相同的
阻塞操作(sleep):
不加 sleep,一秒钟循环上百亿次,load 操作的整体开销非常大,优化的迫切程度就更高。加了 sleep, 一秒钟循环 1000 次load 整体开销就没那么大了.优化的迫切程度就降低了.

所以 内存可见性 问题,其实是个高度依赖编译器优化的问题。啥时候触发这个问题(优化),啥时候不触发(不优化),不好说。更希望,让咱们代码能够确保,无论当前这个线程代码咋写的,都不要出现这种内存可见性问题。

java 提供了 volatile (强制读取内存!!开销是大了,效率是低了,数据的准确性/逻辑的正确性,提高了),就可以使上述的优化被强制关闭,可以确保每次循环条件都会重新从内存中读取数据了。更多的时候,快没有准更重要的。确实也有时候需要快,不需要准,就不加 volatile。引入 volatile 关键字, 把选择权,交给了程序猿自己。

谈到 volatile ->谈到一个词:JMM

Java 内存模型JMM (Java Memory Model)
Java 规范文档上提到的一个抽象的概念. Java 官方文档的术语.
每个线程,有一个自己的“工作内存”(work memory),同时这些线程共享同一个"主内存”(main memory)。当一个线程循环进行上述读取变量操作的时候,就会把主内存中的数据,拷贝到该线程的工作内存中。后续另一个线程修改,也是先修改自己的工作内存,拷贝到主内存里。由于第一个线程仍然在读自己的工作内存,因此感知不到主内存的变化。
咱们前面讲的是,把读内存的操作,优化成读寄存器操作。同样的意思!!!这里的“工作内存”其实不是咱们说的内存,CPU 的寄存器和缓存,统称为 work memory(工作内存)!!内存专业术语 就是 main memory,“主内存” 才是咱们真正所说的内存。
为啥引入 JMM (主内存,工作内存) 这一套抽象的概念, 而不是直接说 CPU 寄存器?
主要是为了"跨平台”!为了能够兼容不同的硬件设备。不同的 cpu, 用来缓存上述内存数据的区域,可能不同的.Java 程序员不需要关心,硬件(CPU) 差别的。不同 cpu寄存器情况不一样,缓存有没有也不一样, 缓存有几级也不一样…变数比较多… 搞 Java 的大佬希望咱们不必关注这些细节的。而且,作为规范文档,要严谨表述,每次都说 优化到 cpu 寄存器或缓存中… (非常拗口)
Java 文档为了严谨 也为了表述的没那么绕,就引入了 工作内存 这个概念,代指 cpu 寄存器 + 缓存这一套东西。

存储数据, 不只是有内存,还有 外存 (硬盘), 还有 cpu 寄存器,cpu 上还有缓存
在这里插入图片描述

面试的时候,被问到内存可见性问题
就可以按照第一种方式(CPU 寄存器 和 内存)或者第二种方式(JMM:主内存和工作内存)来表述。
我们要知道:内存可见性问题是咋回事,怎么来的, 原因(CPU 寄存器 和 内存/JMM),volatile 能够解决的问题是啥样的, 啥样的问题不能解决。

如果给 flag 加上 volatile

 public volatile int flag = 0;
 
// 执⾏效果
// 当⽤⼾输⼊⾮0值时, t1 线程循环能够⽴即结束.

类似的,上述内存可见性问题,使用 synchronized 也能一定程度的解决~~ 引入 synchronized 其实是因为 加锁操作 本身太重量了.相比于 load 来说, 开销更大,编译器自然就不会对 load 优化了.(和加上sleep/io 操作一样)

volatile 不保证原⼦性

volatile 和 synchronized 有着本质的区别. synchronized 能够保证原⼦性, volatile 保证的是内存可⻅性.

代码⽰例
这个是最初的演⽰线程安全的代码.
• 给 increase ⽅法去掉 synchronized
• 给 count 加上 volatile 关键字.

static class Counter {
 volatile public int count = 0;
 void increase() {
 	count++;
 }
}
public static void main(String[] args) throws InterruptedException {
 final Counter counter = new Counter();
 Thread t1 = new Thread(() -> {
	 for (int i = 0; i < 50000; i++) {
	 	counter.increase();
	 }
 });
 Thread t2 = new Thread(() -> {
	 for (int i = 0; i < 50000; i++) {
	 	counter.increase();
	 }
 });
 t1.start();
 t2.start();
 t1.join();
 t2.join();
 System.out.println(counter.count);
}

此时可以看到, 最终 count 的值仍然⽆法保证是 100000.

volatile 这个关键字, 能够解决内存可见性问题引起的线程安全问题,但是不具备原子性这样的特点~~
synchronized 和 volatile 是两个不同的维度:
(两个线程修改)(一个线程读,一个线程修改)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

T哇

!!!您的鼓励是我最大的动力!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值