《Java 并发编程·性能诊断与事故》9.2 安全发布失败的对象

构造构造器注册回调导致的 this 逸出,用逐次计数的命中统计复现半成品对象;再用四个 JCStress 用例分别测量非 final 字段、final 字段、this 逸出与 volatile 发布的命中率,给出本机真实样本数、两轮频率与修复写法。

承接的问题

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

三行数据要分开读,否则会得出错误结论:

  1. 构造器很快时,5000 次里 0 次被观察到半成品。不是因为安全,而是窗口只有几十纳秒,观察线程总共只读了 1927 次,采样密度不够。这是"没复现"而不是"没问题"。
  2. 构造器里做一次 1ms 的初始化,2000 次里 2000 次被抓到。窗口被拉宽到毫秒级,sawPartial 必中。
  3. 同一组参数跑了 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;第二轮只重跑两个用例):

用例结果样本(第一轮)频率第二轮样本第二轮频率
UnsafePubTest1, 04,298 / 268,269,8340.0016%4,118 / 308,164,8740.0013%
FinalPubTest1, 00 / 294,125,8340——
SafePubTest1, 00 / 280,219,9140——
ThisEscapeTest四种中间态合计7,773 / 253,531,4740.0031%5,885 / 270,904,7140.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 里所有需要跨线程共享的配置对象统一按三条规则写:

  1. 字段全部 final,构造器只做赋值和校验,不做 IO、不注册、不启动线程;
  2. 发布用 volatile 字段、AtomicReference 或 final 字段持有(static holder);
  3. 需要在启动时注册的回调,由 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 归因

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收