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 丢数据的坑吗?

目录
相关文章
|
1月前
|
人工智能 JSON 物联网
8G 显存可用|ComfyUI+Qwen AI 漫剧全流程自动化搭建教程(含工作流 & 源码)
本文提出基于Qwen大模型与ComfyUI的本地全链路AI漫剧自动化流水线,专为8GB显存笔记本优化:FP8量化、显存调度、IP-Adapter角色锁定,实现“脚本生成→分镜渲染→动态化→成片合成”全流程本地部署,配套完整工程资源与落地指南。(239字)
|
1月前
|
移动开发 小程序 前端开发
干货分享:微信生态内实现多渠道支付,跨生态唤起支付宝支付技术方案与资金结算架构思考
大量私域、小程序、公众号 H5 平台扎根于微信生态开展经营。很多平台出于用户习惯、经营需求,希望同时支持微信支付与支付宝两种收款渠道。但开发者普遍会遇到一个核心技术壁垒:微信容器环境存在生态隔离策略,无法直接唤起支付宝完成交易。 不少团队盲目尝试各类跳转方案,不仅支付转化率不稳定,还面临域名封禁、账号风控等风险;更易被忽略的是:多渠道收款之后,跨通道资金统一分账、合规清算的难题。本文从底层限制、可行技术方案、风险点,再延伸到多支付渠道下的资金架构选型,完整拆解落地思路,适合平台技术负责人、后端架构师参考。
384 1
|
1月前
|
XML Java Apache
【2026实测】Maven下载、安装、配置一篇搞定(附官网安装包)
Maven是Apache开源的Java项目构建与依赖管理工具,以“约定优于配置”为核心,通过pom.xml自动下载、管理依赖并一键完成编译、测试、打包等流程。生态成熟、上手简单,是Java开发者的标配。
|
13天前
|
存储 人工智能 并行计算
新手小白如何购买阿里云GPU云服务器?新版阿里云GPU云服务器产品功能介绍及配置价格表
随着大模型推理、模型微调、AIGC生成、科学仿真、3D渲染等业务快速普及,越来越多个人开发者、学生、小型研发团队开始接触云端GPU算力。对于刚刚接触GPU云服务器的新手来说,面对繁多的实例规格族、不同GPU芯片型号、显存参数、多种计费模式,很容易踩坑。很多用户会出现显存选小跑不通模型,盲目选购高端实例造成高额账单,忽略GPU驱动安装、可用区库存限制、安全组端口配置等关键环节,购买完成之后实例无法正常识别显卡,甚至产生意料之外的高额扣费。
114 7
|
12天前
|
缓存 API 开发者
DeepSeek Flash 系列降价落地:缓存输入 0.02 元、最高降幅 60%
DeepSeek Flash 系列降价 9/10 中午生效:缓存输入降至 0.02 元、最高降 60%,v4-flash 与 vision-exp 同步调价。
423 0
DeepSeek Flash 系列降价落地:缓存输入 0.02 元、最高降幅 60%
|
1月前
|
存储 算法 Java
Java四大函数式接口一篇讲透:配上Stream流式计算处理集合
Java四大函数式接口是lambda表达式落地的核心,配合Stream流式计算,集合过滤、排序、映射能一行写完。从源码签名讲到链式实战,新手也能看懂。
103 3
Java四大函数式接口一篇讲透:配上Stream流式计算处理集合
|
1月前
|
缓存 安全 Java
Java 并发编程必会的 JUC 辅助类:从 Callable 到阻塞队列
Java 并发编程的 Callable、CountDownLatch、Semaphore、读写锁与阻塞队列,一篇用场景和代码讲透,附四组 API 速查表。
196 2
Java 并发编程必会的 JUC 辅助类:从 Callable 到阻塞队列
|
25天前
|
缓存 监控 供应链
1688 跨境电商 API 接口实战指南:从寻源到代采的全链路技术方案
1688是超60万家工厂的“数字底座”,其开放平台为跨境电商提供商品、供应商及交易数据API。通过`alibaba.product.get`等核心接口,可实现程序化寻源、阶梯价比价、一键代采与库存监控,构建高效闭环供应链。
|
1月前
|
消息中间件 设计模式 算法
责任链模式是什么?适用场景 + 实战示例,告别 if-else 泥潭
责任链模式是什么?本文用请假审批的 Java 实战讲透责任链模式,手把手把 if-else 重构出责任链,并说清它的适用场景与坑。
98 1
责任链模式是什么?适用场景 + 实战示例,告别 if-else 泥潭