Java final 关键字:从“不能改”到“安全发布”的深入理解

前言

final 是 Java 里非常常见的关键字。它看起来简单:不能改、不能继承、不能重写。但真正写项目、看源码、准备面试时,会发现 final 不只是“加个限制”,它还和代码设计、对象不可变、线程安全、JVM 优化都有关系。

可以先用一个比喻理解它:final 像给某个位置贴上封条。封条贴在哪里,限制就发生在哪里。贴在变量上,变量不能换指向;贴在方法上,方法不能被重写;贴在类上,类不能被继承。


一、final 的核心定义

1.1 final 到底限制了什么

final 可以修饰:

  • 变量:变量只能赋值一次。
  • 成员变量:必须在对象构造完成前完成初始化。
  • 静态变量:通常用于定义常量。
  • 方法:子类不能重写。
  • 类:不能被继承。
  • 方法参数:方法内部不能重新给参数赋值。

一句话总结:

final 限制的是“绑定关系”或“继承扩展点”,不是一定让对象本身不可变。

这句话很重要。比如:

1
2
3
final List<String> list = new ArrayList<>();
list.add("A"); // 可以
// list = new ArrayList<>(); // 不可以

final 锁住的是 list 这个变量和原始 ArrayList 对象之间的指向关系,不是锁住 ArrayList 内部的数据。

1.2 final 的三类常见用途

final 最常见的用途有三类:

  1. 表达不变:这个值不应该再次改变。
  2. 收紧扩展:这个类或方法不允许被继承或重写。
  3. 提升安全性:配合不可变对象、线程安全发布、常量设计使用。

在项目里,final 不是为了显得代码高级,而是为了把“不应该发生的变化”挡在编译期。


二、final 修饰变量

2.1 局部变量:只能赋值一次

局部变量被 final 修饰后,只能赋值一次。

1
2
final int count = 10;
// count = 20; // 编译错误

这适合表达“这个中间结果后面不应该再改”。它像把便签贴在桌上:这张便签的内容确定后,不允许被擦掉重写。

示例:

1
2
3
4
5
6
7
public List<String> mutableFinalReference() {
final List<String> values = new ArrayList<>();
values.add("A");
values.add("B");
// values = new ArrayList<>(); // 编译错误
return values;
}

这里 values 不能重新指向另一个集合,但集合内部仍然可以添加元素。

2.2 成员变量:对象构造完成前必须初始化

成员变量被 final 修饰后,必须在声明处、构造方法中或初始化代码块中完成赋值

1
2
3
4
5
private final String id;

public FinalKeywordDemo(String id, List<String> tags) {
this.id = id;
}

这类字段适合表示对象的核心身份。例如订单号、用户 ID、配置项创建时间等。

在对象建好之后,这些字段就不应该再变化。对象像一张身份证,身份证号从创建开始就固定。

2.3 空白 final:先声明,后初始化

只声明但不立刻赋值的 final 字段,叫空白 final。

1
2
3
4
5
6
7
public static class BlankFinalExample {
private final String name;

public BlankFinalExample(String name) {
this.name = name;
}
}

空白 final 的价值是:不同对象可以有不同初始值,但每个对象初始化后都不能再改。

2.4 static final:定义常量

static final 通常用于定义类级别常量。

在 Java 中:

  • static 表示类级别,即这个变量属于类本身,而不是某个对象。所有对象共享同一个值。
  • final 表示不可变,即赋值后不能再修改。
1
public static final int MAX_RETRY = 3;

意思是:

  • 这是类级别的常量;
  • 任何地方都可以通过 ClassName.MAX_RETRY 访问;
  • 它的值永远是 3,不能再改。

命名习惯是全大写,单词之间用下划线,例如:

1
public static final int DEFAULT_TIMEOUT_SECONDS = 30;

注意:只有基本类型和 String 字面量这类编译期常量,才可能被编译器内联到使用方字节码中

2.4.1 编译期常量 vs 运行期常量

Java 编译器对 static final 常量有一个特殊优化:编译期常量会被直接写到使用方的字节码中,这叫内联(inlining)

2.4.2 什么是编译期常量?

  • 基本类型(int、long、double、boolean 等)或者 String 字面量
  • 值在编译时就能确定,例如:
1
2
public static final int MAX_RETRY = 3;
public static final String APP_NAME = "final-demo";

这里 3"final-demo" 都是编译期就能确定的值。

2.4.3 什么不是编译期常量?

  • 运行时才能确定的值,例如:
1
2
public static final int RANDOM = new Random().nextInt();
public static final String NAME = getNameFromConfig();

这些值不能在编译时确定,因此不会被内联。

2.4.4 内联的后果

当你在一个类里定义:

1
public static final int MAX_RETRY = 3;

然后在另一个类使用:

1
int retry = MyClass.MAX_RETRY;
  • 编译器会把 retry = 3; 直接写到字节码里,而不是访问 MyClass.MAX_RETRY
  • 这样在运行时,甚至不需要加载 MyClass 的常量

image-20260522161926097


三、final 修饰引用类型

3.1 final 引用不等于对象不可变

这是 final 最容易被误解的地方。

1
2
3
final StringBuilder builder = new StringBuilder("start");
builder.append("-A"); // 可以
// builder = new StringBuilder(); // 不可以

final 只是不允许变量重新指向别的对象。至于原对象内部能不能变,要看对象自身是否可变。

测试代码:

1
2
3
4
5
6
7
8
public static class FinalReferenceHolder {
private final StringBuilder builder = new StringBuilder("start");

public String append(String value) {
builder.append(value);
return builder.toString();
}
}

对应测试:

1
2
3
4
5
6
7
@Test
public void test_finalReferenceIsNotImmutableObject() {
FinalKeywordDemo.FinalReferenceHolder holder = new FinalKeywordDemo.FinalReferenceHolder();

assertEquals("start-A", holder.append("-A"));
assertEquals("start-A-B", holder.append("-B"));
}

3.2 如何写出真正更安全的不可变对象

步骤:

  1. 类声明为 final
  2. 所有字段 private final
  3. 不提供 setter。
  4. 构造函数中防御性拷贝可变字段。
  5. Getter 返回只读视图或拷贝。
  6. 保证 this 不在构造函数中逃逸。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public final class Person {
private final String name;
private final List<String> tags;

public Person(String name, List<String> tags) {
this.name = name;
this.tags = new ArrayList<>(tags); // 防御性拷贝
}

public String getName() { return name; }
public List<String> getTags() {
return Collections.unmodifiableList(tags); // 返回只读视图
}
}

如果想让对象更接近不可变,只写 final 字段还不够,还要注意防御性拷贝。

1
2
3
4
5
6
7
8
9
10
11
12
13
private final List<String> tags;

// 这样 this.tags 就是一个新的 List 对象,和外部传入的 tags 完全独立。
// 即使外部修改原来的 tags(比如添加/删除元素),内部的 this.tags 不会受影响。
public FinalKeywordDemo(String id, List<String> tags) {
this.tags = new ArrayList<>(tags);
}

// Collections.unmodifiableList 会返回 一个不可修改的视图(read-only view)。
// 调用方可以读取 List 的元素,但如果尝试修改(add/remove/set),会抛出 UnsupportedOperationException。
public List<String> getTags() {
return Collections.unmodifiableList(tags);
}

这里做了两件事:

  1. 构造时复制外部传入的集合,防止外部继续修改原集合影响对象内部。
  2. 返回时包装成不可修改视图,防止调用方通过 getter 修改内部集合。

项目中常见场景:

  • 配置对象。
  • DTO 快照。
  • 订单创建后的核心信息。
  • 缓存 Key。
  • 多线程共享的只读数据。

image-20260522165203104


四、final 修饰方法

4.1 final 方法不能被重写

方法加上 final 后,子类不能重写这个方法。

1
2
3
4
5
public static class ParentService {
public final String stableApi() {
return "stable-api";
}
}

子类中如果写:

1
// public String stableApi() { return "changed"; }

会直接编译失败。

这适合用于稳定流程中的关键步骤。比如模板方法模式中,父类定义主流程,某些关键步骤不允许子类改写,避免破坏整体逻辑。

4.2 final 方法的项目使用场景

常见场景:

  • 安全敏感方法:鉴权、签名校验、金额计算入口。
  • 框架骨架方法:父类规定调用顺序,子类只能改扩展点。
  • 不希望子类破坏语义的方法:例如对象身份、关键状态判断。

但也不要滥用。final 方法会减少扩展能力,如果项目需要通过继承做定制,就要慎重。


五、final 修饰类

5.1 final 类不能被继承

类加上 final 后,不能有子类。

1
2
3
4
5
6
7
8
public static final class FinalUtility {
private FinalUtility() {
}

public static String normalize(String value) {
return value == null ? "" : value.trim().toLowerCase();
}
}

典型代表是 StringString 被设计成 final,一个重要原因是它经常被用于常量池、HashMap Key、类加载、权限判断等基础场景。如果允许继承并篡改行为,整个 Java 生态都会变得不可靠。

5.2 final 类的项目使用场景

适合使用 final 类的情况:

  • 工具类:只提供静态方法,不需要继承。
  • 值对象:如 Money、Coordinate、DateRange。
  • 安全敏感类:不希望子类改写行为。
  • 语义已经完整的类:没有必要开放继承。

不适合使用 final 类的情况:

  • 需要框架代理的类,例如某些 AOP、ORM 场景。
  • 明确作为父类提供扩展点的类。
  • 单元测试需要通过继承替身的旧代码。

六、final 修饰参数和 lambda 捕获

6.1 final 参数:防止方法内部改引用

1
2
3
4
public String appendSuffix(final String value) {
// value = "changed"; // 编译错误
return value + "-suffix";
}

final 参数常用于表达“这个入参只读”。不过现代 Java 项目里并不是所有参数都必须加 final,否则代码会显得啰嗦。更推荐在关键方法、复杂方法或者团队规范要求时使用。

6.2 effectively final:Java 8 lambda 的隐形 final

Java 8 开始,lambda 可以捕获局部变量,但这个变量必须是 final 或 effectively final。

1
2
3
4
public Runnable effectivelyFinalLocalVariable() {
String message = "hello";
return () -> System.out.println(message);
}

message 没有显式写 final,但它赋值后没有再变,所以是 effectively final。

为什么要这样限制?

因为局部变量存在线程栈里,而 lambda 对象可能在方法返回后才执行。Java 不能直接让 lambda 持有那个栈变量本身,只能捕获它的值。为了避免“捕获时一个值,执行时又变了”的混乱,Java 要求它必须事实上不变。


七、final 与底层原理

7.1 编译期检查

final 的很多限制都发生在编译期。

例如:

  • final 变量重复赋值,编译失败。
  • final 方法被重写,编译失败。
  • final 类被继承,编译失败。
  • lambda 捕获非 effectively final 变量,编译失败。

这说明 final 很多时候不是运行时防御,而是把错误提前到编译阶段。

7.2 编译期常量和常量内联

对于这种代码:

1
public static final int MAX_RETRY = 3;

如果其他类使用了 MAX_RETRY,编译器可能直接把 3 写进使用方字节码里。这叫常量内联。

这带来一个项目里的真实问题:如果一个公共 jar 修改了常量值,但调用方没有重新编译,调用方可能还在使用旧值。

所以公共 SDK 里的常量设计要谨慎,尤其是跨模块、跨服务发布时。

7.3 final 字段与 Java 内存模型

final 字段在 Java 内存模型里有特殊语义。

简单说:如果对象构造过程没有发生 this 逃逸,那么构造完成后,其他线程看到这个对象时,能更可靠地看到 final 字段的正确初始化值。

这叫 final 字段的安全发布语义。

例子:

1
2
3
4
5
private final String id;

public FinalKeywordDemo(String id, List<String> tags) {
this.id = id;
}

id 在构造方法中完成赋值。只要构造期间不把 this 暴露出去,其他线程拿到对象后,更不容易看到 id 还是默认值 null 的异常情况。

这也是不可变对象天然更适合多线程共享的原因之一。

7.4 final 和性能优化

早期有人会说:给方法加 final,JVM 就能内联,性能更好。

这句话不能完全当作现代 Java 的主要理由。现代 JIT 编译器会根据运行时类型信息做内联优化,即使方法不是 final,也可能被优化。final 更重要的价值是表达设计意图和限制错误扩展。

也就是说:

不要为了微小性能收益滥用 final,要为了清晰语义和正确边界使用 final。


八、项目中如何使用 final

8.1 推荐使用 final 的场景

建议使用:

  • 常量:public static final
  • 构造后不变的字段:如 ID、创建时间、配置项。
  • 工具类:final class + private constructor
  • 不可变值对象。
  • 不允许子类改写的关键方法。
  • lambda 捕获变量本身需要保持不变的场景。

示例:

1
2
3
4
5
6
7
8
9
public final class Money {
private final long cents;
private final String currency;

public Money(long cents, String currency) {
this.cents = cents;
this.currency = currency;
}
}

这个类一看就表达了:金额对象创建后不应该被随意修改。

8.2 不建议滥用 final 的场景

不建议:

  • 所有局部变量都机械加 final。
  • 所有类都加 final,导致扩展和测试困难。
  • 框架需要生成代理的类上随意加 final。
  • 误以为 final 集合就是不可变集合。

尤其在 Spring AOP、Mockito、Hibernate 等框架场景下,final 类和 final 方法可能影响代理、增强或 mock。是否能支持取决于框架机制和配置,不能一概而论。


九、面试如何考察 final

9.1 高频基础题

常见问题:

  1. final 可以修饰什么?
  2. finalfinallyfinalize 有什么区别?
  3. final 变量和普通变量有什么区别?
  4. final 引用对象还能修改内容吗?
  5. final 方法和 final 类分别有什么作用?

简洁回答:

  • final 是关键字,用于限制赋值、继承、重写。
  • finally 是异常处理结构中的代码块,通常用于释放资源。
  • finalize 是 Object 的方法,曾用于对象回收前回调,但不推荐使用,并且新版本 Java 已经逐步废弃。

9.2 进阶面试题

可能继续追问:

  1. private final List<String> list 是否线程安全?
  2. 如何设计一个不可变类?
  3. static final intstatic final Integer 在编译期常量上有什么区别?
  4. lambda 为什么只能捕获 final 或 effectively final 变量?
  5. final 字段在 Java 内存模型里有什么特殊语义?

回答思路:

  • final 引用不代表对象不可变,更不直接等于线程安全。
  • 不可变类需要 final 类、private final 字段、不提供 setter、防御性拷贝、不要泄露可变内部对象。
  • 基本类型和 String 字面量的 static final 才是典型编译期常量。
  • lambda 捕获的是局部变量的值,不允许后续变化是为了避免语义混乱。
  • final 字段有安全发布语义,但前提是构造期间不能让 this 逃逸。

总结

final 的表层含义是“不能改”,深层价值是“明确边界”。

它可以帮助我们把一些错误提前到编译期:不该重复赋值的变量、不该重写的方法、不该继承的类,都能被更早拦住。

但要记住:final 不是万能不可变药水。final 引用不能换对象,不代表对象内部不能变;final 字段有安全发布语义,也不代表整个对象天然线程安全。

项目里真正好的用法,是把 final 用在能表达设计意图的位置:常量、不可变值对象、稳定 API、关键流程、只读配置。这样代码不仅能跑,还能告诉后来的人:这里的变化边界已经想清楚了。