Skip to content

08.3 - final 的内存语义

定位: final 在 JMM 中有特殊的可见性保证——确保构造函数中写入的 final 字段在其他线程读取时一定是初始化后的值。这是 JSR-133 (JDK 5) 针对构造函数安全发布的修复。 面试高频度: ⭐⭐⭐⭐ 考查方式: final 字段的可见性保证、构造函数逃逸问题、final 不等于不可变、final 与 JIT 优化的关系

一、这是什么?为什么需要它?

JSR-133 之前的 Bug:final 字段可能读到默认值

在 JDK 5 之前(JSR-133 之前),final 字段没有任何特殊的内存语义。这意味着:

JSR-133 之前的 JDK (1.4 及更早):

class FinalFieldProblem {
    final int x;
    static FinalFieldProblem instance;
    
    public FinalFieldProblem() {
        x = 42;           // ① 构造函数中写入 final
    }
    
    static void create() {
        instance = new FinalFieldProblem();  // ② 发布引用
    }
    
    static void reader() {
        FinalFieldProblem obj = instance;
        if (obj != null) {
            int r = obj.x;  // ③ 可能读到 0! 不是 42!
        }
    }
}

根本原因:构造函数中的写操作和引用赋值可以被重排序。

无特殊语义时可能的执行顺序:

时间──→
┌────────────────────────────────────────────────────────────┐
│  Thread A (构造函数):                                       │
│    allocate FinalFieldProblem (分配内存, 字段为默认值 0)    │
│    astore instance      ← 引用赋值被重排到这里!             │
│    ... (构造函数体中, x=42 还没执行)                         │
│                                                             │
│  Thread B (读):                                             │
│    if (instance != null) → true                             │
│    int r = obj.x → 读到 0! (因为 x=42 还没执行)             │
│                                                             │
│  Thread A (继续执行构造函数):                                │
│    x = 42               ← 执行得太晚了                      │
└────────────────────────────────────────────────────────────┘

WHY JSR-133 修复:JMM 团队意识到,final 字段的语义应该是"构造函数完成 → final 字段一定已被初始化"。否则即使是最基本的安全发布模式(在构造函数中初始化,然后发布引用)也无法保证。

JSR-133 对 final 的两条规则

规则 1: 构造函数中的 final 字段写
─────────────────────────────────
  final_field = value;  ← 在构造函数中写入
  [StoreStore 屏障]      ← 在构造函数返回/引用赋值前插入
  return; (或引用赋值)

→ 保证: 在对象引用对其他线程可见前, final 字段已经初始化完成

规则 2: final 字段的首次读
─────────────────────────────────
  [LoadLoad 屏障]         ← 在读 final 字段之前
  int r = obj.finalField;

→ 保证: 读取 final 字段时, 一定看到构造函数中写入的值
         (不会被后续的读操作重排序到前面)

二、原理拆解

2.1 JSR-133 之后的 final 语义细节

关键约束:构造函数不泄漏 this

final 语义只在"构造函数不泄漏 this"时成立。如果构造函数中把 this 发布到外部,final 的保证就失效了:

模式 1: 安全发布 (final 语义生效)
┌────────────────────────────────────────────┐
│ class SafeObj {                             │
│     final int x;                            │
│                                             │
│     SafeObj(int val) {                      │
│         this.x = val;                       │
│         // 构造函数结束前没有暴露 this       │
│     }                                       │
│ }                                           │
│                                             │
│ 方法返回后 → 其他线程看到 obj.x 一定是 val  │
└────────────────────────────────────────────┘

模式 2: this 逃逸 (final 语义失效!)
┌────────────────────────────────────────────┐
│ class UnsafeObj {                           │
│     final int x;                            │
│     static UnsafeObj instance;               │
│                                             │
│     UnsafeObj(int val) {                    │
│         this.x = val;                       │
│         instance = this;  // ← this 逃逸!   │
│     }                                       │
│ }                                           │
│                                             │
│ 其他线程可能看到 obj.x = 0!                 │
│ 因为 this 在构造函数完成前就发布了            │
└────────────────────────────────────────────┘

WHY:如果 this 在构造函数中逃逸,JVM 无法确定"构造函数何时结束"——你让 this 在构造函数中可见,JVM 不能保证 final 字段在其可见之前已被写入。

final 字段和非 final 字段的区别

class Example {
    final int a = 42;      // final → JSR-133 保证
    int b = 7;             // 非 final → "尽可能"可见, 但不保证
    static final int c = 100;  // static final → 编译时常量 (类加载时确定)
    
    Example() {
        // a = 42 → 默认值初始化, 编译后直接写入
        // b = 7  → 在构造函数中赋值
    }
    
    // 非 final 字段的可见性要依赖 synchronized/volatile 链:
    // 如果对象是通过锁安全发布的, b 也对其他线程可见
    // 如果是直接赋值给 volatile 字段 → 借助 volatile hb 链可见
    
    void unsafePublish() {
        // 直接暴露 this → a 和 b 都可能不可见
        holder = this;
    }
}

2.2 final 不等于不可变 (Immutable)

这是最常被误解的地方

final int x = 42;     ← x 不能再赋值
final List<String> list = new ArrayList<>();

list = new ArrayList<>();   // 编译错误! Cannot assign a value to final variable
list.add("hello");          // 可以! 引用本身不变, 但内部状态变了

→ final 只保证引用不改变, 不保证引用的对象内部不变!
真正的不可变对象 (Immutable Object) 需要:
┌────────────────────────────────────────────┐
│ ① 所有字段都是 final                       │
│ ② 类本身是 final (禁止子类覆盖行为)         │
│ ③ 如果字段是引用类型, 确保不可变:          │
│    - 不提供修改该引用的方法                   │
│    - 引用的对象本身不可变 (如 String)        │
│    - 构造时深度拷贝防御性                     │
│ ④ 不泄漏 this 引用                          │
└────────────────────────────────────────────┘

反例:
┌────────────────────────────────────────────┐
│ class NotImmutable {                        │
│     final int x;                           │
│     final List<String> items; // final 引用 │
│                                             │
│     NotImmutable(List<String> items) {      │
│         this.items = items;  // 直接引用!   │
│         this.x = items.size();              │
│     }                                       │
│                                             │
│     List<String> getItems() {               │
│         return items;  // 暴露了内部引用!    │
│     }                                       │
│ }                                           │
│                                             │
│ // 外部可以修改:                             │
│ list.add("hacked");  // 内部状态变了!        │
│ obj.items == list → true (同一个引用)        │
└────────────────────────────────────────────┘

2.3 final 与 JIT 优化的协同

Final 字段帮助 JIT 做常量传播和死代码消除

class Config {
    final int timeout = 5000;  // 编译期已知
    final int maxRetries = 3;
}

// JIT 编译后 (假设对象不逃逸):
for (int i = 0; i < 3; i++) {
    connect(timeout: 5000);   // 常量直接展开
}
// timeout 和 maxRetries 被内联为立即数
// 不需要从对象头读取!

// vs 非 final:
for (int i = 0; i < config.maxRetries; i++) {
    connect(config.timeout);  // 每次都要从堆上读取
    // 如果 C2 证明 config 不逃逸, 也可以优化
    // 但 final 让编译器自信地做常量折叠
}

Final + 逃逸分析:对象不逃逸且字段都是 final → 标量替换后字段直接变成立即数,连对象本身都消失了。

标量替换后:
// Config 对象完全消失!
int timeout$local = 5000;
int maxRetries$local = 3;
for (int i = 0; i < 3; i++) {
    connect(timeout$local);  // 更进一步: connect(5000)
}

2.4 static final 的特殊性

class Constants {
    static final int PORT = 8080;  // 编译期常量
    static final String NAME = "server";
    
    // 类加载时在 <clinit> 中初始化
    // 所有线程在类初始化完成后看到的是同一份值
    
    // 非编译期常量 (需要运行时决定):
    static final long START_TIME = System.currentTimeMillis();
    // 也在 <clinit> 中初始化, 但依赖 JVM 运行状态
}

编译期常量 (static final + 字面量):Java 编译器会直接"内联"到使用处。反编译后,引用 Constants.PORT 的地方变成了 bipush 8080

三、图解全景

3.1 JSR-133 前后对比

JSR-133 之前 (JDK ≤ 1.4):
┌────────────────────────────────────────────────────────────────────┐
│ Thread A (构造函数):                                                │
│   allocate                                                         │
│   astore instance  ← 引用逃逸! 构造函数还没执行完                     │
│   x = 42           ← 太晚了!                                       │
│                                                                     │
│ Thread B (读):                                                      │
│   read instance → 非 null                                           │
│   read instance.x → 0! (默认值)                                    │
│                                                                     │
│ 结论: final 和非 final 一样, 没有可见性保证                           │
└────────────────────────────────────────────────────────────────────┘

JSR-133 之后 (JDK 5+):
┌────────────────────────────────────────────────────────────────────┐
│ Thread A (构造函数):                                                │
│   allocate                                                         │
│   x = 42         ← 先写入 final 字段                                │
│   [StoreStore]   ← 编译器插入的屏障: 确保 x=42 写入后才发布引用       │
│   astore instance ← 此时 x=42 已对其他线程可见                       │
│                                                                     │
│ Thread B (读):                                                      │
│   read instance → 非 null                                           │
│   read instance.x → 42! (一定看到构造函数写入的值)                   │
│                                                                     │
│ 结论: final 字段有可见性保证 (通过 StoreStore 屏障)                  │
└────────────────────────────────────────────────────────────────────┘

3.2 构造函数 this 逃逸场景

场景: 在构造函数中注册事件监听器
┌────────────────────────────────────────────────────────────────────┐
│ class EventSource {                                                │
│     final String name;                                             │
│     static EventSource latest;                                      │
│                                                                     │
│     EventSource(String name) {                                      │
│         this.name = name;                                           │
│         latest = this;              ← (1) this 逃逸!                │
│         GlobalRegistry.register(this); ← (2) 或者这种形式            │
│         new Thread(() -> {                                         │
│             use(this.name);    ← (3) 内部类捕获 this               │
│         }).start();                                                 │
│     }                                                               │
│ }                                                                    │
│                                                                     │
│ 问题: any of (1)(2)(3) 发生后, 其他线程可能通过 latest /            │
│         registry / thread 引用到这个对象——而构造函数还没结束!        │
│       final 语义不保证看到 name 的值!                                │
│       (因为 StoreStore 屏障只在"构造函数结束"时生效,                │
│        逃逸后构造函数不算"结束")                                     │
└────────────────────────────────────────────────────────────────────┘

3.3 Final 字段的引用类型陷阱

class Order {
    final List<String> items;  // final 引用
    
    Order(List<String> items) {
        this.items = items;  // 直接持有外部引用
    }
}

// 危险用法:
List<String> mutable = new ArrayList<>();
Order order = new Order(mutable);
mutable.add("illegal");  // 通过外部引用修改了 order 的内部状态!
order.items.get(0) → "illegal"  // 虽然是 final, 但对象变了!

// 安全做法 (防御性拷贝):
Order(List<String> items) {
    this.items = new ArrayList<>(items);  // 拷贝一份
}

// 或者用不可变集合:
Order(List<String> items) {
    this.items = Collections.unmodifiableList(new ArrayList<>(items));
}

四、实战验证

验证 1: final 字段的可见性

java
public class FinalVisibility {
    private static FinalObj instance;
    
    static class FinalObj {
        final int x;  // final → 保证可见
        int y;        // 非 final → 不一定可见
        
        FinalObj() {
            x = 42;
            y = 42;
        }
    }
    
    // 不安全发布
    static void publish() {
        instance = new FinalObj();
    }
    
    public static void main(String[] args) throws Exception {
        for (int round = 0; round < 10000; round++) {
            instance = null;
            
            Thread writer = new Thread(FinalVisibility::publish);
            Thread reader = new Thread(() -> {
                FinalObj obj = instance;
                if (obj != null) {
                    if (obj.x != 42) {
                        System.out.println("FINAL BUG: x = " + obj.x);
                    }
                    if (obj.y != 42) {
                        System.out.println("NON-FINAL BUG (expected): y = " + obj.y);
                    }
                }
            });
            
            writer.start();
            reader.start();
            writer.join();
            reader.join();
        }
        System.out.println("Done. x should always be 42 (JSR-133 guarantee)");
    }
}

验证 2: this 逃逸问题

java
public class ThisEscape {
    static ThisEscape instance;
    final int value;
    
    public ThisEscape(int val) {
        this.value = val;
        instance = this;  // this 逃逸! final 语义失效
        // 此时其他线程通过 instance 引用可能看到 value=0
    }
    
    public static void main(String[] args) throws Exception {
        for (int round = 0; round < 100000; round++) {
            instance = null;
            
            Thread writer = new Thread(() -> new ThisEscape(42));
            Thread reader = new Thread(() -> {
                ThisEscape obj = instance;
                if (obj != null && obj.value != 42) {
                    System.out.println("ESCAPE BUG: value = " + obj.value);
                }
            });
            
            writer.start();
            reader.start();
            writer.join();
            reader.join();
        }
        System.out.println("Done. this escape may show stale values.");
    }
}

验证 3: final 引用类型的可修改性

java
public class FinalReferenceMutability {
    public static void main(String[] args) {
        final StringBuilder sb = new StringBuilder("Hello");
        
        // sb = new StringBuilder("World");  // 编译错误! Cannot assign
        
        sb.append(" World");  // 可以修改对象内部状态
        System.out.println(sb);  // "Hello World"
        
        // final 阻止的是引用本身改变, 不是对象状态改变
        
        // 语义对比:
        // final int x = 42;     → x 不能再赋值 (值和引用都不可变)
        // final StringBuilder s → s 不能再指向其他对象
        //                         但 s 指向的对象内部状态可变
    }
}

五、面试视角

追问答案要点
final 字段在 JMM 中有什么特殊语义?JSR-133 (JDK 5) 之后,final 字段有特殊的可见性保证:构造函数中写入 final 字段后,编译器会在构造函数返回前插入 StoreStore 屏障。其他线程看到对象引用时,final 字段一定已被构造函数初始化过。
final 能保证对象不可变吗?不能。final 只保证引用本身不能重新赋值,不保证引用的对象内部状态不变。要构造真正的不可变对象,还需要:所有字段 final + 类 final + 引用类型防御性拷贝 + 不泄漏内部引用 + 不泄漏 this。
构造函数中泄漏 this 有什么问题?this 泄漏意味着对象在构造函数完成前就被发布。此时 final 字段的 JSR-133 保证失效——其他线程可能看到默认值(0/null)。因为 JMM 无法确定"构造函数结束"的边界。
JSR-133 对 final 做了什么修复?两条规则:(1) 构造函数中对 final 字段的写 → 在构造函数返回前插入 StoreStore 屏障,确保 final 字段的写入在引用发布前完成。(2) 读 final 字段 → 插入 LoadLoad 屏障,确保读到的是构造函数中写入的值。
static final 和实例 final 有什么区别?static final 在类加载的 &lt;clinit&gt; 阶段初始化,类加载完成后所有线程可见(类加载本身有锁保护)。实例 final 在构造函数中初始化,依赖 JSR-133 的 StoreStore 屏障保证可见性。
final 字段如何辅助 JIT 优化?(1) 常量折叠:final 字段值可被内联为立即数。(2) 标量替换:对象不逃逸 + final 字段 → 字段直接变成局部变量/立即数。(3) 死代码消除:基于 final 值判断的分支可被消除。(4) 循环优化:final 边界值可在编译期确定。
为什么 String 是不可变的?String 类被声明为 final,所有字段都是 private final(value char[] 是 final 引用),不提供修改内部状态的 public 方法,不泄漏内部引用。这是 final 语义 + 类设计共同保证的。
反序列化会破坏 final 吗?是的。反序列化不通过构造函数创建对象(使用 Unsafe.allocateInstanceObjectInputStream),final 字段的 JSR-133 保证不适用。反序列化可能绕过 final 约束——这就是为什么需要 readResolve()readObject() 来控制反序列化行为。

📚 相关链接

  • **JMM与happens-before** — JMM 的核心规则,final 是 hb 规则 8(对象终结规则)的基础
  • **volatile的内存语义** — volatile 的 StoreStore 屏障和 final 的 StoreStore 屏障类似
  • **对象内存布局(MarkWord与Klass与实例数据)** — 对象头和实例数据布局
  • **方法内联和逃逸分析** — 标量替换如何受益于 final
  • **锁消除与标量替换** — 标量替换的条件与 final 协同
  • ← 返回 **JMM索引**

Knowledge4J — Java 知识库