承接的问题
9.1 讨论的是单个字段的可见性:一个线程写、另一个线程读,中间没有 happens-before,读者可能永远读不到。这一节把单位从一个字段换成一个对象:对象已经构造完成、引用也已经发布,对方拿到的是不是一份完整的初始状态?
答案是不一定。Java 里"new 一个对象"和"这个对象可以被别的线程安全使用"是两件事。中间少了一次正确的发布动作,别的线程就可能看到一个引用非空、但字段全是默认值的对象。这类 bug 的临床表现比 9.1 更隐蔽:
- 不会死循环,服务照常跑,只是偶尔抛
NullPointerException或读到0; - 堆栈指向业务代码深处,看起来像"某个字段忘了赋值";
- 本地复现不了,压测时偶发,重启后消失。
这一节分两步:先用裸多线程把"构造器里注册回调"这个最常见的逸出形态做成可量化的实验,再用 JCStress 把 final、非 final、this 逸出、volatile 发布四种情况放在同一张表里对比命中率。
症状:拿到一个字段全是默认值的对象
构造场景:libraryd-conc 的服务对象在构造器里把自己注册到全局注册表,之后才开始读配置。注册表被另一个线程扫描时,就可能拿到还没初始化完的实例。下面把名字简化成 Service,逻辑不变。
public class EscapeTrial {
static final class Service {
static volatile Service registry;
final int poolSize; // final
int timeoutMs; // 非 final
Service(boolean slowInit) {
registry = this; // 构造器里注册:this 逸出
if (slowInit) {
try {
Thread.sleep(1); // 模拟构造器中的耗时初始化
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
poolSize = 8;
timeoutMs = 500;
}
}
public static void main(String[] args) throws Exception {
int trials = Integer.parseInt(args[0]);
boolean slow = Boolean.parseBoolean(args[1]);
java.util.concurrent.atomic.AtomicBoolean sawPartial =
new java.util.concurrent.atomic.AtomicBoolean();
java.util.concurrent.atomic.AtomicLong reads =
new java.util.concurrent.atomic.AtomicLong();
Thread observer = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
Service s = Service.registry;
if (s != null) {
reads.incrementAndGet();
if (s.poolSize != 8 || s.timeoutMs != 500) {
sawPartial.set(true);
}
}
}
}, "observer");
observer.setDaemon(true);
observer.start();
int exposed = 0;
for (int i = 0; i < trials; i++) {
sawPartial.set(false);
Service.registry = null;
new Service(slow);
if (sawPartial.get()) {
exposed++;
}
}
observer.interrupt();
observer.join();
System.out.println("trials=" + trials + " slowInit=" + slow
+ " exposedTrials=" + exposed + " observerReads=" + reads.get());
}
}
本机实跑(Apple M1 Pro 10 核,Temurin 25.0.5+8):
trials=5000 slowInit=false exposedTrials=0 observerReads=1927
trials=2000 slowInit=true exposedTrials=2000 observerReads=410412171
trials=2000 slowInit=true exposedTrials=2000 observerReads=423130702
三行数据要分开读,否则会得出错误结论:
- 构造器很快时,5000 次里 0 次被观察到半成品。不是因为安全,而是窗口只有几十纳秒,观察线程总共只读了 1927 次,采样密度不够。这是"没复现"而不是"没问题"。
- 构造器里做一次 1ms 的初始化,2000 次里 2000 次被抓到。窗口被拉宽到毫秒级,
sawPartial必中。 - 同一组参数跑了 4 轮,4 轮都是 2000/2000,但观察线程的读取次数在 4.08 亿到 4.23 亿之间波动。波动的是采样密度,不是"暴不暴露";窗口宽到毫秒级时,只要读者在跑就必然撞上。不能把这个 100% 反过来当成"线上一定有这个概率"。
这不是"给构造器加 sleep 就制造了 bug"。this 在 poolSize、timeoutMs 赋值之前就已经进了全局可见的注册表,这是代码本身的结构问题;sleep 只是把原本存在但极窄的竞态窗口拉到了可观测范围。生产里等价于"构造器里读远程配置、建连接、解析模板"。
机制一:this 逸出
this 逸出指对象还没构造完,引用就到了别的线程手里。常见四种写法:
| 写法 | 例子 | 问题 |
|---|---|---|
| 注册到全局表 | registry.add(this) | 任意线程可立即遍历到半成品 |
| 构造器里启动线程 | new Thread(this).start() | 新线程可能在字段赋值前运行 |
| 构造器里调用可覆写方法 | init() 被子类覆写 | 子类字段尚未初始化 |
| 构造器里发布到事件总线 | bus.post(this) | 订阅者同步回调,直接看到半成品 |
后两种更隐蔽,因为它们不出现显式的"注册表"。构造器里调用非 private、非 final 的方法,等价于把 this 交给一个未知实现;子类覆写的方法可以在父类字段赋值前执行。
修复方向只有两个:把发布动作挪到构造器之外,或者让对象在构造期不可变、构造完再交给别人。
final class Service {
private final int poolSize;
private final int timeoutMs;
Service(int poolSize, int timeoutMs) { // 构造器只赋值,不发布
this.poolSize = poolSize;
this.timeoutMs = timeoutMs;
}
int poolSize() {
return poolSize;
}
int timeoutMs() {
return timeoutMs;
}
}
public final class ServiceRegistry {
private volatile Service current; // 构造完成后再发布
public void register(Service service) {
current = service;
}
public Service current() {
return current;
}
}
调用方改成两步:先 new Service(8, 500),再 registry.register(service)。构造器里不再有任何外部可见的副作用。
机制二:final 与非 final 的发布语义
JMM 对 final 字段有一条特殊规则:如果对象被正确构造(构造器结束前 this 没有逸出),那么所有线程都能看到 final 字段的初始化值,不需要额外的同步。
这条规则的边界必须记清:
| 条件 | 保证 |
|---|---|
构造器结束前 this 未逸出,字段是 final | 任何线程看到引用时,final 字段已是构造器里写的值 |
构造器结束前 this 未逸出,字段是普通字段 | 不保证,可能看到默认值 |
构造器结束前 this 已逸出 | 连 final 都不保证,可能看到 0 / null |
final 是引用,指向可变对象 | 只保证引用与对象构造完成,对象内部普通字段的后续修改不保证 |
第三行是很多人漏掉的。final 的"冻结"动作发生在构造器末尾;this 在冻结之前跑出去,别的线程就可能在冻结之前读到它。
用 JCStress 测量四种发布方式
裸线程实验只能说明"能观察到半成品",不能区分是 final 还是非 final 的问题。JCStress 可以把四种情况放进同一套采样框架。工程与 8.1 相同,pom.xml 的关键仍是 <proc>full</proc>,四个用例共用一个 @State 模式:一个 publisher actor 发布,一个 consumer actor 读取并把"是否看到对象 + 字段值"写进结果。
非 final 字段 + 普通引用发布:
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.II_Result;
@JCStressTest
@Outcome(id = "0, 0", expect = Expect.ACCEPTABLE, desc = "发布会话尚未完成,读者没看到对象")
@Outcome(id = "1, 42", expect = Expect.ACCEPTABLE, desc = "读者看到完整初始化的对象")
@Outcome(id = "1, 0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "读者看到引用但字段仍是默认值")
@State
public class UnsafePubTest {
static final class Config {
int timeoutMs; // 非 final
Config(int timeoutMs) {
this.timeoutMs = timeoutMs;
}
}
Config config; // 非 volatile 发布
@Actor
public void publisher() {
config = new Config(42);
}
@Actor
public void consumer(II_Result r) {
Config c = config;
if (c != null) {
r.r1 = 1;
r.r2 = c.timeoutMs;
}
}
}
把 int timeoutMs 改成 final int timeoutMs,其余一字不动,就是 FinalPubTest,此时 "1, 0" 标为 FORBIDDEN。再改一版 SafePubTest,把发布字段改成 volatile Config config,验证屏障能否兜住。
第四个用例模拟 this 逸出:构造器里先把 this 写进共享字段,再给 final 和非 final 字段赋值。
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.III_Result;
@JCStressTest
@Outcome(id = "0, 0, 0", expect = Expect.ACCEPTABLE, desc = "读者尚未看到注册的回调")
@Outcome(id = "1, 3, 500", expect = Expect.ACCEPTABLE, desc = "读者看到完全初始化的回调")
@Outcome(expect = Expect.ACCEPTABLE_INTERESTING, desc = "其他中间状态(字段仍是默认值)")
@State
public class ThisEscapeTest {
static final class Handler {
final int maxRetries; // final,但在 this 逸出之后才赋值
int timeoutMs; // 非 final
Handler(ThisEscapeTest owner) {
owner.handler = this; // 构造器里注册:this 逸出
maxRetries = 3;
timeoutMs = 500;
}
}
Handler handler;
@Actor
public void publisher() {
new Handler(this);
}
@Actor
public void consumer(III_Result r) {
Handler h = handler;
if (h != null) {
r.r1 = 1;
r.r2 = h.maxRetries;
r.r3 = h.timeoutMs;
}
}
}
运行命令与 8.1 一致(quick 模式,8 个并发,所有测试一轮约 28 秒):
mvn -q -DskipTests package
mvn -q dependency:build-classpath -Dmdep.outputFile=cp.txt
CP="target/classes:$(cat cp.txt)"
java -cp "$CP" org.openjdk.jcstress.Main -m quick -c 8 -v \
-t 'UnsafePubTest|FinalPubTest|SafePubTest|ThisEscapeTest'
本机两轮结果(第一轮 27.9 秒,load average 55.93;第二轮只重跑两个用例):
| 用例 | 结果 | 样本(第一轮) | 频率 | 第二轮样本 | 第二轮频率 |
|---|---|---|---|---|---|
UnsafePubTest | 1, 0 | 4,298 / 268,269,834 | 0.0016% | 4,118 / 308,164,874 | 0.0013% |
FinalPubTest | 1, 0 | 0 / 294,125,834 | 0 | — | — |
SafePubTest | 1, 0 | 0 / 280,219,914 | 0 | — | — |
ThisEscapeTest | 四种中间态合计 | 7,773 / 253,531,474 | 0.0031% | 5,885 / 270,904,714 | 0.0022% |
ThisEscapeTest 的中间态分布值得单独看(第一轮):
RESULT SAMPLES FREQ EXPECT DESCRIPTION
0, 0, 0 184,525,357 72.78% Acceptable 读者尚未看到注册的回调
1, 0, 0 5,678 <0.01% Interesting 其他中间状态(字段仍是默认值)
1, 0, 500 918 <0.01% Interesting 其他中间状态(字段仍是默认值)
1, 3, 0 1,177 <0.01% Interesting 其他中间状态(字段仍是默认值)
1, 3, 500 69,001,344 27.22% Acceptable 读者看到完全初始化的回调
四行中间态给出了两个结论:
1, 0, 0:final与非final都是默认值,典型的"引用先到、字段后到";1, 0, 500与1, 3, 0同时出现:final字段也会读到默认值;而且两个字段谁先可见没有固定顺序,说明this逸出后,final的冻结保证已经完全失效。
反过来,FinalPubTest 在 2.94 亿个样本里 1, 0 出现 0 次,SafePubTest 在 2.80 亿个样本里也是 0 次。这两行只能表述为"在这两轮样本里没出现",不能说"证明了绝对安全"——final 的安全有 JMM 规则背书,volatile 的安全有 happens-before 背书,JCStress 只是佐证。
另外要如实说明:JCStress 会在多个编译级别组合下重复运行,其中"拆开编译"的配置会故意让 publisher 与 consumer 处在不同级别(本机这轮有一个配置显示 publisher: C1、consumer: Interpreter),目的是拉宽交错窗口;命中率因此不能直接换算成线上的发生概率。
在 libraryd-conc 里落地
conc-core 里所有需要跨线程共享的配置对象统一按三条规则写:
- 字段全部
final,构造器只做赋值和校验,不做 IO、不注册、不启动线程; - 发布用
volatile字段、AtomicReference或final字段持有(static holder); - 需要在启动时注册的回调,由
start()方法完成,构造与注册分离。
import java.util.function.Consumer;
public final class NotificationDispatcher {
public interface Registry {
void register(NotificationDispatcher dispatcher);
}
private final Consumer<String> mail;
private final Consumer<String> sms;
private final Consumer<String> push;
public NotificationDispatcher(Consumer<String> mail,
Consumer<String> sms,
Consumer<String> push) {
this.mail = mail;
this.sms = sms;
this.push = push;
}
public void start(Registry registry) {
registry.register(this); // 构造完成之后才发布
}
}
代码评审时用一条 grep 就能扫出大部分问题:在构造器体内出现 this、start(、register(、addListener(、new Thread、Executors.,一律要求改成"构造 + start 两段式"。
常见错误与边界
| 错误 | 现象 | 正确做法 |
|---|---|---|
双检锁的单例字段不加 volatile | 偶发拿到半初始化单例 | 字段加 volatile,或用静态内部类 holder |
认为 final 数组/集合不可变 | 元素仍可被改,读到的是改到一半的状态 | final 只冻结引用,内容不可变要 List.copyOf |
构造器里 new Thread(this).start() | 新线程读到默认字段 | 在 start() 里启动 |
用 this 作为 synchronized 锁再逸出 | 外部线程可参与竞争,锁语义被破坏 | 用私有 final Object 作锁 |
| 反序列化 / 反射创建对象 | 绕过构造器,final 语义失效 | 显式校验或改用工厂 |
什么时候不要用 volatile 发布:当发布非常频繁、且对象本身很小(比如每秒发布百万次的统计快照)时,volatile 的写屏障会成为热点。此时改用 AtomicReference 的 lazySet(release 语义,不等待写缓冲刷出),或者干脆用不可变对象 + final 字段的 holder 做整体替换。反过来,如果对象只发布一次(启动配置、单例),volatile 的成本可以忽略,不要为了省这点开销引入复杂度。
还有一个容易忽略的边界:final 字段保证的是"看到引用时字段已初始化",不保证"字段指向的对象内部状态不再变化"。final List<String> tags 保证 tags 引用可见且已构造,但另一个线程 tags.add(...) 时,读者仍可能看到中间状态。不可变发布要求整条引用链都不可变。
小结
安全发布失败的本质是:构造过程本身没有 happens-before 保证,只有发布动作才有。final 字段把"构造完成"打包成一次隐式冻结,但前提是 this 在冻结前没有跑出去;一旦逸出,final 和非 final 一样会读到默认值。
本机的数字把这件事分成了两半:裸线程实验里,构造器带 1ms 初始化时 4 轮各 2000/2000 被抓到半成品,构造器极快时 0/5000;JCStress 里,非 final 发布命中率 0.0013%–0.0016%,final 与 volatile 发布在 2.8–2.9 亿样本里 0 命中,this 逸出则连 final 都失效(1, 0, 500 与 1, 3, 0 都出现过)。
下一节把两节的现象收进同一张归因表:看到什么证据,对应违反哪条 happens-before 规则,修复后用什么验证。
阅读导航:上一节 9.1 过期读与无限循环 | 下一节 9.3 从事故到 happens-before 归因
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。