深蓝黑背景封面:左侧大标题「手写一个不可变类」,副标题「final、防御性拷贝与安全发布」;右侧原创示意图——一个被封进琥珀色玻璃罩的类卡片,罩子外三只手各拿一件工具试图修改:一只手被 final 字段的封条挡住,一只手拿着数组引用被「防御性拷贝」剪断,一只手想从构造器里溜走的 this 引用被闸门拦下;左上标注「面试经典 · Java 后端」,右上标注「Day 10 · 11-07」 图 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 的哈希若在放入后变化,条目会永远「找不到」,不可变性在这里是正确性问题,不是风格问题。

准则清单图:中部四块面板逐一对应一条准则与其防御的破坏方式。准则一,类声明 final 或构造器私有:防止子类覆写方法塞进可变行为,挡住「继承开洞」;准则二,字段全部 private final 且不提供 setter:防止引用被改指,挡住「直接赋值」,final 还带来安全发布的内存语义;准则三,可变组件做防御性拷贝:入参先拷一份再存、getter 返回拷一份再给,挡住「引用共享」——外部改了数组就改到你的字段;准则四,构造器里不让 this 逸出:别在构造中注册回调或启线程,挡住「别人看到半成品」。右侧一栏讲收益:任何线程任意时刻看到同一份状态,免锁共享、可安全做缓存 key、可自由传递。底部提示:不可变不是不能变,是每次「变」都产生新对象——String 与 records 都是这个思想 图 2:四道闸门,各挡一种破坏。

可能的追问。

「每次修改都 new 新对象,不浪费吗?」不可变不是不能变,而是每次「变」都产生新对象——String 拼接出新的 String、Java 16 引入的 record 同样遵循这套思想;换来的是任何线程任意时刻看到同一份状态,免锁共享、自由传递、随手缓存。「所有字段都要拷贝吗?」只有可变组件需要;String、基本类型、其他不可变对象可以直接共享。「防御性拷贝的顺序有讲究吗?」先校验参数再拷贝会被竞态钻空子(校验完、拷贝前数据被改),正确顺序是先拷贝、再对副本校验。

特征对象、推理请求上下文如果不可变,就能在线程间自由共享而无需加锁——「不可变」是并发设计里最便宜的正确性保障。你写的类里有几个能过这四道闸门?有没有被「getter 递出内部集合」咬过的经历?欢迎聊聊。