之前,我们在 JavaEE 初阶(6) 这篇文章中,谈到过引起线程不安全的原因,初步了解了 “可见性” “Java内存模型” “指令重排序” ,本章讲解 volatile 会涉及到这三个知识点,详细内容可以参考 JavaEE 初阶(6) 这篇文章。
目录
一. 引入
首先,让我们看一个具体的代码实例:
public class VolatileDemo {
private static int n = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while(n == 0){
//啥都不写
}
System.out.println("t1 线程循环结束");
});
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个数字");
n = scanner.nextInt();
});
t1.start();
t2.start();
}
}
运行结果:
*当我们输入 任何一个非0 数字时, t1 线程并没有结束。这是为什么呢?
我们可以通过 jconsole 发现,此时 t1 线程(Thread-0)依旧是运行状态,与预期结果不符,说明此时出现了bug,同样是线程安全问题!!
此时,问题就出现在while循环这个地方 :
每次循环,都要执行一个 n==0 这样的判断。
JVM 执行的时候,会有两个选择:
- 从内存中读取数据到寄存器中,再执行类似cmp(比较)指令 --> 读取内存,相比读取寄存器,操作速度非常慢
- 直接执行类似cmp(比较)指令,比较0与寄存器中的值,省略第一步 --> 操作速度非常快
此时 JVM执行这个代码的时候,发现每次循环的过程中 --> 1)执行1操作的开销非常大 2)感觉每次执行1操作的结果都一样 3)JVM根本没有意识到 用户可能在未来会修改n
于是JVM就做了一个大胆的操作——直接把操作1优化掉了(即每次循环,不会重新读取内存中的数据,而是直接读取 寄存器 / cache缓存 中的数据)
当JVM做出上述决定之后,此时意味着,循环的开销大幅度降低了!但是,当用户修改 n 的值时,内存中的 n 已经改变了,但是由于 t1 线程每次循环,不会真的读内存,感知不到 n 的改变。内存中的 n 对于 t1 线程来说,是 "不可见” 的,这样就引起了bug --> “内存可见性问题”
"内存可见性"问题 本质上是 编译器 / JVM 对代码进行优化的时候,优化出 bug ~~
- 如果代码是单线程的,编译器 / JVM 的代码优化一般是非常准确的,优化之后,不会影响到逻辑。
- 但是代码如果是多线程的,编译器 / JVM 的代码优化,就可能出现误判(编译器 / JVM 的bug)导致不该优化的地方也给优化了,于是就造成了内存可见性问题了。
* 编译器为啥要做上述的代码优化?为啥不老老实实按照程序员写的代码一板一眼执行?
编译器优化原因:
- 提高执行速度:编译器优化可以减少程序运行时的指令数量,从而减少CPU的负担,提高程序的执行速度
- 减少内存使用:优化可以减少程序在运行时占用的内存空间,例如通过消除死代码、合并变量等方法
- 提高代码效率:编译器优化可以帮助提高代码的执行效率
编译器优化 本身也是一个复杂的话题,站在一个外行人的角度,很难判定某个代码是否是优化的。
编译器优化的效果是非常明显的。我们之前的服务器启动的时候,开启优化,启动时间是10min(当时我们的程序,要从硬盘上加载 100多个G 的数据到内存里)如果不开启优化,启动时间是30min+....
我们可以在 while循环 中加入 sleep操作,让 t1 线程可以感知到 t2 线程的修改:
public class VolatileDemo2 {
private static int n = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while(n == 0){
//此处即使sleep时间非常短,但是刚才的内存可见性问题就消失了
//t2 的修改可以被 t1 感知到
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("t1 线程循环结束");
});
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个数字");
n = scanner.nextInt();
});
t1.start();
t2.start();
}
}
运行结果:
说明加入sleep之后,刚才谈到的针对读取 n 内存数据的优化操作,不再进行了......
但是,和读内存相比,sleep开销是更大的,远远超过了读取内存,就算把读取内存操作优化掉,也是没有意义的~~
* 如果代码的循环里没有 sleep,又希望代码能够没有 bug --> volatile关键字
public class VolatileDemo3 {
private static volatile int n = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while(n == 0){
//啥都不写
}
System.out.println("t1 线程循环结束");
});
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个数字");
n = scanner.nextInt();
});
t1.start();
t2.start();
}
}
运行结果:
二. volatile关键字
volatile关键字:用于修饰变量的类型限定符。它提供了一种轻量级的同步机制,以确保共享变量的“可见性”和“有序性”,但不保证“原子性”。针对一个变量,一个线程修改,一个线程读取时,考虑使用 volatile 修饰这个变量。
a. 保证“可见性”
当一个变量被声明为 volatile 时,它会确保对该变量的写操作立即被其他线程看到。
代码在写入 volatile 修饰的变量的时候
- 改变线程 “工作内存” 中volatile变量副本的值
- 将改变后的副本的值从 “工作内存” 刷新到 “主内存”
代码在读取 volatile 修饰的变量的时候
- 从 “主内存” 中读取volatile变量的最新值到线程的 “工作内存” 中
- 从 “工作内存” 中读取volatile变量的副本
“主内存”(main memory):实际上是 “内存”(其实main不用翻译成“主”,main memory 本身就可以译为“内存”)
“工作内存”(working memory):不是实际意义上的 “内存”,实际上是 CPU的寄存器或者CPU的缓存cache。(其实翻译成 “工作存储区” 更为合适——这个存储区 是用来进行接下来的运算和逻辑判断的......因为Java语言 本身就是想让程序员 不必太关心底层硬件设备的细节和差异,并且cpu结构 也在持续发生变化,所以官方文档干脆不谈硬件细节了,直接使用work memory术语来表示了)
前面我们讨论“内存可见性”时说了,直接访问“工作内存”,速度非常快,但是可能出现数据不一致的情况。
加上 volatile,强制读写内存,速度是慢了,但是数据变的更准确了
* 编译器进行上述优化的前提是:编译器认为,针对这个变量的频繁读取,结果都是固定的。当引入 volatile 时,编译器生成这个代码的时候,就会给这个变量的读取操作附近生成一些特殊的指令,称为“内存屏障” --> 相当于告诉编译器说,这个变量是 “易变” 的。 后续JVM执行到这些特殊指令,编译器就会停止上述的优化,确保每次循环都是从内存中重新读取数据。
编译器的开发者 知道这个场景中 可能出现误判,于是就把权限交给了程序员,让程序员能够部分干预到优化的进行,让程序员显式地提醒编译器,这里别优化~~
b. 保证“有序性”
volatile 可以防止指令重排序优化。编译器和处理器可能会对指令进行重排序以提高性能,但是当变量声明为 volatile 时,它会作为一个“内存屏障”,保证 volatile 变量前后的操作不会被重排序,从而保证了代码的执行顺序。
c. 不保证“原子性”
需要注意的是, volatile 并不能保证复合操作(如自增、自减或检查后执行逻辑)的原子性。如果需要执行复合操作,仍然需要使用synchronized
标签:初阶,Thread,--,t1,编译器,volatile,内存,线程,多线程 From: https://blog.csdn.net/2301_80243321/article/details/140723557