Java 基础体系 · 第 45/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java 25 不可变对象:防御复制、深浅不可变、线程安全和 Builder
不可变对象(immutable object)是指:对象完成构造后,调用者不能再观察到其状态发生变化。这里的“状态”不仅包括对象自身的字段,还包括通过字段可以到达的可变对象。
例如:
final class User {
private final String name;
private final List<String> roles;
User(String name, List<String> roles) {
this.name = name;
this.roles = roles;
}
}
即使 User 的字段都是 final,它也不一定不可变:
List<String> roles = new ArrayList<>();
roles.add("admin");
User user = new User("Alice", roles);
roles.add("auditor"); // user.roles 也会观察到变化
final 只限制字段引用不能再次赋值,并不限制引用指向的对象发生变化。
不可变性的核心不是“字段声明为 final”,而是:
对象构造完成后,任何可达的外部操作都不能改变其可观察状态。
1. 从对象图理解不可变性
把对象及其字段引用看成一张对象图:
User
├── name ──> String
└── roles ──> ArrayList
├── "admin"
└── "auditor"
设:
- 是对外暴露的对象;
- 是从 的字段递归可达的对象集合;
- 是时间 通过公开接口可以观察到的状态。
一个严格的不可变对象需要满足:
也就是说,从构造完成时间开始,在没有重新创建新对象的前提下,任意时刻观察到的状态都相同。
更实用地,可以拆成几个条件:
- 对象初始化完成后,自己的字段不能被重新赋值;
- 不允许通过方法修改自己的状态;
- 不能让外部代码持有并修改内部可变对象的引用;
- 不能通过访问器把内部可变对象直接泄露出去;
- 如果内部对象还引用了其他可变对象,这些对象也必须受到保护;
equals、hashCode等依赖的状态不能在对象进入集合后发生变化。
第 3、4、5 条就是防御复制和深浅不可变要解决的核心问题。
2. final、私有字段和没有 setter 为什么还不够
一个常见的“伪不可变”类如下:
import java.util.ArrayList;
import java.util.List;
public final class Team {
private final List<String> members;
public Team(List<String> members) {
this.members = members;
}
public List<String> members() {
return members;
}
}
它有 final 字段、没有 setter、类也是 final,但仍然完全可变:
List<String> source = new ArrayList<>();
source.add("Alice");
Team team = new Team(source);
source.add("Bob"); // 通过构造参数修改 Team 内部状态
team.members().add("Carol"); // 通过 getter 修改 Team 内部状态
状态变化路径有两条:
构造参数 ──────> Team.members
外部 source ────┘
Team.members() ──> 外部调用者
外部调用者 ──────> Team.members
因此,防御必须发生在两个边界:
- 输入边界:构造或接收参数时,不保存调用者传入的可变对象;
- 输出边界:返回内部状态时,不把内部可变对象直接交给调用者。
3. 防御复制:在输入和输出边界截断别名
3.1 什么是别名
如果两个变量引用同一个可变对象,就形成了别名:
List<String> a = new ArrayList<>();
List<String> b = a;
b.add("x");
// a 也发生了变化
防御复制的目标是消除不受控的别名:
List<String> copied = new ArrayList<>(a);
此时:
a ──> 原 ArrayList
copied ──> 新 ArrayList
修改 a 不会改变 copied 的列表结构。
3.2 输入边界的防御复制
public final class Team {
private final List<String> members;
public Team(List<String> members) {
this.members = List.copyOf(members);
}
public List<String> members() {
return members;
}
}
List.copyOf 在 Java 10 引入,在 Java 25 中仍是标准集合 API。它返回一个不可修改的 List,并且不会继续观察一个普通可变源列表的结构变化:
List<String> source = new ArrayList<>();
source.add("Alice");
Team team = new Team(source);
source.add("Bob");
System.out.println(team.members()); // [Alice]
List.copyOf 还有两个重要语义:
- 如果源集合包含
null,会抛出NullPointerException; - 它只保护列表结构,不复制列表元素本身。
List.copyOf 是否复用某个已经不可修改的列表实例,不应被业务代码依赖;调用者应依赖“不允许修改”和“不会继续受普通源列表结构变化影响”等语义,而不是对象身份。
3.3 输出边界的防御
如果字段本身已经通过 List.copyOf 创建为不可修改列表:
public List<String> members() {
return members;
}
直接返回通常是安全的,因为调用者无法通过 add、remove 等操作修改列表结构。
另一种写法是每次返回一个新副本:
public List<String> members() {
return new ArrayList<>(members);
}
两者语义不同:
- 返回
List.copyOf保存的不可修改列表:避免重复复制,调用者不能修改; - 返回新的
ArrayList:调用者可以修改自己的副本,但不会影响对象。
如果 API 只要求“调用者不能修改内部状态”,不可修改快照通常更合适。如果 API 需要把一个可编辑副本交给调用者,则返回新副本。
不应使用下面的方式作为“不可变返回值”:
return Collections.unmodifiableList(members);
它只是创建一个不可修改视图。如果 members 仍然可能被其他路径修改,调用者观察到的内容仍会变化:
List<String> source = new ArrayList<>();
List<String> view = java.util.Collections.unmodifiableList(source);
source.add("Alice");
System.out.println(view); // [Alice]
view 自己不能修改列表,但底层 source 的变化会反映到 view 中。因此:
unmodifiableList:不可通过该视图修改;List.copyOf:得到不可修改的集合结果,并且通常不再依赖普通源集合的结构变化;- 两者都不自动复制元素。
4. 浅不可变与深不可变
4.1 浅不可变
如果一个对象不能修改自己的字段引用,但字段引用指向的对象仍然可变,这通常称为浅不可变,或者更准确地说,只保护了对象的第一层状态。
例如:
public final class Report {
private final List<StringBuilder> lines;
public Report(List<StringBuilder> lines) {
this.lines = List.copyOf(lines);
}
public List<StringBuilder> lines() {
return lines;
}
}
调用者不能执行:
report.lines().add(new StringBuilder("new line"));
但仍然可以执行:
report.lines().get(0).append(" changed");
原因是:
Report
└── 不可修改 List
└── 可变 StringBuilder
列表结构不变,但列表元素仍然可以变化。
4.2 深不可变
深不可变要求对象图中所有对外可达的状态都不可被外部改变。
对 Report 而言,有三种解决方式:
- 元素类型本身不可变,例如
String; - 构造时复制并转换元素;
- 不暴露元素,返回不可变的值对象或字符串快照。
如果元素是 StringBuilder,仅仅复制列表不够:
this.lines = List.copyOf(lines); // 只复制列表结构
必须为每个元素建立独立且不可变的表示。例如,把它们转成 String:
public final class Report {
private final List<String> lines;
public Report(List<StringBuilder> lines) {
this.lines = lines.stream()
.map(StringBuilder::toString)
.toList();
}
public List<String> lines() {
return lines;
}
}
这里的 String 是不可变类型,Stream.toList() 返回的列表不能通过列表接口修改。关键不是某个 API 名称,而是同时保护:
- 外部的
List不再能改变内部列表结构; - 外部的
StringBuilder不再是内部状态的一部分; - 内部元素
String本身不可变。
4.3 数组是最容易遗漏的可变状态
数组即使引用字段是 final,数组元素仍然可以改变:
public final class Packet {
private final byte[] payload;
public Packet(byte[] payload) {
this.payload = payload;
}
public byte[] payload() {
return payload;
}
}
以下两种操作都会破坏不可变性:
byte[] data = {1, 2, 3};
Packet packet = new Packet(data);
data[0] = 9; // 修改构造参数
packet.payload()[1] = 8; // 修改 getter 返回的内部数组
正确做法是在输入和输出两侧都复制:
public final class Packet {
private final byte[] payload;
public Packet(byte[] payload) {
this.payload = payload.clone();
}
public byte[] payload() {
return payload.clone();
}
}
byte[] 的复制是浅复制的,但数组元素是基本类型,因此这里已经足够深。若数组元素是对象,例如 Address[],还需要复制每个元素,甚至递归复制其内部可变字段。
5. 哪些类型通常可以作为不可变值
以下类型在其标准 API 契约下通常可作为不可变值:
String;- 包装类型,如
Integer、Long; BigInteger、BigDecimal;java.time中的日期时间类型,例如LocalDate、Instant、Duration;- 通过防御复制得到的不可修改集合,但集合元素也必须满足相应的不可变要求。
需要特别区分:
java.util.Date
java.util.Calendar
这类旧日期类型是可变的。即使字段为 final,也不能直接暴露。
不可变性不是 Java 中的统一接口。没有一个“实现了某接口就自动不可变”的语言机制。一个类是否不可变,取决于它的字段、可达对象图、构造逻辑和公开方法共同形成的契约。
6. Record 不等于不可变对象
Java record 自动生成组件字段、访问器、构造器和部分对象方法,但 record 只保证组件引用在 record 实例中不能被重新赋值;它不自动深复制组件。
public record Order(List<String> items) {
}
下面的代码仍然会造成状态变化:
List<String> items = new ArrayList<>();
items.add("book");
Order order = new Order(items);
items.add("pen");
System.out.println(order.items()); // [book, pen]
可以在紧凑构造器中建立防御副本:
import java.util.List;
import java.util.Objects;
public record Order(List<String> items) {
public Order {
Objects.requireNonNull(items, "items");
items = List.copyOf(items);
}
}
但如果组件是 List<StringBuilder>,List.copyOf 仍然只保护列表结构,不保护元素。record 是否不可变,仍然要沿对象图逐层判断。
7. 一个完整的不可变对象示例
下面的示例包含:
- 不可变嵌套对象;
List和Map的防御复制;- 数组的输入、输出复制;
Builder的可变构造状态;- 构造时校验;
equals和hashCode稳定所需的不可变字段。
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public final class UserProfile {
private final String id;
private final String displayName;
private final LocalDate birthday;
private final Address address;
private final List<String> roles;
private final Map<String, String> attributes;
private final byte[] avatar;
private UserProfile(
String id,
String displayName,
LocalDate birthday,
Address address,
List<String> roles,
Map<String, String> attributes,
byte[] avatar) {
this.id = Objects.requireNonNull(id, "id");
this.displayName = Objects.requireNonNull(displayName, "displayName");
this.birthday = Objects.requireNonNull(birthday, "birthday");
this.address = Objects.requireNonNull(address, "address");
// 构造器本身仍然建立边界,避免未来调用方式改变后破坏不变式
this.roles = List.copyOf(roles);
this.attributes = Map.copyOf(attributes);
this.avatar = avatar.clone();
}
public String id() {
return id;
}
public String displayName() {
return displayName;
}
public LocalDate birthday() {
return birthday;
}
public Address address() {
return address;
}
public List<String> roles() {
return roles;
}
public Map<String, String> attributes() {
return attributes;
}
public byte[] avatar() {
return avatar.clone();
}
public static Builder builder() {
return new Builder();
}
public static final class Builder {
private String id;
private String displayName;
private LocalDate birthday;
private Address address;
private final List<String> roles = new ArrayList<>();
private final Map<String, String> attributes = new HashMap<>();
private byte[] avatar = new byte[0];
public Builder id(String id) {
this.id = id;
return this;
}
public Builder displayName(String displayName) {
this.displayName = displayName;
return this;
}
public Builder birthday(LocalDate birthday) {
this.birthday = birthday;
return this;
}
public Builder address(Address address) {
this.address = address;
return this;
}
public Builder roles(List<String> roles) {
this.roles.clear();
this.roles.addAll(Objects.requireNonNull(roles, "roles"));
return this;
}
public Builder addRole(String role) {
this.roles.add(Objects.requireNonNull(role, "role"));
return this;
}
public Builder attributes(Map<String, String> attributes) {
this.attributes.clear();
this.attributes.putAll(
Objects.requireNonNull(attributes, "attributes"));
return this;
}
public Builder avatar(byte[] avatar) {
this.avatar = Objects.requireNonNull(avatar, "avatar").clone();
return this;
}
public UserProfile build() {
if (id == null || id.isBlank()) {
throw new IllegalStateException("id must not be blank");
}
if (displayName == null || displayName.isBlank()) {
throw new IllegalStateException(
"displayName must not be blank");
}
if (birthday == null) {
throw new IllegalStateException("birthday is required");
}
if (address == null) {
throw new IllegalStateException("address is required");
}
return new UserProfile(
id,
displayName,
birthday,
address,
roles,
attributes,
avatar);
}
}
public static final class Address {
private final String country;
private final String city;
public Address(String country, String city) {
this.country = Objects.requireNonNull(country, "country");
this.city = Objects.requireNonNull(city, "city");
}
public String country() {
return country;
}
public String city() {
return city;
}
@Override
public String toString() {
return country + ", " + city;
}
}
public static void main(String[] args) {
List<String> sourceRoles = new ArrayList<>();
sourceRoles.add("user");
byte[] sourceAvatar = {1, 2, 3};
UserProfile profile = UserProfile.builder()
.id("u-100")
.displayName("Alice")
.birthday(LocalDate.of(1990, 1, 1))
.address(new Address("China", "Shanghai"))
.roles(sourceRoles)
.attributes(Map.of("department", "engineering"))
.avatar(sourceAvatar)
.build();
// 修改构造输入,不影响已构造对象
sourceRoles.add("admin");
sourceAvatar[0] = 99;
System.out.println(profile.roles()); // [user]
System.out.println(profile.avatar()[0]); // 1
// 修改返回的数组,只修改副本
byte[] returnedAvatar = profile.avatar();
returnedAvatar[1] = 88;
System.out.println(profile.avatar()[1]); // 2
// roles 是不可修改集合
try {
profile.roles().add("admin");
} catch (UnsupportedOperationException expected) {
System.out.println("roles cannot be modified");
}
// attributes 也是不可修改集合
try {
profile.attributes().put("level", "senior");
} catch (UnsupportedOperationException expected) {
System.out.println("attributes cannot be modified");
}
}
}
编译和运行:
javac UserProfile.java
java UserProfile
预期输出包含:
[user]
1
2
roles cannot be modified
attributes cannot be modified
这里有两层防御复制:
调用者的 roles ──复制──> Builder.roles ──复制──> UserProfile.roles
调用者的 avatar ──复制──> Builder.avatar ──复制──> UserProfile.avatar
Builder 里的集合是可变的,因为 Builder 的职责就是逐步收集输入。UserProfile 构造完成后,内部集合变为不可修改快照,数组也不再暴露内部引用。
构造器再次复制 Builder 的字段看似重复,但它维护了更强的不变式:即使未来增加另一个构造路径,或者修改 Builder 的实现,也不会轻易让 UserProfile 保存 Builder 的可变容器。
8. Builder 解决什么问题,不能解决什么问题
Builder 是一种构造模式,不是不可变性的自动实现。
直接使用构造器时,参数较多会带来几个问题:
new UserProfile(
id,
displayName,
birthday,
address,
roles,
attributes,
avatar);
调用者容易混淆同类型参数,也不容易表达可选字段。Builder 将过程拆成:
UserProfile profile = UserProfile.builder()
.id("u-100")
.displayName("Alice")
.birthday(LocalDate.of(1990, 1, 1))
.address(new Address("China", "Shanghai"))
.addRole("user")
.build();
状态流转可以表示为:
空 Builder
├── 设置 id
├── 设置 displayName
├── 添加 roles
├── 设置其他字段
└── build()
│
├── 校验必填项和跨字段约束
├── 防御复制
└── 创建不可变 UserProfile
build() 是从“可变构造状态”到“不可变业务对象”的边界。
8.1 Builder 自身通常不是线程安全的
下面的用法不应默认安全:
UserProfile.Builder builder = UserProfile.builder();
// 线程 A
builder.displayName("Alice");
// 线程 B
builder.addRole("admin");
Builder 通常只属于一个构造流程,由一个线程使用。它内部的 ArrayList、HashMap 和字段赋值没有并发协调机制。
如果必须跨线程传递,应该:
- 在线程间传递已经构造完成的不可变对象;
- 或者为 Builder 外部加锁;
- 或者让每个线程拥有独立 Builder。
不要因为最终对象不可变,就推断 Builder 也线程安全。
8.2 Builder 复用必须明确语义
UserProfile.Builder builder = UserProfile.builder()
.id("u-100")
.displayName("Alice")
.birthday(LocalDate.of(1990, 1, 1))
.address(new Address("China", "Shanghai"));
UserProfile first = builder.addRole("user").build();
UserProfile second = builder.addRole("admin").build();
在上面的实现中,first 的 roles 已经在构造时复制,因此之后修改 Builder 不会影响 first。但是 second 的角色是 [user, admin]。这可能是预期,也可能是误用。
如果不希望复用状态,Builder 可以设计为一次性使用:
private boolean built;
public UserProfile build() {
if (built) {
throw new IllegalStateException("builder already used");
}
built = true;
// 校验并构造
}
这是 API 设计选择,不是不可变对象的必要条件。
9. 防御复制的层级必须与数据结构匹配
9.1 一层列表
List<String> snapshot = List.copyOf(input);
因为 String 不可变,所以通常已经足够。
9.2 列表中有可变元素
List<Date> snapshot = input.stream()
.map(date -> new Date(date.getTime()))
.toList();
如果还要向外暴露这些日期,就必须在输出时再次复制:
public List<Date> dates() {
return dates.stream()
.map(date -> new Date(date.getTime()))
.toList();
}
只复制列表容器而不复制 Date 元素,仍然是浅不可变。
9.3 嵌套 Map 和 List
Map<String, List<String>> source = ...;
下面只保护最外层 Map:
Map<String, List<String>> shallow = Map.copyOf(source);
调用者仍可能通过原来的列表修改内容:
source.get("roles").add("admin");
如果需要深层保护,要逐层复制:
Map<String, List<String>> deep = source.entrySet().stream()
.collect(java.util.stream.Collectors.toUnmodifiableMap(
Map.Entry::getKey,
entry -> List.copyOf(entry.getValue())));
这里的“深”仍然取决于元素类型。如果列表元素是可变对象,还要继续复制元素。
9.4 复制不是无条件正确
防御复制有成本和语义风险:
- 大数组或大集合会产生额外内存和 CPU 开销;
- 复制可变对象时,必须知道如何正确复制;
- 某些对象包含资源句柄、线程、文件流或连接,简单复制可能没有意义;
- 对象可能非常大,应该改为不可变持有、版本化快照或明确的所有权转移。
不要把“复制所有东西”当作通用答案。真正需要确定的是:谁拥有对象、谁可以修改对象、对象生命周期如何结束。
10. 不可变对象与线程安全
10.1 不可变性为什么降低并发复杂度
如果对象构造完成后状态永远不变,那么多个线程同时读取同一个对象不会产生“读到一半被修改”的问题:
UserProfile sharedProfile = ...;
// 线程 A
sharedProfile.displayName();
// 线程 B
sharedProfile.roles();
只要对象内部的所有可达状态也不可变,读取操作不需要因为对象状态变化而加锁。
不可变对象通常具有以下并发优势:
- 不需要为普通读取加锁;
- 不会出现读写竞争导致的中间状态;
- 可以安全地作为缓存值;
- 可以安全地作为
ConcurrentHashMap的值; - 不需要防止调用者在使用期间修改传入参数。
但“对象不可变”不等于“所有围绕它的操作都原子”。
例如:
if (!cache.containsKey(key)) {
cache.put(key, profile);
}
即使 profile 不可变,这个“检查再写入”仍可能被多个线程交错执行。需要原子操作的地方,应使用 putIfAbsent 或其他并发协议。
10.2 安全发布仍然重要
安全发布是指:一个线程构造对象后,其他线程通过具有 happens-before 关系的方式获得该对象引用,从而能够可靠地看到构造完成的状态。
常见安全发布方式包括:
public static final UserProfile DEFAULT = createDefault();
静态初始化具有类初始化相关的线程安全保证。
也包括:
private volatile UserProfile current;
public void update(UserProfile profile) {
current = profile;
}
public UserProfile current() {
return current;
}
或者通过锁、线程安全集合、Future 等方式传递。
final 字段在 Java 内存模型中具有特殊的初始化可见性保证:正常构造完成后,其他线程通常能够看到 final 字段写入的值。但这不是允许任意发布对象的理由,尤其不能把 final 当作整个对象图和所有普通字段的通用同步机制。
以下做法风险很高:
class Holder {
static UserProfile profile;
static void initialize() {
profile = createProfile();
}
}
如果其他线程没有同步地读取 profile,不能把这段代码当作一般性的线程安全发布协议。应使用静态初始化、volatile、锁或其他明确的 happens-before 机制。
10.3 构造期间不能泄露 this
不可变对象的构造器不应把尚未构造完成的 this 泄露出去:
public final class BadProfile {
private static BadProfile published;
private final String name;
public BadProfile(String name) {
published = this; // 构造尚未完成就发布
this.name = name;
}
}
其他线程可能在构造完成前观察到对象,继而破坏构造和发布的假设。
典型泄露方式包括:
- 在构造器中注册回调;
- 启动线程并把
this传给线程; - 把
this放进全局集合; - 调用可被重写的方法。
使用 final 类、私有构造器和静态工厂,可以减少这类风险,但根本原则是:对象完成所有不变式建立之前,不对外发布它。
11. equals、hashCode 与不可变性
哈希集合依赖元素的 hashCode 在存入期间保持稳定:
Set<UserProfile> profiles = new HashSet<>();
profiles.add(profile);
如果 profile 的 hashCode 依赖一个后来发生变化的列表,那么集合可能找不到它:
profiles.contains(profile); // 可能返回 false
这不是 HashSet 的 bug,而是违反了集合元素的契约:参与相等性和哈希计算的状态在元素作为键或集合成员期间不能改变。
不可变对象天然适合作为:
HashMap的键;HashSet的元素;- 缓存键;
- 事件内容;
- 配置快照;
- 跨线程传递的消息。
如果一个类使用延迟计算的缓存字段,也要区分两种情况:
- 缓存字段变化不影响对外可观察值,这通常称为“逻辑不可变”;
- 缓存字段本身属于对外状态,或者并发更新不安全,则不能简单称为严格不可变。
12. 常见误解与失败表现
12.1 “字段都是 final,所以不可变”
错误原因:
private final List<String> items;
只保证 items 不能指向另一份列表,不保证列表内容不变。
诊断方法是沿着每个字段继续追踪:
字段引用是否 final?
↓
引用指向的对象是否可变?
↓
是否还有外部别名?
↓
getter 是否泄露?
↓
嵌套元素是否可变?
12.2 “返回 Collections.unmodifiableList 就深不可变”
错误原因是不可修改视图仍然观察底层容器:
List<String> source = new ArrayList<>();
List<String> view = java.util.Collections.unmodifiableList(source);
source.add("changed");
如果 source 仍归其他代码所有并可能改变,view 的内容也会改变。
12.3 “List.copyOf 会复制所有对象”
错误原因是集合复制通常只复制容器,不复制元素:
List<StringBuilder> copy = List.copyOf(source);
copy.get(0) 与 source.get(0) 仍然可能是同一个 StringBuilder。
12.4 “Builder 让对象自动不可变”
Builder 可以保存可变状态。若 build() 直接把 Builder 内部的列表、Map 或数组交给结果对象,结果对象仍然可变。
错误实现:
public Product build() {
return new Product(this.tags, this.data);
}
正确实现必须在构造边界复制或转换:
return new Product(
List.copyOf(this.tags),
this.data.clone());
12.5 “不可变对象不需要任何并发考虑”
不可变对象减少的是状态竞争,不是所有并发问题:
- 引用仍需要安全发布;
- 多步业务操作仍可能不是原子的;
- Builder 仍可能不是线程安全的;
- 不可变对象内部若持有可变元素,问题仍然存在;
- 外部缓存、集合或服务状态仍需要独立的并发控制。
13. 何时选择防御复制、不可修改视图或所有权转移
可以根据所有权关系选择策略。
调用者仍然拥有并可能修改输入
使用防御复制:
this.items = List.copyOf(items);
这是最常见的不可变值对象场景。
内部拥有可变集合,但只想临时阻止某个调用者修改
可以使用不可修改视图:
return Collections.unmodifiableList(internalList);
这适合明确知道 internalList 仍会由同一个组件管理、并且调用者应观察实时变化的 API。它不适合表达不可变快照。
数据量很大且所有权能够明确转移
可以采用所有权转移,但必须让 API 契约非常清楚。例如,调用者交出一个数组后不得再使用它。这种方式减少复制,却增加误用风险。Java 类型系统不会自动检查“交出后禁止继续访问”。
在公共库、跨模块边界和异步线程边界,防御复制通常更容易验证;在性能敏感的内部代码中,所有权转移可以成立,但应通过命名、文档和测试明确表达。
14. 一套可验证的不变性检查方法
检查一个类是否真正不可变,可以按以下顺序推导,而不是只检查字段修饰符。
第一步:列出所有可观察状态
包括:
- 普通字段;
- 数组内容;
- 集合元素;
equals、hashCode、toString使用的状态;- 通过 getter、迭代器、流或回调间接暴露的状态。
第二步:标记每个状态的可变性
例如:
String 不可变
LocalDate 不可变
ArrayList 可变
byte[] 可变
List<String> 容器可变,元素不可变
List<Date> 容器和元素都可变
第三步:检查输入别名
构造后修改原始输入:
source.add(...);
source[0] = ...;
如果对象状态改变,输入防御失败。
第四步:检查输出别名
修改 getter 返回值:
object.items().add(...);
object.bytes()[0] = ...;
如果对象状态改变,输出防御失败。
第五步:检查嵌套别名
即使外层集合不能修改,也要尝试:
object.items().get(0).mutate();
如果成功改变对象可观察状态,说明只是浅不可变。
第六步:检查构造期间和发布期间
确认:
- 构造器没有泄露
this; - 所有必需字段在构造完成前已初始化;
- 跨线程发布使用了明确的 happens-before 机制;
- Builder 不会被并发使用,或已明确加同步。
15. 不可变对象的边界
不可变对象不是“永远不分配新对象”,而是“已有对象不改变”。例如:
String original = "Java";
String changed = original.concat(" 25");
original 没有被修改,concat 返回了新字符串。
同样,时间类型的操作也返回新值:
LocalDate date = LocalDate.of(2025, 1, 1);
LocalDate next = date.plusDays(1);
如果业务需要状态变化,应通过创建新实例表达:
UserProfile updated = UserProfile.builder()
.id(old.id())
.displayName("New Name")
.birthday(old.birthday())
.address(old.address())
.roles(old.roles())
.attributes(old.attributes())
.avatar(old.avatar())
.build();
这会牺牲一部分复制成本,但换来稳定的快照语义。实际设计中可以结合结构共享、持久化集合或专用值类型降低成本,不过只要共享部分仍可变,就必须继续处理别名问题。
不可变对象的真正边界可以概括为:
可变输入
│
├── 防御复制 / 元素转换
▼
构造中的 Builder
│
├── 校验
├── 建立所有字段
├── 防止 this 泄露
└── 冻结集合、复制数组和嵌套状态
▼
不可变对象
│
├── 安全发布
├── 多线程共享读取
└── 通过创建新实例表达更新
只要对象图中的可变状态仍存在未受控的外部别名,就不能仅凭 final、record、无 setter 或 Builder 声称对象不可变。真正可靠的判断标准始终是:构造完成后,外部是否还能通过任何可达路径改变对象的可观察状态。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java 25 嵌套类、内部类、局部类与匿名类:捕获和生命周期
- 下一篇:Java equals、hashCode 与比较:等价关系、排序和集合正确性
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论