《Java 并发编程·线程与内存模型》3.3 final 字段与安全发布

解释 final 字段的初始化安全保证及其前提,用 ThisEscape 反例说明构造逸出如何让 final 失效;给出静态初始化、final 字段、volatile 引用、并发容器四类安全发布方式,并划清「引用可见」与「对象内部不可变」的边界。

承接的问题

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());
    }
}

这套组合同时用了三种保证:

  1. StockSnapshot 的组件是 final,构造完成后不会被改;
  2. 紧凑构造器里的 Map.copyOf 做防御性复制,读线程拿不到内部可变 Map;
  3. 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 的语义与边界

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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