图 1:本文封面,四道闸门各挡一种破坏方式。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。题面很短:怎么实现一个不可变类?很多人脱口而出「字段加 final」,但 final 只是四条准则里的一条。把每条准则防的是哪种破坏方式讲清楚,才是这道题的得分点。
准则一:类声明 final,挡住「继承开洞」。
类不 final,别人就能写个子类覆写你的方法,在里面塞可变行为——你自以为拿着不可变对象,实际是个披着外衣的突变体。声明 final 类(或把构造器私有化、只留静态工厂),把这条路从类型层面焊死。
准则二:字段全部 private final 且不提供 setter,挡住「直接赋值」。
private 挡住外部直接摸字段,final 挡住构造之后的重新赋值,两者缺一不可。final 还有一层常被忽略的内存语义:JLS 第 17.5 节规定了 final 字段的安全发布——只要构造器里 this 没有逸出,任何线程看到构造完成的引用时,一定能看到 final 字段的初始化值,不需要额外同步。这是不可变对象能免锁共享的规范基础。
准则三:可变组件做防御性拷贝,挡住「引用共享」。
这是最容易翻车的一条。字段是 final 的数组或集合,final 锁住的只是引用本身,引用指向的内容照样能被改。两个方向都要防:构造器收到外部传入的可变对象,先拷一份再存——否则调用方事后改原数组,你的字段跟着变;getter 要返回内部可变对象时,拷一份再给——否则调用方拿到的是你内部状态的遥控器。
准则四:构造器里别让 this 逸出,挡住「别人看到半成品」。
在构造器里注册监听器、启动线程、把 this 传给别的对象,都可能让外部在构造完成前就拿到这个对象的引用——看到的是字段还没赋完值的半成品,final 的安全发布保证也随之作废。四道闸门全部落位,不可变才算成立。
把破坏方式逐个演示一遍。
下面这份 JDK 17 代码用一个「看似 final 实则漏风」的反例和四条准则到位的正例对照,直接编译运行(javac ImmutableDemo.java && java ImmutableDemo):
// 反例:看似 final,实则两处漏风
static class NaiveConfig {
private final int[] thresholds;
NaiveConfig(int[] thresholds) {
this.thresholds = thresholds; // 漏洞一:直接存外部数组引用
}
int[] getThresholds() { return thresholds; } // 漏洞二:getter 把内部数组递出去
}
// 正例:四条准则全部到位
static final class ImmutableConfig { // 准则一:final 类,挡住继承开洞
private final int[] thresholds; // 准则二:private final,无 setter
ImmutableConfig(int[] thresholds) {
this.thresholds = thresholds.clone(); // 准则三:入参防御性拷贝
}
public int[] thresholds() { return thresholds.clone(); } // 出参也拷贝
}
int[] input = { 80, 90, 95 };
NaiveConfig naive = new NaiveConfig(input);
ImmutableConfig safe = new ImmutableConfig(input);
input[0] = 1; // 外部改原数组
// naive 被改穿,safe 不动
int[] attempt = safe.thresholds();
attempt[1] = 2; // 改的只是拷贝,safe 内部不动
实测输出:
外部改入参数组后:
naive.thresholds = [1, 90, 95] <- 被外部改穿
safe.thresholds = [80, 90, 95] <- 拷贝挡住
通过 getter 改内部数组后:
naive.thresholds = [1, 2, 95] <- 再次失守
对 safe 的 getter 结果动手脚:
safe.thresholds = [80, 90, 95] <- 内部不动如山
作为 Map key 使用:推理配置缓存
反例两轮失守的路径清清楚楚:构造时存的引用和 getter 递出的引用,都是外部伸进对象内部的手。正例用两次 clone 把两只手都剪断,之后它就能安心当 HashMap 的 key——key 的哈希若在放入后变化,条目会永远「找不到」,不可变性在这里是正确性问题,不是风格问题。
图 2:四道闸门,各挡一种破坏。
可能的追问。
「每次修改都 new 新对象,不浪费吗?」不可变不是不能变,而是每次「变」都产生新对象——String 拼接出新的 String、Java 16 引入的 record 同样遵循这套思想;换来的是任何线程任意时刻看到同一份状态,免锁共享、自由传递、随手缓存。「所有字段都要拷贝吗?」只有可变组件需要;String、基本类型、其他不可变对象可以直接共享。「防御性拷贝的顺序有讲究吗?」先校验参数再拷贝会被竞态钻空子(校验完、拷贝前数据被改),正确顺序是先拷贝、再对副本校验。
特征对象、推理请求上下文如果不可变,就能在线程间自由共享而无需加锁——「不可变」是并发设计里最便宜的正确性保障。你写的类里有几个能过这四道闸门?有没有被「getter 递出内部集合」咬过的经历?欢迎聊聊。