HashMap vs ConcurrentHashMap:从源码到并发选型全解析 📅 发布时间:2026/9/17 22:44:35 👁 浏览次数: 开篇先聊点实际的。HashMap应该是绝大多数Java程序员最早接触到、也是面试时被问得最频繁的一个集合类。你会发现一个有意思的现象每次聊到HashMap必然会被拉上HashTable和ConcurrentHashMap一起对比。三者的关系就像“单线程小作坊”、“笨重但稳妥的老牌工厂”和“现代高性能流水线”各自的适用场景和代价完全不一样。这篇文章我打算把这三个Map的底层实现、并发处理思路、性能差异掰开了讲透重点放在JDK 1.8之后的HashMap和ConcurrentHashMap到底是怎么设计的以及在真实项目里应该如何选型。如果你是刚入行两三年的Java开发或者正在准备面试这篇内容应该能帮你把这条知识线完整串起来。1. 内容整体设计与思路拆解1.1 为什么这三者总是被放在一起讨论很多人背八股文时会背到一句话HashMap线程不安全HashTable线程安全但效率低ConcurrentHashMap线程安全且效率高。这句话没错但太笼统了。真正的价值在于理解“为什么”这也是我写这篇文章的核心逻辑。三者都实现了Map接口都是基于哈希表这种数据结构来存储键值对。所谓哈希表本质上是一个数组加链表或红黑树的组合。当你调用put(key, value)时系统会先通过key的hashCode计算出一个哈希值再用这个哈希值定位到数组中的某个桶位bucket如果发生哈希冲突就用链表或树结构把冲突的元素串起来。听起来很简单但真正复杂的点在于多线程环境下多个线程同时往Map里写数据时会发生什么事早期Java的设计者们在JDK 1.0时期用synchronized关键字把整个Map锁起来这就是HashTable的做法——简单粗暴但效率低下。到了JDK 1.5时代并发大师Doug Lea用分段锁技术设计了ConcurrentHashMap极大提升了并发读写的吞吐量。而在JDK 1.8中ConcurrentHashMap又抛弃了分段锁改用CAS比较并交换加synchronized的组合方案性能进一步提升。理解了这条演进脉络你就能明白这三者不是简单的“能用与不能用”而是在不同时代、不同并发场景下的最优解。把它们放在一起对比本质上是在对比三种解决并发问题的手段不加锁、全表锁、细化锁粒度。1.2 一张表看懂核心差异我根据自己的实践经验把三者最核心的差异整理成下表方便你快速建立整体认知特性HashMapHashTableConcurrentHashMap线程安全否是是锁粒度无锁整个Map实例桶位或分段JDK 1.8为桶级锁实现无synchronizedCAS synchronized允许null键/值是否否底层结构数组链表红黑树数组链表数组链表红黑树默认初始容量161116扩容机制2倍扩容2倍1扩容2倍扩容迭代器行为fail-fastfail-fast弱一致性能高并发不适用极低高这里要注意两个容易忽略的细节其一HashTable和ConcurrentHashMap都不允许null键和null值原因是它们在并发环境下无法区分“键不存在”和“键对应的值为null”这两种情况而HashMap允许null是为了单线程场景下的简化操作。其二ConcurrentHashMap的迭代器是弱一致性的这一点在之后的章节里会专门展开解释。2. HashMap底层实现原理详解2.1 从数组链表到红黑树的进化写到HashMap的底层原理时我需要先说明一个背景JDK 1.7和JDK 1.8的HashMap差异非常大现在主流的线上环境几乎都是JDK 8及以上所以下面的讲解以JDK 1.8为准只在关键差异处提1.7的旧设计。HashMap的核心数据结构是NodeK,V[] table这个数组的每个元素就是一个“桶位”。当你put一个键值对时会执行这样一个流程先根据key.hashCode()计算出哈希值h然后通过(n - 1) hn为数组长度计算出桶位下标。这里用位运算代替取模运算是因为当n是2的幂次方时(n - 1) h等价于h % n但位运算的性能更高。如果这个桶位已经存在Node节点那就发生了哈希冲突。JDK 1.8的解决方案是先把冲突节点追加成链表当链表长度超过阈值8并且数组长度达到64时链表会转换为红黑树。这个设计的核心动机是防止哈希攻击——当恶意构造大量哈希值相同的键时链表会退化成一个超长的线性结构查询复杂度从O(1)恶化到O(n)。而红黑树能保证最坏情况下的查询复杂度为O(log n)。我见过很多初学者对“链表转红黑树”有误解以为只要链表长度超过8就转树。实际上有两个前置条件必须同时满足链表长度要达到8且数组容量要达到64。如果数组容量还没到64系统会优先进行扩容而不是转树。原因很容易理解当容量较小、哈希值分布又过于集中时本质上是数组太小导致的这时应该扩大数组让元素散开而不是用红黑树去强行兜底。2.2 put和get方法的完整执行流程put方法的执行流程可以用下面这段伪代码来表示我尽量写得和真实源码逻辑一致但更易读final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { NodeK,V[] tab; NodeK,V p; int n, i; // 1. 如果table为空或长度为0调用resize()初始化 if ((tab table) null || (n tab.length) 0) n (tab resize()).length; // 2. 计算桶位下标如果该位置为空直接放入新节点 if ((p tab[i (n - 1) hash]) null) tab[i] newNode(hash, key, value, null); else { // 3. 桶位已有元素说明发生哈希冲突 NodeK,V e; K k; if (p.hash hash ((k p.key) key || (key ! null key.equals(k)))) e p; // 4. 哈希值和key都相同视为覆盖操作 else if (p instanceof TreeNode) e ((TreeNodeK,V)p).putTreeVal(this, tab, hash, key, value); // 5. 红黑树节点插入 else { // 6. 遍历链表要么找到相同key覆盖要么追加到链表末尾同时统计长度 for (int binCount 0; ; binCount) { if ((e p.next) null) { p.next newNode(hash, key, value, null); if (binCount TREEIFY_THRESHOLD - 1) // 达到7时说明链表长度为8 treeifyBin(tab, hash); break; } if (e.hash hash ((k e.key) key || (key ! null key.equals(k)))) break; p e; } } // 7. 如果找到相同key的节点返回旧值 if (e ! null) { V oldValue e.value; if (!onlyIfAbsent || oldValue null) e.value value; afterNodeAccess(e); return oldValue; } } modCount; // 8. 元素个数超过阈值threshold触发扩容 if (size threshold) resize(); afterNodeInsertion(evict); return null; }从这段伪代码可以看出一个很重要的设计思想HashMap在写入时并不加锁所以多个线程同时执行第2步时两个线程可能同时发现同一个桶位为null然后各自创建新节点并赋值后写的线程会覆盖先写的线程造成数据丢失。这就是HashMap线程不安全的最直接体现。至于get方法它的流程相对简单先根据哈希值定位到桶位如果桶位首节点就是目标节点则直接返回否则判断该节点是树节点还是链表节点分别用find或遍历链表的方式查找。在树节点上的查找本质上是用二叉搜索树的方式定位时间复杂度O(log n)。2.3 扩容机制resize()到底做了什么扩容机制是HashMap中最容易让人困惑的部分尤其是在多线程环境下JDK 1.7版本的扩容还会导致死循环问题这也是面试中非常经典的一个考点。我先说JDK 1.8的扩容流程。当元素个数size超过thresholdthreshold 容量 * 加载因子默认加载因子0.75时触发resize()。新容量是老容量的2倍然后需要把老数组中的所有元素重新计算桶位并迁移到新数组中。之所以要重新计算桶位是因为桶位下标是用(n - 1) hash计算的数组长度变了同一个元素映射到的桶位大概率也会变。JDK 1.8对迁移过程做了一个很好的优化因为新容量是2倍的扩展所以每个元素在新数组中的位置要么是原下标位置要么是“原下标 老容量”的位置。举例来说老容量为16元素哈希值为21它映射到的下标是(16-1) 21 5。扩容到32后新下标是(32-1) 21 21也就是5 16。这个规律来自于哈希值第5位以16为基数时的1位是1还是0如果哈希值那一位是0下标不变如果是1下标加老容量。因此JDK 1.8在迁移时不需要重新计算每个元素的哈希值只需要看那一位即可这也是loHead和hiHead两条链表分别收集的原因。JDK 1.7的扩容有一个严重问题它会采用头插法把新迁移的节点插到链表头部来转移元素。在单线程下这没问题但多线程同时触发扩容时可能造成链表成环。一旦链表成环下一次get操作时会进入无限循环CPU飙到100%。JDK 1.8改成了尾插法一定程度上避免了这个问题但HashMap在多线程下仍然不是安全的数据覆盖和丢失依旧会发生。2.4 HashMap为什么线程不安全这里给出三个最典型的并发问题使用HashMap做并发的读写操作最常遇到的三个问题第一个是数据覆盖。两个线程同时执行put操作且要写入的key对应同一个数组下标时两个线程都判断出当前桶位为null然后分别创建节点。先执行的线程写入了节点A后执行的线程直接用节点B覆盖了节点AA的数据就丢了。第二个是size计数不准确。size这个操作不是原子的它实际上是“读取-加1-写回”三个步骤。两个线程同时读到size5各自加1后写回结果size6但实际插入了7个元素。这会直接导致后续的扩容判断出错。第三个是在JDK 1.7中可能发生扩容死循环。当两个线程同时发现size超过了threshold并调用resize()时线程A执行到一半被挂起线程B完成了整个迁移过程之后线程A恢复执行就会基于已经被B改过的链表继续头插迁移形成环形链表。后续对该链表的遍历查询就永远走不完。正因为这三个问题的存在在并发环境下我们绝不建议直接使用HashMap。这也是为什么我们在日常开发中只要检测到会有多线程写入同一个Map就一定要改用ConcurrentHashMap或者用Collections.synchronizedMap()包裹一层。3. HashTable的锁策略与性能瓶颈3.1 全表锁到底锁住了什么HashTable是JDK 1.0时代就存在的“老古董”它的线程安全策略非常直接几乎所有涉及读写的方法都用synchronized修饰。也就是说当多个线程同时访问HashTable时不论你是get、put还是remove都会竞争同一把对象锁。锁住整张表意味着什么我用一个生活场景来类比你去一个小饭馆吃饭整个饭馆只有一个厨师不管有多少客人点菜都必须排队等同一个厨师炒菜。如果这个厨师正在做一道耗时的炖菜后面十几个客人点的拍黄瓜也只能干等着。HashTable的问题就在这里——所有操作串行化CPU的多核能力完全被浪费。我实测过一个场景8个线程并发向HashTable写入10万条数据耗时大约是ConcurrentHashMap的4到6倍。在高并发读多写少的场景下HashTable的get操作同样会被put操作阻塞这是最让人难以接受的一点。读操作本身是无副作用的按理说应该能够并发执行但因为加了全表锁读也被迫等待。3.2 为什么永远不应该在新代码中使用HashTable有的开发者不理解一个道理反正有现成的线程安全Map为什么不用HashTable答案很简单它的设计理念已经严重落后于现代高并发场景。首先是性能问题。全表锁意味着任何一定规模的并发访问都会形成锁竞争线程越多性能越差。我在一个压测项目里做过对比4个线程时HashTable和ConcurrentHashMap的吞吐差距大约在2倍左右到16个线程时差距拉大到5倍以上。原因是锁竞争加剧时线程大量时间耗在等待锁上而CAS和细粒度锁能大幅减少这种等待。其次是功能缺失。HashTable不包含任何为高并发设计的特性比如它不是用volatile修饰内部共享变量在多线程可见性上也不如ConcurrentHashMap严谨。它的迭代器是fail-fast快速失败的也就是说在迭代过程中如果其他线程修改了Map会抛出ConcurrentModificationException。虽然ConcurrentHashMap没有这个缺陷但和HashMap相比HashTable没有任何优势。第三是历史包袱。HashTable设计于Java还在JDK 1.0的时代那时计算机普遍是单核CPU设计者考虑的是如何简单粗暴地保证线程安全而非如何利用多核。如今市面上几乎所有的服务器都是多核CPU再使用HashTable等于主动放弃硬件红利。所以我的结论很明确无论从哪方面看HashTable都是一个应该被打入冷宫的实现。如果你想用线程安全的MapConcurrentHashMap是首选如果只是临时需要在一个低并发场景下保证安全用Collections.synchronizedMap()配合必要的同步块也更灵活。不过我得说一句真正的大型项目里这些方案都建议谨慎评估能不用带锁的Map就别用带锁的Map很多时候我们完全可以通过线程封闭或不可变对象来规避并发问题。4. ConcurrentHashMap的底层设计与并发演进4.1 JDK 1.7的分段锁是怎么工作的ConcurrentHashMap在JDK 1.7时期的设计非常经典它是Doug Lea从Concurrent Programming in Java中提炼出来的一套分段锁思想。分段锁的核心思路是把整张表分成若干段Segment每个Segment本身就是一个小的HashTable拥有自己的锁。默认情况下ConcurrentHashMap创建16个Segment每个Segment独立加锁。一个线程写入数据时只需要锁住对应的Segment其他线程可以无阻碍地读写别的Segment。这样一来理论上并发度可以达到16倍。在定位数据时ConcurrentHashMap需要两次哈希第一次哈希定位到Segment第二次哈希定位到Segment内部的桶位。这种设计让锁的粒度从“整张表”降到了“一段表”相比HashTable是巨大的进步。但在高并发下仍然有问题如果操作的key分布不均匀某个Segment可能积累大量热点数据这个Segment的锁竞争会非常激烈而且table扩张时是有序扩张先扩Segment内部的数组某些场景下会出现某些Segment先满、某些Segment很空的情况。4.2 JDK 1.8为什么抛弃分段锁改用CASsynchronizedJDK 1.8抛弃了Segment这种分段锁设计改用粒度更细的“桶级锁”直接对数组中的每个桶位即链表的头节点或红黑树的根节点加锁。加锁方式选择了synchronized关键字而不是ReentrantLock。这背后有几个考量synchronized在JDK 1.6之后被大规模优化过引入了偏向锁、轻量级锁、重量级锁的锁升级机制。在低竞争场景下synchronized性能已经非常不错而且不需要手动释放锁代码更简洁。设计者选择synchronized还有一个好处锁对象的生命周期和桶位节点的生命周期一致当桶位为null时不需要加锁直接用CAS尝试插入只有当桶位已有节点时才对这个头节点加synchronized锁。CASCompare And Swap则用于无锁状态下的插入操作。当桶位下标对应的位置为null时线程通过CAS原子地放入新节点。CAS的过程是先读取内存中的值如果这个值仍然为null没有被其他线程修改过就把新节点写入如果不为null说明其他线程抢先了一步当前线程需要重新获取桶位锁进行加锁插入。这个组合的精妙之处在于无竞争时用CAS几乎零开销有竞争时用synchronized锁住单个桶位允许不同桶位之间并发操作。锁粒度比分段锁更小并发度更高。边界上需要说明的是JDK 1.8的ConcurrentHashMap在扩容时也有专门的协助扩容机制多个线程可以一起帮忙迁移元素进一步利用多核资源。4.3 put流程与扩容时的高并发处理细节JDK 1.8的ConcurrentHashMap在put操作时整体流程我结合源码分析过很多次核心逻辑如下首先判断key和value是否为null如果是直接抛出NullPointerException。这是和HashMap最大的区别之一原因前面已经说过并发环境下无法接受null键值。接着计算哈希值定位到数组下标。如果该位置为null尝试用CAS直接插入节点。如果CAS失败说明有竞争进入下一步对桶位头节点加synchronized锁然后按照链表或红黑树的方式完成插入。在插入过程中还会检查当前是否正在扩容如果是先协助完成扩容再做写入操作。元素个数通过baseCount配合CounterCell[]计数CounterCell在并发竞争时用来分散计数压力避免所有线程都去争抢同一个计数变量。扩容过程是ConcurrentHashMap中最复杂的部分我简单解释一下当size超过阈值时触发扩容老数组会被一个nextTable替代。扩容过程中老的数组的所有桶位会被逐个迁移到新数组迁移完成的桶位会被标记为ForwardingNode这个节点的hash值是一个特殊值-1。当其他线程访问到这个ForwardingNode时就知道这个桶位正在或已经迁移完毕读线程会直接去新数组中查找写线程则会帮助扩容完成后再执行写入。这种“协助扩容”的思想在Java并发架构中是先驱级的它让扩容不再是一个阻塞操作而是由所有参与线程共同完成的任务。实际运行中即使是扩容期间请求方依然能保持较快的响应速度。4.4 弱一致迭代器读操作无需加锁ConcurrentHashMap的get操作是完全不加锁的它直接基于volatile读来保证可见性。这种方式有一个特点弱一致性。所谓弱一致性是指在迭代过程中如果其他线程修改了Map迭代器不保证能立即看到最新数据也不保证不会抛出异常。与HashMap的fail-fast不同ConcurrentHashMap的迭代器不会抛出ConcurrentModificationException。这个特性在实际开发中非常实用。举个例子一个配置中心的Map在运行时可能会被某个线程更新同时多个线程会遍历读取。如果用HashTable遍历时其他线程写入轻则阻塞重则抛异常而ConcurrentHashMap允许这种并发操作遍历线程看到的是“某个时间点的快照”不一定是最新的但不会崩溃。如果要保证最多只允许一个线程修改同时读时能拿到最新值就应该使用CopyOnWriteArray之类的容器。这里借这个例子提醒一下锁选型和一致性要求有强关联不能只盯着吞吐量这一个维度。5. 实践中的选型建议与排查实录5.1 高并发场景下如何正确选择Map实现做了这么多年Java开发我总结了一个选型判断树写在这里供你参考首先是判断你的Map是否会跨线程共享。如果不会用HashMap这是性能最优解。在单线程或线程封闭场景下强行用ConcurrentHashMap相当于给不拥挤的道路装红绿灯白白增加开销。其次是如果有多线程共享再判断读写比例。写多读少且对实时一致性要求高用ConcurrentHashMap。读极多、写极少并且可以容忍一定的读延迟可以考虑用CopyOnWriteMap但要注意它每次写都会复制整个底层数组数据量大时内存开销非常客观。第三个维度是key和value是否允许为null。在服务端代码里我通常建议在入口处就把null提前过滤掉不要依赖Map来容忍null。这样从源头避免NPE问题也为将来更换Map实现留出余地。第四个维度是如果只要求整体有序或按插入顺序遍历Map选型还得考虑LinkedHashMap或TreeMap但这已经是另一个话题了这里不展开。5.2 常见问题速查表我整理了一份在面试和实战中最高频遇到的相关问题清单每个问题附上核心结论问题结论HashMap和HashTable有什么区别线程安全、null处理、容量、锁机制、迭代器等均有差异HashMap的加载因子为什么是0.75时间与空间折中过高降低查询性能过低浪费空间为什么链表转红黑树的阈值是8基于泊松分布的统计理想情况下桶位元素个数为8的概率极低JDK 1.8的ConcurrentHashMap为什么放弃分段锁锁粒度更细、synchronized升级后性能可观、代码更简洁ConcurrentHashMap读操作需要加锁吗不需要基于volatile读和弱一致性保证HashMap扩容时多线程会死循环吗JDK 1.7会JDK 1.8链表采用尾插法已基本规避但仍不安全表格里的第五个问题需要补充一句ConcurrentHashMap的读操作虽然不加锁但是在极端情况下可能读到旧数据这在设计上是被接受的因为它追求的就是高吞吐和最终一致性。5.3 一次线上故障HashMap引发的数据覆盖事故我在很多年前遇到过一起真实的生产事故原因就是HashMap被多线程并发写入。当时一个订单系统在抢购活动中会多线程更新每个用户的优惠券Map用的是HashMap。活动刚开始时并发量不高问题没有暴露但到了高峰时段突然有部分用户下单后优惠券金额显示为0。排查时发现刚开始GC日志正常也没有OOM最后通过jstack和日志定位发现是HashMap在多线程写入时发生了数据覆盖。优惠券Map被多个线程同时put一个线程写入的金额被另一个线程覆盖成了默认值而且因为size计数不准确部分优惠券根本没放进去。后来我们把HashMap换成了ConcurrentHashMap问题立刻消失那之后我对于多线程下的集合选型就格外谨慎。这次经历让我明白一个道理很多Java基础知识看起来是“面试题”实际上就是线上故障的源头。HashMap为什么不安全ConcurrentHashMap为什么能替代它只有踩过坑或者深度理解原理才能真正记住。最后再分享一个小技巧在排查类似隐患时可以在代码里加一个简单的断言或监控——如果在启动参数中开启了-ea可以判断Map的类型也可以周期性统计Map的size和外部操作次数进行一致性比对多一道防线总会心安不少。这个技巧我们后来一直沿用屡试不爽。