本文聚焦 final 关键字在 Java 内存模型(JMM)中的作用,尤其是它如何保证对象的安全发布,以及与内存屏障的关系。


一、final 的基本作用回顾

  • 修饰变量:一旦初始化,值不可变(对基本类型)或引用不可变(对引用类型,但对象内部状态仍可变)。
  • 修饰方法:禁止重写。
  • 修饰类:禁止继承。

我们这里只讨论并发语义final 字段在对象构造完成后的可见性保证,这是很多开发者忽略但极其重要的特性。


二、JMM 对 final 的特殊规则(保证安全发布)

JMM 规定:在构造函数内对一个 final 字段的写入,与随后将该对象的引用赋值给另一个引用变量之间,存在 happens-before 关系

更直白的说:如果一个对象在构造过程中正确设置了其 final 字段(没有让 this 逃逸),那么在其他线程看到这个对象的引用时,一定能看到该 final 字段的正确初始值(不是默认值 0/null/false)。

2.1 问题背景:没有 final 时的风险

public class Unsafe {
    private int x;   // 普通字段
    public Unsafe(int x) { this.x = x; }
    public int get() { return x; }
}

// 线程 A
Unsafe obj = new Unsafe(42);
sharedRef = obj;   // 发布

// 线程 B
if (sharedRef != null) {
    int val = sharedRef.get(); // 可能看到 0,而不是 42
}

原因:构造函数的赋值 this.x = xsharedRef = obj 可能被重排序(CPU 或 JIT 优化)。线程 B 可能先拿到对象的引用(此时 sharedRef 非空),但看到 x 仍是默认值 0。

2.2 使用 final 修复

public class Safe {
    private final int x;   // final 字段
    public Safe(int x) { this.x = x; }
    public int get() { return x; }
}

// 线程 A
Safe obj = new Safe(42);
sharedRef = obj;

// 线程 B
if (sharedRef != null) {
    int val = sharedRef.get(); // 保证看到 42
}

JMM 保证:对 final 字段的写入(this.x = x)不会与引用赋值(sharedRef = obj)重排序。即构造器的任何写入 final 字段的操作,都会在引用被外部可见之前完成


三、背后的内存屏障实现

为了保证上述规则,编译器和 CPU 会插入合适的屏障。

3.1 在构造函数返回之前插入 StoreStore 屏障

public Safe(int x) {
    this.x = x;          // 写入 final
    // <-- StoreStore 屏障(这里插入)
}
  • StoreStore 保证:屏障前的所有写操作(尤其是对 final 字段的写)先于屏障后的写操作(这里指构造器返回后的任何写,例如将引用赋值给共享变量)。
  • 效果:禁止构造器内部对 final 的写与外部引用赋值的重排序。

3.2 具体到 x86 架构

在 x86 上,由于硬件不会重排序写-写(StoreStore 天然满足),通常不需要额外指令。但 JMM 仍然要求这个语义,因此 JIT 编译器在生成代码时只需保证不重排序(例如通过禁止某些优化),不一定要插入 mfence

对于其他更弱内存模型的 CPU(如 ARM、PowerPC),则必须显式插入内存屏障指令。

3.3 final 与 volatile 的相似与不同

  • 相似点:都是通过禁止重排序来保证可见性。
  • 不同点volatile 保证每次读都从主存取最新值,且对所有线程实时可见;final 只保证在对象构造完成时,字段值对所有后续看到该对象的线程是确定的,之后若字段是引用且内部可变,则那个内部状态不再受 final 保护。

四、复杂情况:final 引用类型与数组

4.1 final 引用类型

public class Holder {
    private final int[] array;
    public Holder() {
        array = new int[]{1,2,3}; // final 引用
    }
}

final 保证 array 引用本身不可变(不能指向另一个数组),但 不保证数组元素对其他线程的可见性。也就是说,线程 B 拿到 Holder 对象后,虽然能看到 array 不为 null,但 array[0] 可能还是默认值 0(如果没有其他同步)。要保证数组内容的可见性,要么用 volatile 修饰数组引用,要么使用 AtomicReferenceArray 或锁。

4.2 更深层的保证:final 字段的冻结(freeze)

JMM 定义了一个“冻结”动作:在构造函数退出前,对 final 字段的写会被“冻结”。冻结之后,其他线程读取该字段时,将看到冻结时的值(或更晚的写,但不会看到默认值)。

对于 final 引用所指向的对象内部的普通字段,如果该对象在构造函数中创建且没有逃逸,那么 JMM 也提供一定的保证(但较复杂,不深入)。


五、final 与 happens-before 规则

JMM 明确了一条 final 规则

  • 在构造函数内对一个 final 字段的写入,与随后在另一个线程中对这个对象引用的读取,存在 happens-before 关系,前提是该对象的引用没有在构造过程中逃逸(即没有让其他线程在构造完成前看到该对象)。

换句话说:

写入 final 字段 (在构造函数内) 
    happens-before 
在另一个线程读取该对象的引用 (且该引用是从共享变量中获取的)

这正好与 volatile 的写-读规则类似,但作用范围仅限于对象构造。


六、常见误区与注意事项

  1. final 不会让对象的所有字段都安全发布:如果对象包含非 final 字段,即使该对象通过 final 引用发布,其他线程仍可能看到那些普通字段的默认值或过时值。所以线程安全的对象要么所有字段都是 final,要么额外同步。
  2. 构造器内的 this 逃逸会破坏 final 保证:如果在构造器中将 this 赋值给一个静态变量或启动线程,那么其他线程可能看到未完全初始化的对象,final 的保证失效。
    public class Bad {
        final int x;
        public Bad() {
            x = 42;
            globalRef = this; // 逃逸!
        }
    }
    
  3. final 与反射/序列化:通过反射修改 final 字段是可能的(setAccessible(true)),但要后果自负;序列化时 final 字段需要特殊处理(如 readResolve)。

七、总结表

特性 final volatile
主要目的 安全发布对象(保证构造后字段可见) 跨线程可见性及禁止重排序
适用场景 不可变对象、只读配置 状态标志、轻量级同步
内存屏障 构造函数返回前插入 StoreStore volatile 写前后插入多种屏障
对复合操作 不涉及 不保证原子性
重排序限制 禁止 final 写与引用赋值的重排序 限制更广,涉及读写
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐