Java 内存模型
Java 内存模型(JMM)规定一个线程可以观察到其他线程的哪些读写结果。本文从普通字段的可见性问题入手,讲解 happens-before 规则。接着用 volatile 和并发组件推导结果,最后说明数据竞争与线程等待的边界。
1. 面试核心
先记住这几条,再读正文- happens-before。 happens-before 规定动作之间的可见性与内存顺序,并能通过传递性连接多次同步。卡 · happens-before 规则
- 锁的可见性。 同一 monitor 的解锁 happens-before 后续加锁,使解锁前的写入对加锁后的读取可见。
- volatile 发布。 同一 volatile 字段的写 happens-before 同步顺序中的后续读,可用于发布此前写入的普通数据。
- 线程终止。 线程中的全部动作 happens-before 另一线程成功检测到它终止后的动作。
- 等待与同步的区别。
sleep和yield没有同步语义,等待时间再长也无法建立跨线程可见性。卡 · sleep 与 join 的同步边界
2. 跨线程可见性问题
一个线程先写数据、再写完成标志,另一个线程却未必能按这个顺序看到结果。下面用两个普通字段说明这个问题。
int value;
boolean ready;
// Thread A
value = 42;
ready = true;
// Thread B
if (ready) {
System.out.println(value);
}
本例没有跨线程同步,所以 B 即使读到 ready == true,也可能读到 value == 0。这里缺少的是写入与读取之间的 happens-before 关系。happens-before 常译为“先行发生”,用来规定动作之间的可见性与顺序。
编译器、即时编译器(JIT)和处理器可以在 JMM 允许的范围内重排执行。因此,分析两个线程时,只把源代码按不同顺序穿插起来,会漏掉合法结果。需要先找出跨线程同步关系,再推导哪些写入对读取可见。
3. happens-before 规则
happens-before 用来判断前面的写入怎样传递给后面的读取。如果 A happens-before B,B 就能观察到 A 的效果,且 A 在内存模型顺序上先于 B。
monitor 是 Java 对象关联的监视器,synchronized 通过它实现互斥。结合它和其他同步操作,可以使用以下常用规则:
- 同一线程中,前面的动作先于后面的动作。
- 同一 monitor 的解锁先于后续加锁。
- volatile 写先于同步顺序中该字段的后续读。
start()调用先于新线程的全部动作。- 线程动作先于另一线程成功检测到其终止。
- 对线程的中断先于其他线程检测到该中断。
这里的“先于”均表示 happens-before,检测线程终止可通过成功等待结束的 join() 完成。此外,对象字段的默认初始化也先于程序中的后续动作,例如把整数字段初始化为零。
happens-before 还具有传递性:若 A 先于 B,B 先于 C,就能推出 A 先于 C。这样可以把线程内顺序和跨线程同步连接起来,推导数据从写入者传递到读取者的过程。它约束的是可观察结果,机器指令仍可在满足约束的前提下优化。
4. volatile 发布链
把完成标志改成 volatile,就能通过这个标志发布此前写入的数据。下面的示例只发布一次,发布后保持 value 不变。
private int value;
private volatile boolean ready;
public void publish() {
value = 42;
ready = true;
}
public int consume() {
if (!ready) {
throw new IllegalStateException("not ready");
}
return value;
}
当消费者读到 ready == true 时,可以沿着以下 happens-before 链推导结果:
- 发布线程先执行
value = 42,再写入 volatile 标志。线程内程序顺序连接这两个动作。 - 对
ready的 volatile 写先于消费者读到true的动作。同一个 volatile 字段连接两个线程。 - 消费者读到标志后再读取
value。结合传递性,最初的赋值对这次读取可见,所以返回 42。
这条链要求写入者和读取者使用同一个发布标志。换成无关的 volatile 字段,就无法连接上述动作。
发布保证的是可见性,复合更新仍需单独保证原子性,详见 volatile。
5. JUC 组件的交接语义
JUC 是 java.util.concurrent 包及其子包,提供常用并发工具。它们在任务提交、结果获取和数据交接时定义内存顺序。使用这些组件时,可以直接利用以下交接关系:
- 向 Executor 提交任务前,提交线程的动作先于任务中的动作。
- 异步计算中的动作先于另一线程成功调用
Future.get()取得结果后的动作。 - 将元素放入并发队列前的动作,先于另一线程取出该元素后的动作。
- CountDownLatch 计数归零前,各次
countDown()前的动作先于成功await()后的动作。 - 对同一个 Semaphore,
release()前的动作先于另一线程成功acquire()后的动作。
并发集合也有类似保证:放入元素前的操作,先于另一线程访问或移除该元素后的操作。因此,组件除了调度线程,还能让接收方看到交接前写入的数据。满足这些交接条件时,普通字段无需为此次发布额外声明为 volatile。
这些规则只覆盖 API 约定的交接方向。比如,发送方在入队后继续修改消息,接收方读取这些新修改时仍需额外同步。
6. 数据竞争与顺序一致性
数据竞争说明两个线程访问同一普通变量时缺少必要的同步。其中至少一个线程在写入,而这些冲突访问之间没有 happens-before 顺序。
正确同步的程序保证顺序一致性。也就是说,可以保持每个线程内的程序顺序,再把多个线程的动作交错起来理解结果。存在数据竞争时,还要考虑编译优化和 JMM 允许的重排序。因此,排查时先找缺失的同步关系,再分析具体执行过程。
顺序一致性仍允许线程在业务操作中间交错执行。如果检查和更新分开同步,两者之间就可能被其他线程插入操作。
这种业务竞态的例子见 线程安全。
7. sleep 与 join 的同步边界
等待时间变长和建立同步关系是两件事。Thread.sleep() 只暂停当前线程,yield() 则提示调度器让出执行机会。两者都没有同步语义,所以晚几秒读取共享变量也无法保证看见新值。
对已启动线程,无超时 join() 正常返回表示已终止。带超时的 join(long) 返回时,目标线程仍可存活。未启动线程的 join() 会直接返回。需要确认线程已结束,才能使用终止检测规则。
worker.start();
worker.join(100);
if (worker.isAlive()) {
throw new IllegalStateException("still running");
}
System.out.println(result);
示例先等待最多 100 毫秒,再用 isAlive() 确认 worker 是否已经终止。仍在运行时直接报错,确认终止后才读取结果。假设 result 仅由 worker 写入,终止检测规则保证读者能看到它的最终写入。
8. 常见问题
8.1 happens-before 要求两个动作在时间上紧挨着吗?
不要求。happens-before 约束的是可见性和内存顺序,两个动作之间可以有大量其他动作。反过来,仅观察到现实时间上的先后,还需要同步关系才能推导跨线程可见性。
8.2 理解 JMM 时,需要推测缓存何时刷新吗?
推导正确性时,应直接使用 JMM 的同步关系。JMM 规定允许观察到的结果,JVM 与处理器负责实现这些约束。它们会使用内存屏障和缓存一致性机制,具体方式取决于实现。把 volatile 简化为“刷新所有变量到主内存”,容易漏掉真正需要的发布关系。
9 · 面试题卡片
9.1 什么是 happens-before,常用规则有哪些
出现公司:美团
考察重点
- 能说明 happens-before 怎样约束可见性和内存顺序。
- 能解释 monitor、volatile、start 和 join 分别连接哪些动作。
- 能利用传递性推导普通字段何时对另一个线程可见。
相关内容:happens-before 规则至JUC 组件的交接语义。
参考回答
happens-before 是 JMM 中规定可见性和顺序的偏序关系。
偏序意味着有些动作之间存在先后约束,有些动作之间则没有。若 A happens-before B,B 就能观察到 A 的效果。
推导时常用以下同步规则:
- 同一线程中,前面的动作先于后面的动作。
- 同一 monitor 的解锁先于后续加锁。
- 同一 volatile 字段的写先于同步顺序中的后续读。
start()先于新线程的动作,线程中的动作先于另一线程成功检测到它终止。
这些规则具有传递性。例如,先写普通数据,再写 volatile 发布标志。消费者读到标志后,再读取普通数据,就能看到发布前的写入。
9.2 sleep 后读共享变量安全吗,超时 join 返回又意味着什么?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:能说明 sleep 为什么无法发布数据,以及超时 join 返回后还要确认什么。
相关内容:sleep 与 join 的同步边界。
参考回答
sleep 无法保证共享变量的可见性,超时 join 返回也只说明本次等待结束。
前者没有同步语义,后者还需要确认目标线程是否已经终止。
对于已启动的线程,可以在超时返回后用 isAlive() 检查。成功检测到终止后,目标线程中的动作才通过终止规则先于后续读取。这样,读取方才能依靠这条关系看到线程写入的结果。