Baeldung 翻译系列之Java并发基础:
Java中的concurrent包
Java中的Synchronized关键字
Future介绍
ThreadLocal介绍
Java线程的生命周期
如何杀掉一个Java线程
Java中的线程池介绍
实现Runnable接口还是继承Thread类
Java中的wait和notify方法
Runnable vs Callable
wait和sleep的区别
Thread.join方法介绍
Java中使用锁对象
ThreadPoolTaskExecutor中的corePoolSize和maxPoolSize
Java中的异步编程
概览
在没有必要的同步情况下,编译器、运行时或处理器可能会应用各种优化。尽管这些优化通常是有益的,但有时它们可能会导致微妙的问题。
缓存和重排序是可能在并发环境中让我们感到惊讶的优化。Java和JVM提供了许多控制内存顺序的方式,volatile
关键字就是其中之一。
本教程将重点讨论Java的基础但常常被误解的概念,即volatile
关键字。首先,我们将从一些关于底层计算机架构如何工作的背景知识开始,然后我们将熟悉Java中的内存顺序。接着,我们将理解在多处理器共享架构中并发的挑战,以及volatile
如何帮助解决这些问题。
共享多处理器架构
处理器负责执行程序指令。因此,它们必须从RAM中获取程序指令和所需的数据。
由于CPU每秒可以执行许多指令,从RAM中获取数据对它们来说并不理想。为了改善这种情况,处理器使用了一些技巧,如乱序执行、分支预测、推测执行和缓存。
这就是以下的内存层次结构发挥作用的地方:
随着不同的核心执行更多的指令和操作更多的数据,它们会用更相关的数据和指令填充其缓存。这将以引入缓存一致性挑战为代价提高整体性能。
我们应该仔细考虑当一个线程更新一个缓存值时会发生什么。
3.缓存一致性问题
为了更深入地解释缓存一致性,我们将借用一本名为《Java并发实践》的书中的一个例子(译者:看来国内外的大家看的都是这些经典的书籍呢):
public class TaskRunner {
private static int number;
private static boolean ready;
private static class Reader extends Thread {
@Override
public void run() {
while (!ready) {
Thread.yield();
}
System.out.println(number);
}
}
public static void main(String[] args) {
new Reader().start();
number = 42;
ready = true;
}
}
TaskRunner
类维护了两个简单的变量。它的main方法创建了另一个线程,只要ready变量为false,该线程就会持续运行。当变量变为true时,线程会打印number变量。
许多人可能期望这个程序在短暂的延迟后打印42
;然而,延迟可能会更长。甚至可能永远挂起或打印零。
这些异常的原因是缺乏适当的内存可见性和重排序,下面我们来详细解释。
3.1. 内存可见性
这个简单的例子有两个应用线程:主线程和读取线程。让我们想象一个场景,其中操作系统将这两个线程调度到两个不同的CPU核心上,其中:
- 主线程在其核心缓存中有ready和number变量的副本
- 读取线程也最终得到了它的副本
- 主线程更新了缓存的值
大多数现代处理器的写请求并不会在发出后立即应用。处理器倾向于将这些写入排队在一个特殊的写缓冲区中。过一段时间后,它们会一次性将这些写入应用到主内存中。(译者:也就是说,这里是有延迟的,有不确定性
)
有了所有这些,当主线程更新number和ready变量时,我们不能保证读取线程可能看到什么。换句话说,读取线程可能立即看到更新的值,可能有一些延迟,或者可能根本就看不到。
这种内存可见性可能会在依赖可见性的程序中导致活性问题。
3.2. 重排序
更糟糕的是,读取线程可能会以与实际程序顺序不同的顺序看到这些写入。例如,由于我们首先更新了number变量:
public static void main(String[] args) {
new Reader().start();
number = 42;
ready = true;
}
我们可能期望读取线程打印42。但实际上,打印的值可能是零。
重排序是一种用于性能改进的优化技术。有趣的是,不同的组件可能会应用这种优化:
- 处理器可能会以与程序顺序不同的顺序刷新其写缓冲区
- 处理器可能会应用乱序执行技术
- JIT编译器可能通过重排序进行优化
4.volatile内存顺序
我们可以使用volatile
来解决缓存一致性的问题。
为了确保变量的更新能够以可预测的方式传播到其他线程,我们应该将volatile
修饰符应用到这些变量上。
这样,我们可以与运行时和处理器进行通信,不对涉及volatile
变量的任何指令进行重排序。此外,处理器明白它们应该立即刷新对这些变量的任何更新。
public class TaskRunner {
private volatile static int number;
private volatile static boolean ready;
// same as before
}
这样,我们就可以与运行时和处理器进行通信,不对涉及volatile
变量的任何指令进行重排序。此外,处理器明白它们应该立即刷新对这些变量的任何更新。
使用volatile
修饰符可以帮助我们解决内存可见性和重排序的问题。当我们将一个变量声明为volatile
时,JVM和处理器都会知道它们不能对涉及这个变量的任何指令进行重排序,而且当这个变量被更新时,必须立即将更新刷新到主内存,而不是仅仅保留在本地缓存中。这样,其他线程就能够看到这个变量的最新值,从而确保内存的可见性。
5.volatile和线程同步
对于多线程应用程序,我们需要确保一些规则以保证一致的行为:
- 互斥 - 一次只有一个线程执行临界区
- 可见性 - 一个线程对共享数据所做的更改对其他线程可见,以维持数据一致性
- 同步方法和块提供了以上两种属性,但是以应用程序性能为代价
volatile
是一个非常有用的关键字,因为它可以帮助确保数据更改的可见性,而不提供互斥。因此,在我们可以接受多个线程并行执行代码块的情况下,它是有用的,但我们需要确保可见性属性。
6.Happens-Before 排序
volatile
变量的内存可见性效果超出了volatile
变量本身。
为了让问题更具体,假设线程A写入一个volatile
变量,然后线程B读取同一个volatile
变量。在这种情况下,写入volatile
变量之前对A可见的值将在读取volatile
变量之后对B可见。
从技术上讲,对volatile
字段的任何写入操作都发生在对同一字段的每个后续读取操作之前。这是Java内存模型(JMM)
的volatile
变量规则。
6.1. Piggybacking
(译者:"Piggybacking"在计算机科学中通常指的是一种利用已有的数据传输来传递额外信息的方法。在并发编程中,“piggybacking"可以指的是一种优化手段,通过这种手段,一些操作或者数据可以"搭便车”,利用其他的操作或者数据的可见性或者顺序性规则来传递。)
由于"happens-before
"内存排序的强大性,有时我们可以借用另一个volatile
变量的可见性属性。例如,在我们的特定示例中,我们只需要将ready变量标记为volatile
:
public class TaskRunner {
private static int number; // not volatile
private volatile static boolean ready;
// same as before
}
写入true
到ready
变量之前的任何事情对读取ready变量之后的任何事情都是可见的。因此,number
变量借用了ready
变量强制执行的内存可见性。简单来说,尽管它不是一个volatile
变量,但它展示出了volatile
的行为。
利用这些语义,我们可以在我们的类中只定义几个变量为volatile,并优化可见性保证。
- 总结
在这篇文章中,我们探讨了volatile
关键字,它的能力,以及从Java 5开始对它的改进。
评论区