跳到主要内容
约 9 分钟3 张卡

不可变对象

不可变对象在构造完成后,对外可观察的状态始终保持不变。本文从 final 字段的作用入手,说明怎样通过输入和输出边界隔离外部修改。随后讲解 record 的可变组件、线程间共享与复制成本,语言和 API 以 Java 17、21 为准。

1. 面试核心​

先记住这几条,再读正文
  • 修改入口隔离。 不可变对象通过防御性复制隔离可变输入输出,并在构造完成前避免外部取得 this 引用。卡 · final 字段
  • final 的边界。 final 只固定引用字段的指向,字段引用的可变对象仍需复制或由当前对象独占。
  • 输入输出复制。 构造器复制数组输入,getter 返回数组副本,才能同时隔离输入和输出带来的外部修改。卡 · 复制与校验顺序
  • record 的边界。 record 的引用组件是 final,传入可变列表时仍需复制列表并处理可变元素。
  • 初始化可见性。 对象正确构造且 this 未提前逸出时,其他线程能看到 final 字段在构造时写入的值。卡 · 线程间共享

2. final 字段​

final 限制字段重新赋值,却不会冻结字段指向的对象。下面的日程类直接保存调用方传入的列表:

public final class Schedule {
private final List<Instant> times;

public Schedule(List<Instant> times) {
this.times = times;
}
}

字段不能改指向另一列表,但调用方仍持有原引用:

List<Instant> source = new ArrayList<>();
Schedule schedule = new Schedule(source);
source.add(Instant.now());

source 与 Schedule.times 指向同一列表,所以向 source 添加时间,也改变了日程内部保存的内容。问题出在共享可变列表,即使字段是 private final,外部仍有修改这份数据的入口。

3. 输入边界的复制​

防御性复制是指保存输入对象的副本,让调用方后续修改原对象时,不影响内部状态。对于日程列表,可以在构造器中用 List.copyOf 隔离原列表的结构变化:

public final class Schedule {
private final List<Instant> times;

public Schedule(List<Instant> times) {
this.times = List.copyOf(times);
}

public List<Instant> times() {
return times;
}
}

List.copyOf 得到不可修改的列表,并拒绝 null 元素。调用方再向原列表添加或删除元素,内部列表的结构也会保持不变。这里的 Instant 表示一个时间点,本身不可变,所以元素引用可以安全共享。

如果元素可变,仅复制列表不够:

public Snapshot(List<MutablePoint> points) {
this.points = points.stream()
.map(MutablePoint::copy)
.toList();
}

示例通过 MutablePoint.copy 为每个点创建副本。复制到哪一层,取决于哪些对象仍可由外部修改;对象及其引用关系组成的整体称为对象图。图中不可变的对象可以共享,可变对象则需要复制或由当前对象独占。这样才能切断外部修改内部状态的路径。

4. 输出边界​

输入复制完成后,还要检查返回值是否会把内部对象暴露出去。比如,下面的 getter 直接返回内部数组,调用方取得后就能修改其中的字节:

public byte[] digest() {
return digest;
}

返回数组副本,就能让调用方只修改自己的那一份:

public byte[] digest() {
return digest.clone();
}

构造时也要 clone 一次,防止传入数组随后变化。输入和输出两个边界缺一不可。

内部保存的是 List.copyOf 结果,且元素不可变时,可以直接返回该列表。因为调用方既无法改变列表结构,也无法修改元素,所以每次读取都复制一次没有必要。

5. 不可变类的条件​

检查一个类是否不可变,需要同时检查字段、构造过程和对外方法。可以沿着以下七项约束逐一确认:

  1. 对象构造后没有修改状态的方法。
  2. 字段私有,必要字段为 final。
  3. 构造期间完成验证,并避免 this 逸出,也就是在构造完成前被外部取得。
  4. 对输入中的可变对象做防御性复制。
  5. 不返回可修改内部状态的引用。
  6. 防止子类增加可变语义,或让构造与方法设计在继承下仍安全。
  7. equals 与 hashCode 基于稳定状态。

声明 final 类可以直接禁止继承。也可以通过私有构造器限制实例创建,或用 sealed 层次限制允许的子类。选择这些方式时,仍要逐个保证实现满足不可变契约;任意子类若能增加可变状态,父类型就无法保证整体不可变。

6. record 与可变组件​

record 会为组件生成字段、访问器和相等性方法,但可变组件仍需要处理。下面的团队记录会直接保存传入的成员列表:

public record Team(String name, List<User> members) {}

record 的组件引用是 final,所以无法改指向另一列表,但原列表仍可被外部修改。可以在紧凑构造器中复制列表;这种构造器省略参数列表,直接校验或转换组件值:

public record Team(String name, List<User> members) {
public Team {
Objects.requireNonNull(name, "name");
members = List.copyOf(members);
}
}

复制后,Team 的成员列表结构保持稳定。如果 User 可变,外部仍能通过共享的用户引用修改成员信息。因此,要让整个团队记录不可变,还需使用不可变用户值,或在输入和输出时复制用户对象。

7. 不可变对象的更新​

不可变对象通过返回新值表达更新,原对象继续表示旧状态。比如,金额相加时可以创建新的 Money:

public Money add(Money other) {
requireSameCurrency(other);
return new Money(amount.add(other.amount), currency);
}

这里先检查币种,再把相加结果放入新对象,原金额保持不变。复杂对象可以用 withX 方法返回修改后的新值,或用 Builder 收集多个修改:

Configuration next = current.withTimeout(Duration.ofSeconds(2));

调用方需要接收 next 才能取得新配置,current 仍保留原来的超时值。方法名和契约应明确这一点,避免调用方误以为原对象已经被修改。

连续小步更新会反复创建对象,增加分配和复制成本。可以先在单线程 Builder 中累积修改,再在 build 时生成不可变结果。

8. 线程间共享​

不可变对象在构造后没有状态写入,多个线程读取时就无需用锁协调状态修改。正确构造且 this 未提前逸出时,final 字段还有初始化安全保证。也就是说,其他线程取得对象引用后,能看到这些字段在构造时写入的值。

共享时还要区分对象自身与外围操作:

  • 替换共享引用时,需要使用 volatile 或 AtomicReference,让其他线程看到新对象。
  • 保存对象的可变集合仍需单独同步,元素不可变只解决元素自身的状态问题。
  • 内部若写入懒加载缓存,缓存字段也需同步;懒加载指第一次使用时才计算并保存结果。
  • 方法若读取远程资源,调用仍会阻塞,不可变状态只解决状态共享问题。
  • 大对象反复更新时,创建新副本仍会消耗时间和内存。

9. 复制成本与替代方案​

复制大数组或大对象图需要遍历数据,并为副本分配内存。数据量较大时,可以根据所有权与共享方式选择替代方案:

  • 明确转移所有权,调用后原持有者放弃读写原引用,只由接收方管理数据。
  • 使用不可变持久化数据结构。这里的持久化指新旧版本共享未变化部分,同时保留旧版本,并非写入磁盘。
  • 用只读接口隐藏修改方法,同时控制实际持有对象的代码,避免它从其他入口修改数据。
  • 在能约束所有修改入口的包内共享数据,在公共 API 边界再复制。
  • 对大型二进制数据使用只读缓冲区。它与原缓冲区共享底层数据,所以原持有者的写入仍会影响读取结果。

这些做法都要明确由谁持有数据,并封闭其他写入入口。只有注释要求“不要修改”,却继续暴露可写引用,就无法阻止意外修改。

10. 复制与校验顺序​

构造器需要保存可变输入时,应先复制,再校验实际保存的副本。若先校验原对象,调用方可以在校验与复制之间修改内容,导致保存的值与校验通过的值不同。下面同时展示数组的输入复制、长度校验和输出复制:

public record Digest(byte[] bytes) {
public Digest {
bytes = bytes.clone();
if (bytes.length != 32) {
throw new IllegalArgumentException("expected 32 bytes");
}
}

@Override
public byte[] bytes() {
return bytes.clone();
}
}

构造器与访问器分别隔离输入和输出,保存的数组长度必须为 32。数组长度创建后就固定了,所以这个长度检查本身不会受到元素修改影响。若约束涉及字节内容,也应检查同一个副本,保证检查与保存的是同一份数据。

record 默认生成的 equals 对数组组件比较引用。若 Digest 需要按字节内容判断相等,就要重写 equals 和 hashCode。

复制期间输入也要保持稳定。若同时有线程写入,副本可能混合不同时间点的元素值。调用方存在并发写入时,需要双方使用同一把锁,或先完成所有权转移再复制。

11. 常见问题​

11.1 unmodifiableList 能隔离原列表的修改吗?​

Collections.unmodifiableList 与原列表共享数据,原列表的变化仍会反映到视图中。需要固定列表结构时,应使用 List.copyOf,或先复制再包装。

11.2 getter 每次都要复制吗?​

11.3 不可变对象适合作为 HashMap 的键吗?​

11.4 深拷贝怎样处理循环引用?​

12 · 面试题卡片​

12.1 怎样设计一个不可变类​

出现公司:字节跳动、阿里巴巴

考察重点

  • 说明 final 固定字段引用后,为什么仍需检查引用对象及其元素能否修改。
  • 说明构造器和 getter 分别怎样隔离外部修改。
  • 说明怎样限制子类修改状态、避免构造逸出,并保持相等性判断稳定。

相关内容:final 字段至record 与可变组件、线程间共享。

参考回答

不可变类要在构造后保持可观察状态稳定,并切断外部修改内部状态的入口。

字段保持私有,固定字段使用 final,构造时完成必要校验,之后只提供读取或返回新值的方法。

数组、集合和其他可变输入需要复制或由当前对象独占,getter 则返回不可变值或副本。如果集合元素仍可变,就要继续处理元素的输入和输出。

还要限制子类破坏不可变约定,最直接的方式是把类声明为 final。构造期间避免 this 逸出,equals 与 hashCode 使用稳定字段。record 组件含可变对象时,也要遵守这些所有权约定。

12.2 不可变对象为什么更容易在线程间共享​

出现公司:京东、字节跳动

考察重点

  • 说明没有状态写入就没有同一对象上的读写竞态。
  • 解释 final 字段的初始化安全保证,以及 this 提前逸出为什么会影响这一保证。
  • 说明容器与元素的线程安全需要分开。

相关内容:线程间共享。

参考回答

不可变对象在构造后没有状态写入,多个线程只读同一份状态,所以无需用锁协调修改。

对象正确构造且 this 未提前逸出时,final 字段还能保证其他线程看到构造时写入的值。

这个保证只覆盖正确构造的状态。更新共享引用仍需使用 volatile 等机制,让其他线程看到新对象。外层可变集合、可变元素与内部懒加载缓存,也要分别处理同步和所有权。

12.3 防御性复制应在校验前还是校验后,record 数组访问器要怎样处理?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:解释为什么应校验实际保存的副本,以及 record 的数组访问器如何隔离外部修改。

相关内容:复制与校验顺序。

参考回答

应先复制,再校验实际保存的副本;record 的数组访问器则应覆盖为返回副本。

这样,校验通过与保存的是同一份数据,调用方拿到的也是另一份数组。

先校验原对象再复制时,外部可以在两步之间改变内容。复制期间也需要通过锁或所有权转移保持输入稳定。数组默认按引用判断相等,因此需要按内容比较时,还要自定义 equals 和 hashCode。

13. 参考资料​