ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ConcurrentHashMap并发读写报null?从源码到实战的避坑指南

ConcurrentHashMap并发读写报null?从源码到实战的避坑指南 这几年凡是教人做并发编程的几乎都绕不开 ConcurrentHashMap。最近那个热搜词ConcurrentHashMap 同时读写 报 null我也刷到了评论区不少人直接开喷说这玩意儿在并发读写时会莫名返回 null稳定性不如 Hashtable。作为看过源码、也真在线上因为 null 排查过几轮的老开发我得说一句这口锅大半是扣歪了。ConcurrentHashMap 确实和 null 纠缠得很深但它报 null 的姿势、原因、以及你的业务该怎么处理 null和大部分人想象的不一样。这篇我准备把 ConcurrentHashMap 从设计初衷、锁粒度演进、源码关键路径一直讲到弱一致性契约和线上误用争取让你看完之后不光会背八股文还能在真实项目里把它用得明明白白。1. 先回答热搜并发读写时出现的 null 到底从哪来1.1 最常见的报 null其实是 HashMap 的并发读写我在排查一线问题的时候发现一个很有意思的现象只要报障信息里写着多线程读写 Map 出现 null一半以上用的根本是 HashMap不是 ConcurrentHashMap。这算是一种历史惯性——大家觉得Map 就是拿来随便读写的却忽略了一个关键前提HashMap 从诞生起就没有承诺任何线程安全性。HashMap 在并发读写下会出现几种极端现象多个线程同时 put后写的值覆盖先写的值业务上表现为莫名其妙丢数据两个线程同时触发扩容数组复制时互相踩踏链表结构被破坏读取线程遍历到一个损坏的链某个节点的 next 或者 value 变成了 nullJDK7 时代更狠resize 用了头插法并发扩容时可能形成环形链表get 一个不存在的 key 能把你 CPU 跑到 100%。这些场景里读取线程拿到的 null 并不是这个 key 真的对应 null而是 HashMap 内部数据结构在并发写的过程中被撕裂了。你拿 ConcurrentHashMap 去替换后问题立刻消失于是很多人就得出ConcurrentHashMap 在并发读写时不报 null的结论。但准确的说法应该是HashMap 的并发损坏会导致奇怪的 null而 ConcurrentHashMap 不会因为并发损坏而给你制造假的 null。1.2 ConcurrentHashMap 自己也会抛 NPE但和并发没有关系ConcurrentHashMap 从设计上就禁止 null key 和 null value。你往里面 put 一个 null它直接抛出NullPointerException。ConcurrentHashMapString, String map new ConcurrentHashMap(); map.put(key, null); // 抛 NullPointerException map.put(null, value); // 也抛 NullPointerException这个 NPE 和并发读写完全无关它就是一道防御性校验。真正常见的问题是你的业务代码在某个地方从外部接口拿到一个可能为 null 的对象然后顺手往 map 里塞结果一跑就炸。这种问题的正确解法是提前判空、给默认值、或者用 Optional 包装而不是换容器。1.3 get 返回 null 有两种含义别把没找到当成值没了因为不允许 null value所以 ConcurrentHashMap 里get返回 null 只能代表一件事当前这个 key 不存在。这个语义本身很干净。但很多业务代码写得不清不楚String v map.get(key); if (v null) { // 这里到底是 key 不存在还是 value 真的为空 // 在 ConcurrentHashMap 里没有第二种可能所以可以放心按不存在处理 }这种设计是故意的。如果允许 value 为 null那么get返回 null 时你没法区分key 不存在和key 存在但值为 null。ConcurrentHashMap 用一刀切禁止 null value换来了语义上的无歧义代价就是你必须在插入之前把 null 挡在门外。1.4 热搜背后真正的诉求我理解同时读写 报 null这个词条想表达的真实需求是并发容器到底能不能让我无脑地在多线程里读写答案是不能无脑但 ConcurrentHashMap 是已知并发 Map 实现里最接近可以放心用的一个。它不会因为并发而把内部结构搞坏但它有弱一致性、有单 key 原子性限制、有 compute/merge 这类特殊语义这些才是你真正要啃的东西。后面几节我逐个拆。2. 并发场景的容器选型为什么从 HashMap 换到 ConcurrentHashMap2.1 HashMap 在并发下丢了两样东西一致性和结构安全HashMap 的设计目标是单线程下的极致简单数组加链表必要时转红黑树扩容时重新哈希。这个模型本身没有错误处理没有锁没有版本号所有状态一变就能被其他线程看到。并发写的时候两个线程同时往同一个桶里追加节点可能发生经典的覆盖丢失。两个线程同时发现需要扩容各自 new 一个新数组各自复制原数组里的节点最后谁先完成谁后完成完全不确定链表的指向关系可能错乱。读线程这时去 get轻则拿到 null重则遍历到一个断了 next 的链上抛 NPE 都算运气好的。我印象很深的一次排查一个做商品库存同步的服务多个定时任务线程共用同一个全局 HashMap偶尔出现库存值变成 null的报警。起初大家怀疑数据库最后发现某个任务线程在扩容复制的过程中把另一个线程刚 put 的新值覆盖掉了读取线程在链表切换的间隙拿到了一个残缺的节点。当时的第一反应就是把 HashMap 换成 ConcurrentHashMap问题立刻消失。2.2 Hashtable 和 Collections.synchronizedMap 撑不住的原因Hashtable 是线程安全的但它做了一笔非常朴素的交易所有公开方法全部加锁。put锁、get锁、size也锁整个 Map 就是一个大临界区。多线程并发读时Hashtable 依然是串行的吞吐量会随着线程数增加而一路下滑。Collections.synchronizedMap本质上也是把所有方法用synchronized包一层区别只是你可以选择加锁对象性能特征没有本质变化。在读多写少的业务里这两种方案的锁竞争非常难受。换句话说它们能在并发下保证安全但保证不了性能也不符合现代多核 CPU 上尽量并行读、只在写冲突时串行的主流思路。2.3 ConcurrentHashMap 的承诺到底是什么到了 ConcurrentHashMap 这里它的设计目标就变成在保证线程安全的前提下把锁的粒度尽可能缩小让不同线程操作不同数据时不用互相等待。它给出的承诺包括单个 key 的 put、get、remove、replace 等操作是原子的读操作绝大多数场景不加锁靠 volatile 语义保证可见性迭代器是弱一致性的不会抛ConcurrentModificationException不能存 null key 和 null value。但它没有承诺多个 key 的组合操作是原子的迭代器和 size() 能反映一个精确到纳秒的全局快照读取过程中不会被并发修改影响只能保证不会坏。理解了这些边界你才不会在用它做复杂业务时踩坑。3. 锁粒度演进从分段锁到 CAS synchronized3.1 JDK7 的分段锁思路JDK7 的 ConcurrentHashMap 是分段锁Segment实现的。Segment 本身是一个小型的 HashMap同时也是一把可重入锁继承了ReentrantLock多个 Segment 组成一个大的 Map。默认情况下内部有 16 个 Segment也就是最多支持 16 个线程同时写入不同的段互不干扰。操作 key 时先根据 key 的哈希决定落在哪个 Segment然后只锁住这个 Segment。这样理论上并发度比 Hashtable 高 16 倍。分段锁的思路在当时是很先进的设计但它有两个问题并发度上限被锁死在分段数上你最多只能开到 16 个写线程并行一些跨段操作比如size()需要把所有段都锁一遍才能得到一个相对一致的统计结果代价很高。3.2 JDK8 为什么改成 CAS synchronizedJDK8 的 ConcurrentHashMap 把 Segment 拿掉了直接用一个Node数组来存数据。空桶插入时使用 CAS不锁桶里有数据发生哈希冲突时再对链表头节点或树根节点加synchronized锁。这个改动有几层原因锁粒度从段缩小到了单个桶不同的 key 即使落在同一个 Segment 但只要不是同一个桶也可以并行写入CAS 负责最常用的空桶首次写入场景这是无竞争或者低竞争路径连锁都不用synchronized在 JDK6 之后经过了锁升级优化无竞争时是偏向锁竞争激烈时再膨胀为重量级锁比单纯用ReentrantLock在低竞争场景下更轻。你可以把 JDK8 的做法理解成尽量没有锁实在避不开冲突时才锁定最小范围。3.3 JDK7 与 JDK8 的核心差异对比维度JDK7 分段锁JDK8 CAS synchronized数据结构Segment 数组 HashEntry 链表Node 数组 链表 / 红黑树锁粒度Segment默认 16 个段单个 Node 桶无竞争写入需要获取段锁直接 CAS无锁扩容方式扩段内的数组整个 table 扩容支持协助迁移并发度受 Segment 数量限制几乎可以随桶数量扩展null 限制不允许 null key/value不允许 null key/value从工程实践上看JDK8 之后除非你的环境下 JVM 版本实在太老否则不需要再关心 JDK7 的段数配置JDK8 的默认实现已经覆盖了绝大多数场景。4. 源码级走读put/get/扩容是怎么协同起来的4.1 先看内部布局的几个关键点JDK8 ConcurrentHashMap 的存储单元是NodeK,V它内部维护了一个 volatile 的Node[]数组也就是table。Node的val和next都是 volatile 的这是读操作无锁的关键。它还利用Unsafe提供的方法来做原子操作tabAt(tab, i)以 volatile 方式读取数组第 i 个位置的引用casTabAt(tab, i, c, v)以 CAS 方式把位置 i 从 c 换成 vsetTabAt(tab, i, v)以 volatile 方式写入位置 i。数组本身的引用是 volatile数组内部元素的可见性则依赖tabAt这类 volatile 读。这就是一个很微妙的点你不能直接写tab[i]去访问必须走tabAt否则无法保证跨线程可见。4.2 put 的完整流程put 方法最终会进入到内部的putVal。流程大致如下final V putVal(K key, V value, boolean onlyIfAbsent) { // 1. 空值校验 if (key null || value null) throw new NullPointerException(); // 2. 计算 spread 后的 hash int hash spread(key.hashCode()); int binCount 0; // 3. 循环插入table 为空则先初始化 for (NodeK,V[] tab table;;) { // 如果 tab 为 null调用 initTable 初始化 // 找到目标桶 f tabAt(tab, i) // 4. 桶为空casTabAt 插入新节点一次 CAS 成功 if (casTabAt(tab, i, null, new NodeK,V(hash, key, value))) { break; } // 5. 桶头节点 hash 为 MOVED说明正在扩容当前线程帮忙迁移 // helpTransfer / 参与扩容 // 6. 桶非空且未迁移锁住桶头节点在链表或红黑树中插入 synchronized (f) { // 二次检查头节点是否变化 // 如果是链表遍历找同 key找到就更新并返回旧值 // 找不到就在尾部追加 // 如果是 TreeBin调用红黑树的 putTreeVal } // 7. 如果链表长度达到阈值调用 treeifyBin 尝试转红黑树 // 8. addCount 更新元素计数并判断是否需要扩容 } }binCount用来记录链表当前节点数。当链表长度大于等于 8 时会尝试把链表转成红黑树但前提是当前 table 容量不小于 64。如果容量还没到 64会先做一次扩容而不是直接树化。这个先扩容再树化的设计是想让桶分布更均匀避免小容量下频繁树化。读写线程同时出现的报 null问题往往就藏在 put 流程的扩容协作里。因为某个桶被标记为ForwardingNode后新 put 的线程不会直接写旧桶而是去新表执行插入。读线程如果还在读旧表会根据这个标记跳转到新表继续找。整个迁移过程很复杂但不是靠一把全局锁串起来的而是靠桶级别的锁配合 CAS 完成的。4.3 get 为什么可以不加锁get方法在多数实现里没有任何锁全靠 volatile 读。核心逻辑是这样public V get(Object key) { NodeK,V[] tab; NodeK,V e, p; int n, eh; K ek; int h spread(key.hashCode()); if ((tab table) ! null (n tab.length) 0 (e tabAt(tab, (n - 1) h)) ! null) { // 先检查头节点 // 如果头节点 hash 等于要找的 hash并且 key 相同直接返回 head.val // 如果头节点是 ForwardingNodehash MOVED去 nextTable 里继续找 // 否则在链表或红黑树里查找 } return null; }不加锁的安全性来自table引用是 volatile读线程可以感知到扩容后的新数组Node的val是 volatile写入后的新值能被其他线程立即看到桶位置的读取使用tabAt的 volatile 语义即使读到中间状态也最多是没找到或旧值不会读到损坏的数据。这就是为什么 ConcurrentHashMap 的读性能很好它把保证看到最新值交给 volatile把保证结构完整性交给写入时的桶锁读线程几乎不做额外等待。4.4 transfer 扩容的协作机制扩容和 put 之间的关系是理解 ConcurrentHashMap 并发模型的重点。JDK8 的扩容不是一次性把整张表搬完而是拆成多个任务让多个线程一起协作。一个大致的搬运流程从原数组的最后一个桶开始向前遍历每个参与迁移的线程按stride领取一段桶区间的任务对每个桶如果桶为空直接用ForwardingNode占位如果桶不为空锁住桶头节点把里面的链表拆成高位链和低位链低位链放到新数组的相同索引位置高位链放到新数组的原索引 原长度位置搬运完成后在旧数组对应位置放一个ForwardingNodehash 值为 MOVED。高位和低位节点的区分逻辑来自一个很巧妙的观察扩容后容量翻倍原来 index hash (n-1) 的元素要么留在原位置要么移动到 index oldCap 的位置。判断依据就是看那一位新增位是 0 还是 1。早期版本的实现经常会带上一个lastRun优化把尾部连续地位相同的节点整体搬移减少遍历次数。因为新 put 的线程如果遇到ForwardingNode会调用helpTransfer也参与搬运所以并发写入不但不会破坏迁移反而能加速扩容完成。这也是 ConcurrentHashMap 在扩容高峰期还能保持吞吐量的原因。5. 不允许 null与弱一致性ConcurrentHashMap 的两条隐藏契约5.1 禁止 null key/value 的真正原因从设计角度说禁止 null 是为了消除歧义。Map 的get返回 null用户无法区分是没有这个 key还是有这个 key但 value 是 null。如果允许 null value那么所有涉及查找的语义都要额外提供一种判断方式不仅增加 API 复杂度还会让并发场景下的判断更难写。Doug Lea 在注释里表达过类似的意思ConcurrentHashMap 不能用 null 来表示值因为它需要给该 key 是否存在于表中提供一个明确的信号。这个信号就是 null。一旦允许 value 为 null信号就失效了。对并发实现来说还有一个隐性问题如果用 null 表示节点被删除或正在迁移中这类内部状态那么业务数据里的 null 会跟内部状态串台。把所有 null 挡在外面实现起来更干净也不会干扰迭代器和迁移逻辑对 null 的依赖。5.2 弱一致性迭代器到底弱在哪里ConcurrentHashMap 的迭代器和 HashMap 的fail-fast迭代器完全不同。HashMap 在迭代时如果检测到结构修改会立刻抛ConcurrentModificationException这是为了给单线程误用一个快速提醒。ConcurrentHashMap 则选择不抛异常它允许迭代器在创建后看到其他线程在迭代过程里对 Map 做的修改但不保证一定看到也不保证能把所有修改都反映出来。具体表现迭代开始时迭代器能看到此刻 Map 的整体状态迭代过程中其他线程做了 put 或 remove迭代器可能看到也可能看不到迭代器绝不允许把内部结构搞坏即使某个桶正在扩容读线程也只是跳到新表继续遍历size()和mappingCount()也是尽力而为它们通过累加CounterCell得出一个近似值不是精确值。这个弱一致特性在大多数缓存、配置加载、注册表场景里完全够用但在需要强一致的业务里就是坑。比如你要遍历所有元素计算总额同时又有人并发修改那么你算出来的数可能就是某个时间点的近似值。你需要接受这个特性或者在业务层面用外部锁把遍历 修改串行化。5.3 compute/merge 系列和 null 的相互作用ConcurrentHashMap 从 JDK8 开始提供了一批原子操作方法比如computeIfAbsent(key, mappingFunction)key 不存在时才去执行函数函数返回 null 则什么都不存computeIfPresent(key, remappingFunction)key 存在时才执行函数返回 null 会删除这个 keycompute(key, remappingFunction)不管 key 是否存在都会执行返回 null 表示删除merge(key, value, remappingFunction)函数返回 null 时也会把已有 key 删除。这里的 null 是一个删除信号非常容易踩坑。假设你写map.computeIfAbsent(key, k - { String result loadFromRemote(k); return result; // 如果 result 恰好为 null也不会缓存 });函数返回 null 不会抛异常它只是不把结果放进 map。后续代码再用get拿这个 key得到的依然是 null业务上很容易误判为加载逻辑还没执行。如果想区分没执行和执行了但没结果建议给 value 套一个包装对象或者在函数内部记录状态。computeIfAbsent还有一个更隐蔽的坑递归更新。如果在映射函数内部再次对该 map 执行同 key 的 compute 类操作可能抛出IllegalStateException: Recursive update。原因很简单内部用了一个占位节点来标记这个桶正在计算你的递归调用会撞上这个标记从而给出非常难懂的报错。实际开发中这种问题更多出现在用 ConcurrentHashMap 做缓存加载函数里又调用同一个缓存的场景。5.4 遍历时的空值容忍策略在同时读写场景下业务代码最稳妥的写法是不要把从 map 里 get 出来的 value 直接当成非空对象使用。你可以在遍历或取值后加一层if (v ! null)判断或者在工具类里封装一个给默认值的方法static String getOrDefaultStrict(ConcurrentHashMapString, String map, String key) { String v map.get(key); return v ! null ? v : ; }但要注意这跟 HashMap 的getOrDefault并不是完全等价。如果业务上需要区分key 不存在和默认值建议用containsKey先判断再把 get 结果和 containsKey 的结果放一起校验避免双重查找带来的性能损耗。ConcurrentHashMap 的get很快一次containsKey加一次get在绝大多数场景都不是瓶颈但如果你在一个千万级并发循环里频繁这么干挨个优化还是有意义的。6. 实操避坑线上真的会踩到的几个 ConcurrentHashMap 误区6.1 用 get put 做原子累加很多人在用 ConcurrentHashMap 做计数的时候会这么写int old map.get(key); map.put(key, old 1);这两个操作分开看都是线程安全的合在一起就不安全了。两个线程同时 get 到同一个旧值各自加 1 再 put结果可能是只加了 1 而不是 2。这不叫并发安全这叫把两个原子操作拼成了一个非原子的复合操作。正确做法是使用merge或者computemap.merge(key, 1, Integer::sum);merge会针对单个 key 做原子性的读-改-写内部会锁住目标桶粒度足够小。线上做访问次数统计、阈值累加这类逻辑直接用merge最省心。6.2 在 computeIfAbsent 回调里做重量级 IOcomputeIfAbsent里的函数执行时当前 key 对应的桶可能处于被锁状态。虽然不同 key 的桶可以并行计算但如果你的函数里做了大量外部 IO比如查数据库、调用远程接口而这个 key 的桶同时又被高频访问那么其他线程要操作同一个桶时就得等你的 IO 完成。这种锁内放慢操作很容易把并发性能拖垮极端情况下表现为请求响应变慢。我遇到过一回业务代码用computeIfAbsent做缓存加载加载函数里去查用户中心单次调用就要几十毫秒。缓存击穿时有多个线程同时打进来结果这些线程全部堵在同一个桶锁上接口耗时直接翻了十倍。如果确实需要按 key 加载我建议用独立的LoadingCache组件比如基于 Caffeine 的缓存它自带了刷新的语义或者在computeIfAbsent外面套一层先 get不存在再加锁加载的双检锁无论如何别在桶锁里执行超过微秒级耗时的操作。6.3 把 size() 当精确值ConcurrentHashMap 的size()返回的是baseCount加上CounterCell数组里的值累加结果它并不是一个严格意义上在某一时刻锁定的精确快照。用size()来判断map 是否为空通常没问题因为 0 和 非 0 的误差感知足够了。但如果你的业务要求严格的容量控制比如 超过 10000 必须停止写入那么单靠size()做判断可能会在并发写入下出现轻微偏差。更稳的做法是维护一个独立的LongAdder在每次 put 成功后加一remove 成功后减一。注意这个计数器的维护和 map 操作不是原子的所以它也只能做到近似精确但误差会比反复调用size()小性能也更好。6.4 该换容器时不要硬扛ConcurrentHashMap 不是并发场景下的万能药。我遇到过有人为了保护一段强一致的业务逻辑把读写都放进 ConcurrentHashMap然后在外层用一把大锁把所有操作串行化。这种情况下你其实不需要 ConcurrentHashMap直接用 HashMap 加读写锁或者干脆用CopyOnWriteArrayList配合外部同步更简单。另外如果你需要一次操作多个 key并且要求这些操作整体原子ConcurrentHashMap 做不到。它只保证单个 key 的原子性。要做同时更新 A 和 B这种事务性操作你得自己设计锁顺序或者引入真正的数据库事务。用 ConcurrentHashMap 的桶锁去拼多 key 原子性是高风险的锁顺序没设计好就是一个死锁隐患。我个人现在处理缓存 并发更新 允许短暂不一致这类需求时首选依然是 ConcurrentHashMap但在用它之前会先问自己三个问题能不能接受弱一致单个 key 的原子性够不够null 值能不能提前过滤三个问题都能过关它就会是你在 JDK 并发集合里最可靠的朋友。
返回列表