第 8 集:ConcurrentHashMap 是线程安全的,计数为什么还会丢?

发布时间:2026/11/2 1:55:33
第 8 集:ConcurrentHashMap 是线程安全的,计数为什么还会丢? 资深 Java 面试到 Agent 开发工程师 · 第 8 集多个线程共同修改一个 Map 时我们通常会想到ConcurrentHashMap。它是线程安全的但下面这次“加一”仍可能算错intoldmap.get(count);map.put(count,old1);如果get、put都安全为什么放在一起就出了问题遇到这种代码是继续加锁还是换一种写法这里讨论的是多个线程使用同一个集合。例如同一个 Service 对象的成员变量可能被多个请求线程访问。若每次调用都在方法里新建一个HashMap并且只在当前线程使用就没有这种共享修改的问题。1. 两个线程各加一次为什么结果是 1先创建一个 Map把计数设为 0。初始化在两个线程开始工作之前完成ConcurrentHashMapString,IntegermapnewConcurrentHashMap();map.put(count,0);然后线程甲和线程乙各执行一次开头的“读取、加一、写回”。它们可能按这个顺序执行线程甲读到 0存在自己的 old 变量里 线程乙也读到 0存在自己的 old 变量里 线程甲计算 0 1把 1 写入 Map 线程乙计算 0 1也把 1 写入 Map最后的值是 1。线程乙写入时Map 里已经是 1但它不会重新读取它用的仍是之前保存在自己变量里的 0。put(count, 1)的意思是“把值设为 1”。Map 没有收到“再加一次”的要求当然也就不会替线程乙把 1 改成 2。ConcurrentHashMap能处理多个线程对它的并发访问。get返回以后其他线程仍可以修改同一个 key这次读取不会替后面的put提前锁住它。前面学过的count也有读、改、写三个步骤。即使每个方法调用都安全把它们拼起来仍不一定是一次不可分割的操作。这种由多个步骤组成的操作叫复合操作。2. 让 Map 一次完成“读取、加一、写回”计数可以直接写成map.merge(count,1,Integer::sum);三个参数分别是要更新的 key、本次增加的 1、怎样合并旧值与这个 1。Integer::sum是求和函数的简写相当于map.merge(count,1,(oldValue,increment)-oldValueincrement);key 不存在时merge放入 1已经存在时用当前值加 1再保存结果。对ConcurrentHashMap而言这一整次更新是原子的。计数原来是 0两个线程各调用一次merge先执行的把 0 改成 1后执行的接着把 1 改成 2。它们不会同时拿着旧的 0各自覆盖成 1。如果需要的操作没有现成方法也可以自己加锁。例如下面是一个共享对象里的字段和方法privatefinalObjectlocknewObject();privatefinalMapString,IntegermapnewHashMap();publicvoidincrement(){synchronized(lock){intoldmap.getOrDefault(count,0);map.put(count,old1);}}锁包住整个读取和写回其他调用increment()的线程要等这次加一结束。这种写法使用普通HashMap就可以读取、删除等其他访问也必须使用同一把锁字段不能直接暴露给外部随意操作。仅仅在一处写synchronized (map)并不能让所有直接调用ConcurrentHashMap.put()的线程跟着等。后者没有使用你加的这把外部锁。3. merge 内部怎样保护这次更新Map 内部需要先找到 key 对应的位置。哈希表数组中的一个位置通常称为“桶”同一桶可能放着多个不同 key 的节点每个节点保存 key 和 value。OpenJDK 21 的merge在处理已有链表节点时会锁住桶头节点然后计算新值并写回。下面是该分支的删节查找和其他分支用注释省略synchronized(f){// 复检桶头再沿链表找到匹配的节点 e// ...valremappingFunction.apply(e.val,value);if(val!null)e.valval;// ... 返回 null 时删除映射等分支}f是桶头节点e.val是找到的旧值value是传入的 1。remappingFunction就是刚才的求和函数计算结果暂存在val中再写回节点。计算和写回都在同一个synchronized内。同一桶中也需要获取这把锁的更新会等当前线程退出后一个线程进入后使用更新后的值继续计算。不同 key 如果落在同一个桶也可能互相等待。空桶还没有节点可以锁插入路径会用 CAS 尝试放入新节点。CAS 会检查位置是否仍为空只有检查成立才写入若被其他线程抢先占用就重新检查。这和第 3 集讲的“比较后更新”是同一种思路。这也带来了一个实际限制如果合并函数里调用远程接口等待返回期间可能一直持有桶上的锁同桶的其他更新也会被拖住。因此合并函数适合做求和、比较这类短计算不适合长时间等待也不应在里面递归修改这个 Map。读取则走另一条路径。get通常不用获取更新时的锁节点的值是volatile字段节点的发布和读取也有相应的内存可见性保证。读取得到某次更新的值与把这个值保存到局部变量后继续计算是两件事后者仍然可能拿着旧值工作这正是第一节的问题。4. Map 里的 ArrayList也一起变安全了吗前面的值是Integer。现在把值换成一个可以修改的列表就多了一种需要检查的操作ConcurrentHashMapString,ListStringmapnewConcurrentHashMap();map.put(names,newArrayList());// 在线程开始前初始化两个线程随后都执行ListStringnamesmap.get(names);names.add(A);第一行让 Map 返回列表的引用第二行调用的是这个列表的add。此时 Map 的get已经结束两个线程拿到的是同一个普通ArrayList可能同时修改它的数组和元素个数。两个add可能把元素写到同一个位置最终少存一个元素。所以Map 能安全地保存、返回这个引用却不会给列表之后的每次操作自动加锁。如果要继续使用ArrayList可以让所有访问同一个列表的代码都使用这个列表作为锁ListStringnamesmap.get(names);synchronized(names){names.add(A);}读取、删除、遍历也要遵守同一规则。这里假定初始化后的列表不会被替换或从 Map 移除否则还需要处理“一个线程继续操作旧列表另一个线程已经换了新列表”的情况。项目里常把查找和创建写在一行map.computeIfAbsent(names,key-newArrayList()).add(A);computeIfAbsent表示“有这个 key 就返回值没有才计算并放入一个值”。它保证查找或建立映射这一步的原子性末尾的add仍然是另一次调用。把代码缩成一行并没有把这两步合成一个操作。5. 列表一边遍历一边修改怎么办前一节用锁保护列表。遍历也放在同一把锁里时写入者需要等遍历结束。若列表经常被遍历很少修改而且一次遍历可以继续使用开始时的内容还可以考虑CopyOnWriteArrayList简称 COW 列表。它把元素保存在数组里。添加元素时写入者复制出一个新数组把新元素放进去再让列表指向新数组已经开始遍历的迭代器继续读旧数组。这个短例子可以直接看出差别CopyOnWriteArrayListStringlistnewCopyOnWriteArrayList();list.add(A);IteratorStringitlist.iterator();list.add(B);it.forEachRemaining(System.out::println);// 只输出 ASystem.out.println(list);// 输出 [A, B]示例使用java.util.Iterator和java.util.concurrent.CopyOnWriteArrayList。迭代器是在添加 B 之前创建的它手里的数组只有 A。列表现在指向的数组有 A、B两边读的不是同一个数组。OpenJDK 21 的add实现如下publicbooleanadd(Ee){synchronized(lock){Object[]esgetArray();intlenes.length;esArrays.copyOf(es,len1);es[len]e;setArray(es);returntrue;}}es是数组e是新元素。Arrays.copyOf创建一个长一格的新数组setArray把它保存到列表的volatile数组引用中。锁让多个写入者依次完成更新避免各自复制旧数组后互相覆盖。迭代器的创建代码是publicIteratorEiterator(){returnnewCOWIteratorE(getArray(),0);}它拿到当前数组引用从下标 0 开始遍历。创建迭代器没有再复制数组后续添加、删除产生的新数组也不会替换它已经保存的引用。因此其他线程修改列表时它不会抛出ConcurrentModificationException但也不会看到新加入的元素。例如一组回调函数经常被依次执行注册和注销很少发生就可能适合这种列表。若某个回调在本轮遍历开始后被注销旧迭代器仍可能执行它业务需要能接受这一点。频繁追加大量数据则会不断复制数组。列表越长每次追加要复制的内容越多。数组复制的还是对象引用元素对象内部的字段仍需单独处理并发读写。常见面试题怎么答HashMap 和 ConcurrentHashMap 有什么区别后者怎样保证线程安全HashMap不保证并发修改安全可以由一个线程独占使用也可以由调用者统一加锁。ConcurrentHashMap在内部处理并发访问并提供merge等原子更新方法。以 OpenJDK 21 的常见路径为例空桶插入用 CAS已有节点的部分更新路径用桶头作为锁读取利用节点发布和volatile等可见性机制通常不获取更新锁。不同 key 可能落在同一桶因此也可能竞争同一把锁。ConcurrentHashMap 能保证 get 后再 put 的原子性吗不能。每次调用之间都有空隙另一个线程可以在这时读取或修改同一个值。计数可以用merge根据旧值计算新值可以用compute。如果自己加锁需要包住完整操作并让所有相关访问遵守同一把锁。这些方法仍有范围一次对一个 key 的更新不会自动保护另一个 key从 Map 取出的普通列表也不会自动获得线程安全。CopyOnWriteArrayList 为什么适合读多写少添加元素时复制并发布新数组迭代器继续遍历自己保存的旧数组不需要获取写入锁。代价是写入需要复制旧迭代器看不到新内容且它不支持remove()调用会抛出UnsupportedOperationException。所以除了读写比例还要看数组大小和业务是否接受旧内容。复制数组不等于复制元素对象元素的可变字段也不因此受到保护。读完自测两个线程调用merge更新计数。另一个线程仍执行get后加一再put最后的计数一定准确吗Map 里的列表从ArrayList换成CopyOnWriteArrayList后if (!list.contains(A)) list.add(A)能保证不出现重复的 A 吗COW 列表已有 A、B创建迭代器后又删除了 B。旧迭代器会看到什么如果要求 B 此后绝不能再被处理只调用remove够吗第 1 题参考不一定。第三个线程仍可能读到旧值随后覆盖其他线程通过merge写入的新值。所有加一操作都要使用能够完整保护这次更新的做法。第 2 题参考不能。两个线程可能都先判断“不包含 A”再各添加一次。contains和add分别安全组合仍不原子COW 列表的addIfAbsent(A)可以完成这次“没有才添加”参与添加的代码应采用一致做法。第 3 题参考旧迭代器仍会看到 A、B它持有的数组没有改变。仅remove不够还需要协调停止处理与正在执行的遍历明确已经开始处理的 B 如何处置。参考资料Java 21ConcurrentHashMapJava 21CopyOnWriteArrayListOpenJDK 21ConcurrentHashMap 源码OpenJDK 21CopyOnWriteArrayList 源码

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询