承接的问题
3.2 的结论是:跨线程可见性需要 hb 边。但对象发布还有一个特殊问题:new 出来的对象,引用赋值给共享字段,这一步本身没有同步动作,另一个线程可能看到引用却看不到构造器写的字段。
JMM 对 final 字段给了一条额外保证,常被概括成「final 字段构造完成后一定可见」。这句话省略了两个关键前提:对象没有在构造期间逸出,以及final 只保证构造完成时的状态,不冻结之后的变化。
这一节回答:final 字段到底保证了什么、构造逸出为什么让保证失效、四类安全发布各依赖哪条 hb 规则,以及为什么「final 引用可见」不等于「对象不可变」。
final 字段保证什么
JLS 17.5 的定义可以简化成一句话:如果一个线程只能在对象完全初始化之后看到它的引用,那么该线程一定能看到这个对象所有 final 字段的正确值。
关键词是「只能在完全初始化之后看到」。满足这个前提时,即使引用是通过普通字段、数组或集合发布的,没有锁也没有 volatile,final 字段的值仍然正确。
public final class BookSnapshot {
private final String isbn;
private final int stock;
public BookSnapshot(String isbn, int stock) {
this.isbn = isbn;
this.stock = stock;
}
public String isbn() { return isbn; }
public int stock() { return stock; }
}
发布与读取:
// 共享字段,没有 volatile
private static BookSnapshot snapshot;
// 线程 A
snapshot = new BookSnapshot("978-7-111-0001", 3);
// 线程 B
BookSnapshot current = snapshot;
if (current != null) {
System.out.println(current.stock()); // 一定看到 3
}
线程 B 可能看到 null(引用本身的可见性没有保证),但只要看到非空引用,isbn 和 stock 就是构造器写入的值,不会是 null 或 0。
实现层面,JIT 在构造器返回前会保证 final 字段的写对其他线程有序:x86 的 TSO 本身保证 StoreStore 顺序,通常不需要额外指令;ARM 上会生成相应的屏障。读侧则依赖「引用可见时构造器已完成」这个前提。这也是 record、String、Instant 适合做不可变值对象的原因。
final 字段必须在构造器结束前完成赋值,编译器会做确定赋值检查。比如只在 if 分支里赋值,编译会报 variable value might not have been initialized。编译器保证 final 只写一次且构造结束前必须写,但管不了「this 有没有提前泄漏出去」。
构造逸出:ThisEscape 反例
最经典的错误是在构造器里把 this 注册到事件总线、静态集合或线程池。
public class ThisEscape {
private final int threshold;
public ThisEscape(EventBus bus) {
bus.register(new EventListener() {
@Override
public void onEvent(String isbn, int stock) {
if (stock < threshold) { // 读取外部实例的字段
System.out.println("low: " + isbn);
}
}
});
this.threshold = 5; // 注册之后才赋值
}
}
new EventListener() { ... } 是匿名内部类,它隐式持有外部 ThisEscape.this 的引用。bus.register(...) 把这份引用交给了 EventBus,此时构造器还没执行到 threshold = 5。
如果另一个线程在 register 之后、赋值之前调用 publish,监听器读到的 threshold 是默认值 0。final 语义在这里帮不上忙:对象在构造期间就已经逸出,不满足「只能在完全初始化之后看到」的前提。
EventBus 的最小定义:
import java.util.concurrent.CopyOnWriteArrayList;
public final class EventBus {
private final CopyOnWriteArrayList<EventListener> listeners =
new CopyOnWriteArrayList<>();
public void register(EventListener listener) { listeners.add(listener); }
public void publish(String isbn, int stock) {
listeners.forEach(listener -> listener.onEvent(isbn, stock));
}
}
public interface EventListener {
void onEvent(String isbn, int stock);
}
CopyOnWriteArrayList 保证注册和遍历不抛 ConcurrentModificationException,但它不阻止「注册发生在构造完成之前」这个逻辑问题。
正确写法:构造完成后再发布
把构造和注册分开,用静态工厂在对象完全构造后发布:
public final class SafeStockListener implements EventListener {
private final int threshold;
private SafeStockListener(int threshold) { this.threshold = threshold; }
public static SafeStockListener create(int threshold, EventBus bus) {
SafeStockListener listener = new SafeStockListener(threshold);
bus.register(listener); // 构造完成,this 才逸出
return listener;
}
@Override
public void onEvent(String isbn, int stock) {
if (stock < threshold) {
System.out.println("low: " + isbn + " left=" + stock);
}
}
}
构造器私有,外部只能走 create。register 调用时 threshold 已经赋值,即使别的线程立刻回调,读到的也是 5。
同样的错误还有几种变体:
| 写法 | 逸出点 | 修法 |
|---|---|---|
构造器里 new Thread(this::run).start() | 线程可能在构造完成前访问字段 | 静态工厂里启动 |
构造器里 staticList.add(this) | 静态集合被其他线程遍历 | 构造后注册 |
| 构造器里调用可覆盖方法 | 子类字段尚未初始化 | 方法改为 private 或 final |
构造器里 executor.execute(this) | 任务可能在字段赋值前执行 | 构造后提交 |
「构造器里调用可覆盖方法」容易被忽略:即使没有多线程,子类重写的方法也会在子类字段初始化之前执行,读到默认值。它和逸出是同一类问题。
四类安全发布
| 方式 | 依赖的 hb 来源 | 适用场景 | 注意 |
|---|---|---|---|
| 静态初始化 | 类初始化锁 | 全局配置、单例 | 只保证引用,不保证对象内部不变 |
| final 字段 | final 冻结 | 不可变值对象 | 前提是构造期间不逸出 |
| volatile 引用 | volatile 写 hb 读 | 快照替换、状态标志 | 每次替换整个对象,不原地改 |
| 并发容器 / 锁 | put hb get、unlock hb lock | 缓存、注册表、共享集合 | 单个操作安全,组合操作仍需锁 |
静态初始化
类初始化由 JVM 加锁,<clinit> 中的写 hb 后续任何对该类的使用。所以 static final 字段只要在静态初始化里完成,其他线程第一次访问时一定能看到完整值,比如 public static final int QUOTA = 5; 和 public static final List<String> CHANNELS = List.of("mail", "sms", "push");。
边界:static final 只保证引用安全发布。如果它指向一个可变 ArrayList,其他线程仍可能看到后续修改,或者并发修改时出错。要发布集合就用 List.of / Map.of 这类不可变副本。
final 字段
适合值对象:构造时一次算好,之后不再变。record 是最省事的写法,组件天然是 final。
public record LoanReceipt(String loanId, String isbn, int stockAfter) {
}
LoanReceipt 的引用无论通过哪种方式发布,只要构造期间没有逸出 this,读线程看到非空引用时,三个组件都是构造器写入的值。
volatile 引用
适合「整体替换」的快照。写线程构造新对象,赋给 volatile 字段;读线程读一次字段,拿到的是一个完整版本。
private volatile StockSnapshot snapshot;
public void refresh(Map<String, Integer> latest) {
snapshot = new StockSnapshot(Map.copyOf(latest), Instant.now());
}
public StockSnapshot current() {
return snapshot;
}
不要写成「先拿到旧快照,再逐个改它的字段」;那样 volatile 只保护引用,保护不了对象内部。
并发容器或锁
把对象放进 ConcurrentHashMap、BlockingQueue,或在一把锁的保护下读写。ConcurrentHashMap.put 与后续同 key 的 get 之间有一致性保证;锁则通过 unlock hb lock 传递。
private final ConcurrentMap<String, Book> cache = new ConcurrentHashMap<>();
public void put(Book book) {
cache.put(book.isbn(), book);
}
public Optional<Book> find(String isbn) {
return Optional.ofNullable(cache.get(isbn));
}
边界:容器保证单次操作安全,不保证「先检查再写入」的组合原子性。containsKey 后再 put 仍是 check-then-act,要用 computeIfAbsent 或 putIfAbsent。
什么时候不要用这些方式
- 对象需要频繁原地修改(比如计数器):用原子类或锁,不要每次
new一个快照; - 对象很大且修改很少:快照替换会带来复制成本,考虑锁保护的可变对象;
- 需要跨多个字段的强一致读:单靠 final 或 volatile 引用不够,要让整个快照不可变,或读时加锁。
final 保证的边界
引用可见不等于对象不可变
一个 private final List<String> values 字段,引用不会被重新赋值,但 values.add(...) 仍然可以改内容。另一个线程看到外层对象时,能保证看到构造完成时列表的状态,但不能保证之后不被修改,也不能保证并发修改时的安全。
正确做法是防御性复制并发布不可变视图:
public final class Tags {
private final List<String> values;
public Tags(List<String> values) {
this.values = List.copyOf(values); // 复制 + 不可变
}
public List<String> values() {
return values;
}
}
List.copyOf 会拒绝 null 元素,并返回不可变列表;调用方拿到的列表不能改,Tags 内部也不再暴露可变引用。
final 数组只保护数组引用
private final int[] stock = new int[10];
stock 引用不能换,但 stock[0] = 1 随时可以发生,对其他线程的可见性没有任何保证。需要并发访问数组元素就用 AtomicIntegerArray,或者把整个数组替换成新的不可变副本。
构造逸出会让 final 保证失效
这是最重要的一条。JLS 17.5 的保证以「引用只在对象完全初始化后可见」为前提。构造器里把 this 交给别的线程、放进静态集合、注册为回调,都会让其他线程在构造完成前看到对象,此时 final 字段可能还是默认值。前面 ThisEscape 的 threshold 就是例子:即使它是 final,publish 的接收方也可能读到 0。final 不是「任何情况下都安全」,它只在正确构造的前提下生效。
其他边界
- 反序列化:
readObject不走构造器,final 字段由反序列化机制填充,不能套用构造器的保证; - 反射:
setAccessible(true)可以修改 final 字段(受模块和版本限制),会破坏不可变假设; - record:组件是 final,但 record 引用本身仍需安全发布;
record里的可变组件(比如List)仍要防御性复制; - 静态字段:
static final的初始化安全由类初始化锁保证,但static非 final 字段的赋值没有这条保证。
在 libraryd-conc 里落地
库存查询需要给监控线程和业务线程提供一致快照:不能读到「一半旧、一半新」的库存。做法是每次刷新构造一个不可变 StockSnapshot,用 volatile 引用整体替换。
package com.plume.conc.core;
import java.time.Instant;
import java.util.Map;
public record StockSnapshot(Map<String, Integer> stockByIsbn, Instant capturedAt) {
public StockSnapshot {
stockByIsbn = Map.copyOf(stockByIsbn);
}
public int stockOf(String isbn) {
return stockByIsbn.getOrDefault(isbn, 0);
}
}
package com.plume.conc.core;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public final class StockCache {
private volatile StockSnapshot snapshot =
new StockSnapshot(Map.of(), Instant.EPOCH);
public StockSnapshot current() {
return snapshot;
}
public void refresh(BookRepository repository, List<String> isbns) {
Map<String, Integer> stock = new HashMap<>();
for (String isbn : isbns) {
repository.findByIsbn(isbn)
.ifPresent(book -> stock.put(book.isbn(), book.stock()));
}
snapshot = new StockSnapshot(stock, Instant.now());
}
}
这套组合同时用了三种保证:
StockSnapshot的组件是 final,构造完成后不会被改;- 紧凑构造器里的
Map.copyOf做防御性复制,读线程拿不到内部可变 Map; snapshot是 volatile,写 hb 读,读线程要么看到旧快照,要么看到新快照,不会看到半个。
监控线程调用 current() 后,应该把返回的 StockSnapshot 存进局部变量再用:
StockSnapshot view = cache.current();
for (String isbn : isbns) {
if (view.stockOf(isbn) < threshold) {
System.out.println("low: " + isbn + " left=" + view.stockOf(isbn));
}
}
如果每次循环都调用 cache.current(),中途可能换 snapshot,同一轮巡检会混用两个版本。存局部变量既减少 volatile 读,也保证一轮判断基于同一个快照。
常见错误与边界
| 错误 | 后果 | 修法 |
|---|---|---|
| 构造器里注册监听器 / 启动线程 | this 逸出,final 失效 | 静态工厂,构造后发布 |
final List 当作不可变 | 内容仍可被修改 | List.copyOf / List.of |
final int[] 当作线程安全 | 元素读写无保证 | AtomicIntegerArray 或整体替换 |
static 可变字段直接赋值 | 无 hb 边,可能读到旧值 | static final 或 volatile |
| 反序列化后直接共享 | 绕过构造器,final 语义不适用 | 自定义 readObject 做校验 |
| DCL 忘记 volatile | 快路径看到半成品 | 见 3.2 的推导 |
还有两个判断口径:
「安全发布」不等于「线程安全」。 发布只保证对象在交接那一刻状态正确;对象之后是否会被并发修改,取决于它的设计和后续同步。一个安全发布的 ArrayList 仍然是可变集合,多线程写照样出错。
「不可变」要沿着引用链检查。 一个对象所有字段都是 final,不代表它不可变:字段可能指向可变集合、数组或另一个可变对象。真正的不可变要求「所有可达状态在构造后都不变」,通常要配合防御性复制和不可变类型。
小结
- final 字段的保证是:对象完全初始化后引用才可见时,读线程一定能看到 final 字段的正确值;前提是构造期间
this没有逸出。 - 构造器里注册回调、启动线程、放进静态集合,都会让
this提前可见,final 也救不了。 - 四类安全发布分别依赖类初始化锁、final 冻结、volatile 写读、并发容器或锁;选哪种取决于对象是「一次构造不再变」还是「整体替换」。
- final 保证构造完成时的字段值,但不冻结之后的变化;集合内容、数组元素、被引用对象的内部状态仍可能变。
- 落地时优先用「不可变 record + 防御性复制 + volatile 引用」,让读线程拿到一致快照,而不是逐字段拼装。
阅读导航:上一节 3.2 happens-before 八条规则 | 下一节 4.1 volatile 的语义与边界
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。