1. 从一次线上事故说起为什么并发场景下HashMap会变成定时炸弹几年前我参与排查过一个线上问题至今印象深刻。一个看似普通的缓存服务在流量高峰期偶尔会出现CPU飙到100%、请求大面积超时的情况重启后恢复正常但过一段时间又会复现。日志里没有任何异常堆栈GC日志也看不出明显问题。最后用线程转储工具抓了几次现场才发现有若干业务线程卡在一个哈希表结构的操作上形成了一个环形链表导致遍历操作陷入死循环。这个问题的根源就是多线程环境下使用了非线程安全的哈希表实现。当多个线程同时对同一个桶位进行扩容或插入时链表指针被并发修改很容易形成环。这也是为什么在并发场景下我们需要一个专门的线程安全哈希表实现——ConcurrentHashMap。这篇文章不是API手册的搬运而是想把我这些年使用和理解ConcurrentHashMap的经验系统性地梳理一遍。它适合已经会基本使用、但对其内部机制一知半解的开发者也适合准备技术面试、想真正搞懂它为什么快、为什么安全的朋友。我会从设计动机讲起拆解它的核心数据结构、并发控制策略、扩容机制再结合实操中的坑和调优经验尽量把每个为什么都讲透。需要先明确一个前提ConcurrentHashMap在不同版本里的实现差异非常大。早期版本用的是分段锁而后续版本改成了CAS加同步块的方案。这两种思路的取舍是理解它的关键。下面我会以目前主流的实现为主线同时对比早期设计帮助大家建立完整的认知。2. 分段锁到CASConcurrentHashMap的并发控制思路演进2.1 早期分段锁设计解决了什么又留下了什么早期版本的核心思路是分而治之。整个哈希表被拆成若干个段每个段本质上是一个独立的小哈希表各自持有一把可重入锁。当线程访问某个段时只需要锁住这个段其他段依然可以被其他线程访问。这样就把一把大锁的竞争分散成了多把小锁的竞争并发度理论上等于段的数量。这个设计在当时是很聪明的它让并发读写不再互相阻塞到全局。但它有几个明显的短板。第一段的数量在初始化时就固定了并发度上限被写死无法根据实际负载动态调整。第二内存开销偏大每个段都要维护独立的哈希表结构。第三某些需要跨段的操作比如求整个表的元素总数实现起来比较别扭需要逐个段去统计一致性也难以保证。我在实际使用中感受到的另一个问题是当某个段恰好承载了大量热点键时这个段的锁竞争依然会很激烈而其他段却很空闲负载并不均衡。分段锁只是把竞争概率降低了并没有从根本上消除热点竞争。2.2 新方案为什么改用CAS加同步块后来的实现换了一个思路不再分段而是回到单一的哈希表结构把锁的粒度进一步细化到单个桶位。具体来说它大量使用了CAS操作来保证无锁化的原子更新只有在真正发生哈希冲突、需要操作链表或红黑树时才用同步块锁住该桶的头节点。这样做的收益很直接。并发度不再受限于固定的段数而是取决于桶的数量理论上可以支持更高的并发。内存占用也更紧凑因为不需要维护额外的段结构。对于元素总数的统计它采用了一种类似分治计数的方案把计数分散到多个单元里避免所有写操作都去竞争同一个计数器。这里有个容易混淆的点很多人以为ConcurrentHashMap是完全无锁的。其实不是它是尽量无锁必要时加锁。CAS负责处理空桶插入、计数更新这类简单原子操作而链表和树的复杂操作仍然依赖同步块。理解这个混合策略是理解它性能特征的前提。2.3 两种思路的取舍对比维度分段锁方案CAS加同步块方案锁粒度段级别桶级别并发度固定受段数限制动态取决于桶数量内存开销较大需维护段结构较小结构更紧凑计数实现跨段统计一致性弱分散计数一致性较好热点竞争段内仍可能激烈单桶竞争范围更小从这张表能看出新方案在并发度和内存上都有优势代价是实现复杂度大幅上升。这也是为什么它的源码读起来比早期版本要费劲得多。但只要你抓住CAS处理简单操作、同步块处理复杂操作这条主线很多细节就能串起来。3. 拆开看内部数组、链表、红黑树与计数单元如何协同3.1 主数组与桶位的基本结构ConcurrentHashMap的主体是一个数组数组的每个位置称为一个桶。当我们往里面放键值对时先根据键的哈希值定位到某个桶。如果这个桶是空的就直接用CAS把新节点放进去这一步不需要加锁速度很快。如果桶里已经有元素了说明发生了哈希冲突。这时候就要沿着桶里的链表或树往下找看是否存在相同的键。存在就更新值不存在就追加到末尾。这个过程中为了线程安全会锁住该桶的头节点。注意锁的只是这一个桶其他桶完全不受影响。这里有个设计细节值得说数组的每个元素本身也参与并发控制。比如扩容时某些桶会被标记成特殊节点其他线程遇到这种标记就知道当前正在扩容可以去帮忙而不是傻等。这种协作式的设计是它高并发能力的重要来源。3.2 链表转红黑树的阈值与理由当某个桶里的链表越来越长时查找效率会从常数级退化到线性级。为了控制最坏情况当链表长度达到一定阈值默认是8且数组容量也达到一定规模默认是64时这个桶里的链表会转换成红黑树把查找复杂度降到对数级。为什么是8这个数字这是基于概率统计的权衡。在哈希分布均匀的理想情况下一个桶里出现8个元素的概率已经非常低属于小概率事件。设置这个阈值既能覆盖极端冲突的情况又不会因为频繁转换而带来额外开销。如果数组容量还没到64即使链表到了8也会优先选择扩容而不是转树因为扩容能更均匀地打散元素成本更低。我在调优时踩过一个坑有人为了优化把转树阈值调得很低结果发现性能反而下降了。原因是频繁的树化与反树化操作本身有开销而且红黑树的节点比链表节点占用更多内存。所以除非你的哈希函数分布确实很差否则不建议动这个阈值。3.3 分散计数单元如何避免计数瓶颈元素总数这个看似简单的功能在并发场景下其实很棘手。如果所有写操作都去更新同一个计数器这个计数器就会成为新的竞争热点把前面辛苦优化的并发度又拉回去。它的解法是把计数分散到一组计数单元里。每个线程根据自己的探针值选择一个单元去累加这样不同线程大概率落在不同单元上减少了竞争。当需要读取总数时把所有单元的值加起来即可。这种方案牺牲了一点实时精确性换来了高并发下的吞吐量。注意正因为计数是分散累加的某些极端并发时刻读到的总数可能不是某一瞬间的精确值。如果你的业务强依赖精确的实时总数需要自己额外做同步处理不能想当然地认为它随时都是精确的。3.4 一个帮助理解的类比你可以把整个结构想象成一栋公寓楼。主数组是楼层每个桶是房间。空房间可以直接用钥匙卡CAS开门入住不需要管理员。房间里已经有人时要进去就得先跟房间管理员同步块打招呼。链表是房间里排队的访客人太多就升级成有编号的座位表红黑树。而整栋楼的入住总人数不是由一个前台统计而是每层楼各自记一笔最后汇总。这个类比虽然简化了但能帮你快速建立整体印象。4. 扩容这件事多线程如何协作搬运数据4.1 扩容的触发条件与基本流程当数组里的元素数量超过容量乘以负载因子默认0.75时就会触发扩容。扩容的本质是创建一个更大的新数组然后把旧数组里的元素重新分布到新数组里。这个过程如果由单线程完成在数据量大时会阻塞很久影响响应时间。它的做法是让多个线程一起参与搬运。每个参与扩容的线程负责一部分桶的迁移工作迁移完自己的部分后如果还有没搬完的就继续认领。这种协作机制让扩容的停顿被分摊到多个线程上整体耗时大幅缩短。4.2 迁移过程中的读写如何保证不丢数据这是很多人关心的问题扩容时如果有线程来读或写会不会读到旧数据或者丢数据答案是有一套精细的标记机制来协调。当一个桶正在被迁移时它会被替换成一个特殊的转发节点。其他线程如果访问到这个桶会顺着转发节点找到新数组里对应的位置从而读到最新的数据。写操作遇到转发节点时也会被引导到新数组去执行。这样就保证了迁移期间读写都能正确进行不会出现数据丢失或读到脏数据。我在阅读源码时最大的体会是这套机制的精妙之处在于不阻塞。它没有用一把大锁把整个扩容过程锁死而是通过状态标记让所有线程都能感知到扩容正在进行并各自做出正确的响应。这种设计思想在很多高性能并发结构里都能看到。4.3 扩容中的常见误区第一个误区是认为扩容是原子的、瞬间完成的。实际上它是一个渐进的过程期间新旧数组同时存在。第二个误区是认为扩容期间性能一定下降。恰恰相反因为多线程协作扩容期间的吞吐量往往比单线程扩容要好得多。第三个误区是忽略了扩容对内存的瞬时压力新旧两个数组同时存在内存占用会短暂翻倍在内存紧张的环境下需要留意。误区实际情况扩容瞬间完成渐进式迁移新旧数组并存扩容必然卡顿多线程协作停顿被分摊扩容不影响内存峰值内存接近翻倍扩容时不能读写通过转发节点正常读写5. 实操中的坑这些场景我踩过你别再踩5.1 复合操作不是原子的这是最经典也最容易犯的错误。ConcurrentHashMap保证的是单个操作如put、get的线程安全但先检查再操作这种复合逻辑并不是原子的。比如如果不存在就放入这种需求如果你写成先判断再put两个线程可能同时判断为不存在然后都执行put导致其中一个覆盖另一个。正确的做法是使用它提供的原子复合方法比如不存在才放入和存在才替换这类接口。这些方法内部通过CAS或加锁保证了整个判断加操作是一个原子单元。我见过太多人在这里翻车包括我自己早期也写过这种有问题的代码。// 错误示范判断和放入之间存在竞态窗口 if (!map.containsKey(key)) { map.put(key, value); } // 正确做法使用原子复合方法 map.putIfAbsent(key, value);5.2 遍历时的弱一致性它的迭代器是弱一致的意思是遍历过程中如果其他线程修改了map迭代器不一定会抛出异常也不保证能看到所有最新的修改。它反映的是创建迭代器那一刻到遍历过程中的某个中间状态。这个特性本身不是bug而是一种设计取舍目的是避免遍历时加锁影响并发。但如果你在遍历时依赖看到完整且一致的数据快照就会出问题。需要强一致快照时要么在遍历期间自己加外部同步要么先复制一份数据再遍历。5.3 键和值不能为null和某些允许null键值的哈希表不同ConcurrentHashMap不允许键或值为null。这个限制是有意为之的。因为在并发环境下如果get返回null你无法区分是这个键不存在还是这个键存在但值是null这在并发逻辑里会造成歧义。所以它干脆从设计上禁止了null让语义保持清晰。我遇到过有人从普通哈希表迁移过来时因为历史数据里有null值导致报错。迁移前一定要先清洗数据把null值处理掉。5.4 初始容量设置不当的代价如果创建时不给初始容量它会用默认值。当你要放入大量元素时就会经历多次扩容每次扩容都有数据迁移成本。反过来如果你预估得太大又会浪费内存。一个实用的估算方法是预估元素数量除以负载因子再向上取整到最近的2的幂次。比如你预计放1000个元素1000除以0.75约等于1334向上取到2的幂次就是2048。这样基本可以避免扩容又不会浪费太多空间。// 预估1000个元素初始容量设为2048 ConcurrentHashMapString, String map new ConcurrentHashMap(2048);提示这个估算只适用于元素数量相对确定的场景。如果元素数量波动很大宁可稍微设大一点因为扩容的成本通常比多占一点内存更值得关注。6. 性能调优与选型什么时候该用它什么时候不该6.1 读多写少场景下的表现在读多写少的场景里它的表现非常出色。因为读操作基本不需要加锁只有在遇到正在迁移的桶时才需要特殊处理。多个读线程可以几乎无阻碍地并行读取吞吐量很高。这也是它最常见的适用场景比如本地缓存、配置表、路由映射等。但要注意如果读操作里夹杂着大量复合逻辑比如读之后根据结果做条件更新那实际的有效并发度会下降因为复合操作往往需要加锁或CAS重试。这种情况下要评估重试带来的开销。6.2 高写入竞争下的取舍当大量线程同时写入同一个桶时竞争会集中在这个桶的锁上性能会下降。虽然比全局锁好得多但也不是没有上限。如果你的业务存在明显的热点键所有线程都在更新同一个键那无论用什么并发结构这个键本身都会成为瓶颈。这时候的优化方向不是换数据结构而是从业务层面拆分热点。比如把一个热点计数器拆成多个分片计数器最后再汇总。这种思路和它内部的分散计数是异曲同工的。6.3 和其他并发容器的对比容器并发策略适用场景ConcurrentHashMapCAS加桶级锁通用高并发键值存储同步包装的哈希表全局锁并发极低兼容老代码写时复制容器写时整体复制读极多写极少数据量小并发跳表无锁链表加索引需要有序遍历的场景选择的关键在于你的读写比例、是否需要有序、数据量大小。ConcurrentHashMap是通用性最强的但如果你需要按顺序遍历键并发跳表可能更合适。如果几乎不写、只读写时复制容器在读取时完全无锁也值得考虑。6.4 我个人的调优经验第一不要过早优化。先用默认配置跑起来用压测数据说话确认瓶颈真的在哈希表上再动手。第二关注负载因子和初始容量这两个最常调的参数它们对性能的影响最直接。第三监控扩容频率如果发现频繁扩容说明初始容量设小了。第四热点键问题优先从业务拆分解决而不是指望调参。还有一点它的性能在不同数据规模和访问模式下差异很大网上那些每秒多少万次操作的 benchmark 只能作为参考一定要在自己的真实场景下压测。我见过太多人拿着别人的测试数据做决策结果上线后完全不是那么回事。7. 写在最后几个我反复验证过的小结论关于ConcurrentHashMap我这些年用下来最深的体会是它的强大不在于某个单点技术而在于一整套组合拳——CAS处理简单路径、桶级锁处理冲突、多线程协作扩容、分散计数避免热点。每一处设计都在回答同一个问题如何在保证线程安全的前提下把竞争降到最低。如果你只想记住几件事我希望是这几条。复合操作一定要用原子方法别自己写判断加操作。遍历是弱一致的需要强一致快照就自己复制。键值不能为null迁移数据前先清洗。初始容量按预估元素数除以负载因子再取2的幂次来设。热点键问题从业务拆分入手别硬扛。最后分享一个我常用的排查手法当你怀疑并发容器有性能问题时先抓线程转储看有多少线程卡在同一个桶的锁上。如果大量线程集中在少数几个桶基本可以确定是热点键问题如果线程分散但整体吞吐上不去可能是扩容太频繁或者复合操作重试太多。这个判断方法帮我快速定位过好几次问题比盲目调参有效得多。