云计算百科
云计算领域专业知识百科平台

第 1 集:线程为什么看不到另一个线程改过的值?

资深 Java 面试到 Agent 开发工程师 · 第 1 集

先看问题

class Worker {
private boolean stop = false;

void run() {
while (!stop) {
doWork();
}
}

void shutdown() {
stop = true;
}
}

两个线程操作同一个 Worker 对象:线程甲执行 run(),线程乙执行 shutdown()。

线程乙把 stop 改成 true 后,按直觉,线程甲下一次检查 stop 就应该退出循环。但 Java 只凭这段代码,无法保证线程甲会读到这次修改。它可能继续读到旧值 false,所以循环可能一直不退出。

这里实际发生了两个动作:

线程乙:stop = true; // 写入
线程甲:读取 stop // 读取

代码里虽然有写入和读取,但两个动作之间没有锁、volatile 或其他同步操作。线程乙写完,不等于线程甲已经看见。

JMM 到底规定什么

Java 内存模型(JMM)不是一块所有线程共用的物理内存,也不是要求每次写入都立刻刷到内存条。它是一组规则,规定线程怎样共享变量,以及什么条件下一个线程的读取必须看到另一个线程的写入。

它要回答两个问题:

  • 一个线程写入的数据,另一个线程在什么条件下必须看见?
  • 多个线程的读写,哪些先后关系可以依赖?
  • 同一线程内,程序顺序让 stop = true happens-before 后面的动作。编译器和处理器可以调整实际执行顺序,但必须满足 Java 内存模型的要求,包括同步带来的跨线程约束;这个线程内的关系也不会自动连接到线程甲。

    这段代码没有同步关系,所以 Java 不承诺线程甲什么时候能读到新值。Java 也没有规定线程甲每次循环都要重新看到线程乙的写入,继续使用先前读到的 false 仍然符合这段代码的规则。

    happens-before 是什么

    Java 用 happens-before 表示一条可以依赖的先后关系,由它规定哪些可见性和顺序有保证。

    如果 A happens-before B:

    • 按 Java 定义的顺序,A 先于 B;
    • 如果 A 写入共享变量,B 读取同一个变量,且某次旧写入 happens-before A,那么 B 不能读取那次旧写入。若还有其他与读取未建立先后关系的并发写入,B 也不一定读到 A 写入的值。

    放回停止标志的例子:

    线程乙写入 stop = true
    │
    │ happens-before
    ▼
    线程甲读取 stop = true

    这条关系不会自动出现。常见规则包括:

    • 同一把锁的解锁 happens-before 后续对这把锁的加锁;
    • 一个字段的 volatile 写入 happens-before 后续对这个字段的读取;
    • 调用 start() 的动作 happens-before 新线程中的动作;
    • 线程结束 happens-before 另一个线程成功返回 join()。

    没有这条关系时,不能把两个线程里的源码顺序拼成一条执行顺序。

    写入和读取之间需要可见性关系

    用 volatile 修复停止标志

    class Worker {
    private volatile boolean stop = false;

    void run() {
    while (!stop) {
    doWork();
    }
    }

    void shutdown() {
    stop = true;
    }
    }

    stop 声明为 volatile 后,线程乙写入 stop = true,线程甲随后读取同一个字段时,这次写入会通过 volatile 规则对读取可见。线程甲下一次读到 true 后,循环就能退出。

    volatile 只解决这个字段的可见性,并为相关读写提供有序性。它不会让其他字段的单独读写自动变成线程安全,也不会把整段业务代码变成原子操作。如果 doWork() 正在阻塞,volatile 也不会替它中断这个调用。

    为什么要先写数据,再写 ready

    volatile 还可以用来发布已经准备好的数据。

    下面假设两个线程操作的是同一个 Config 对象,而且 config 引用本身已经通过线程启动、锁或其他同步方式安全地共享给读取线程。

    class Config {
    int data;
    volatile boolean ready;
    }

    写入线程:

    config.data = 42;
    config.ready = true;

    读取线程:

    if (config.ready) {
    System.out.println(config.data);
    }

    ready 表示“data 已经写完”。它必须最后写入,因为这次 volatile 写会把同一线程中它之前的写入传递给读取线程。

    data = 42
    │ 同一个线程里的程序顺序
    ▼
    ready = true
    │ ready 的 volatile 写入 happens-before 读取线程对 ready 的读取
    ▼
    读取 ready == true
    │ 同一个线程里的程序顺序
    ▼
    读取 data

    因此,读取线程看到 ready == true 时,也能看到之前写入的 data == 42。

    如果先把 ready 改成 true,数据还没有准备好,读取线程就可能看到 true,却读到旧值或默认值。

    volatile 不能解决 count++

    private volatile int count;

    count++;

    count++ 包含三步:

    读取 count
    把结果加一
    写回 count

    两个线程可能同时读到 0,然后都把 1 写回去。在这个交错下,最终结果是 1,不是 2。

    volatile 能让单次读写具备可见性,但不能把“读—改—写”三步合成一个不可分割的动作。可见性和原子性是两个问题。

    常见面试题怎么答?

    1. JMM 和 happens-before 解决什么问题?

    JMM 规定线程怎样共享变量,以及哪些可见性和顺序有保证。同一线程的程序顺序、同一把锁的释放与后续获取、volatile 写入与后续读取等可以建立 happens-before 关系,让另一个线程依赖之前完成的写入。

    2. volatile 为什么不能保证 count++ 正确?

    count++ 包含读取、加一、写回。volatile 提供单次读写的内存语义,两个线程仍可能读到相同的旧值并写回同一个结果。要保护整个更新,应使用原子类或同一把锁。

    3. 为什么要先写 data,再写 volatile ready?

    写线程先准备 data,后发布 ready。读线程读到 ready 为 true 后,才能沿 happens-before 关系看到前面写好的 data。先发布状态,就可能允许读取方在数据尚未准备好时读取。

    读完自测

    1. 为什么线程乙执行了 stop = true,线程甲仍然可能继续循环?

    参考要点:两个线程之间缺少同步关系,普通字段的写入没有对另一个线程的读取建立可见性保证。可以用同一个 volatile 字段通信;若线程正阻塞在 doWork(),还要处理实际调用的超时或取消。

    2. volatile 写入和后续读取之间建立了什么关系?

    参考要点:对同一字段的 volatile 写入 happens-before 后续读取。结合写线程、读线程各自的程序顺序,读取方还能看到该次写入之前已经准备好的数据。

    3. 为什么 volatile int count 仍然不能保证 count++ 的结果正确?

    参考要点:count++ 包含读取、加一、写回。两个线程可能都读到同一个旧值,再写回相同的新值;可见性不会把这三步合成原子操作。

    规范依据:Java Language Specification 17:Threads and Locks(Java 8)

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 第 1 集:线程为什么看不到另一个线程改过的值?
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!