Java 内存模型(JMM)- final 关键字与内存屏障
本文聚焦 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 = x 和 sharedRef = 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 的写-读规则类似,但作用范围仅限于对象构造。
六、常见误区与注意事项
final不会让对象的所有字段都安全发布:如果对象包含非final字段,即使该对象通过final引用发布,其他线程仍可能看到那些普通字段的默认值或过时值。所以线程安全的对象要么所有字段都是final,要么额外同步。- 构造器内的
this逃逸会破坏final保证:如果在构造器中将this赋值给一个静态变量或启动线程,那么其他线程可能看到未完全初始化的对象,final的保证失效。public class Bad { final int x; public Bad() { x = 42; globalRef = this; // 逃逸! } } final与反射/序列化:通过反射修改final字段是可能的(setAccessible(true)),但要后果自负;序列化时final字段需要特殊处理(如readResolve)。
七、总结表
| 特性 | final |
volatile |
|---|---|---|
| 主要目的 | 安全发布对象(保证构造后字段可见) | 跨线程可见性及禁止重排序 |
| 适用场景 | 不可变对象、只读配置 | 状态标志、轻量级同步 |
| 内存屏障 | 构造函数返回前插入 StoreStore | volatile 写前后插入多种屏障 |
| 对复合操作 | 不涉及 | 不保证原子性 |
| 重排序限制 | 禁止 final 写与引用赋值的重排序 | 限制更广,涉及读写 |
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)