概述
JMM 规范指出, 每一个线程都有自己的工作内存(working memory), 当变量的值发生变化时, 先更新自己的工作内存, 然后再拷贝到主存(main memory), 这样其他线程就能读取到更新后的值了.
注意: 工作内存和主存是 JMM 规范里抽象的概念, 在 JVM 的内存模型下, 可以将 CPU 缓存对应作线程工作内存, 将 JVM 堆内存对应主存.
写线程更新后的值何时拷贝到主存? 读线程何时从主存中获取变量的最新值? hotspotJVM 中引入 volatile 关键字来解决这些问题, 当某个变量被 volatile 关键字修饰后, 多线程对该变量的操作都将直接在主存中进行. 在 CPU 时钟顺序上, 某个写操作执行完成后, 后续的读操作一定读取的都是最新的值.
内存可见性带来的问题
如下代码片段, 写线程每隔 1 秒递增共享变量 counter, 读线程是个死循环, 如果读线程始终能读取到 counter 的最新值, 那么最终的输出应该是 12345.
- public class App {
- // 共享变量
- static int counter = 0;
- public static void main(String[] args) {
- Thread thread1 = new Thread(() -> {
- int temp = 0;
- while (true) {
- if (temp != counter) {
- temp = counter;
- // 打印 counter 的值, 期望打印 12345
- System.out.print(counter);
- }
- }
- });
- Thread thread2 = new Thread(() -> {
- for (int i = 0; i <5; i++) {
- counter++;
- // 等待 1 秒, 给读线程足够的时间读取变量 counter 的最新值
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- // 退出程序
- System.exit(0);
- });
- thread1.start();
- thread2.start();
- }
- }
在没有 volatile 的情况下, 实际的输出结构如下:
- 1
- Process finished with exit code 0
通过 volatile 解决问题
将共享变量用 volatile 关键字修饰即可, 如下:
- // 共享变量
- static volatile int counter = 0;
再次执行程序, 输出结果如下:
- 12345
- Process finished with exit code 0
综上, volatile 关键字使得各个线程对共享变量的操作变得一致. 在非 volatile 字段上做更新操作时, 无法保证其修改后的值何时从工作内存 (CPU 缓存) 刷新到主存. 对于非 volatile 字段的读操作也是如此, 无法保证线程何时从主存中读取最新的值.
volatile 无法保证线程安全性
如下代码片段, 多个线程同时递增一个计数器:
- public class App {
- // 共享变量
- static volatile int counter = 0;
- public static void main(String[] args) throws InterruptedException {
- Thread thread1 = new Thread(() -> {
- for (int i = 0; i <10000; i++) {
- counter++;
- }
- });
- Thread thread2 = new Thread(() -> {
- for (int i = 0; i <10000; i++) {
- counter++;
- }
- });
- thread1.start();
- thread2.start();
- thread1.join();
- thread2.join();
- System.out.println("总和:" + counter);
- }
输入结果:
总和: 12374
如果 volatile 能保证线程安全, 那么输出结果应该是 20000, 但上面的代码输出 12374, 所以说, volatile 不能解决线程安全 (thread) 的问题.
所以, 还是要通过其他手段来解决多线程安全的问题, 比如 synchronized.
volatile 和 synchronized 的区别
在上述的代码示例中, 我们并没有涉及到多线程竞态 (race condition) 的问题, 核心点是 "多线程情况下, 对共享变量的写入如何被其他线程及时读取到".
synchronized 关键字是 Java 中最常用的锁机制, 保证临界区 (critical section) 中的代码在同一个时间只能有一个线程执行, 临界区中使用的变量都将直接从主存中读取, 对变量的更新也会直接刷新到主存中. 所以利用 synchronized 也能解决内存可见性问题.
代码如下:
- public class App {
- // 共享变量
- static int counter = 0;
- public static void main(String[] args) {
- // 读取变量的线程
- Thread readThread = new Thread(() -> {
- int temp = 0;
- while (true) {
- synchronized (App.class) {
- if (temp != counter) {
- temp = counter;
- // 打印 counter 的值, 期望打印 12345
- System.out.print(counter);
- }
- }
- }
- });
- // 修改变量的线程
- Thread writeThread = new Thread(() -> {
- for (int i = 0; i < 5; i++) {
- synchronized (App.class) {
- counter++;
- }
- // 等待 1 秒, 给读线程足够的时间读取变量 counter 的最新值
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- System.exit(0);
- });
- readThread.start();
- writeThread.start();
- }
- }
运行, 输入结果:
- 12345
- Process finished with exit code 0
虽然通过 synchronized 也能解决内存可见性的问题, 但是这个解决方案也带来了其他问题, 比如性能会比较差.
总结
多线程可以提升程序的运行速度, 充分利用多核 CPU 的算力, 但多线程也是 "恶魔", 会给程序员带来很多问题, 比如本文中的内存可见性问题. volatile 可以使变量的更新及时刷新到主存, 变量的读取也是直接从主存中获取, 保证了数据的内存一致性. 但是 volatile 不是用来解决线程安全问题的, 无法替代锁机制.
参考:
[1] Java Memory Model - Visibility problem, fixing with volatile variable
[2] Guide to the Volatile Keyword in Java https://www.baeldung.com/java-volatile
[3] Managing volatility
[4] Java Volatile Keyword
[5] Thread and Locks
来源: https://www.cnblogs.com/junejs/p/12686902.html