ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

不可变对象如何彻底解决并发线程安全问题

不可变对象如何彻底解决并发线程安全问题 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 valueBoolean 内部是 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 里当 keymap 本身该加锁还得加锁两者的责任要分开。3.3 不可变对象 volatile并发场景里的黄金搭档先看一个实际的并发读写场景配置中心、黑白名单、网关路由表。这类数据有两个特点更新不频繁、读取极频繁。如果用锁保护每笔请求都要做一次锁竞争性能难看如果不用锁又可能读到“写了一半”的数据。标准解法就是不可变对象 volatile 引用public class RouterTable { private final MapString, Router routers; public RouterTable(MapString, 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(MapString, 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 MapString, 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 MapString, String configs; public ConfigSnapshot(MapString, String configs) { this.configs Collections.unmodifiableMap(new HashMap(configs)); } public String get(String key) { return configs.get(key); } public MapString, String snapshot() { return Collections.unmodifiableMap(configs); } }public class ConfigCenter { private volatile ConfigSnapshot snapshot new ConfigSnapshot(Collections.emptyMap()); public void replaceAll(MapString, String newConfigs) { snapshot new ConfigSnapshot(newConfigs); } public String get(String key) { return snapshot.get(key); } public MapString, 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, ListString 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 ListString list; public MyConfig(ListString list) { this.list Collections.unmodifiableList(list); } public ListString getList() { return list; } }表面看没问题但外部如果这样操作就能突破“不可变”ListString original new ArrayList(); MyConfig config new MyConfig(original); original.add(hack); // config 内部 list 也跟着变化原因很简单unmodifiableList 只是不让通过这个包装去改但包装指向的还是原来的那个 ArrayList。要真正不可变构造时必须 copythis.list Collections.unmodifiableList(new ArrayList(list));或者直接this.list List.copyOf(list);。这个坑基本一期 CR 就能遇到一次凡是用 unmodifiable 系方法包装字段的务必先考虑“底层的 list 会不会还在被人改”。5.5 适用场景边界不可变也不是银弹做一个简单的判断当你要解决并发问题前先问自己两个问题这个对象的状态变化频率高吗每次变化可以接受“全量替换”吗如果变化频率极低比如配置、路由表、字典数据不可变 volatile 是非常合适的方案。如果变化频繁比如一个计数器要每秒加几千次就不可能每次都 new 一个新对象替换这时候 AtomicLong、LongAdder 这类可变状态类更合适。如果对象内部结构复杂、体积大且更新频繁不可变方案会带来大量的对象创建和 GC 压力这时候反而得不偿失。我自己在使用的过程中也有几次想强行不可变最后因为对象太大、复制成本太高而放弃。不可变是一个极好的设计方向但选型的时候要算账共享并发带来的收益是否大于全量复制的成本。计算方式可以用一个朴素的估算如果读操作是写操作的几十上百倍那么全量复制的成本被分摊到海量读操作上通常是划算的反过来如果读写频率接近那就别硬凑不可变了。真正让我对“不可变”这个概念产生信任的是在排查过无数个加锁加出毛病的案例之后。加锁是在不确定的状态上强行拼接秩序而不可变是从一开始就消除了这种不确定。你可以把它理解成两种做事风格一种是不停地开会讨论每个人的意见最后达成一致另一种是把目标写死在纸上所有人都照做。后者的执行效率天然就高一个数量级。
返回列表