ARTICLE DETAIL

资讯详情

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

ConcurrentHashMap与Hashtable深度对比:从锁机制到实战踩坑

ConcurrentHashMap与Hashtable深度对比:从锁机制到实战踩坑 面试几乎必问的两个Java集合名一个是 ConcurrentHashMap另一个是 Hashtable。这题我在模拟面试和真实面试里都被反复问到过也看过不少候选人从“锁整个表”和“分段锁”一路背到 Segment 参数。但真正在线上服务里排查过并发问题之后你才会意识到区别表背后藏着一整套关于并发取舍的设计哲学。这篇就把两个类的区别彻底拆开从源码机制、迭代器行为、null 边界到实际踩坑一次讲透。1. 写在前面这个问题为什么值得认真聊1.1 面试常问但不只是背答案几乎所有 Java 后端面试都会出现“ConcurrentHashMap 和 Hashtable 的区别是什么”很多人的回答模板是“Hashtable 是线程安全的所有方法都加了 synchronizedConcurrentHashMap 是分段锁所以并发性能更好”。说完这两句就停了。面试官一般会接着追问Java 8 的 ConcurrentHashMap 还有分段锁吗为什么 ConcurrentHashMap 不允许 null 值它的迭代器是 fail-fast 还是弱一致这时候只背答案的人就会被问住。实际上这个问题的价值体现在两个维度。对候选人来说能把“锁粒度从整个 Map 缩小到单个桶”这个演进讲清楚是区分“背八股”和“理解并发”的分水岭。对开发来说写代码时真正理解两者的行为差异能省下一堆线上事故并发下 get 到 null 被自动拆箱导致 NPE、迭代时被并发修改抛异常、复合操作非原子导致的数据不一致这些坑我都踩过后面会逐个拆解。1.2 两者的身世与设计立场Hashtable 是 JDK 1.0 就存在的“老古董”它诞生的年代里并发编程远没有今天这么普遍设计也很朴素任何线程调用它的公开方法整体加锁。这种方案在小规模并发下能用但并发稍微上来一点就成瓶颈——所有读写操作抢同一把锁线程之间的竞争被无限放大。ConcurrentHashMap 是 JDK 1.5 加入 java.util.concurrent 包的设计目标非常明确在保证线程安全的前提下尽可能减少锁竞争提升并发吞吐量。它内部最初采用分段锁Segment机制把整张表切成若干段每一段有自己的锁不同线程操作不同分段时互不干扰。到了 JDK 1.8实现被彻底重写Segments 被废弃锁粒度进一步细化到单个桶Node并引入 CAS 和红黑树优化。理解这条演进线再看后面的所有区别就会发现每一处差异都是设计取舍的自然结果。2. 核心差异逐项拆解2.1 同步机制全局锁、分段锁到 CAS 加局部锁Hashtable 的线程安全靠的是在方法级别加 synchronized。以 put 为例整个方法体被锁住任意时刻只有一个线程能执行 put。这带来的问题在放大镜下看非常直观多个线程同时往里面写数据实际是串行的查询操作也一样要抢锁读操作之间完全没有并发可言。如果用人类超市来类比Hashtable 就像只有一个收银台的超市所有顾客不管买什么只能排一列哪怕你只进去看一眼价格也得排队。ConcurrentHashMap 在 JDK 1.7 时代用 Segment 数组实现分段锁。默认创建 16 个 Segment每个 Segment 本质是个小 Hashtable有自己的锁。一个写操作只锁住它要写入的那个 Segment其他 15 个 Segment 不受影响。类比一下就是超市开了 16 个收银台顾客按商品分区结账互不干扰。这个设计的并发上限就是 16因为只有 16 把锁。JDK 1.8 之后Segment 方案被彻底移除改为更细粒度的桶锁。底层数据结构变为 Node 数组每个 Node 就是一个“桶”。put 操作先用 hash 值定位到具体桶如果桶为空就通过 CAS 尝试写入如果桶不为空才对这个桶的头节点加 synchronized 锁其他桶的读写依然可以并行。锁粒度从“16 段”下降到了“单个桶”并发上限从 16 提升到了接近桶的个数。再加上读操作依赖 volatile 变量保证可见性基本无锁读多写少场景的性能表现就非常好了。注意JDK 1.8 的 ConcurrentHashMap 虽然还有 synchronized但锁的是桶头节点而不是整个 Map。网上不少旧文章还在说“分段锁”放到 1.8 语境下是不准确的面试中建议按版本分开说明。2.2 null 值边界一个微妙而危险的设计Hashtable 和 ConcurrentHashMap 都继承自 Map 接口但在 null 键和 null 值的处理上有着相同的态度一律拒绝。这一点和 HashMap 正好相反HashMap 允许一个 null key 和任意多个 null value。为什么并发容器不允许 null表面原因是源码里直接写死了ConcurrentHashMap 的 putVal 方法开头就有逻辑key 或 value 为 null 直接抛 NullPointerException。但背后的设计理由更值得琢磨如果允许 null value那么 get(key) 返回 null 时你无法区分是“这个 key 不存在”还是“这个 key 存在但值就是 null”。在并发场景里这种歧义是致命的。因为并发下“先判断 key 是否存在再取出值”这种复合操作是不原子的你在 containsKey 和 get 之间key 完全可能被另一个线程删掉。如果值本身还不允许是 null至少可以保底get 返回 null 一定是 key 不存在。这给并发代码的判断逻辑省去了大量不确定性。我在实际项目中遇到过一类 bug就是团队成员习惯了 HashMap 的 null 特性把 ConcurrentHashMap 当缓存用的时候直接 put(key, null)结果现场直接抛 NPE代码走到 put 那行就挂了。这不是 ConcurrentHashMap 的缺陷是“带习惯迁移”带来的误伤。在选并发容器时必须先把对 null 的预期统一掉。2.3 迭代器行为fail-fast 和弱一致性的取舍Hashtable 的迭代器是 fail-fast 的。所谓 fail-fast是指遍历过程中检测到结构性修改modCount 变化会立即抛出 ConcurrentModificationException而不是继续遍历可能已经损坏的数据。听起来很安全但实际在并发场景下这个设计反而很麻烦你只读一个 Hashtable另一个线程往里 put 一条数据你这边迭代就会中断。如果遍历的同时还有写入需求就必须自己加外部锁把整个遍历包起来。ConcurrentHashMap 走的完全是另一条路线弱一致性迭代器。它的迭代器遍历过程中容忍并发的修改不会抛 ConcurrentModificationException。但它也不承诺一定能看到最新的写入遍历可能反映的是某个“近似状态”。比如线程 A 正在遍历线程 B 恰好往已经遍历过的节点加了新值线程 A 可能看不到往还没遍历到的节点加值线程 A 可能看到也可能看不到取决于遍历推进的时序。这个特性对多数缓存类业务来说是刚好够用的遍历取一份大致视图做统计、做批量上报不会因为并发写入就崩溃。代价是如果你需要“遍历期间快照完全一致”ConcurrentHashMap 做不到得靠外部锁或者拷贝副本。我自己的习惯是有强一致遍历需求时优先考虑用 synchronizedMap 或者干脆加读写锁而不是死磕 ConcurrentHashMap 的弱一致。2.4 其他实用差异size 计算、红黑树与原子方法除了线程安全机制和迭代器还有几个点在实际开发中同样重要。size() 方法的语义不同。Hashtable 的 size() 由于方法加锁返回的是一个准确值但为了拿这个准确值你也要和其他操作抢锁。ConcurrentHashMap 在 1.8 里通过 CounterCell 数组来统计元素个数高并发写入时计数操作被分散到不同的计数单元size() 遍历计数单元汇总因此它返回的是一个近似值但在绝大多数业务场景下够用。这点经常被忽略如果你写的是一个强依赖“精确条数”的逻辑就要留意它可能不准确。数据结构在冲突处理上不同。Hashtable 一直是最朴素的数组加链表链表过长时查询效率会退化到 O(n)。ConcurrentHashMap 在 1.8 中引入了红黑树优化当链表长度超过阈值默认是 8尝试转为红黑树将查询复杂度降低到 O(log n)。这在 hash 分布不均匀、单个桶冲突严重时是实打实的性能保障。ConcurrentHashMap 还具有一批原子复合操作方法computeIfAbsent、computeIfPresent、compute、merge。这些方法允许你把“判断 更新”合并为一步原子操作这是 Hashtable 不具备的。实际开发中用 compute 做累加、用 merge 做合并、用 computeIfAbsent 做惰性初始化都能大幅减少人工加锁的复杂度。2.5 一张表收拢关键区别对比维度HashtableConcurrentHashMap引入版本JDK 1.0JDK 1.5JDK 1.8 重写线程安全机制synchronized 锁实例1.7 分段锁1.8 CAS 锁桶头节点null 键/值不支持直接 NPE不支持直接 NPE迭代器fail-fast并发修改抛异常弱一致不抛异常但可能看不到新数据size()准确但需抢锁近似值基于 CounterCell 汇总冲突处理链表链表 红黑树桶长度超过 8原子复合操作无compute、merge、computeIfAbsent 等适用场景遗留系统、低并发高并发缓存、并发状态管理这张表可以作为面试答题的骨架但光有骨架不够真正有价值的是下面这些实战里的坑。3. 实战现场同时读写场景下为什么报 null最近一个比较热的搜索词叫“concurrenthashmap 同时读写 报null”相信很多人是把 ConcurrentHashMap 用在并发缓存里然后突然被线上 NPE 搞得一头雾水。这里要区分一件事是 get 方法正常返回了 null还是代码在拿这个 null 继续使用时抛了 NullPointerException绝大多数“报null”指的是后者。下面拆解三个典型原因。3.1 报 null 的三个典型原因原因一自动拆箱导致的 NPE。这是最常见的现场。比如你声明了一个 ConcurrentHashMapString, Integer然后写出了这样的代码MapString, Integer cache new ConcurrentHashMap(); // 某个线程里 int count cache.get(order:123);如果 key 恰好在当前时刻不存在get 返回 nullnull 赋值给 int 时触发自动拆箱直接抛 NullPointerException。这个错误常常发生在高并发下前面一个线程刚刚 put 成功你读的时候 key 在于是本地测试一切正常上线后被另一个线程恰好在那个窗口期 remove 了 key你的代码就炸了。解决办法很简单拆箱前判空或者直接存基本类型包装类后用 Integer 接住再判空。原因二先判断后使用的复合操作不原子。看这段代码if (map.containsKey(key)) { Object value map.get(key); // 对 value 做处理 }两个操作之间有一个窗口期另一个线程完全可能在这期间把 key 删除或修改。你以为 containsKey 为 true 时get 一定拿得到非 null 值但在并发世界里没有这个保证。get 返回 null 以后任何没有判空逻辑的处理都会引发 NPE。解决方式是把这类“判断加获取”改成直接 get 后判空或者用 computeIfPresent 这类原子方法。原因三写入时就埋了雷。如果你曾经尝试过map.put(key, null)那 NPE 在 put 那一刻就抛了根本轮不到读的时候报。所以如果线上日志显示的是读时报 null不必怀疑“之前是否存过 null 值”因为那一步已经被挡下了。还有一种更隐蔽的情况自定义 key 的 hashCode 或 equals 方法本身有并发问题比如它们内部引用了可变字段字段在并发下被改成 null遍历链表时调 equals 触发 NPE。这种坑比较罕见但排查到最后也不能排除。3.2 排查与定位三步走遇到并发相关 NPE第一步不是改代码而是先看完整堆栈。堆栈会告诉你 NPE 具体发生在哪一行如果指向int count cache.get(key)这一行基本就是拆箱问题如果指向 get 之后的业务逻辑多半是“返回 null 未判空”如果指向 put 调用处那是 null 值被拒绝。第二步复现。并发 bug 最难的就是复现我的经验是把线程数加大、把冲突 key 集中写一个循环 put/remove/get 的压测脚本通常几百万次操作就能稳定复现。复现后减少变量先固定单 key 多线程再扩大到多 key能更快定位是哪一类竞态。第三步对照 API 语义。问自己一个问题这段代码里的两步操作是不是必须作为一个整体执行如果是直接用 compute、merge 或者外部锁不要依赖“先查后用”的侥幸。ConcurrentHashMap 提供的原子方法就是为这类需求设计的用起来既安全又优雅。3.3 并发读写安全姿势的推荐写法分享三组可以直接抄的写法。第一组缓存读取防止拆箱 NPEInteger cached cache.get(key); if (cached null) { cached loadFromSource(key); cache.put(key, cached); } return cached;这里用 Integer 接先判控再处理业务避免自动拆箱。第二组原子累加或更新cache.compute(key, (k, oldValue) - oldValue null ? 1 : oldValue 1);compute 方法保证复合逻辑的原子性不用再担心“读旧值、写新值”之间存在窗口期。第三组如果确实需要“先判断存在再改写”用 computeIfPresent 代替 containsKey 加 putcache.computeIfPresent(key, (k, oldValue) - oldValue delta);只有当 key 存在时才执行更新逻辑上的“判断”和“更新”是一个原子操作。注意Java 8 里 computeIfAbsent 有一个容易踩的递归坑。如果映射函数内部又对同一个 Map 的同一个 key 调用 computeIfAbsent会抛出 IllegalStateException报错信息类似“Recursive update”。这是设计上防止死循环的机制不是 bug。用嵌套 compute 操作时要格外小心。4. 选型决策不同场景下到底该用谁4.1 Hashtable 还有存在的意义吗说实话现代新项目里已经很少看到 Hashtable 的身影了。它存在的意义主要是兼容老代码和作为面试题素材。如果项目还在维护一个有 Hashtable 的模块优先考虑逐渐迁移到 ConcurrentHashMap。如果面试官问“Hashtable 和 Collections.synchronizedMap 有什么区别”本质两者都是全锁方案行为类似但 Hashtable 不允许 null而 synchronizedMap 包装的 HashMap 允许 null了解这个细节也够了。不过也不是说 Hashtable 完全一无是处。在极低并发甚至单线程环境下它和 ConcurrentHashMap 的差距并不明显甚至因为实现简单少了很多 CAS 和桶锁的开销。但谁也不能保证业务流量以后不涨与其赌“永远低并发”不如一开始就选个扩展性更好的容器。4.2 ConcurrentHashMap 的适用边界我把它拆成几个明确的适用场景高并发缓存读多写少允许短时间读到旧值ConcurrentHashMap 是首选。配合 volatile 的可见性能提供很好的读写吞吐。并发状态管理比如在线人数、库存余量、热点计数器配合 compute 或 merge 方法可以做到无锁化的原子更新。需要遍历但不要求强一致的统计数据弱一致迭代器能在并发写入下安全遍历适合做监控指标汇总、批量快照。不适合的场景也要说清楚需要强一致快照的业务。比如导出某时刻全量数据要求遍历期间所有值保持一致ConcurrentHashMap 的弱一致迭代器满足不了。复合操作跨多个 key 的事务性更新。ConcurrentHashMap 只保证单个方法的原子性跨 key 的原子操作它管不了必须引入外部锁或分布式锁。需要有序遍历的场景。它不保证任何顺序这时应该考虑 ConcurrentSkipListMap那是基于跳表实现的有序并发 Map。4.3 其他并发 Map 方案横向参考方案线程安全有序性适用场景Hashtable安全全锁否遗留代码Collections.synchronizedMap安全全锁取决于传入 Map简单包装低并发ConcurrentHashMap安全桶级并发否高并发缓存与计数器ConcurrentSkipListMap安全CAS 跳表是按 key 排序需要有序并发访问选型时不要迷信某一个类先把业务对一致性、有序性、并发压力的要求列出来再挑容器。并发量高、不需要排序、读多写少大概率是 ConcurrentHashMap要求有序且并发安全直接上 ConcurrentSkipListMap只是临时给旧接口做个线程安全包装Collections.synchronizedMap 够了。5. 我的实测记录与经验教训5.1 一个压测小实验的所见所得有次为了排查库存扣减的性能问题我用 8 个线程对同一个 key 做 100 万次自增操作一组用 Hashtable 的 get 加 put另一组用 ConcurrentHashMap 的 compute。结果不出意料Hashtable 那组耗时明显高出几个量级原因是每次自增都要抢同一把锁8 个线程被串行化等待时间远大于计算时间ConcurrentHashMap 那组虽然也有锁竞争但锁只作用在单个桶上配合 CAS 快速失败重试整体吞吐高出太多。这个实验最有价值的结论不是谁快谁慢而是“锁竞争会随线程数增加而恶化锁粒度细化能显著缓解恶化速度”。Hashtable 的瓶颈不是单次操作慢而是并发上来以后大家一起排队ConcurrentHashMap 的设计理念就是尽量避免排队实在避不开才在局部加锁。另一个值得记录的坑是 size 统计。当时我们在做一个批量任务依赖 ConcurrentHashMap.size() 判断是否处理完所有条目。高并发写入时 size 明显不准偶尔漏掉几条导致任务提前结束。后来改成长轮询直到多次 size 稳定才继续才彻底解决。所以再次强调需要精确数量时别直接用 size() 作为强一致判断依据。5.2 写在最后的心得回看这个经典问题我的体会有三层。底层是并发容器进化史从全局锁到分段锁再到桶锁和 CAS每一步都在想办法减少“不必要的等待”。中间层是行为差异null 规则、迭代器语义、size 近似性这些和线程安全同样重要却常常被忽视。最上层是业务选型没有绝对最好的容器只有最匹配场景的选择。最后分享一个小技巧阅读 ConcurrentHashMap 源码时1.8 版本的代码复杂度比 1.7 高了不少涉及数组初始化、扩容迁移、树化操作等多个并发状态容易看晕。我的建议是不要一开始就死磕完整实现先看 put 和 get 两条主线把桶定位、CAS 插入、锁头节点这几个动作理解清楚再回头补充扩容和树化的细节。这样一轮读下来再遇到任何关于并发 Map 的面试题你都能站到设计意图的角度去回答而不是停留在背结论。
返回列表