工作这几年,多线程下操作集合容器翻车,基本是我见过频率最高的并发事故类型。前几天帮一个同事排查线上偶发的数据丢失,最后定位到就是 HashMap 并发 put 互相覆盖:单测跑一万遍都是绿的,压测一上就现原形。这篇我还是聚焦 JavaSE 本身,把多线程下安全使用容器的知识点、选型逻辑和真实排坑经验完整梳理一遍,方便刚接触并发的同学少走弯路,也方便有经验的同行当一份速查文档。
先预告一下全文会覆盖什么:普通容器为什么线程不安全;Vector、Hashtable、Collections.synchronizedXxx、CopyOnWriteArrayList、ConcurrentHashMap 这几类方案各自的原理和适用场景;两个能直接抄的实战案例;以及我自己排查并发容器问题时常用的步骤和心得。文章不会讲多深的理论推导,但会把"为什么这么选""为什么这里要加锁"讲明白,至少让读者遇到类似问题时有个清晰的判断路径。
1. 多线程下的容器:并发隐患从哪来
1.1 先从 ArrayList 的 add 说起
很多新手觉得 ArrayList 不就是个数组嘛,往里面塞元素怎么还会出事?问题的根源在于 add 这个操作从来不是一个原子动作。ArrayList 内部用一个 Object[] elementData 保存元素,还有一个 size 字段记录当前有效长度。add 流程大致是:先检查 elementData 是否够用,不够就扩容,然后把新元素写到 elementData[size] 位置,最后 size++。这三个步骤之间没有任何锁保护,单线程下顺序执行没问题,两个线程同时执行时就全是问题。
举个例子,两个线程同时执行 add,A 和 B 同时读到 size = 5,于是都往 elementData[5] 写值,A 写了自己的对象,B 随后把它覆盖掉,size 只会增加 1,数据就这样悄悄丢了一条。更麻烦的是扩容环节。扩容时会创建一个更大的数组,再把旧数据搬过去,两个线程同时进到扩容分支,可能出现一个线程已经把 elementData 指向新数组,另一个线程还在往旧数组上写值,最终导致数组越界或者元素彻底丢失。
我自己做了一个很简单的复现实验:开两个线程,各往同一个 ArrayList 里 add 1 万条数据,结束之后打印 list.size()。正常情况下这个值应该是 20000,实际跑出来经常是 19000 多,偶尔直接抛 ArrayIndexOutOfBoundsException。这个实验我在 JDK8 和 JDK11 上都复现过,环境不同只是表象略有差异,根子都一样:ArrayList 没有做任何并发控制。
1.2 HashMap 的扩容与数据覆盖
HashMap 的问题比 ArrayList 更隐蔽。HashMap 的 put 流程里牵扯计算桶位、冲突处理、扩容、树化等一堆逻辑,每个判断都是"先读再写",天然存在竞态窗口。两个线程同时对同一个桶做 put,可能出现头插位置的覆盖;两个线程同时触发 resize,可能出现旧表数据被丢弃,表现为 key 还在但 value 变成 null,或者 size 统计混乱。
这里最出名的是 JDK 7 时代并发 resize 时形成环形链表,导致下次 get 死循环的问题。JDK 8 改成尾插加红黑树后,死循环的场景大幅缓解,但并发覆盖丢数据的问题依然存在,只是从"CPU 跑满"变成了"数据莫名消失",更难察觉。所以不要觉得 JVM 升级了就可以放心在并发环境用 HashMap。
我自己排查过一个库存场景:并发扣减时用 HashMap 记录 sku 和扣减次数,压测到 500 并发就发现记录值偏小,后来换成 ConcurrentHashMap 并配合 compute 方法才稳定。这就是普通容器在多线程下的真实杀伤力:不一定会马上抛异常,但结果一定不可信。
1.3 线程安全容器有三路可选
既然普通容器不扛并发,Java 给我们的选择其实分三路。第一路是早期 JDK 自带的同步容器,Vector、Hashtable、Stack,它们的方法用 synchronized 整个锁住,性能堪忧但可用。第二路是 JDK 1.2 推出的 Collections 工具类包装方法,可以把任意一个集合包装成线程安全版本。第三路是 JUC(java.util.concurrent)包下的并发容器,ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentSkipListMap、BlockingQueue 等,它们在锁粒度、读写策略上都做了大量优化,是现在的主力选择。
新手最容易踩的坑是把这几类混着用:有人用 ArrayList 做读多写少的共享资源,也有人用 Vector 做高并发计数,都不是最优解。选错容器的后果,平时数据量小看不出来,一旦并发上来,性能差距可能是几十倍。后面第 3 节会专门给一张选型决策表。
| 容器类别 | 代表类 | 锁策略 | 遍历语义 | 适用场景 |
|---|---|---|---|---|
| 同步容器 | Vector、Hashtable | 方法级 synchronized | fail-fast | 简单低并发、教学示例 |
| 包装容器 | Collections.synchronizedXxx | 整表锁(mutex) | fail-fast,需手动加锁 | 已有容器的快速改造 |
| 并发容器 | ConcurrentHashMap、CopyOnWriteArrayList | CAS + 桶锁 / 读写分离 | 弱一致性 | 生产环境主力 |
2. 三类线程安全容器的工作原理与性能权衡
2.1 Vector 与 Hashtable:方法级悲观锁
Vector 和 Hashtable 是 Java 1.0 就存在的"老同志",思路非常直接:所有公开方法都加 synchronized,同一时刻只有一个线程能进入方法体。这种设计确实保证了每个单独方法的原子性,但代价是并发度为 1,即使两个线程一个做 get 一个做 add,也要排队。高并发下,锁竞争会把吞吐量打到很低,所以现在业务代码里已经很少直接 new Vector 或者 Hashtable 了,面试倒是经常被拿来问。
有一点必须说清楚:方法级同步只保证单个方法的线程安全,不保证复合操作的安全。经典场景是先判断 vector.size() > 0,再执行 vector.get(0),这两个操作之间别的线程可能已经把元素删光了,于是判断通过,取元素时依然抛 IndexOutOfBoundsException。所以就算用了同步容器,也不能在复合操作上掉以轻心,这个坑我在 3.4 节会再举例。
2.2 Collections.synchronizedXxx:包装器模式
Collections.synchronizedList、synchronizedMap、synchronizedSet 这一组方法,本质是把一个普通容器包在代理类里,代理对象内部维护一个 mutex 锁,所有操作都先拿锁再执行。如果你不指定 mutex,默认用包装对象自身。它的优点是简单,可以把任何已有容器快速变成线程安全版本;缺点是依然没有解决"复合操作不原子"的问题,而且锁的粒度是整个容器,并发场景下性能瓶颈明显。
synchronizedMap 还有一个非常经典的坑:迭代必须显式加锁。它包装的 HashMap 在被整体遍历时,如果其他线程同时做修改,一样会抛 ConcurrentModificationException。官方注释里写得明明白白,遍历时需要自己 synchronized(map)。很多人只注意到 put/get 安全了,忘了 iterator 这层,导致线上偶发异常,这类故障我在第 4 节的排查实录里详细写。
2.3 CopyOnWriteArrayList:写时复制的读多写少利器
CopyOnWriteArrayList 的思路和前面完全不一样:读的时候永远不加锁,直接读取当前数组;写的时候先加锁,然后把整个底层数组复制一份,在新数组上做修改,改完再把内部引用指向新数组。这样写操作不会影响并发读的线程,读线程拿到的可能是一份稍旧的快照,但对读多写少的场景来说非常合适。
典型的应用场景是观察者列表、黑名单、配置项缓存这类"读极其频繁、写非常稀少"的数据。但它有非常明显的性能边界:每次 add 都要复制整个底层数组,如果列表里有 10 万条数据,一次 add 的代价就是复制 10 万个引用,频繁写入时内存开销和 GC 压力都会飙升。所以它不是一个可以乱用的"万能安全 List",而是要结合读写比例来判断。有人拿它当普通 List 用,写多读少,结果线上频繁 Full GC,就是没想清楚写时复制的代价。
2.4 ConcurrentHashMap 的锁粒度演进
ConcurrentHashMap 是并发 Map 里的主力,设计经历了两次大版本变化。JDK 7 用 Segment 分段锁,把哈希桶分成 16 段,每段一把锁,不同段之间可以并行写;JDK 8 直接放弃 Segment,改用 CAS + synchronized 锁住单个桶的头节点,锁粒度从段细化为桶,只有发生哈希冲突、往同一个桶写入时才需要竞争锁,并发度大幅提升。
读取方面也有大量优化,比如用 volatile 修饰节点数组和 Node 的 next、val 字段,保证可见性;遍历返回的迭代器是弱一致性的,不会抛 ConcurrentModificationException,但也意味着遍历过程中可能看不到同时发生的更新。这个特性在排查问题时经常让人困惑:为什么我都用 ConcurrentHashMap 了,遍历结果还是不全?因为快照语义和一致性语义本来就是两回事,弱一致性换来的是更高的并发吞吐,代价是"不保证实时看到最新值"。
3. 实战:安全容器的选型与代码落地
3.1 按读写比例选型,别问"哪个最快"
选型的关键不是"哪个类最快",而是"我的运行场景是怎么样的"。我自己习惯按三个维度判断:读多还是写多、是否需要强一致、是否需要复合原子操作。把场景想清楚了,容器选型基本是水到渠成的事,不需要背任何口诀。
读多写少、数据量不大,优先考虑 CopyOnWriteArrayList、CopyOnWriteArraySet,或者干脆用不可变快照加 volatile 发布;读写均衡、写并发高,用 ConcurrentHashMap、ConcurrentSkipListMap;写入极频繁、允许读快照,可以考虑普通容器加同步包装,或者用 BlockingQueue 配合生产者消费者模型;需要排序、范围查询的并发 Map,直接上 ConcurrentSkipListMap。
我写代码的习惯是:只要 Map 会被多线程共享,默认先考虑 ConcurrentHashMap;List 如果是读多写少,用 CopyOnWriteArrayList;如果写也很频繁,优先考虑用不可变快照配合发布,或者用 synchronizedList,而不是死磕 CopyOnWrite 的复制开销。选型别贪快,多一点场景思考,后面省回来的排障时间是按天算的。
3.2 实战一:并发用户访问计数
先给一个最简单但很典型的需求:一个接口会被大量线程并发调用,需要统计某个 key 的访问次数,最后输出总数。很多人的第一版代码是 HashMap<String, Integer>,然后写一个 increment 方法,先 get 再加 1 再 put。这个写法在并发下必然丢计数,因为 get 到 put 之间不是原子的,多个线程读到同一个旧值,加完之后覆盖写回,前面线程的 +1 就丢了。
正确做法可以直接用 ConcurrentHashMap 的 compute 方法:
Map<String, Integer> countMap = new ConcurrentHashMap<>(); countMap.compute(key, (k, v) -> v == null ? 1 : v + 1);compute 的核心价值在于:取值、计算新值、写回这三个动作在内部是原子完成的,不需要我手动加锁。如果还要追求更高的统计性能,可以用 LongAdder,或者把 ConcurrentHashMap<String, LongAdder> 组合起来用。
有人会问,直接用 LongAdder 不就行了?单 key 场景用 LongAdder 完全够,但如果是"一堆动态 key 各自计数"的场景,比如按用户 ID 统计访问次数,就必须用一个并发 Map 把 key 与 LongAdder 关联起来。这时候最简洁的写法是:
Map<String, LongAdder> counters = new ConcurrentHashMap<>(); counters.computeIfAbsent(userId, k -> new LongAdder()).increment();这里 computeIfAbsent 保证同一个 key 只有一个 LongAdder 实例被创建并发布,不会出现两个线程各 new 一个、导致计数各走各的的情况。
3.3 实战二:黑名单与配置缓存
再来看一个读多写少的经典场景:黑名单判断。接口收到请求后要判断某个用户 ID 是否在黑名单里,黑名单被后台任务周期性刷新。如果直接用同步容器,所有读请求都会排队,性能很差;如果直接上 CopyOnWriteArrayList,判断 contains 虽然不加锁,但底层是线性扫描,黑名单很大时不理想。
更合适的做法是配合 Set。CopyOnWriteArraySet 底层就是 CopyOnWriteArrayList,适合黑名单规模不大的场景,代码很简单:
Set<String> blackSet = new CopyOnWriteArraySet<>(); public boolean isBlocked(String userId) { return blackSet.contains(userId); } public void refresh(List<String> newList) { blackSet.clear(); blackSet.addAll(newList); }这里有个细节,refresh 里 clear 和 addAll 是两个写操作,并发执行时可能会有线程读到空集合或半新半旧的状态。更好的写法是直接替换整个引用,用不可变快照,newSet 构建完成后一次性赋值给 volatile 字段,读方永远拿到一个完整快照,不需要任何锁:
private volatile Set<String> blackSet = Collections.emptySet(); public void refresh(List<String> newList) { Set<String> newSet = new HashSet<>(newList); blackSet = newSet; // 原子发布 } public boolean isBlocked(String userId) { return blackSet.contains(userId); }这个"volatile 发布不可变快照"的思路在生产里非常实用,很多开源项目都有类似写法,比 CopyOnWrite 更省内存,也比同步容器更高效。遇到读多写少的数据,先想到这里,大概率能省下不少性能调优的时间。
3.4 复合操作一定要额外同步
前面反复强调复合操作需要额外保护,这里给一个最直观的代码级案例。用户下单时,要先判断库存容器里还有没有商品,有的话取出并移除。简单写是:
if (!list.isEmpty()) { Item item = list.get(0); list.remove(0); }即使 list 是 CopyOnWriteArrayList 或 synchronizedList,这段代码仍然是错的。因为 isEmpty、get、remove 三个调用之间的时间窗口里,另一个线程完全可能已经把元素消费掉了。另一个线程 remove(0) 后,当前线程再 get(0) 就会拿错数据甚至越界。
这类场景不能靠"容器自带的安全"来解决,必须把复合动作变成一个原子操作。最简单的方式是给整个判断加锁,或者用自带复合方法的容器。JDK 的 BlockingQueue 在这种场景好用,就是因为它提供了 poll、take 这样的原子复合操作,取和删天然绑定。多线程编程里我一直强调一个原则:先想清楚"哪些操作必须在一个原子区间内完成",再决定用什么容器和锁方案。容器安全只是第一层防线,业务原子性才是最终目标。
4. 常见问题与排查技巧实录
4.1 ConcurrentModificationException 的完整排查思路
这个异常可能是多线程容器事故里出场率最高的。它的机制叫 fail-fast:集合内部维护一个 modCount 字段,每次结构性修改都会加 1;迭代器创建时记住当时的 modCount,每次 next 时对比一次,发现变了就立刻抛 ConcurrentModificationException。注意,这种检测不是绝对可靠的,比如 HashSet 在迭代中修改自身,不同 JVM 下表现可能不一样,所以不能把"没抛异常"等同于"安全"。
排查步骤我一般这样走:先看堆栈里哪个迭代器在抛,再顺藤摸瓜找到正在修改容器的线程,用 jstack 抓线程快照确认修改点;然后审视代码,问自己三个问题:这个容器是不是被多个线程共享了?修改和遍历是不是并发的?如果用同步容器包装,迭代时是否加了锁?绝大多数情况下,答案都会落在"遍历和修改未同步"上。
如果是迭代器自身的 remove 操作,比如遍历时删除元素,一定要用 iterator.remove(),不要用 list.remove(index),否则也会触发 modCount 变化。这个细节面试很爱考,实际开发也常踩。
4.2 synchronizedMap 的迭代锁陷阱
有一次小伙伴的线上代码长这样:
Map<String, String> safeMap = Collections.synchronizedMap(new HashMap<>()); // 某线程频繁 put // 主线程遍历: for (String k : safeMap.keySet()) { process(k); }看起来 safeMap 已经线程安全了,可线上还是偶发 ConcurrentModificationException。原因很单纯:synchronizedMap 只保证单个方法调用安全,增强 for 循环的底层是迭代器,迭代器遍历期间其他线程做 put 修改,就会触发 fail-fast。修法也简单,遍历时手动拿同一把锁:
synchronized (safeMap) { for (String k : safeMap.keySet()) { process(k); } }如果不想每次都记得加锁,干脆换成 ConcurrentHashMap,它的迭代器是弱一致的,不会抛这个异常。这个小问题曾经让一个团队排查了接近两个晚上,拿到根因后其实就一行代码的事。写进笔记之后,我自己后来再遇到类似问题,基本都是直接扫一眼代码里是否还有"同步容器 + 增强 for"的组合。
4.3 大小判断后再删除的原子性陷阱
这个坑我在 3.4 已经写过代码,实际业务里还有很多变体。比如多线程环境下,每个人都写 "if (map.containsKey(key)) { map.remove(key); }",或者 "if (queue.size() > 0) { queue.poll(); }"。containsKey 和 remove 之间、size 和 poll 之间都有天然的竞态窗口。ConcurrentHashMap 做了很多努力,单独调用 containsKey 和 remove 都是安全的,但放在一起就还需要额外保护。遇到这一类需求,优先看 JUC 里有没有现成的复合 API,比如 putIfAbsent、remove(key, value)、computeIfAbsent,实在没有就老实加锁。
这里也顺带说一个面试高发点:ConcurrentHashMap 为什么不能完全替代 Hashtable 的所有需求?其中一个原因是它不支持整体锁,比如需要做全表操作(遍历全量数据并同步修改)时,还是要自己加全局锁。对大部分业务来说用不到这种全表锁,但面试时能答出这一点,会显得你对并发模型的理解有深度。
4.4 用 jstack 定位容器冲突现场
真到排查落地的环节,我推荐一套上手最快的组合拳:先观察异常堆栈,再用 jstack 抓现场。假设系统偶发数据丢失,先不要盲目改代码,确认事故现场比什么都重要。步骤一般是:
- 用 ps 或 top 找到 Java 进程 PID。
- 用 jstack > thread.txt 抓线程快照,最好连续抓两三次,间隔 2 秒。对比两次快照,看哪些线程在同一把锁上阻塞,或者哪个线程在循环热调用某个集合方法。
- 如果多个线程都在访问同一个集合的 add/put 方法,基本就能锁定共享容器,再回到代码里确认是否有并发修改。
这个方法不只能排查容器问题,排查任何并发不安全的共享对象都有效。有的同学一遇到并发问题就怀疑是中间件、网络、框架,其实先抓一把线程快照,比自己瞎猜定位快得多。工具用熟了以后,定位一个并发丢失问题通常十分钟内就能完成。再补一句经验之谈:抓快照最好选在故障发生时,迟了几分钟线程现场可能就变了,后面分析就只能靠运气。
这些年在多线程容器上踩过的坑,几乎每个都能归到一句话:默认容器不安全,安全容器也不解决所有问题。我在实际项目里的习惯是,凡是会被多个线程共享的集合,第一版代码就按并发容器来写,宁可后面根据性能数据调整,也不要等线上出事故再补救。选型的时候想清楚读写比例和是否需要复合原子操作,大多数问题都能在写代码之前就规避掉。
最后再分享一个不算技巧的小技巧:遇到奇怪的偶发问题,先在纸上画出两个线程同时访问集合的时间线,答案往往就在那个交叉点上。多线程没有银弹,但掌握好这些基础容器特性,真的能少熬好几个大夜。