1. 认识 Java Memory Model (JMM)
可见性与排序问题
在单线程程序中,一切都很简单:把值写入变量后——立刻就能把它读出来。在多线程的现实中则不同。处理器会缓存值,编译器和 JVM 有时会改变指令顺序,一个线程可能看到“旧”值,即使另一个线程刚刚修改过它。
Java Memory Model (JMM) 描述了线程如何通过内存进行通信:一个线程的更改何时对其他线程可见,以及操作以何种顺序发生。如果不加以考虑,程序可能会表现得不可预测,尽管乍看之下一切正常。
理解 JMM 有助于明白为什么有时线程看不到最新数据,如何正确使用 volatile、synchronized 和原子类,以及为什么并发代码中的 bug 往往只在生产环境才显现。简而言之,JMM 是内存的游戏规则;如果忽视它们,即便是再谨慎的代码也可能反噬你。
类比
想象你有两个人(线程)在白板(内存)上写读便条(变量)。有时一个人写了,另一个却还看不到——因为他看的是自己的白板副本(缓存)。JMM 规定了这些便条何时以及如何对所有人可见。
2. happens-before:JMM 的基石
什么是 happens-before?
happens-before 是程序中两个动作之间的关系:如果动作 A happens-before 动作 B,那么 A 中所做的所有更改在 B 中一定可见。
重要:happens-before 不只是“先发生”,而是“可见性得到保证”。
happens-before 的基本规则
1. 单个线程内
同一线程内的操作有序:如果你先写后读——你会看到自己的修改。
2. 同步块/监视器
在退出同步块(synchronized)之前发生的一切,对随后进入该块的线程是可见的。
synchronized(lock) {
sharedVar = 42; // 写入
}
// ...
synchronized(lock) {
System.out.println(sharedVar); // 保证能看到 42
}
3. volatile 写/读
对 volatile 字段的写入 happens-before 其他线程随后对该字段的读取。
volatile boolean ready = false;
// 线程 1
data = 123;
ready = true; // volatile write
// 线程 2
if (ready) { // volatile read
System.out.println(data); // 保证能看到 data = 123
}
4. 线程的启动与结束
- 调用 Thread.start() happens-before 线程开始运行。
- 线程结束 happens-before 从 Thread.join() 返回。
5. Executor 中任务的完成
如果你向 Executor 提交任务并等待其完成(Future.get()),那么任务中所做的所有更改在 get() 之后都是可见的。
6. final 字段
在构造函数中对带有 final 修饰的字段进行初始化,happens-before 对该对象引用的发布。这对不可变对象很重要。
3. 安全发布对象
问题:“陈旧”的对象
如果一个线程创建对象并在没有同步的情况下把它交给另一个线程,后者可能看到字段的“原始”值(例如未初始化或旧值)。
class Holder {
int value;
Holder() { value = 42; }
}
Holder holder = null;
// 线程 1
holder = new Holder(); // 创建对象
// 线程 2
if (holder != null) {
System.out.println(holder.value); // 可能看到 0,而不是 42!
}
如何正确发布对象?
1. 通过 final 字段
如果对象的所有字段都是 final 并在构造函数中初始化,那么可以在不额外同步的情况下安全发布该对象。
class SafeHolder {
final int value;
SafeHolder() { value = 42; }
}
2. 通过 volatile 引用
如果指向对象的引用声明为 volatile,那么在赋值之后,该对象对其他线程一定可见。
volatile Holder holder;
// 线程 1
holder = new Holder();
// 线程 2
if (holder != null) {
System.out.println(holder.value); // 保证能看到 42
}
3. 在公开之前进行单线程初始化
如果对象在对其他线程可见之前就已经在单个线程中创建并初始化,那么这是安全的。
Holder holder = new Holder(); // 仅在单个线程中
// ... 然后 holder 才对其他线程可见
4. 通过加锁
如果对象在同步块内创建,且对它的读取也只在相同的同步块内进行,那么这是安全的。
Holder holder;
synchronized(lock) {
if (holder == null) {
holder = new Holder();
}
}
// ... 在另一个线程中
synchronized(lock) {
if (holder != null) {
// 安全
}
}
4. Double-checked locking 与 volatile
什么是 double-checked locking?
这是一种用于懒初始化单例对象的模式:
class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton();
}
}
}
return instance;
}
}
问题:如果没有对 instance 使用 volatile 引用,这段代码并不正确!线程可能会看到未完全初始化的对象。
为什么没有 volatile 会出问题?
JVM 可能会“重排”指令,使得对象引用在构造函数结束之前就被赋值。其他线程会看到未初始化的对象。
正确的做法?
将 instance 声明为 volatile:
private static volatile Singleton instance;
现在 double-checked locking 能正确工作:volatile 保证了引用写入与读取之间的 happens-before 关系。
替代方案:静态初始化
最简单且安全的方式创建单例——使用静态初始化:
class Singleton {
private static final Singleton INSTANCE = new Singleton();
public static Singleton getInstance() { return INSTANCE; }
}
在这里,JVM 会自行保证正确的初始化。
5. VarHandle:现代的低层访问
什么是 VarHandle?
VarHandle 是一个现代 API(Java 9+),允许以低层方式操作变量:读取、写入、执行原子操作,并控制可见性与指令顺序。
既然有原子类,为什么还需要 VarHandle?
— VarHandle 可以处理任意字段(不仅是 int/long/Reference)。
— 允许显式选择访问语义:volatile、acquire/release、opaque。
— 可用于实现高性能的数据结构。
访问语义
- Volatile:提供完整的 happens-before 保证(与 volatile 字段相同)。
- Acquire/Release:保证更弱,但更快(用于无锁结构)。
- Opaque:提供最小的可见性保证,但性能最佳。
VarHandle 使用示例
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
class Counter {
int value;
static final VarHandle VALUE_HANDLE;
static {
try {
VALUE_HANDLE = MethodHandles.lookup().findVarHandle(Counter.class, "value", int.class);
} catch (Exception e) {
throw new Error(e);
}
}
}
Counter counter = new Counter();
Counter.VALUE_HANDLE.setVolatile(counter, 42);
int v = (int) Counter.VALUE_HANDLE.getVolatile(counter);
何时使用 VarHandle?
- 实现自定义的无锁数据结构。
- 当需要最大化性能并精细控制指令顺序时。
- 在常规应用中,原子类与 synchronized 往往已足够。
6. 伪共享(false sharing)与缓存对齐
False sharing 指的是两个线程操作不同的变量,但这些变量位于同一处理器缓存行中。结果是线程相互干扰,因为修改一个变量会使另一个变量所在的缓存行失效。
类比:两个人坐在同一张桌子(缓存行)前,但各自在自己的半张纸上写。如果其中一人改动了内容,另一人就不得不“重新阅读”整张纸。
为什么不好?
- 性能会急剧下降:处理器会把时间花在缓存同步上。
- 对那些被不同线程频繁修改的“热点”变量尤为致命。
如何避免?
将“热点”字段分散到不同对象中,或使用特殊的注解/结构进行填充与对齐(例如 @Contended)。在现代 JVM 中,可以开启 -XX:-RestrictContended 并使用 @sun.misc.Contended(Java 8+)对字段进行对齐。
示例:
@sun.misc.Contended
public volatile long value1;
@sun.misc.Contended
public volatile long value2;
注意:@Contended 不属于标准 API,但在 JDK 中用于优化原子类。
7. 实践:修复 singleton 与 JMH 迷你基准
修复有问题的 singleton
不好(没有 volatile):
class BrokenSingleton {
private static BrokenSingleton instance;
public static BrokenSingleton getInstance() {
if (instance == null) {
synchronized (BrokenSingleton.class) {
if (instance == null) {
instance = new BrokenSingleton();
}
}
}
return instance;
}
}
较好(使用 volatile):
class SafeSingleton {
private static volatile SafeSingleton instance;
public static SafeSingleton getInstance() {
if (instance == null) {
synchronized (SafeSingleton.class) {
if (instance == null) {
instance = new SafeSingleton();
}
}
}
return instance;
}
}
最好——静态初始化:
class StaticSingleton {
private static final StaticSingleton INSTANCE = new StaticSingleton();
public static StaticSingleton getInstance() { return INSTANCE; }
}
JMH 迷你基准:可见性与原子性
注意:JMH 是专门用于 Java 微基准的框架。不要在没有 JMH 的情况下贸然做性能结论——结果往往会被 JVM 与处理器缓存的优化所误导!
示例:验证 volatile 的可见性
public class VolatileVisibility {
volatile boolean flag = false;
public void writer() {
flag = true;
}
public void reader() {
while (!flag) {
// 自旋,直到看到 true
}
// 已看到变化
}
}
示例:volatile 的非原子性
public class VolatileNotAtomic {
volatile int counter = 0;
public void increment() {
counter++; // 非原子操作!
}
}
尽管使用了 volatile,当多个线程同时递增时,最终值会小于预期(操作 counter++ 会拆分为读取、计算与写回)。
8. 使用 JMM、volatile 与发布时的常见错误
错误 1:指望 volatile 具有原子性。
volatile 只保证更改的可见性,不保证操作的原子性。操作 counter++ 并不会因为变量是 volatile 而变成原子。
错误 2:在没有同步的情况下发布对象。
如果你在一个线程中创建对象并把它交给另一个线程,但没有使用 volatile、synchronized 或 final 字段——另一个线程可能会看到“原始”值。
错误 3:double-checked locking 没有使用 volatile。
如果单例引用没有使用 volatile,其他线程可能会拿到未初始化的对象。
错误 4:在虚拟线程中使用老式锁。
某些旧的同步机制(native monitor)可能会妨碍 JVM 高效调度虚拟线程。
错误 5:忽视伪共享。
如果“热点”变量在内存中彼此相邻,线程会因缓存行而相互干扰。
错误 6:不使用 JMH 就草率做性能结论。
没有 JMH 的微基准往往会因为 JVM 与处理器缓存的优化而给出误导性结果。
GO TO FULL VERSION