仅用final修饰字段不足以保证不可变性,关键在于防止可变对象引用泄露、构造时防御性拷贝、序列化时重写readresolve等防护措施。

为什么直接用 final 修饰字段还不够
很多人以为把所有字段标成 final、类也加 final,就完成了不可变。错——只要字段是可变引用类型(比如 ArrayList、StringBuilder),外部仍能通过 getter 拿到引用并修改内部状态。
String 类真正关键的不是“不让改字段”,而是“不暴露可变对象的引用”。它所有返回字符数组或字节的操作,都返回副本(如 toCharArray())或只读视图(如 getChars() 的拷贝逻辑)。
- 别在 getter 中直接返回私有可变容器:❌
return this.items;→ ✅return new ArrayList(this.items);或Collections.unmodifiableList(this.items) - 构造函数里别信任传入的可变对象:❌
this.data = data;→ ✅this.data = new byte[data.length]; System.arraycopy(data, 0, this.data, 0, data.length); - 如果字段是
java.time.LocalDateTime这类本身不可变的类,可以安全返回;但Date不行——它可变,必须封装或转成Instant
String 的私有字段为什么没用 private final char[] 而是 private final byte[]
这是 JDK 9+ 的实现细节变化,核心不是“为了省内存”,而是为了统一编码抽象和规避 char 的代理对(surrogate pair)陷阱。但对你设计不可变类有直接启发:字段的底层表示,未必需要和对外 API 一致。
比如你可以内部用 byte[] 存 UTF-8 编码,对外提供 String 视图(通过 new String(bytes, StandardCharsets.UTF_8)),这样既避免重复解码,又防止外部拿到原始字节数组后篡改。
立即学习“Java免费学习笔记(深入)”;
- 内部存储格式可以和公开接口解耦,只要保证每次对外暴露都做一次“洁净复制”
- 别为了“看起来干净”而暴露内部结构:❌ 把
private final int[] hashCache加 getter;✅ 用Objects.hash(...)动态算,或缓存但绝不暴露 - JDK 自己也在变:String 从
char[]→byte[] + coder,说明不可变类的内部优化空间很大,前提是封装边界牢靠
如何处理带 builder 的不可变类(比如 Person)
Builder 模式本身是可变的,但最终产出的实例必须彻底不可变。常见错误是 builder 和目标类共用同一份字段引用,或者 builder 的 build() 方法没做防御性拷贝。
String 没 builder,但它用静态工厂方法(如 String.valueOf())替代了构造器,本质也是控制实例创建路径——你也可以学这点,把 builder 当作“仅限内部使用的构造辅助”,而非公开 API。
- builder 的每个
setXxx()方法应返回新 builder 实例(不可复用自身),避免链式调用污染状态 -
build()里对所有传入参数再做一次防御性拷贝,哪怕构造函数已经做过——因为 builder 可能被反复调用 - builder 类本身建议设为
static且private,不暴露给用户;对外只留静态工厂方法,如Person.create().name("a").age(25).build()
序列化时绕过构造函数导致的不可变性破坏
Java 序列化默认会跳过构造函数和字段初始化逻辑,用反射直接设置 final 字段。String 没这个问题,因为它实现了 readResolve(),确保反序列化后仍是规范实例。但你自己写的类不会自动有这层保护。
一旦你没显式处理,攻击者就能通过恶意序列化流注入非法状态(比如空 name 字段、负数 id),绕过所有构造校验。
- 所有不可变类必须实现
private Object readResolve(),返回一个合法的新实例(比如用静态工厂方法重建) - 别依赖
serialVersionUID或transient来“躲开”问题——transient字段在反序列化后是 null,可能直接 NPE - 更彻底的做法:实现
Externalizable,完全掌控序列化过程;或者干脆禁用序列化(抛InvalidObjectException)
不可变性的最大敌人从来不是语法限制,而是那些你以为“它不可能被改”的地方——比如序列化、反射、JNI、甚至 JVM 自身的 Unsafe 操作。String 的健壮,是几十年在这些缝隙里反复打补丁的结果。你写一个新类,至少得先堵住 readResolve 和构造参数拷贝这两条最宽的缝。









