CodeGym /课程 /JAVA 25 SELF /Java Memory Model (JMM)

Java Memory Model (JMM)

JAVA 25 SELF
第 58 级 , 课程 4
可用

1. 认识 Java Memory Model (JMM)

可见性与排序问题

在单线程程序中,一切都很简单:把值写入变量后——立刻就能把它读出来。在多线程的现实中则不同。处理器会缓存值,编译器和 JVM 有时会改变指令顺序,一个线程可能看到“旧”值,即使另一个线程刚刚修改过它。

Java Memory Model (JMM) 描述了线程如何通过内存进行通信:一个线程的更改何时对其他线程可见,以及操作以何种顺序发生。如果不加以考虑,程序可能会表现得不可预测,尽管乍看之下一切正常。

理解 JMM 有助于明白为什么有时线程看不到最新数据,如何正确使用 volatilesynchronized 和原子类,以及为什么并发代码中的 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)。
— 允许显式选择访问语义:volatileacquire/releaseopaque
— 可用于实现高性能的数据结构。

访问语义

  • 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:在没有同步的情况下发布对象。
如果你在一个线程中创建对象并把它交给另一个线程,但没有使用 volatilesynchronizedfinal 字段——另一个线程可能会看到“原始”值。

错误 3:double-checked locking 没有使用 volatile。
如果单例引用没有使用 volatile,其他线程可能会拿到未初始化的对象。

错误 4:在虚拟线程中使用老式锁。
某些旧的同步机制(native monitor)可能会妨碍 JVM 高效调度虚拟线程。

错误 5:忽视伪共享。
如果“热点”变量在内存中彼此相邻,线程会因缓存行而相互干扰。

错误 6:不使用 JMH 就草率做性能结论。
没有 JMH 的微基准往往会因为 JVM 与处理器缓存的优化而给出误导性结果。

1
调查/小测验
深入多线程第 58 级,课程 4
不可用
深入多线程
深入多线程
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION