深蓝黑背景封面:左侧原创示意图——一棵三分支语义树,根节点「final 关键字」分出三支:修饰类则不能被继承、修饰方法则不能被子类覆写、修饰变量则只能赋值一次;变量一支下再分出「基本类型:值不变」与「引用类型:指向不变,对象内容可变」两枚小注。右侧大标题「final 到底锁住了什么」,副标题「锁的是继承、覆写与重新赋值,不是对象内容」;左上标注「面试经典 · Java 后端」,右上圆角胶囊标注「Day 09 · 11-16」 图 1:本文封面,三分支语义树总览。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「final 有什么用」几乎每个 Java 面试都会问,多数人能背出三个位置的答案,但面试官追问「final 引用指向的对象能改吗」「final 在并发里还有什么用」时,背书就露馅了。这道题真正考的是:final 锁住的到底是哪条边界。

三个位置,三种「不许」。

Java 语言规范把 final 的语义分散在三处。修饰类(JLS 8.1.1.2):这个类不能有子类,继承的大门被焊死,String、Math 这类基础与工具类都是 final 的——设计者不希望任何人通过继承改写它们的行为契约。修饰方法(JLS 8.4.3.3):子类可以继承并调用这个方法,但不能覆写它,实现被钉死。修饰变量(JLS 8.3.1.2):只能被赋值一次。对基本类型,「赋值一次」就是值从此不变;对引用类型,是引用的指向从此不变——这两者的差别正是第二个考点。

引用不可变,不等于对象不可变。

这是最容易答错的一问。final 修饰引用变量时,锁住的是「这个变量指向谁」,不是「指向的那个对象长什么样」。一个 final 的 List,你不能再让它指向另一个 List,但往这个 List 里增删元素完全合法。真正的不可变对象(比如 String)靠的是一整套设计手段——类 final、字段私有 final、不提供任何修改方法,final 关键字只是其中一块砖。别把关系说反:final 引用是造不可变对象的必要材料之一,但光有 final 引用,对象照样可以是可变的。

用代码把语义跑出来。

下面这段程序演示三个位置的行为(本机 JDK 21 实测,javac FinalDemo.java && java FinalDemo):

import java.util.ArrayList;
import java.util.List;

public class FinalDemo {
    // final 类不能被继承:String 就是典型代表,取消下面这行注释会编译报错
    // static class MyString extends String {}

    static class Base {
        final void connect() { System.out.println("Base.connect 执行"); }
    }

    static class Child extends Base {
        // final 方法不能覆写:取消注释同样编译报错
        // @Override void connect() {}
    }

    public static void main(String[] args) {
        final int limit = 100;
        // limit = 200; // 编译错误:final 变量不能二次赋值
        System.out.println("final 基本类型变量: limit = " + limit);

        final List<String> config = new ArrayList<>();
        config.add("model=A");   // 引用是 final,对象内容照样能改
        config.add("retry=3");
        // config = new ArrayList<>(); // 编译错误:final 引用不能改指向
        System.out.println("final 引用没换,但列表内容变了: " + config);

        new Child().connect(); // 子类继承并使用 final 方法,只是不能改实现
    }
}

实测输出:

final 基本类型变量: limit = 100
final 引用没换,但列表内容变了: [model=A, retry=3]
Base.connect 执行

被注释掉的四行如果放出来,编译器会直接拒绝。比如给 final 变量二次赋值,javac 实测报错:

FinalError.java:4: error: cannot assign a value to final variable limit

最后一行输出值得多看一眼:子类 Child 继承并正常调用了 final 方法 connect——final 方法禁的是覆写,不是使用。

深蓝黑背景概念图,分三大区块。左上对比表「final 的三个位置」:修饰类禁止被继承,典型例子是 String 等工具与基础类;修饰方法禁止子类覆写,子类能调用但不能改实现;修饰变量只能赋值一次,基本类型值不变、引用类型指向不变。右侧示意图「引用不可变 ≠ 对象不可变」:一个标注 final 的引用变量框,箭头被锁形标记固定指向一个列表容器,箭头旁写「指向不能换」;列表容器内部的元素格却在增删变化,旁注「内容仍可增删」。底部通栏卡片「并发里的 final」:构造方法正常结束时 final 字段一定已完成写入,之后任何线程无需额外同步就能读到正确值,这是安全发布的底线;前提是构造期间不要让 this 引用逸出 图 2:三重语义、引用与对象的分野、并发保证。

并发里的 final:安全发布的底线。

final 最深的一层语义藏在内存模型里。JLS 17.5 节给出一条重要保证:一个对象如果在构造器正常结束后才被其他线程看到,那么这些线程不需要任何额外同步,就一定能读到它 final 字段的正确初始化值。规范原文的例子对比很直观:同一个对象里,final 字段保证看到构造时写入的值,而普通字段可能还是默认值零。这就是「安全发布」:不可变对象可以不加锁地在线程之间传递,靠的就是 final 字段的这条语义。

但这条保证有个前提,规范同样写明了:构造期间不能让 this 引用逸出——比如在构造器里把 this 注册进某个全局容器,别的线程就可能在构造器跑完之前看到「半成品」对象,final 的保护随之失效。所以并发任务里到处传递的配置对象用 final 钉死字段,不只是代码风格问题,而是在买这条内存模型保证。

回到面试现场,建议的答题结构:先按三个位置给出「不能继承、不能覆写、只能赋值一次」的基本盘,再主动拆「引用不变与对象不变」这个陷阱,最后把安全发布与 this 逸出前提抛出来。基本盘是及格线,并发语义是区分度。你在代码里给哪些对象上过 final,是图不可变,还是图这条并发保证?欢迎聊聊你的用法。