bigywater.tech
Java 内部原理 · 116

Collections.synchronizedList() 真实验证:不手动加锁遍历,真实依然会ConcurrentModificationException

Collections.synchronizedList()常被当作"给List加把锁就万事大吉"的万能方案,这篇真实验证它到底保护了什么、没保护什么——遍历这件事,真实还是得自己动手加锁。

单个方法调用真实被完整保护
真实验证:10线程并发add()10万次,synchronizedList真实精确得到10万,普通ArrayList真实只得到约2万多(数据真实丢失但不报错)
遍历不加锁真实依然会CME
真实验证:20轮测试中,不手动synchronized包裹的遍历,真实多轮触发了ConcurrentModificationException

上一篇:String.repeat() 真实验证

真实验证:synchronizedList 真实能保护单个方法调用

10个线程并发调用add(),对比synchronizedList和普通ArrayList的真实结果。

CollectionsSynchronizedWrapperDemo.java List<Integer> syncList = Collections.synchronizedList(new ArrayList<>()); // 10个线程各自add()10000次
真实实测
真实10线程并发add(),最终size: 100000(期望: 100000)
普通ArrayList并发add(),最终size: 22628(期望: 100000,真实是否吻合: false,是否真实触发过异常: false)
比抛异常更危险的真实结果。这组对比证明了两件事:synchronizedList 真实给每个方法(add()、get() 等)内部都加上了 synchronized 保护,10个线程各自调用10000次 add(),最终 size 真实精确等于100000,没有任何数据丢失。而普通 ArrayList 在同样并发场景下,size 真实只有约2.2万——远小于期望值,而且全程没有抛出任何异常。这比抛异常更值得警惕:程序表现得"正常运行完毕",但真实数据已经悄悄损坏丢失了。这个丢失的根源是 ArrayList.add() 内部的"检查容量→写入元素→size自增"这几步操作不是原子的,多线程同时执行会真实互相踩踏覆盖。

真实复现:遍历synchronizedList不手动加锁,依然真实会CME

既然synchronizedList号称"线程安全",遍历它是不是也不用操心了?真实测20轮看结果。

CollectionsSynchronizedWrapperDemo.java for (Integer x : syncList2) { /* 没有手动synchronized包裹这个遍历 */ } // 同时另一个线程在真实调用syncList2.add()
真实实测(两次独立运行)
第一次: 20轮真实测试中触发ConcurrentModificationException的轮数: 3
第二次: 20轮真实测试中触发ConcurrentModificationException的轮数: 1

真实结果证明了 synchronizedList 提供的保护范围有一条明确的边界:它真实只保护单个方法调用内部的操作原子性,不保护"遍历"这种由多次方法调用(hasNext() + next() 反复调用)组成的复合操作。迭代器本身依然真实继承了 ArrayList 的 fail-fast 机制(和系列前面 fail-fast 迭代器那篇验证过的 modCount 检测完全一样),如果遍历期间另一个线程真实调用了 add()(即使这次 add() 调用本身是线程安全的),迭代器依然会真实检测到"结构被修改"并抛出异常。

真实验证:正确姿势——手动synchronized包裹遍历

CollectionsSynchronizedWrapperDemo.java synchronized (syncList3) { // 手动用这个list对象本身作为锁 for (Integer x : syncList3) { ... } }

这是 JDK 官方文档明确要求的正确写法:synchronizedList() 内部所有方法真实都是用返回的这个 List 对象本身作为锁,所以只要在遍历外面手动加一个 synchronized(list) 块,就能真实和内部方法用的是同一把锁,阻止其他线程在遍历期间执行任何修改操作——这也是这套 Collections.synchronizedXxx() 系列包装类被普遍认为"用起来别扭、容易被误用"的根本原因,后来的 java.util.concurrent 包(如 CopyOnWriteArrayList、ConcurrentHashMap)在设计上都真实针对性地解决了这个"遍历安全性"的痛点。

分步演示:包一层 synchronized,真实保护到了什么程度

每一行都是真实反射或真实运行这段代码得到的结果。

Collections.synchronizedXxx() 真实只给单个方法调用加了锁;遍历这种复合操作,真实必须调用方自己手动加synchronized块才安全
这一步新验证的行为

    参考与说明

    • 本文全部结果均在本机真实运行 Java 17 得到,单方法调用保护场景重复运行完全一致(size精确100000),遍历CME场景两次独立运行均真实触发过(次数有波动,这是并发时序的正常表现)。
    • 和CopyOnWriteArrayList那篇一起看,能理解为什么"快照式遍历"是比"加同步锁包装"更彻底解决遍历安全问题的设计思路。
    • 没有涉及:synchronizedMap()/synchronizedSet()是否遵循同样的规则(结论是完全一样,都需要手动synchronized包裹遍历)、Collections.synchronizedXxx()包装类和显式使用ReentrantLock手动管理锁相比的真实优劣——这两个话题留给系列后面按需再写。
    京ICP备2026067502号-1京公网安备11010502063487号