大家好,我是晚安code。
假设一个场景你的接口老丢数据,排查半天发现是 HashMap 被多个线程同时 put——好家伙,缓存越写越少。这坑凡是写过并发的估计都踩过。这篇 Java并发集合详解,我从 util 集合为什么不安全讲起,拆 HashMap 的加载因子和初始容量,再把 CopyOnWriteArrayList、ConcurrentHashMap 原理扒干净。看完全文,你就知道并发下集合到底该怎么选。
一、并发下,普通的集合写法是安全的吗?
并发下拿 HashMap、ArrayList 直接写,数据丢了不是运气问题,是结构必然。 这个结论我交了学费才懂。先用最经典的 demo 复现,10 个线程同时往一个 ArrayList 里塞数据:
// 10 个线程同时往同一个 ArrayList 里 add
List<String> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
list.add(UUID.randomUUID().toString().substring(0, 5));
}, String.valueOf(i)).start();
}
多跑几次,你会见到三类问题:抛 ConcurrentModificationException、抛 ArrayIndexOutOfBoundsException、或者干脆元素少一截。原因不玄乎——ArrayList 的 add 底层是「检查容量 → 扩容 → 写数组 → size++」一串动作,任何一个动作之间被别的线程插一脚,后面的操作就建立在错的数据上。HashMap 更直接:多个线程哈希到同一个桶,后写直接覆盖先写;扩容那一下新老数组切换,别的线程可能读到 null 或错位数据。
JDK7 的 HashMap 还有更狠的一课:头插法在扩容时可能把链表绕成环,一旦成环,后续 get 就是 CPU 打满的死循环,进程直接卡死——这个梗面试官都爱问。



二、Java 自带的线程安全方案为什么不够
同步容器是把整条马路封起来让车一辆一辆过,安全但慢,复合操作还照样不安全。 很多人第一反应是用 Vector 或 Collections.synchronizedList,但这两个方案有共同的硬伤:一把全局锁,所有线程读写全部串行,多线程读也被挡在门外,性能直接退化成单线程。
还有个更隐蔽的坑:就算单个方法加了锁,「先判断再操作」这种复合步骤还是不安全。比如先 size() 再 get(),两个方法之间锁已经释放了,别的线程插进来一改,你的判断就作废。
线程安全集合(Thread-Safe Collection):能在多线程并发读写下保持一致性的集合,通过锁、原子操作等手段保证操作互不干扰。你可以理解为「带门卫的仓库,进出都有规矩」。
看一张 util 和 JUC 的对照表,就知道我们为什么要换阵营:
| 并发场景 | util 写法 | 问题 | JUC 写法 |
|---|---|---|---|
| List 读多写少 | ArrayList | 并发 add 丢数据 | CopyOnWriteArrayList |
| Set 读多写少 | HashSet | 并发 add 丢数据 | CopyOnWriteArraySet |
| Map 高并发读写 | HashMap | 并发 put 覆盖、丢数据 | ConcurrentHashMap |
顺带说个冷知识:HashSet 底层就是一个 HashMap,add 的本质是 map.put(e, PRESENT),PRESENT 是一个固定的常量对象,靠「key 不能重复」实现去重。
// JDK 源码示意:HashSet 内部就是 HashMap
private static final Object PRESENT = new Object();
public boolean add(E e) {
return map.put(e, PRESENT) == null; // key 唯一,重复返回 false
}
所以 HashSet 线程安不安全,完全取决于它内部的 HashMap——结论自然是不安全。那 JUC 的方案到底怎么做到「读不加锁、写少冲突」?先从最基础的 HashMap 说起。
三、先搞懂 HashMap:初始容量 16 和加载因子 0.75 怎么来的
加载因子不是拍脑袋定的,0.75 是空间和哈希冲突博弈出来的平衡点。 这个数面试问烂了,但真能答出「为什么」的不多。我当年面试被问只能背答案:初始容量 16,加载因子 0.75,装到 12 个就扩容。后来啃源码才明白背后的权衡。
先看结构。HashMap 底层是一个桶数组(Node[]),每个桶挂着一条链表,链表太长会转成红黑树:

- 默认初始容量 16(
DEFAULT_INITIAL_CAPACITY = 1 << 4) - 默认加载因子 0.75
- 扩容阈值 threshold = 容量 × 加载因子 = 16 × 0.75 = 12,元素超过 12 个就扩容翻倍
为什么容量必须是 2 的幂?因为定位桶不用取模运算 %,而是位运算 (n - 1) & hash——快得多,而且扩容翻倍后,元素要么留在原桶,要么整体挪到「原下标 + 旧容量」,rehash 成本极低。
那 0.75 又是怎么定的?这是空间利用率和哈希冲突概率的权衡。 加载因子越大,数组利用率越高,但冲突越多、链表越长;加载因子越小,冲突越少,但数组很多格子空着,还得频繁扩容。0.5 太浪费空间,1.0 冲突太多,0.75 是长期测试下来的折中点。再往深一层:假定哈希足够随机,桶内链表长度服从泊松分布,链表长到 8 的概率只有约 6 千万分之一,所以 JDK 把「链表转红黑树」的阈值定在 8(还要求桶数 ≥ 64)——大部分情况下链表根本长不到 8,红黑树基本是个保险栓。
可能有人会问:HashMap 默认容量为什么是 16,不能是 15 或 20?
因为容量必须是 2 的幂,才能用
(n - 1) & hash位运算取模,扩容翻倍也正好是二进制左移一位。16 不大不小,多数场景装个十来个键值对不用急着扩容,是最稳的起步值。
加载因子(Load Factor):容器判定「装了多少就该扩容」的阈值比例,HashMap 默认 0.75,即元素数达到容量的 75% 就触发扩容。可以理解为「水杯装到七分满就该换大杯,太满倒水容易洒」。
好,搞懂了 HashMap 的基础,接下来看它的并发替代方案怎么做到「读不加锁、写少冲突」。
四、CopyOnWriteArrayList:写时复制,读多写少的答案
CopyOnWriteArrayList 的核心思路是读写分离:读永远不加锁,写一次拷全表。 它是 ArrayList 的线程安全替代品,写操作不直接在原数组上改,而是复制一份新数组,改完再把整个引用换过去;读线程永远读的是旧数组那份完整快照,所以读完全不用锁。
写时复制(Copy-On-Write,COW):计算机里一种优化策略——要修改数据时先复制一份副本,在副本上改,改完原子地替换原数据,读者永远拿到一致的旧快照。类比改纸质文档:先复印一份,在复印件上改,改完把整本换掉,看文档的人始终看到完整版本。
里面的关键是一个 volatile Object[] array 字段。写线程 setArray(newArray) 之后,读线程通过 volatile 保证立刻能看到新数组,不需要任何同步。代价是写一次要复制整个数组,写操作是 O(n)。
所以它的适用场景很明确:读多写少。比如配置白名单、监听器列表这种「读得特别勤、偶尔改一次」的数据。反过来,如果写很频繁,每次都拷全表,那性能和内存都扛不住——这是我踩过坑才理解的,别把并发写多的场景硬塞给它。
CopyOnWriteArraySet 更省事:它内部就是一个 CopyOnWriteArrayList,add 走 addIfAbsent 去重,本质是线性扫描判断重不重复。所以它只适合「集合很小 + 读多写少」,元素一多,add 的成本肉眼可见地涨。
把第一章那个报错的 demo 换一行就稳了:
// 换成 CopyOnWriteArrayList,同样 10 线程并发 add 不报错、不丢
List<String> list = new CopyOnWriteArrayList<>();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
list.add(UUID.randomUUID().toString().substring(0, 5));
}, String.valueOf(i)).start();
}
System.out.println(list); // 元素齐全,读无锁

那 Map 呢?CopyOnWrite 那套复制整个数组的思路没法直接搬到 Map 上,Map 的并发方案走的是另一条路——ConcurrentHashMap。
五、ConcurrentHashMap:尽量不锁,锁也只锁一个桶
ConcurrentHashMap 的设计核心是尽量不锁:空桶用 CAS 直接写,冲突了才锁住那一个桶,绝不锁整张表。 这是 JDK8 之后的设计,也是它跟 HashMap、同步容器的根本区别。
JDK7 的版本叫分段锁:把整张表切成 16 个 Segment,每段一把 ReentrantLock,并发度上限固定 16,段内还是串行。JDK8 把 Segment 整个删掉,改成 Node[] 桶数组 + CAS + synchronized(桶头节点),并发度从「固定 16」解放成「等于桶数量」。整体流程看这张图:

走一遍 put 的完整流程:
- 计算 hash 定位桶下标
- 桶是空的 → 用 CAS 原子写入,不加锁
- 桶非空 → synchronized 锁住这个桶的头节点,再写链表或红黑树
- 如果桶头是 ForwardingNode,说明正在扩容,别的线程会来「协助扩容」
为什么锁桶头就够了?因为一个桶里的所有节点都挂在头节点下面,锁住头就锁住了整桶;而不同桶的线程互不干扰,各自锁各自的桶——这就是并发度等于桶数量的原因。读操作则完全不加锁,靠 volatile 保证可见性,读到扩容中的转发节点还会自动去新数组找。
CAS(Compare-And-Swap):比较并交换,一个 CPU 级的原子操作,先判断内存里的值是不是预期的旧值,是才换成新值,不是就重试。没有锁也能安全地改数据。类比:只有当柜台上还是我放的那张纸时才替换,不是就重新来。
这里有两个容易记混的细节。第一,ConcurrentHashMap 不允许 null 的 key 和 value,传了直接抛 NullPointerException——因为在并发下无法区分「这个 key 不存在」和「这个 key 的值就是 null」。第二,size() 和 isEmpty() 是近似值:它用 CounterCell 分段累加计数,避免所有线程挤在一个计数器上,所以拿到的是「够用就行」的估值,不是精确值。
// 高并发写:key 打散到不同桶,写互不阻塞,读不加锁
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
for (int i = 0; i < 10; i++) {
int idx = i;
new Thread(() -> map.put("key-" + idx, idx)).start();
}
可能有人会问:ConcurrentHashMap 的 key 和 value 为什么不能为 null?
因为并发下没法靠「查不到」和「查到的值是 null」做区分——HashMap 单线程时 get 返回 null 还能判断 key 不存在,ConcurrentHashMap 多线程时这个判断不可靠,所以直接禁掉 null,从根上消除歧义。

六、到底怎么选
并发集合没有银弹,读多写少选 CopyOnWrite,读写都并发选 ConcurrentHashMap。 别一听说并发就无脑上 ConcurrentHashMap,选型前先回答两个问题:并发程度高不高?读写比例什么样?
| 场景 | 选什么 | 读 | 写 |
|---|---|---|---|
| 读多写少、List | CopyOnWriteArrayList | 无锁 O(1) | 拷全表 O(n) |
| 读多写少、小 Set | CopyOnWriteArraySet | 无锁 | 拷全表 + 去重 |
| 读写都并发、Map | ConcurrentHashMap | 无锁 | 桶级锁 / CAS |
| 基本不并发 | 普通 ArrayList / HashMap | 最快 | 最快 |
说到底,Java并发集合的设计哲学就一句话:能不加锁就不加锁,必须加锁就只锁最小的范围。 CopyOnWrite 用「复制代替锁」换读性能,ConcurrentHashMap 用「桶级锁 + CAS」换写并行度,方向不同,但都是为了并发下的正确和高效。
本文基于 JDK 8+ 实现讲解,2026 年 8 月整理,会随 JDK 版本同步修订。下一篇我准备聊聊 HashMap 扩容到底发生了什么,以及并发队列怎么选,感兴趣可以先关注。
我是晚安code,持续分享编程干货。觉得有用的话记得点赞收藏和关注~也欢迎在评论区聊聊:你项目里的 Java并发集合都是怎么选的?踩过 HashMap 丢数据的坑吗?