Java并发集合详解:从HashMap到ConcurrentHashMap

简介: Java并发集合必须用JUC才安全,并发写会丢数据。本文拆解HashMap加载因子与ConcurrentHashMap原理,讲透线程安全集合选型。

大家好,我是晚安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.util.ConcurrentModificationException

二、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[]),每个桶挂着一条链表,链表太长会转成红黑树:

HashMap 数据结构:Java并发集合底层的桶数组、链表与红黑树

  • 默认初始容量 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」解放成「等于桶数量」。整体流程看这张图:

ConcurrentHashMap put 流程:Java并发集合的空桶 CAS 写入与锁桶头

走一遍 put 的完整流程:

  1. 计算 hash 定位桶下标
  2. 桶是空的 → 用 CAS 原子写入,不加锁
  3. 桶非空 → synchronized 锁住这个桶的头节点,再写链表或红黑树
  4. 如果桶头是 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,从根上消除歧义。

ConcurrentHashMap 锁的是桶,不是整张表,所以不同桶的线程能并行

六、到底怎么选

并发集合没有银弹,读多写少选 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 丢数据的坑吗?

目录
相关文章
|
4天前
|
存储 弹性计算 缓存
阿里云服务器租赁费用:新版租赁收费标准及活动报价参考
本文更新了2026年阿里云全系列云服务器租赁活动报价,所有特惠资源均可前往阿里云活动中心选购,整体覆盖从个人入门到企业级高性能场景的全梯度需求。其中轻量应用服务器主打极致性价比,2核2G峰值200M带宽配置每日10点、15点限时抢购价仅38元/年,2核4G配置379元/年起;高性价比的经济型e实例、通用算力型u2i实例覆盖2核4G至4核32G全档位,适配开发测试与中小型企业业务;搭载英特尔至强6处理器的第九代c9i企业级实例算力较上代提升20%,支撑高并发生产环境,不同实例规格价差清晰,用户可根据自身业务负载与预算灵活选型。
1528 110
|
11天前
|
云安全 人工智能 运维
阿里云联动百位企业安全专家,共识Agent防御最佳实践
当Agent成为新员工,你的安全边界在哪里?
1936 8
阿里云联动百位企业安全专家,共识Agent防御最佳实践
|
5天前
|
人工智能 程序员 API
Codex 接入 DeepSeek-V4-Flash:还能补上识图,提供两套方案
Codex 接入 DeepSeek-V4-Flash 怎么配?本文覆盖 CLI 与桌面端,再用 qwen3-vl-flash 补识图,两套方案可直接照做
|
5天前
|
编解码 人工智能 安全
2核4G/4核8G/8核16G阿里云服务器如何选择实例?经济型e、通用算力型u2i与计算型c9i选哪个?
本文介绍了阿里云2核4G、4核8G、8核16G三档主流配置下经济型e、通用算力型u2i和计算型c9i三种实例的最新活动价格与适用场景。同配置下三者价差显著,以2核4G为例,经济型e低至599.93元/年,计算型c9i则高达1742.08元/年。文章详细解析了各实例的性能定位:经济型e适合轻负载入门场景,u2i兼顾稳定算力与性价比,c9i凭借第9代至强处理器与芯片级安全能力支撑高性能业务。同时提示用户可叠加满减优惠券享受折上折,建议根据业务负载与预算综合决策。
522 112
|
9天前
|
存储 人工智能 关系型数据库
阿里云AI产品与云产品最新组合套餐:Token Plan、AI coding及云服务器和建站等组合优惠价
阿里云推出全新“算力+模型+应用”一站式云与AI组合套餐活动,覆盖从个人开发者到中大型企业的全场景需求。核心亮点为分三档定价的Token Plan订阅服务,支持Qwen3.8-Max-Preview大模型调用,错峰时段最低可享0.2折优惠。活动同步推出AI Coding、智能体部署、云电脑托管、0代码建站等十余类场景化组合,搭配99元/年的普惠云服务器、88元/年的入门数据库等经典特惠产品,还为企业提供1V1定制化AI转型方案,大幅降低了不同用户群体拥抱AI的技术门槛与采购成本。
713 111
|
17天前
|
人工智能 前端开发 Linux
Codex 桌面版安装 + CC Switch 接入第三方 API 完整教程(2026 最新)
2026最新教程:手把手教你安装Codex桌面版,通过CC Switch v3.17.0一键接入Fenno等国产API(兼容OpenAI Responses格式),跳过账号登录,完整启用代码审查、多步任务与上下文感知功能。零基础友好,全程图文实操。(239字)
2362 3
|
19天前
|
人工智能 JSON 安全
Fastjson远程代码执行漏洞,阿里云AI安全为您保驾护航
阿里云AI安全产品联动防御Fastjson攻击
2619 13
Fastjson远程代码执行漏洞,阿里云AI安全为您保驾护航
|
6天前
Qoder 一周年 × Qwen3.8-Max 正式上线,多重好礼限时领
8月3日,Qwen3.8-Max 正式上线Qoder,迎来Qoder一周年。新老用户可领800次免费调用,下单再赠2000次;夜间(22:00–08:00)调用5折;邀请好友双方得积分与调用额度。
398 0
|
5天前
|
人工智能 JSON Shell
2026AI漫剧本地全开源方案(附各个软件模型链接),8G显卡也能流畅运行
这是一套完全本地化部署的AI漫剧生成技术链路:涵盖LLM剧本分镜生成、FLUX文生图(IP-Adapter人脸锁定)、StoryDiffusion时序连贯控制、LTX-2.3唇形同步视频生成,及ComfyUI全流程调度。零云端费用,仅耗硬件算力,单集2–4小时可产出竖屏短视频,适配抖音/B站分发。