☰
不可变对象如何彻底解决并发线程安全问题
2026/9/26 6:31:03 网站建设 项目流程

1. 为什么共享对象总在并发里翻车

1.1 线程安全到底在防什么

先聊个最常见的场景:多个线程同时读一个 HashMap,或者同时操作同一个 SimpleDateFormat,线上动不动就出现脏数据、死循环、甚至 CPU 拉满。你去排查,发现代码写得没什么问题,就是普通地读写一个对象,凭什么就出事?

因为线程安全问题的本质,是多个线程在“没有协调”的情况下,同时对同一块内存做读写,导致状态不可预期。Java 内存模型里有三个东西在捣鬼:可见性、原子性、有序性。可见性是说线程 A 改了变量,线程 B 不一定能立刻看到;原子性是说一个操作在中途可以被插队,读一半被写、写一半被读;有序性是说编译器和 CPU 为了优化会重排指令,你以为先执行的操作,实际可能后执行。

以前大家解决这些问题,第一反应就是加锁。synchronized、ReentrantLock、ConcurrentHashMap,都是靠“互斥”来保证线程安全。互斥的思路是:你们别同时动,排队来。这样确实有效,但它有个天然的代价——锁竞争。读操作也要拿锁,并发量一上去,锁就成了瓶颈。而且锁用不好还会引入死锁、活锁、性能抖动这些新的麻烦。

那有没有一种方式,压根不需要锁?有,就是标题里说的:不可变的共享对象。

1.2 换一个思路:让对象“不能变”

想象你在公司群里发了一个 Excel 文件,所有人都能看。如果这个文件是可以编辑的,那十个人同时改,一定乱套。但如果这个文件是只读的、内容永远不变,那随便多少人同时看都没有问题,不需要任何排队机制。

不可变对象就是这种“只读文件”。它一旦创建,内部状态就再也不会改变。所有线程拿到的都是同一个对象、同一份数据,谁也不能修改它,那自然就谈不上竞争。可见性问题也不存在了,因为根本没有“写”这回事;原子性问题也被绕开了,因为对象只有一个状态,要么存在,要么不存在,不存在“读一半”的情况;有序性问题更不用谈,对象创建完成后就发布,它的构造过程中的重排不会影响已经完整构建出来的对象。

所以,不可变对象是把并发控制的复杂度,从“运行时协调”转移到了“设计时约束”。运行时代价几乎为零,这是它最值钱的地方。

2. 不可变对象的定义与设计要点

2.1 不可变类的五条黄金规则

Java 里所谓“不可变”,不是你想当然的“没有 setter 方法就行”。一个类真正不可变,需要满足以下条件:

  • 类用 final 修饰,防止子类继承后重写方法、破坏不可变性。
  • 所有字段都用 final 修饰,保证字段在构造时被赋值,且只能赋值一次。
  • 不提供任何修改内部状态的方法,包括 setter、能改变引用的 update 方法。
  • 构造完成后,不允许通过任何方式修改字段引用的对象。如果字段引用的是可变对象(如数组、List、Date),调用方必须不能直接拿到这个引用。
  • 确保构造阶段不会发生 this 逃逸。也就是说,在构造方法里不能把这个对象发布出去,否则别的线程可能在对象还没建好时就看到不完整的对象。

这五条看着简单,实际上每一条背后都有坑。第五条的 this 逃逸,是很多人没意识到的:在构造函数里启动线程、注册监听器、或者把 this 传给别的方法,都属于逃逸。这种代码写出来,就算类本身写得很“不可变”,并发下照样出问题。

2.2 final 关键字在这里的真正作用

很多人都以为 final 就是“赋值一次不能再改”,其实在 Java 内存模型里,final 有更大的价值:JMM 对 final 字段有特殊的可见性保证。

具体来说,只要对象是安全发布的,那么任何线程读取这个对象的 final 字段,都能看到构造方法里写入的值,不需要额外的同步。这一点和普通字段完全不同——普通字段你读到的可能是初始默认值(0、null),因为写操作还没被“冲刷”到读线程。但 final 字段会被编译器和运行时特殊处理,在构造方法结束后,JMM 保证 final 字段的写入结果对所有线程可见,前提是对象引用本身没有被不安全地发布。

这个特性是“不可变对象线程安全”的基石。你可以把它理解成:final 字段就像一份盖了公章的公告,构造完成就等于公告发出,所有看到公告的人,内容一定一致。而普通字段就像黑板上的粉笔字,有人写了一行字,但你走过去的时候看到的可能是擦掉一半的残迹。

注意:final 只能保证“字段引用的可见性”,不能保证“引用对象内部状态的可见性”。所以针对数组、List,你要么别提供访问入口,要么提供副本引用。

2.3 不可变不等于安全发布,发布方式依然要小心

看这两段代码:

// 不安全发布 while (true) { Holder holder = new Holder(42); new Thread(() -> System.out.println(holder.value)).start(); }
// 安全发布方式之一:通过 volatile 发布 private volatile Holder holder; public void publish() { holder = new Holder(42); } public void read() { Holder h = holder; if (h != null) { System.out.println(h.value); } }

第一段代码的问题是:虽然 Holder 的值是 final 的,但 holder 这个引用本身是通过普通局部变量逃逸给线程的,没有建立 happens-before 关系。读者线程可能拿到一个“只构造了一半”的对象引用,虽然 final 字段本身有保证,但整个对象的完整性是不确定的。

第二段代码用 volatile 发布引用,volatile 写和读之间建立了 happens-before,这样读者线程看到的对象必然是一个完整的对象。这是“不可变 + volatile”的经典组合,也是很多高性能无锁代码的底子。

3. JDK 里的不可变类是怎么写的

3.1 String:看似简单,坑全在细节

String 是 Java 里最典型的不可变类,final class,内部是一个 final 的 byte[],所有方法都不修改这个数组,而是返回新对象。你调 concat、replace、substring,原字符串永远不动。

为什么 String 一定要不可变?除了线程安全,还有几个关键原因:

  • 字符串常量池可以放心缓存字符串引用。如果 String 可变,池里的引用可能被篡改,所有使用该字符串的代码都会遭殃。
  • String 的 hashCode 运算结果可以安全缓存。源码里有个 int hash 字段,默认是 0,第一次调用 hashCode 时计算并缓存。因为 String 不可变,这个缓存永远不会失效,hashCode 真正变成了 O(1)。
  • 网络连接、文件路径、类全限定名这些底层系统大量依赖字符串,String 可变的话,安全模型会被直接击穿。

一个我实际踩过的坑:用反射去改 String 的 value 字段。早年 JDK 8 时代确实能通过反射改 String 内部的 char[],但这属于作弊——改完以后这个字符串的 hashCode 缓存没失效的话,就会读到旧值,新值又进不了缓存,整个对象的不变量被破坏。所以后来 JDK 9+ 加了更多内部校验,禁止这种操作。这说明不可变类在设计时是考虑了“即使你绕过权限,也不该能破坏它”的。

3.2 包装类和 BigDecimal:缓存池的妙用

Integer、Long、Boolean、BigDecimal 这些类,本质也都是不可变类。Integer 内部有个 final int value,Boolean 内部是 final boolean value,判断相等可以直接用。

这里最值得说的是 Integer 的缓存池。Java 对 -128 到 127 之间的 Integer 做了缓存,自动装箱时直接用缓存对象。所以Integer a = 100; Integer b = 100;用==比较是 true;但Integer c = 1000; Integer d = 1000;用==比较就是 false,因为两个是不同的对象。面试里这道题经常被拿出来考,但背后的设计动机其实也很实际:小整数在系统里用得极其频繁,缓存可以避免频繁创建对象,同时因为 Integer 不可变,缓存对象被任意线程共享也不会有任何问题。

BigDecimal 更典型。它的内部有 final BigInteger intVal 和 final int scale,所有运算方法如 add、multiply、setScale,都是返回新对象。金融系统里为什么都用 BigDecimal?除了精度,并发安全也是重要原因——账务数据频繁被多个线程读取比对,如果不是不可变类,Double 和 Double 之间还有精度问题,BigDecimal 的不可变特性至少让数据一致这块不会出错。不过要提醒一句:BigDecimal 是不可变的,但如果你把它放在可变的 HashMap 里当 key,map 本身该加锁还得加锁,两者的责任要分开。

3.3 不可变对象 + volatile:并发场景里的黄金搭档

先看一个实际的并发读写场景:配置中心、黑白名单、网关路由表。这类数据有两个特点:更新不频繁、读取极频繁。如果用锁保护,每笔请求都要做一次锁竞争,性能难看;如果不用锁,又可能读到“写了一半”的数据。

标准解法就是不可变对象 + volatile 引用:

public class RouterTable { private final Map<String, Router> routers; public RouterTable(Map<String, Router> routers) { this.routers = Collections.unmodifiableMap(new HashMap<>(routers)); } public Router lookup(String type) { return routers.get(type); } } public class RouterCenter { private volatile RouterTable table = new RouterTable(Collections.emptyMap()); public void update(Map<String, Router> newRouters) { table = new RouterTable(newRouters); } public Router lookup(String type) { return table.lookup(type); } }

关键点在于:RouterTable 内部是 unmodifiableMap,构造时做了副本拷贝,谁也没法改。RouterCenter 里用 volatile 持有引用,每次更新直接替换整个 RouterTable 对象。读线程拿到 volatile 的引用,要么是最新的,要么是上一次的完整快照,绝无中间状态。写线程不需要和读线程互相等待,更新完一赋值,下一条读请求就自然看到新表。这就是“写时复制”的思路在无锁并发里的应用。

我实际在网关项目里这么干过,之前用 CopyOnWriteArrayList 存路由规则,每次更新全列表拷贝,内存开销不小;改成这种不可变快照 + volatile 之后,读路径零锁、零拷贝,更新频率低,性能提升非常明显。注意适用场景:读多写少、更新时可以接受短暂延迟和全量替换带来的一定开销。如果写入极其频繁,这个方案就不合适,还是得回到加锁或者 ConcurrentHashMap。

4. 从可变到不可变:一个查询配置中心的改造案例

4.1 可变版本:读多写少也翻车

我们团队以前有个配置中心 SDK,里面的配置项是用一个普通 HashMap 存的,更新配置时直接往 map 里 put。线上跑着没什么大问题,直到有一次运营同学批量刷新了几百条配置,瞬间就有两个线程开始报 NullPointerException。

看代码就明白了:

public class ConfigCenter { private Map<String, String> configs = new HashMap<>(); public void update(String key, String value) { configs.put(key, value); } public String get(String key) { return configs.get(key); } }

单个 put 和单个 get 在无锁 HashMap 下本身是线程不安全的,即使这次刚好没出事,下一次 HashMap 扩容时,多个线程同时操作会导致链表成环、死循环。即使我们换成 ConcurrentHashMap,看起来安全了,但 get 和 update 的复合操作依然没法保证一致。比如读线程先查 keyA 再查 keyB,中间被写线程改了一条,读线程就拿到了两批不同版本的配置。对配置中心来说,这种“半新半旧”的数据往往是致命的——你上线的开关 A 开了、开关 B 没开,行为就会很奇怪。

另一种常见做法是加锁,读写都要 synchronized。这样数据一致了,但每次查询都要竞争锁,全公司几千个接口都跑在这上面,性能显然不乐观。而且 synchronized 的粒度不好控制,粗了性能差,细了容易漏。

4.2 不可变版本:线程安全直接从设计上消除

后来我们改成不可变快照模式,整个 ConfigCenter 的读取路径不再需要任何锁:

public final class ConfigSnapshot { private final Map<String, String> configs; public ConfigSnapshot(Map<String, String> configs) { this.configs = Collections.unmodifiableMap(new HashMap<>(configs)); } public String get(String key) { return configs.get(key); } public Map<String, String> snapshot() { return Collections.unmodifiableMap(configs); } }
public class ConfigCenter { private volatile ConfigSnapshot snapshot = new ConfigSnapshot(Collections.emptyMap()); public void replaceAll(Map<String, String> newConfigs) { snapshot = new ConfigSnapshot(newConfigs); } public String get(String key) { return snapshot.get(key); } public Map<String, String> getAll() { return snapshot.snapshot(); } }

更新操作从“改内部状态”变成了“整体替换快照”。读线程无论何时拿到 snapshot,都是一个完整的、不变的配置集合。写线程并发更新也不怕,因为大家都是在基于旧的 snapshot 构造新的 snapshot,最后 volatile 赋值那一下决定胜负。更新的瞬时性保证:要么看到旧版本,要么看到新版本,不会看到中间拼接的版本。

这个改完以后,线上再没出现过 NPE 和半新半旧的问题,而且读性能几乎没有损耗。唯一要注意的是:如果配置集合非常庞大(几百 MB 级别的配置),每次全量复制内存和 GC 压力都很可观,这时候就需要权衡是否采用增量更新、分层快照等手段。但绝大多数业务场景下,配置也就是几十到几千条,全量快照完全没问题。

4.3 给不想写大量不可变类的懒人方案:记录类型和库

Java 14+ 引入的 record 类型天生适合做不可变对象。record 的所有组件都是 private final 的,自动生成构造器、equals、hashCode、toString,还自动提供访问方法。只要你别在 record 组件里塞可变对象,它就是一个标准的不可变类:

public record RoutingRule(String prefix, List<String> targets, int weight) { public RoutingRule { targets = List.copyOf(targets); } }

上面这段用了紧凑构造器做防御性拷贝,把外部传进来的 List 拷贝成不可变 List,这样调用方后来修改自己的 list 不会影响到 record 内部。record 用起来非常省事,推荐所有需要“值语义 + 不可变”的地方直接用它,而不是手写一堆 getter、equals。

另外,Google Guava 里也有 ImmutableMap、ImmutableList、ImmutableSet 这些工具类,语义和 JDK 的 unmodifiableMap 有区别:JDK 的 unmodifiableMap 只是“禁止通过这个包装引用修改”,底层原 map 如果被其他地方改了,包装后的视图也会变化;而 Guava 的 ImmutableMap 是真正拷贝之后不可变,底层没有任何人能改。所以做不可变快照时,我更倾向先 new HashMap 拷贝,再用 unmodifiableMap 包装,或者直接用 ImmutableMap。

5. 常见误区与真实避坑

5.1 final 修饰的数组依然是可变对象

这个是最容易翻车的点。很多人一看到private final int[] data,就觉得这个数组成为了不可变字段。实际上 final 限定的只是 data 这个引用不能重新指向新数组,数组内部元素照样可以用 data[0] = xxx 修改。而且数组引用一旦被外部拿到,谁都能改里面的内容。

public final class Wrapper { private final int[] data = {1, 2, 3}; public int[] getData() { return data; // 错误!外部可以直接 data[0] = 100 } }

正确做法是 getData 返回副本:

public int[] getData() { return data.clone(); }

或者干脆内部就存不可变 List,用List.copyOf(Arrays.asList(1, 2, 3))替代数组。JDK 8 以后还有Collections.unmodifiableList,但要注意包装前先拷贝,否则底层的“可变性”还是能透过来的。

5.2 不可变对象里的“间接可变”成员

不可变类里的字段如果指向的是一个可变对象,这个类实际上“不可变得很不彻底”。最经典的就是 Date。你声明了private final Date createTime,外部如果拿到了这个 Date 引用,调用 setTime 一样能改。

防御性拷贝是唯一正解:

public final class Event { private final String name; private final Date createTime; public Event(String name, Date createTime) { this.name = name; this.createTime = new Date(createTime.getTime()); } public Date getCreateTime() { return new Date(createTime.getTime()); } }

构造时拷一份,返回时再拷一份,外部永远不接触内部真实引用。代价是多了一些对象创建,但对绝大多数场景来说,这点代价换来的是“类可以放心共享”这个强保证。在 Java 8+ 时代,我更推荐用java.time.Instant、LocalDateTime这类本身就是不可变的日期类型,直接从源头上消灭 Date 的可怕可变性。

5.3 面试题里常见的几个坑与标准答法

很多面试题围绕“不可变对象的线程安全”提问,我把高频的几道整理成表格,附上答法的关键点:

面试题考察点答题要点
String 为什么是不可变的不可变的价值字符串池缓存、hashCode 缓存、安全模型、并发安全
final 和不可变的关系JMM 的 final 语义final 字段安全发布的特殊保证,但引用类型内部仍可变
不可变对象一定线程安全吗发布安全问题需要安全发布,建议 volatile 发布引用;this 逃逸要避免
如何设计一个不可变类五条规则的完整度final 类、final 字段、无 setter、防御性拷贝、避免 this 逃逸
Integer 的 == 比较常量池 + 不可变-128 到 127 缓存,对象复用;超出范围则 new 新对象
不可变对象能替代锁吗适用场景边界读多写少、整体替换可行时适合;频繁增量更新时不适用
为什么 BigDecimal 是线程安全的不可变 + 无状态所有运算返回新对象,原对象无状态变更,共享无风险

还有一道容易答偏的题:“既然 String 不可变,那 StringBuilder 为什么可变?”答这个问题的关键点是:StringBuilder 是为了拼字符串省内存的,它内部维护一个可变 char[],append 直接改数组,避免每次都产生新 String 对象。代价就是需要单线程独占使用,如果多个线程共享同一个 StringBuilder,那得自己处理线程安全。两个类的设计目标不同,一个追求“共享安全”,一个追求“拼接效率”,没有谁替代谁。

5.4 一个我删过无数次的“不可变”写法

团队里有同事写过一个所谓不可变类,用了Collections.unmodifiableList包装字段:

public final class MyConfig { private final List<String> list; public MyConfig(List<String> list) { this.list = Collections.unmodifiableList(list); } public List<String> getList() { return list; } }

表面看没问题,但外部如果这样操作,就能突破“不可变”:

List<String> original = new ArrayList<>(); MyConfig config = new MyConfig(original); original.add("hack"); // config 内部 list 也跟着变化

原因很简单:unmodifiableList 只是不让通过这个包装去改,但包装指向的还是原来的那个 ArrayList。要真正不可变,构造时必须 copy:

this.list = Collections.unmodifiableList(new ArrayList<>(list));

或者直接this.list = List.copyOf(list);。这个坑基本一期 CR 就能遇到一次,凡是用 unmodifiable 系方法包装字段的,务必先考虑“底层的 list 会不会还在被人改”。

5.5 适用场景边界:不可变也不是银弹

做一个简单的判断,当你要解决并发问题前,先问自己两个问题:这个对象的状态变化频率高吗?每次变化可以接受“全量替换”吗?

  • 如果变化频率极低,比如配置、路由表、字典数据,不可变 + volatile 是非常合适的方案。
  • 如果变化频繁,比如一个计数器要每秒加几千次,就不可能每次都 new 一个新对象替换,这时候 AtomicLong、LongAdder 这类可变状态类更合适。
  • 如果对象内部结构复杂、体积大且更新频繁,不可变方案会带来大量的对象创建和 GC 压力,这时候反而得不偿失。

我自己在使用的过程中也有几次想强行不可变,最后因为对象太大、复制成本太高而放弃。不可变是一个极好的设计方向,但选型的时候要算账:共享并发带来的收益,是否大于全量复制的成本。计算方式可以用一个朴素的估算:如果读操作是写操作的几十上百倍,那么全量复制的成本被分摊到海量读操作上,通常是划算的;反过来,如果读写频率接近,那就别硬凑不可变了。

真正让我对“不可变”这个概念产生信任的,是在排查过无数个加锁加出毛病的案例之后。加锁是在不确定的状态上强行拼接秩序,而不可变是从一开始就消除了这种不确定。你可以把它理解成两种做事风格:一种是不停地开会讨论每个人的意见,最后达成一致;另一种是把目标写死在纸上,所有人都照做。后者的执行效率,天然就高一个数量级。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询