REJAVA / LEARNING NOTES
ConcurrentHashMap(并发哈希映射)
从统计丢更新出发,理解原子方法的保证边界、JDK 7/8 的实现差别、put 流程与 size 计数。
根据课程材料整理
本次更新说明
- 补充 Redis(内存数据存储)的中文释义。
- 按新版课程重写:增加 JDK 7/8 实现、put 流程与扩容、null 与 size 两个细节。
- 补充代码改错练习、常见追问与应用场景候选。
热身 · 一 保证边界 · 二 原子方法 · 三 仍需自理 · 四 JDK 7 与 8 · 五 put(写入方法) 与扩容 · 六 null(空值) 与 size(数量方法) · 七 检验 · 八 讲出来 · 九 追问 · 十 隔日复测
学完这一课,你能做到
拿到一段用 ConcurrentHashMap(并发哈希映射) 的并发代码,能指出“每一步都线程安全,合起来却出错”的位置,用一次原子方法改对;并能脱稿讲清 JDK 7 与 JDK 8 的实现差别和 put(写入方法) 流程。
和前几课的联系: i++ 丢更新是第3课的内容,桶锁用的是第4课的 synchronized(内置同步关键字),前面线程池里多个任务同时改一张表,就是本课的场景。
主读: Java 21 ConcurrentHashMap(并发哈希映射) 官方文档。读开头的类说明,再查 merge(原子合并方法)、computeIfAbsent(缺失时计算的方法)、mappingCount(映射数量方法) 三个方法。第四、五节讲内部实现,以源码里的实现注释为准。
热身:先回忆前几课(3 分钟)
先凭记忆回答,再展开核对。答不上来就记一句,接着往下学,这不是进入新课的门槛。
第5课:corePoolSize(核心线程数) 是 4,池里现在有 3 个工作线程,全都空闲。新任务来了,线程池做什么?
新建第 4 个工作线程来执行它。判断时比较的是 线程总数 和核心数,不看有没有线程空闲。官方原文:“even if other worker threads are idle”。对照第5课接纳流程。
第3课:volatile int count,两个线程各执行 1000 次 count++,结果一定是 2000 吗?
不一定。count++ 是读、改、写三步,volatile(可见性关键字) 只保证可见性,不能把三步合成一步,所以会丢更新。本课第一节就是这个问题换成表里的一个值。
第4课:synchronized(内置同步关键字) 修饰实例方法时,锁的是哪个对象?代码块 synchronized (x) 呢?
实例方法锁当前对象 this(当前实例);代码块锁括号里的对象 x。也就是说,任何对象都能当锁。本课第四节会用到这一点:JDK 8 直接拿桶里的第一个节点当锁。
一、它保证什么:单次调用是原子的
ConcurrentHashMap(并发哈希映射) 是线程安全的,用它写的统计代码在多线程下一定对吗?
场景:系统按部门统计已完成的审批数。key(键) 是部门编号,value(值) 是数量。每完成一项审批,就执行下面两行。
Integer old = counts.get("dept-A"); // 读
counts.put("dept-A", old + 1); // 写
验证:示例程序让两个线程都先读完再写,在 JDK 17/21 上稳定输出“分步更新=1”(从 0 开始,应为 2)。
部门A 现在是 5。线程A 和线程B 各完成一项审批,各执行一次这两行。正确结果是 7。但下面这种顺序完全可能发生:
| 顺序 | 发生的事 | 表里的值 |
|---|---|---|
| 1 | 线程A 读到 5 | 5 |
| 2 | 线程B 也读到 5 | 5 |
| 3 | 线程A 算出 6,写回 | 6 |
| 4 | 线程B 也算出 6,写回,盖掉了A的结果 | 6 |
每一步单独看都没错:get(读取方法) 确实读到了 5,put(写入方法) 确实写进了 6。ConcurrentHashMap(并发哈希映射) 保证的是 每一次方法调用 完整地执行,多个线程同时调用也不会把表弄坏。它管不到两次调用之间的空隙。
“读、加一、写回”在业务上是一件事,在代码里却是两次调用。这种由几步拼成的操作叫 composite operation(组合操作)。两次修改只留下一次效果,叫 lost update(更新丢失)。这和热身里的 count++ 是同一个问题。
给 value(值) 加 volatile(可见性关键字) 解决不了,原因和第3课一样:volatile(可见性关键字) 只管可见,不管合并步骤。用 synchronized(内置同步关键字) 把两行包起来能算对,但前提是所有修改这张表的地方都用同一把锁;而且这样一来所有更新都要排队,ConcurrentHashMap(并发哈希映射) 的并发能力就用不上了。所以更好的办法是用它自带的原子方法[JG],见下一节。
先自己说一遍,再看参考回答
ConcurrentHashMap(并发哈希映射) 的线程安全,指的是单个方法调用是原子的,多个线程同时读写不会破坏内部结构。但 get(读取方法) 之后再 put(写入方法) 是两次调用。两个线程可能读到同一个旧值,后写的会覆盖先写的,造成更新丢失。正确做法是把读和写合并到一次调用里,比如用 merge(原子合并方法)。
二、怎么改:四个原子方法
这段统计应该怎么改?ConcurrentHashMap(并发哈希映射) 还提供了哪些类似的方法?
计数的意思是“这个部门再加 1”,并不需要先把旧值取出来。把两行换成一次 merge(原子合并方法) 调用就行:
counts.merge("dept-A", 1, Integer::sum);
验证:没有 dept-A 时调用一次,结果为 1;原值为 5 时调用一次,结果为 6。示例程序中两线程各调用一次,从 0 稳定得到 2。
merge(原子合并方法) 有三个参数:要改哪个 key(键);本次的增量 1;以及旧值和增量怎么合并。Integer::sum 是方法引用,等价于 (oldCount, added) -> oldCount + added。表里还没有这个 key(键) 时,merge(原子合并方法) 直接放入 1;已经有值时,就用这个函数算出新值再写回。读旧值、计算、写新值都在这一次调用里完成,对同一个 key(键),另一个线程插不进来[2]。
同一类方法一共有四个,区别在于“什么时候算”和“返回什么”:
| 方法 | 适合什么情况 | 返回什么 |
|---|---|---|
putIfAbsent(k, v) | 值已经准备好,只在 key(键) 不存在时放入 | 原来的值;原来没有就返回 null(空值) |
computeIfAbsent(k, f) | key(键) 不存在时才创建值,比如新建一个列表 | 表里现在的值,可能是已有的,也可能是刚算出的 |
compute(k, f) | 根据当前值(可能为 null(空值))计算新值,分支较多时用 | 新值 |
merge(k, v, f) | 累加、拼接这类“合并”操作 | 新值 |
putIfAbsent(不存在才放入的方法) 和 computeIfAbsent(缺失时计算的方法) 的区别 在于值什么时候造出来。putIfAbsent(不存在才放入的方法) 的值要先造好再传进去,即使最后没放入,造值的开销也已经花掉了。computeIfAbsent(缺失时计算的方法) 只在 key(键) 不存在时才执行函数,所以造值成本高时(新建列表、加载配置)选它。
computeIfAbsent(缺失时计算的方法) 的“只算一次”只限一次调用: key(键) 不存在时函数恰好执行一次,已存在时一次也不执行[2]。函数返回 null(空值) 或抛出异常,这次就不建立记录,下一次调用还会再算;记录后来被删掉,也会重新算。顺带记住:函数算出 null(空值) 时,computeIfAbsent(缺失时计算的方法) 不放入,compute(原子重算方法) 和 merge(原子合并方法) 会把这个 key(键) 删掉。
传进去的函数要短。 官方文档写明,函数执行期间,其他线程对这张表的一些更新可能会被阻塞,所以计算应当“short and simple”;函数里也不能再修改这张表[2]。不要在函数里调用短信、支付这类远程接口:一是慢,会让别的更新跟着等;二是短信一旦发出,后面即使出错也撤不回来。
先自己说一遍,再看参考回答
把复合操作换成一次原子方法。计数用 merge(key, 1, Integer::sum),意思是给这个 key(键) 的值加 1。只在不存在时放入,用 putIfAbsent(不存在才放入的方法) 或 computeIfAbsent(缺失时计算的方法);后者在 key(键) 已存在时根本不会执行创建函数。需要根据当前值走不同分支时,用 compute(原子重算方法)。这些方法对同一个 key(键) 是原子的。传进去的函数要短,不能在里面调远程接口,也不能在里面改这张表。
三、原子方法之外,还要自己管什么
用了 ConcurrentHashMap(并发哈希映射),也用了原子方法,是不是就不用再考虑并发问题了?
不是。它只保证 单个 key(键) 的单次操作 是原子的。超出这个范围的事,它都不管。常见的有四种:
| 情况 | 为什么仍会出错 | 怎么处理 |
|---|---|---|
| value(值) 是 ArrayList(可变数组列表) | computeIfAbsent(k, x -> new ArrayList<>()) 保证的只是“取到或建立列表”。之后调用 add(追加方法),用的是 ArrayList(可变数组列表) 自己的方法,而 ArrayList(可变数组列表) 不是线程安全的[3]。 | 换成线程安全的列表,比如创建时就用 Collections.synchronizedList 包装;或者让所有读写这个列表的地方用同一把锁。 |
| 同时改两个 key(键) | 比如把一项审批从部门A转到部门B,A 减 1,B 加 1。两次调用之间,别的线程可能读到“A 已经减了,B 还没加”的中间状态。 | 如果业务不能接受中间状态,就要另行设计,比如用同一把锁同时保护这两次修改和相关的读取。 |
| 先查数量再插入 | 规定最多 100 条。两个线程都看到 99 条,都决定插入,最后变成 101 条。这是“先检查、再执行”被拆成了两步。而且并发时 size(数量方法) 本身只是估计值(见第六节)。 | 所有可能新增记录的地方,都要走同一套准入控制。 |
| 遍历整张表 | 遍历是一项一项读的。读到后面时,前面可能已经被改过,拿到的结果不是同一时刻的 snapshot(快照)。这种遍历叫 weakly consistent:别人修改时仍能继续,也不会因此抛 ConcurrentModificationException(并发修改异常)[1]。 | 只需要大致结果时可以直接遍历;需要严格一致的快照,就要另外加锁再复制。 |
可见性方面,它提供了一个保证:一个线程先把对象准备好再 put(写入方法) 进去,另一个线程 get(读取方法) 到这个对象后,能看到 put(写入方法) 之前的准备结果。这是 put(写入方法) 与读到这个值的 get(读取方法) 之间的 happens-before(先行发生关系)[1]。但对象放进表以后,如果再修改它的普通字段,这个保证就不覆盖了。
先自己说一遍,再看参考回答
不是。ConcurrentHashMap(并发哈希映射) 只保证单个 key(键) 的单次操作是原子的,并且 put(写入方法) 之前写入的数据,对 get(读取方法) 到这个值的线程可见。以下几件事要自己处理:value(值) 是 ArrayList(可变数组列表) 这类可变对象时,对它的修改;需要一起生效的多个 key(键) 的修改;先查 size(数量方法) 再插入这种“先检查再执行”;还有遍历结果不是同一时刻的快照。
四、内部实现:从 JDK 7 分段锁到 JDK 8 桶锁
ConcurrentHashMap(并发哈希映射) 是怎么保证线程安全的?JDK 7 和 JDK 8 有什么不同?
先看最早的 Hashtable(旧式同步哈希表)。它的方法基本都用 synchronized(内置同步关键字) 修饰,整张表只有一把锁。一个线程在 put(写入方法) 时,其他线程连 get(读取方法) 都得等[JG]。
JDK 7 的做法是把一张大表切成若干段,每段叫一个 Segment(分段锁),每段配一把锁。Segment(分段锁) 继承了 ReentrantLock(可重入锁),段内是数组加链表。修改数据前先锁住所在的段,写不同段的线程互不影响。段数在创建时就固定了,默认 16,所以写并发度最多大约是 16[JG]。
JDK 8 以后 去掉了分段,结构和 HashMap(普通哈希映射) 一样:一个数组,每个位置是一个 bin(桶),桶里是链表,链表太长时转成 red-black tree(红黑树)。并发控制分三种情况[5]:
- 往空桶放第一个节点: 用 CAS(比较并交换) 直接放入,不加锁。官方注释说这是 put(写入方法) 最常见的情况。
- 桶里已经有节点: 用 synchronized(内置同步关键字) 锁住这个桶的 第一个节点 ,只锁这一个桶。锁住后还要再确认它仍是第一个节点,不是就重试。
- 读: get(读取方法) 不加锁,靠 volatile(可见性关键字) 读拿到最新的节点。
锁的范围从“一段”缩小到“一个桶”,不同桶上的更新通常可以同时进行。但两个不同的 key(键) 如果落在同一个桶里,还是会互相等待。官方注释承认这是按桶加锁的缺点:比如 equals 或传入的函数很慢时,同桶的其他更新会被拖住;不过哈希分布随机时,这种情况不常见[5]。
为什么用 synchronized(内置同步关键字) 而不是 ReentrantLock(可重入锁)? 官方注释给出的理由是:不想为每个桶单独配一个锁对象,那样太占内存,所以直接拿桶的第一个节点当锁[5]。热身里说过,synchronized(内置同步关键字) 可以锁任意对象,正好满足这个需求。另外,JDK 6 以后 synchronized(内置同步关键字) 做了大量优化,性能已经不是问题[JG]。
| 实现 | 结构 | 锁的范围 |
|---|---|---|
| Hashtable(旧式同步哈希表) | 数组 + 链表 | 整张表一把锁,读写都要排队 |
| Collections.synchronizedMap(同步映射包装方法) | 包装一个普通 Map(映射) | 整张表一把锁,遍历时要手动加锁[4] |
| ConcurrentHashMap(JDK 7) | Segment(分段锁) 数组,每段里是数组 + 链表 | 每段一把 ReentrantLock(可重入锁),默认 16 段 |
| ConcurrentHashMap(JDK 8+) | Node 数组 + 链表/红黑树 | 空桶用 CAS(比较并交换);非空桶锁桶的第一个节点;读不加锁 |
Java 21 的源码里还能看到一个 Segment(分段锁) 类,但它只在序列化时用到,是为了兼容旧版本。构造方法里的 concurrencyLevel 参数也只是保留下来,不再决定锁的数量[5]。
先自己说一遍,再看参考回答
JDK 7 用分段锁:表被分成若干个 Segment(分段锁),每个 Segment(分段锁) 继承 ReentrantLock(可重入锁),默认 16 段,不同段可以并发写。JDK 8 去掉了分段,结构变成 Node 数组加链表或红黑树。往空桶插入用 CAS(比较并交换),不加锁;桶里已有节点时,用 synchronized(内置同步关键字) 锁住桶的头节点;读操作不加锁,靠 volatile(可见性关键字) 保证可见性。锁的粒度从一段细化到一个桶,并发度更高。
五、put(写入方法) 流程、get(读取方法) 与扩容
说一下 ConcurrentHashMap(并发哈希映射) 的 put(写入方法) 流程。get(读取方法) 和扩容呢?
下面按 Java 21 源码里的 putVal 方法,从上到下讲一遍[6]:
检查 null(空值)。 key(键) 或 value(值) 是 null(空值),直接抛 NullPointerException。
计算 hash(哈希)。 用
spread把 hashCode 的高位也混进来,再算出数组下标。进入循环。 每种情况处理失败就回到循环开头重试:
- 数组还没创建:先建数组。数组是第一次插入时才创建的。
- 目标桶是空的:用 CAS(比较并交换) 放入新节点。成功就结束;失败说明被别的线程抢先了,回到循环开头。
- 桶头是 forwarding node(转发节点):说明正在扩容,先帮忙迁移,再到新数组上重试。
- 其他情况:synchronized(内置同步关键字) 锁住桶头节点,再确认它仍是桶头。如果是链表,就逐个比较:先比 hash(哈希),hash(哈希) 相同再用 equals(相等比较方法)比较 key(键)。两者都相同才是同一个 key(键),这时替换 value(值);比到链尾都没有相同的,就接到链尾。只比 hashCode(哈希码)不够,因为不同的 key(键) 可能算出相同的 hashCode,这就是 hash collision(哈希冲突)。如果是红黑树,就按树的方式插入。
检查是否转红黑树。 链表长度达到 8 时调用
treeifyBin。但如果数组长度还不到 64,会先扩容,而不是转树。计数加一。 调用
addCount,同时检查是否需要扩容。
// 简化自 Java 21 putVal,省略了部分分支
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
for (Node[] tab = table;;) {
if (tab == null) tab = initTable();
else if (桶 i 为空) {
if (casTabAt(tab, i, null, 新节点)) break; // 空桶:CAS,不加锁
}
else if (桶头.hash == MOVED) tab = helpTransfer(tab, 桶头); // 正在扩容:先帮忙
else synchronized (桶头) { /* 链表:hash 相同且 equals 才算同一个 key,替换;否则接到链尾。红黑树:按树插入 */ }
}
addCount(1L, binCount); // 计数 + 检查扩容
验证:逐行对照本机 JDK 21 源码摘录末尾的 putVal 原文。中文部分是说明,不能编译。
为什么是 8 才转树? 官方注释给了一张概率表:哈希分布随机时,一个桶里有 8 个节点的概率大约只有一亿分之六。所以链表长到 8,多半说明哈希分布有问题,这时才用红黑树兜底[5]。记住“正常情况几乎碰不到,这是兜底手段”就够了,不用背概率值。
get(读取方法) 不加锁:先定位到桶,比较桶头;桶头是特殊节点(转发节点或红黑树桶)时,交给它自己去查找;否则沿着链表往下找。读数组时用的是 volatile(可见性关键字) 读[5]。
扩容(resize(扩容)) :元素数超过容量的 0.75 倍时,数组扩成原来的两倍。其他线程发现正在扩容时,会领取一段桶来帮忙迁移,不是干等着。一个桶迁移完后,旧位置会放一个 forwarding node(转发节点)。读写线程遇到它,就转到新数组继续操作。所以扩容进行到一半时照样能读:没迁移的桶在旧数组里读,已迁移的桶跟着转发节点去新数组读[5]。
先自己说一遍,再看参考回答
put(写入方法) 时先检查 key(键) 和 value(值) 不能为 null(空值),再计算 hash(哈希),然后进入循环:数组没初始化就先初始化;目标桶为空就用 CAS(比较并交换) 放入,失败就重试;遇到转发节点说明正在扩容,当前线程先帮忙迁移;其他情况用 synchronized(内置同步关键字) 锁住桶头,在链表里先比 hash(哈希)、再用 equals 比 key(键),相同就替换,没有就接到链尾;红黑树则按树的方式插入。链表长度达到 8 会尝试转成红黑树,但数组长度小于 64 时会优先扩容。最后计数加一,并检查是否需要扩容。get(读取方法) 全程不加锁,靠 volatile(可见性关键字) 读。扩容时多个线程可以分段协助迁移。
六、两个细节:为什么不许 null(空值),size(数量方法) 准不准
HashMap(普通哈希映射) 允许 null(空值),ConcurrentHashMap(并发哈希映射) 为什么不允许?它的 size(数量方法) 准确吗?
不许 null(空值):避免歧义
如果允许 null(空值),get(读取方法) 返回 null(空值) 时含义就不明确:可能是没有这个 key(键),也可能是这个 key(键) 的值本来就是 null(空值)。用 HashMap(普通哈希映射) 时,单线程下可以再调一次 containsKey 来区分。但在并发环境下,两次调用之间别的线程可能已经改了表,containsKey 的结果对不上刚才那次 get(读取方法)。
ConcurrentHashMap(并发哈希映射) 的作者 Doug Lea 的原话是:在非并发的 Map(映射) 里勉强能忍受的歧义,在并发的 Map(映射) 里无法接受[JG]。所以干脆禁止 null(空值)。这样 get(读取方法) 返回 null(空值) 只有一种含义:没有这个 key(键)。如果业务上确实需要表示“空”,可以定义一个专门的空对象来占位,例如 static final Object NONE = new Object();。
size(数量方法):分散计数,并发时是估计值
如果只用一个变量计数,所有线程每次插入都去改它,这个变量就会成为争抢的热点。所以它的计数分成两部分:一个基础计数 baseCount,加一组 CounterCell(计数单元)。没人争抢时,用 CAS(比较并交换) 修改 baseCount;CAS(比较并交换) 失败说明有竞争,线程就改到不同的格子里,互不干扰。官方注释说,这是 LongAdder(分散累加器) 的一种特化实现[5]。
调用 size(数量方法) 时,把 baseCount 和所有格子加起来(sumCount)[6]。加的过程中,其他线程还在增删记录,所以并发时得到的是一个 估计值 。官方推荐用 mappingCount(映射数量方法) 代替 size(数量方法):它返回 long,记录数超过 int 范围时也不会被截断。它同样是估计值[6]。
先自己说一遍,再看参考回答
null(空值): 为了避免歧义。允许 null(空值) 的话,get(读取方法) 返回 null(空值) 就分不清是“没有这个 key(键)”还是“值就是 null(空值)”。单线程的 HashMap(普通哈希映射) 可以再用 containsKey 判断,但并发下两次调用之间表可能已被修改,这个判断就不可靠了。所以直接禁止 null(空值),get(读取方法) 返回 null(空值) 只表示不存在。
size(数量方法): 计数用类似 LongAdder(分散累加器) 的思路:一个 baseCount 加一组 CounterCell(计数单元),没有竞争时 CAS(比较并交换) 改 baseCount,有竞争时分散到不同格子累加。size(数量方法) 把它们加起来返回,并发下是估计值,不能拿它做严格的上限控制。官方更推荐用 mappingCount(映射数量方法),它返回 long。
七、检验:先关掉上面,凭记忆做
5 道题:先回答,再展开核对
- counts 里 dept-A 的值是 3。两个线程同时各执行一次
counts.merge("dept-A", 1, Integer::sum)。结果是?
- A. 一定是五
- B. 可能是四
- C. 可能是三
查看答案与解释
答案:A
merge(原子合并方法) 对同一个 key(键) 是原子的:读旧值、计算、写回在一次调用里完成,两次加 1 都会算进去。
- JDK 8 以后,往一个空桶放入第一个节点时,用的是什么方式?
- A. 锁住整张表放入
- B. 比较并交换放入
- C. 锁所在分段放入
查看答案与解释
答案:B
空桶用 CAS(比较并交换) 直接放入,不加锁;桶里已有节点时,才用 synchronized(内置同步关键字) 锁桶头。“锁分段”是 JDK 7 的做法。
- 某个桶的链表长度达到 8,但数组长度只有 16。接下来会怎样?
- A. 转成红黑树
- B. 直接抛异常
- C. 先扩容数组
查看答案与解释
答案:C
数组长度小于 64 时,treeifyBin 会先扩容,而不是转树。扩容后,节点会分散到更多的桶里。
- 在 ConcurrentHashMap(并发哈希映射) 上调用
get(k)返回了 null(空值),说明什么?
- A. 没有这个键
- B. 值存的是空
- C. 两种都可能
查看答案与解释
答案:A
它不允许存 null(空值) 值,所以 get(读取方法) 返回 null(空值) 只有一种含义:没有这个 key(键)。这正是禁止 null(空值) 的目的。
- 其他线程正在并发插入时,调用
size()得到的是?
- A. 一定精确的
- B. 可能不精确
- C. 总是偏小的
查看答案与解释
答案:B
size(数量方法) 把 baseCount 和各个 CounterCell(计数单元) 加起来,加的过程中别人还在改,所以是估计值。结果可能偏大也可能偏小,没有固定方向。
代码改错:找出所有并发问题
下面的方法登记部门的待办审批,同时统计总待办数,多个线程会同时调用它。写出 ①②③ 各自会丢掉什么,再写出修改后的方法。
ConcurrentHashMap<String, List<String>> todo = new ConcurrentHashMap<>();
ConcurrentHashMap<String, Integer> total = new ConcurrentHashMap<>();
void addTodo(String dept, String approvalId) {
if (!todo.containsKey(dept)) { // ①
todo.put(dept, new ArrayList<>());
}
todo.get(dept).add(approvalId); // ②
Integer n = total.get("all"); // ③
total.put("all", n == null ? 1 : n + 1);
}
验证:这是待改的错误代码,不要直接使用。参考答案在 JDK 17 和 21 上用 8 个线程登记 20 万条,总数与列表条数都正确(2026-10-03 运行)。
写完再展开核对
① 先检查、再放入,被拆成了两步。两个线程可能都看到“没有”,后 put(写入方法) 的线程会用一个新的空列表覆盖先放入的列表,先登记的审批就丢了。改用 computeIfAbsent(缺失时计算的方法)。
② 取到列表之后的 add(追加方法) 是 ArrayList(可变数组列表) 自己的方法,没有任何保护。创建列表时用 Collections.synchronizedList 包装。
③ 和第一节一样,get(读取方法) 之后再 put(写入方法) 会丢失更新。改用 merge(原子合并方法)。
void addTodo(String dept, String approvalId) {
todo.computeIfAbsent(dept, d -> Collections.synchronizedList(new ArrayList<>()))
.add(approvalId);
total.merge("all", 1, Integer::sum);
}
验证:同上,JDK 17/21 各运行一次,20 万次登记无丢失。
加分点: todo 和 total 是两张表、两次独立的修改。别的线程可能看到“列表里已经有了,总数还没加上”。如果业务要求两者严格一致,需要另行设计(第三节表格第二行)。
八、用完整回答检验理解
连续回答下面四问,可以写下来,也可以先口述再整理成文字。写完再对照各节的参考回答。
- ConcurrentHashMap(并发哈希映射) 怎么保证线程安全?JDK 7 和 8 有什么不同?
- 说一下 put(写入方法) 的流程。
- 它是线程安全的,为什么你的统计代码还会算错?怎么改?
- 说一个适合用它的场景,说明为什么,以及要注意哪些限制。
练习:请在自己的笔记中写出答案,再展开参考内容核对。
九、常见追问
每问先自己答,再展开核对。答案都在前面各节里。
关于线程安全边界(第一、三节)
给 value(值) 加 volatile(可见性关键字) 能解决丢更新吗?用 synchronized(内置同步关键字) 包起来呢?
volatile(可见性关键字) 不行,它只保证可见,不能把读和写合成一步。synchronized(内置同步关键字) 能算对,但所有修改这张表的地方都必须用同一把锁,而且所有更新都要排队,用不上 ConcurrentHashMap(并发哈希映射) 的并发能力。优先用原子方法。
遍历时别的线程在改,会抛 ConcurrentModificationException(并发修改异常) 吗?
不会。它的遍历是弱一致的:别人修改时仍能继续,结果可能包含部分修改,但不是同一时刻的快照。HashMap(普通哈希映射) 的遍历才会在检测到并发修改时抛这个异常。
关于原子方法(第二节)
putIfAbsent(不存在才放入的方法) 和 computeIfAbsent(缺失时计算的方法) 有什么区别?
putIfAbsent(不存在才放入的方法) 的值要先造好,即使没放入,造值开销也花掉了,返回原来的值。computeIfAbsent(缺失时计算的方法) 只在 key(键) 不存在时才执行函数,返回表里现在的值,适合造值成本高的情况。
computeIfAbsent(缺失时计算的方法) 能保证每个 key(键) 只算一次吗?
只保证一次调用内恰好算一次。函数返回 null(空值) 或抛异常时不建立记录,下次还会再算;记录被删除后也会重新算。
关于内部实现(第四、五节)
JDK 8 为什么用 synchronized(内置同步关键字),而不是 ReentrantLock(可重入锁)?
不想为每个桶单独配一个锁对象,太占内存,所以直接拿桶的第一个节点当锁,synchronized(内置同步关键字) 能锁任意对象。JDK 6 以后 synchronized(内置同步关键字) 优化很多,性能不是问题。
两个不同的 key(键) 会互相等待吗?
落在同一个桶里就会。equals 或传入的函数很慢时,同桶的其他更新会被拖住;哈希分布随机时这种情况不常见。
为什么链表长到 8 才转红黑树?
哈希分布随机时,一个桶里有 8 个节点的概率极低。长到 8 多半说明哈希分布有问题,红黑树是兜底手段。另外数组长度不到 64 时先扩容,不转树。
扩容进行到一半时,还能读吗?
能。没迁移的桶在旧数组里读;已迁移的桶留下了转发节点,get(读取方法) 遇到它就转去新数组查找。
应用场景:可能用在哪里(候选)
四个候选场景,和两个必须会说的限制
- 字典数据的本地缓存: 行政区划、事项编码、部门编码这类很少变的数据,查询频繁。用
computeIfAbsent(code, c -> 查库)第一次用到时才加载,之后直接读内存。对应第二节:已有记录时不会再执行查库函数。 - 按部门或事项的内存计数: 例如统计各接口的调用次数、各部门当天的受理量,用
merge(key, 1, Integer::sum)。对应第一、二节的丢更新问题。 - 防重复提交: 同一个申请编号正在处理时,用
putIfAbsent(applyNo, 占位)抢占,返回不是 null(空值) 就说明已有请求在处理,直接拒绝;处理完再 remove。对应第二节:返回 null(空值) 才表示这次放进去了。 - 在线连接表: 用户编号对应推送连接,办结时查表推送站内消息。多个线程同时登录、下线、推送,所以用并发映射。
限制一:只在一个进程里有效。 系统部署了多个实例时,每个实例各有一张表:计数各算各的,防重复也只防得住打到同一个实例上的请求。需要多个实例共享时,要用 Redis(内存数据存储) 这类共享存储。
限制二:它没有过期和容量上限。 拿它做缓存,数据只增不减,字典改了也不会自动刷新。要么数据量很小且有刷新机制,要么换成带过期淘汰的本地缓存方案。
十、隔日复测:明天换条件再答(约 5 分钟)
展开题目
- putIfAbsent(不存在才放入的方法) 和 computeIfAbsent(缺失时计算的方法) 的返回值分别是什么?什么时候选后者?
- 表里已有 99 条记录,两个线程各用 putIfAbsent(不存在才放入的方法) 插入一个不同的 key(键)。为什么仍然可能超过 100 条?
- 第5课:corePoolSize(核心线程数) 是 3,池里现在有 2 个工作线程,而且都闲着。新任务来了,线程池会怎么做?
作答后核对
① putIfAbsent(不存在才放入的方法) 返回原来的值,原来没有就返回 null(空值);computeIfAbsent(缺失时计算的方法) 返回表里现在的值。造值成本高时选后者,因为 key(键) 已存在时它不会执行函数。
② 每次插入只管它自己那个 key(键)。“先数总量、再插入”仍然是分开的两步。
③ 总数 2 小于核心数 3,所以 新建 第 3 个工作线程,即使已有线程空闲。比较的是总数,不是空闲数。对照第5课接纳流程。
主来源与引用
[2] 四个原子方法: 同一文档中 putIfAbsent(不存在才放入的方法)、computeIfAbsent(缺失时计算的方法)、compute(原子重算方法)、merge(原子合并方法) 的说明,包括“short and simple”和不得修改本表的要求。摘录在同一文件中部。
[3] ArrayList(可变数组列表) 并发修改: 官方说明要求外部同步,或用 Collections.synchronizedList 包装。摘录在同一文件末尾。
[4] HashMap(普通哈希映射) 与 Collections.synchronizedMap(同步映射包装方法): 同一摘录中的对应章节。
[5] 内部实现: Java 21 源码里的 Implementation overview(实现注释),包括空桶 CAS(比较并交换)、桶头加锁、转发节点、协助扩容、计数方式、Segment(分段锁) 仅用于兼容。摘录在同一文件中部。
[6] put(写入方法) 流程与计数源码: 本机 Temurin 21.0.10 的 putVal、treeifyBin、addCount、sumCount、mappingCount(映射数量方法) 原文。摘录在同一文件末尾(2026-10-03 补录)。
[JG] JavaGuide: 集合面试题(下),用于选题参考、JDK 7 分段锁结构(本机没有 JDK 7 源码可对照)、Hashtable(旧式同步哈希表) 对比,以及 Doug Lea 关于 null(空值) 的原话转引。
本课速查表 · 完整术语表 · 第5课 · 第4课 · 第3课
学完后,记下判断理由和仍不确定的地方。隔日回看时先尝试回答,再核对课程里的解释。