安全发布
安全发布让其他线程取得对象引用时,也能看到发布前完成的初始化。本文先说明直接共享引用的风险,再讲构造期间的 this 逸出和 final 字段保证。最后用双重检查锁定与静态持有者说明单例怎样发布。
1. 面试核心
先记住这几条,再读正文- volatile 引用发布。 线程读到
volatile引用的新值时,也能看到写入该引用前完成的对象初始化。 - 构造期逸出。 构造期间把
this交给外部代码,会让外部代码提前访问尚未初始化的字段。 - final 字段。 对象在构造期间没有逸出时,取得引用的线程能看到
final字段在构造结束时的值。卡 · 构造期间的 this 逸出 - 双重检查锁定。 双重检查锁定用锁保证只初始化一次,用
volatile实例字段向无锁读取者发布构造结果。卡 · 双重检查与静态持有者 - 发布后的修改。 安全发布保证发布前的状态可见,构造后的共享可变状态仍需同步保护。
2. 未同步发布的问题
把对象引用赋给共享字段后,还要保证读取者能看到初始化结果。下面的普通字段赋值缺少这种保证:
final class Registry {
private Configuration configuration;
void reload() {
configuration = new Configuration(8080, List.of("api"));
}
}
其他线程无同步地读取 configuration 时,就会与 reload() 的写入形成数据竞争。也就是说,读写之间缺少约定的先后和可见性保证。即使读到了非空引用,读取者也未必能看到构造器对普通字段的全部写入。
为了让引用和初始化结果一起交给读取者,可以选择以下安全发布方式:
- 在类初始化中创建对象。
- 在同一把锁下写入和读取引用,让解锁与后续加锁建立可见性。
- 通过
volatile字段或原子引用交接对象。 - 通过具有内存一致性保证的并发集合或队列交接对象。
- 在
Thread.start()前准备新线程所需对象。
这些方式在发布者与读取者之间建立 happens-before 关系,即保证前面的写入对后面的读取可见。final 字段还有独立的初始化保证,范围见下文。
3. 构造期间的 this 逸出
this 逸出指对象还在构造,外部代码就已经取得了它的引用。这时外部代码能提前调用对象,读到尚未赋值的字段:
public final class Listener {
private final int threshold;
public Listener(EventBus bus) {
bus.register(this); // this 已经可被外部调用
threshold = 10;
}
}
若 register 立即触发回调,或把监听器交给另一个线程,回调就能提前读取字段。此时 threshold 还未赋为 10,回调会读到默认值 0。
可以改为构造结束后再注册:
private Listener() {
threshold = 10;
}
public static Listener create(EventBus bus) {
Listener listener = new Listener();
bus.register(listener);
return listener;
}
该片段替换原来的公开构造器。工厂方法先完成构造,再注册监听器,所以回调读取时 threshold 已完成赋值。事件总线跨线程分发回调时,还需安全发布这个已构造的对象。
检查构造器时,应留意以下提前暴露对象的途径:
- 注册监听器或回调,让外部代码提前拿到
this。 - 启动访问当前对象的新线程,让线程在构造结束前读取字段。
- 把
this放入静态集合,让其他代码通过集合取得对象。 - 调用可被子类重写的方法,让子类方法访问尚未初始化的子类字段。
4. final 字段的初始化安全
final 字段让正确构造的不可变对象更容易在线程间共享。对象在构造期间没有逸出时,其他线程取得引用后,就能读到构造结束时的 final 值。
public final class Endpoint {
private final String host;
private final int port;
public Endpoint(String host, int port) {
this.host = host;
this.port = port;
}
}
另一个线程取得正确构造的 Endpoint 后,能读到构造器赋给 host 和 port 的值。保证还覆盖通过 final 引用读取的对象或数组在构造结束前已写入的状态。
需要区分的是,final 固定字段的值,却不会冻结引用指向的可变对象:
private final List<String> roles = new ArrayList<>();
这里固定的是 roles 指向哪个列表,列表内容仍可增删。构造后继续共享并修改列表时,需要另行同步。若要构造不可变对象,应复制外部传入的可变数据,并限制所有可达对象的后续修改。
5. 双重检查锁定
双重检查锁定用于延迟创建单例,并让初始化后的调用跳过加锁。两次检查分别处理正常读取和首次创建时的竞争:
private static volatile Client instance;
public static Client getInstance() {
Client local = instance;
if (local == null) {
synchronized (Client.class) {
local = instance;
if (local == null) {
local = new Client();
instance = local;
}
}
}
return local;
}
这段代码通过两次检查与 volatile 发布,保证只创建一次并安全交接:
- 外层检查发现实例已经存在时,直接返回,省去加锁。
- 多个线程同时读到
null时,内层锁让它们依次进入。后来的线程再次检查,就能发现前一个线程已经创建了实例。 - 创建者把引用写入
volatile字段。其他线程读到该引用时,也能看到此前的构造写入。
局部变量 local 保存本次读到的引用,避免在返回时再次读取 volatile 字段。该示例假设单例创建后持续复用,后续业务状态的修改仍要单独保证线程安全。
6. 双重检查与静态持有者
双重检查需要同时维护互斥和发布关系。内层锁控制谁来初始化,volatile 负责让外层无锁读取者看到构造结果。若只保留初始化分支的锁,外层普通读取就缺少这条发布关系。
静态持有者可利用类初始化实现延迟创建:
final class Service {
private Service() {}
private static class Holder {
static final Service INSTANCE = new Service();
}
static Service getInstance() {
return Holder.INSTANCE;
}
}
第一次读取 Holder.INSTANCE 时,JVM 会触发 Holder 的类初始化。类初始化协议协调并发访问,让一个线程完成初始化,再让其他线程使用结果。因此,这里可以直接依赖 JVM 保证延迟创建与安全发布。
该单例的范围是定义它的类及对应类加载器。若无需这种延迟创建方式,也可以用直接静态初始化、枚举单例或容器生命周期管理实例。
7. 常见问题
7.1 字段都是 final,就可以随意共享引用吗?
共享时仍要确保对象正确构造,并明确引用怎样交给读取者。final 的初始化保证以构造期间没有逸出为前提,也不保证读线程何时取得共享引用。若字段指向可变对象,还要限制后续修改。因此,实际交接对象时仍应使用明确的发布协议。
7.2 不可修改视图可以当作不可变快照吗?
不可修改视图仍会随原列表变化,快照则保留创建时的列表内容。Collections.unmodifiableList(source) 只禁止通过视图修改列表;其他代码修改 source 后,视图也会反映这些变化。
需要快照时,可以使用 List.copyOf(source) 得到不可修改的列表。原列表之后的增删与快照独立,但二者仍可引用同一个可变元素。所以,元素本身可变时,还需复制元素或约束修改。
8 · 面试题卡片
8.1 final 字段为什么有额外的线程安全语义
出现公司:拼多多、美团
考察重点
- 解释构造期间的
this逸出为什么会暴露未初始化字段。 - 说明
final字段的初始化保证覆盖哪些写入。 - 区分固定引用与限制被引用对象修改这两件事。
相关内容:构造期间的 this 逸出和final 字段的初始化安全。
参考回答
JMM 为正确构造对象的 final 字段提供额外的初始化可见性保证。
只要构造期间没有 this 逸出,取得引用的线程就能看到构造结束时的 final 值。通过该引用读取的对象或数组,其构造结束前已写入的状态也受保护。
final 固定的是基本值或引用,引用指向的集合仍能变化。因此,构造后的可变状态要用锁、并发容器或不可变快照管理。其他普通字段仍应通过安全发布协议交接。
8.2 双重检查单例为什么使用 volatile,静态持有者如何替代?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:解释内层锁为什么无法覆盖外层读取,以及类初始化怎样完成单例的创建与发布。
相关内容:双重检查与静态持有者。
参考回答
双重检查用 volatile 向外层无锁读取者发布构造结果,静态持有者则由类初始化完成发布。
内层锁和第二次检查负责只创建一次,但外层读取会跳过锁,所以还需要 volatile 保证可见性。
静态持有者在首次读取 Holder.INSTANCE 时触发初始化。JVM 协调并发初始化与结果发布,因此代码可以省去手写的双重检查。