本文聚焦 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 写与引用赋值的重排序 限制更广,涉及读写

更多推荐