Java并发集合详解:HashMap与ConcurrentHashMap区别及应用
时间:2026-08-21 | 作者:星河游者 | 阅读:0大家好,我是晚安code。
不妨先想一个很常见的场景:接口数据总是莫名其妙地丢,顺着链路查了半天,最后才发现是多个线程同时对 HashMap 执行 put——结果很扎心,缓存非但没越积越多,反而越写越少。只要写过并发,这种坑大概率都不陌生。接下来这篇 Ja va 并发集合详解,会先从 util 里的集合为什么在线程环境下不安全说起,再把 HashMap 的加载因子和初始容量掰开讲透,最后把 CopyOnWriteArrayList、ConcurrentHashMap 的底层原理一层层拆明白。整篇看完,并发场景下集合到底该怎么选,思路也就清楚了。
一、并发下,普通的集合写法是安全的吗?
并发下拿 HashMap、ArrayList 直接写,数据丢了不是运气问题,是结构必然。 这个结论我交了学费才懂。先用最经典的 demo 复现,10 个线程同时往一个 ArrayList 里塞数据:
// 10 个线程同时往同一个 ArrayList 里 addList 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 打满的死循环,进程直接卡死——这个梗面试官都爱问。
二、Ja va 自带的线程安全方案为什么不够
同步容器是把整条马路封起来让车一辆一辆过,安全但慢,复合操作还照样不安全。 很多人第一反应是用 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 内部就是 HashMapprivate 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,红黑树基本是个保险栓。
加载因子(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 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 map = new ConcurrentHashMap<>();for (int i = 0; i < 10; i ) { int idx = i;new Thread(() -> map.put("key-" idx, idx)).start();} 六、到底怎么选
并发集合没有银弹,读多写少选 CopyOnWrite,读写都并发选 ConcurrentHashMap。 别一听说并发就无脑上 ConcurrentHashMap,选型前先回答两个问题:并发程度高不高?读写比例什么样?
| 场景 | 选什么 | 读 | 写 |
|---|---|---|---|
| 读多写少、List | CopyOnWriteArrayList | 无锁 O(1) | 拷全表 O(n) |
| 读多写少、小 Set | CopyOnWriteArraySet | 无锁 | 拷全表 去重 |
| 读写都并发、Map | ConcurrentHashMap | 无锁 | 桶级锁 / CAS |
| 基本不并发 | 普通 ArrayList / HashMap | 最快 | 最快 |
说到底,Ja va并发集合的设计哲学就一句话:能不加锁就不加锁,必须加锁就只锁最小的范围。 CopyOnWrite 用「复制代替锁」换读性能,ConcurrentHashMap 用「桶级锁 CAS」换写并行度,方向不同,但都是为了并发下的正确和高效。
本文基于 JDK 8 实现讲解,2026 年 8 月整理,会随 JDK 版本同步修订。下一篇我准备聊聊 HashMap 扩容到底发生了什么,以及并发队列怎么选,感兴趣可以先关注。
我是晚安code,持续分享编程干货。觉得有用的话记得点赞收藏和关注~也欢迎在评论区聊聊:你项目里的 Ja va并发集合都是怎么选的?踩过 HashMap 丢数据的坑吗?
免责声明:文中图文均来自网络,如有侵权请联系删除,心愿游戏发布此文仅为传递信息,不代表心愿游戏认同其观点或证实其描述。
相关文章
更多-
- 迅捷路由器怎么调信号最强,设置时要注意什么?
- 时间:2026-08-27
-
- vivo浏览器怎么卸不掉?原因和解决方法在这里
- 时间:2026-08-27
-
- OPPO R11s黑屏了,怎么强制恢复出厂设置?
- 时间:2026-08-27
-
- 飞利浦显示器包装盒有生产日期和保修期吗?怎么看?
- 时间:2026-08-27
-
- 联想新平板开机必须联网吗?怎么做?
- 时间:2026-08-27
-
- 平板横竖屏切换设置与问题解决
- 时间:2026-08-27
-
- 移动电源容量怎么测?要准备哪些工具?
- 时间:2026-08-27
-
- 荣耀90 Pro防水吗?防水级别多少?怎么用才安全
- 时间:2026-08-27
精选合集
更多大家都在玩
大家都在看
更多-
- 糖尿病完全不能吃糖吗
- 时间:2026-09-15
-
- 蚂蚁庄园小课堂2026年9月16日最新题目答案
- 时间:2026-09-15
-
- 小鸡答题今天的答案是什么2026年9月16日
- 时间:2026-09-15
-
- 蚂蚁庄园每日答题答案2026年9月16日
- 时间:2026-09-15
-
- 以下哪种粮食是酿造绍兴黄酒的主要原料 蚂蚁庄园今日答案9月16日
- 时间:2026-09-15
-
- 劝学名句“及时当勉励,岁月不待人”出自哪位诗人 蚂蚁庄园今日答案9.16
- 时间:2026-09-15
-
- 蚂蚁庄园今天答题答案2026年9月16日
- 时间:2026-09-15
-
- 蚂蚁庄园答题今日答案2026年9月16日
- 时间:2026-09-15





