ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Java Map集合源码深度解析:从HashMap到ConcurrentHashMap

2026/9/30 6:24:45 拓冰建站 浏览量
Java Map集合源码深度解析:从HashMap到ConcurrentHashMap 写这篇博客的初衷很简单我做了快十年的Java开发面试过几百人也带过不少新人发现大家聊到Map集合的时候基本都停留在“会用”的层面——知道HashMap存键值对、知道线程不安全要用ConcurrentHashMap但一旦深问“put一个键值对到底发生了什么”“为什么容量要设成2的幂”“扩容的时候数据怎么迁移”十有八九会卡壳。这篇博客我就把Map集合掰开揉碎从源码层面好好讲一遍不只是让你看懂HashMap、LinkedHashMap、TreeMap、ConcurrentHashMap这几个核心实现更想帮你掌握一套读懂源码的方法论。不管你是刚入门想打牢基础还是工作几年想补一补底层知识这篇应该都能给你一些实打实的收获。1. 先把Map的“族谱”理清楚从接口设计到七大实现1.1 Map接口它到底规定了哪些“契约”很多人会把Map和Collection混在一起说其实这是Java集合框架里并列的两大体系。Collection是单列数据的集合不管是List还是Set存的都是单个元素而Map是双列数据的集合每个元素都是一个键值对Entry通过key来定位value。我经常打一个比方Collection像是一个储物柜每个格子里放一件东西Map更像是一个通讯录你通过“姓名”这个key才能找到对应的“电话”这个value。打开java.util.Map的源码我用的是JDK 8版本这也是目前生产环境最主流的一个版本你会发现接口本身定义了两大类方法。第一类是基础操作put(K key, V value)放入键值对、get(Object key)根据键取值、remove(Object key)删除键值对、containsKey/containsValue判断是否包含某个键或值。第二类是视图操作keySet()返回所有键的集合、values()返回所有值的集合、entrySet()返回所有键值对的集合。这里有个容易被忽略的细节就是Map接口在Java 8之后新增了很多default方法比如getOrDefault、putIfAbsent、computeIfAbsent、merge等。这些default方法特别值得花时间读一读源码因为它们的很多实现都是基于get和put这两个基础方法组合出来的。比如说computeIfAbsent它的典型场景是做缓存key不存在的时候才去计算value并放入Map避免了“先判断再放入”这种非原子操作带来的并发问题。源码里它的实现先检查key是否存在于当前Map如果不存在再调用mappingFunction计算新值然后判断新值非空才put进去。很多人用ConcurrentHashMap做本地缓存时都会优先选择computeIfAbsent而不是“先get再put”就是因为这个方法是原子性的。还有一个小知识点Map接口支持null键和null值吗这个得看具体实现。HashMap允许一个null键和多个null值Hashtable和ConcurrentHashMap连null键都不允许。为什么因为ConcurrentHashMap的作者在注释里写过null键或者null值会带来二义性——get返回null时你分不清是“这个key不存在”还是“这个key对应的value就是null”。为了在并发场景下保证语义清晰干脆直接禁止。1.2 七大实现类各自是干什么的Map接口的大家庭里日常开发中你会碰到的实现类大致有七个HashMap、Hashtable、LinkedHashMap、TreeMap、ConcurrentHashMap、WeakHashMap、IdentityHashMap。很多初学者看到这一堆名字就头大其实从设计和应用场景来看它们的关系并不复杂。HashMap是基础实现无序、允许null、线程不安全日常90%的Map场景都在用它。Hashtable是早期JDK提供的线程安全实现所有方法都加了synchronized但正因为同步粒度太粗、性能太差基本已经被ConcurrentHashMap取代了现在也就是面试题里拿来对比一下。LinkedHashMap继承了HashMap在内部额外维护一条双向链表用来记录元素插入的顺序所以它是“有序”的而且它还有一种特殊的构造参数accessOrder设置为true时会按照访问顺序排序这是实现LRU缓存的经典方案。TreeMap基于红黑树key必须可以比较实现Comparable或者传入Comparator迭代出来的顺序是key的自然顺序或者自定义顺序。ConcurrentHashMap是并发场景下的王牌JDK 8之后采用CAS加synchronized读操作无锁写操作只锁对应的桶位并发性能比Hashtable高了不止一个量级。WeakHashMap比较冷门它的key是弱引用一旦key不再被外部引用垃圾回收时就会自动把对应的键值对移除适合做某些缓存场景。IdentityHashMap更特殊它比较key用的是引用相等而不是equals一般只在一些框架源码、序列化工具里有使用场景。我建议你在学习的时候不要一个类一个类孤立地看而是以HashMap为基准搞清楚它和其他每个实现之间的差异点在哪里用一个对比表把差异点串起来这样效率会高很多。下面这个表是我整理的建议收藏实现类底层结构有序性null键线程安全典型场景HashMap数组链表红黑树无序允许否绝大多数通用场景LinkedHashMapHashMap结构双向链表插入序/访问序允许否LRU缓存TreeMap红黑树key自然序/比较器序不允许否范围查询、排序Hashtable数组链表无序不允许全方法synchronized基本已被淘汰ConcurrentHashMap数组链表红黑树CASsynchronized无序不允许是并发缓存、统计WeakHashMap数组链表无序允许否缓存弱引用场景IdentityHashMap数组链表线性探测无序允许否引用相等比较场景1.3 Map.Entry为什么Map需要这样一个内部接口再说一个很多人没关注过的细节Map接口内部还定义了一个嵌套接口EntryK,V。为什么Map要搞一个内部接口出来因为一个键值对本身就是Map里的一个最小组成单元把“键值对”这个抽象概念抽出来既能方便外部通过entrySet()拿到所有键值对进行遍历也方便框架层面对键值对做统一的处理。在JDK 8里HashMap的Node类就实现了Map.Entry接口它除了保存key、value之外还有一个next指针指向下一个Node。这一点在链表法解决哈希冲突时非常关键——当多个key映射到同一个数组下标时它们就通过这个next指针串成一条链表。深入源码之后你会发现读Map源码时看懂Entry的实现基本上就相当于握住了整条线索的主干。2. HashMap源码深度拆解一次put操作背后发生了什么2.1 底层存储结构数组、链表、红黑树HashMap在JDK 8里的底层结构一句话即可概括数组加链表链表长度达到阈值后转为红黑树。专业一点说是“散列表 拉链法 红黑树优化”。为什么这么设计这里面其实有一段演进历史。最原始的HashMap就是数组加链表。数组的每个位置叫作桶bucketput一个键值对时先用hash函数算出key对应的桶下标如果这个桶还没有元素就直接把Node放进去如果这个桶已经有元素了就顺着链表往后挂。get时也是同样的过程先定位桶再在链表上一个个比较key是否相等。理想情况下如果hash函数足够均匀每个桶里最多只有一两个元素那么put和get的复杂度都是O(1)非常快。但现实世界的hash函数不可能绝对均匀假设有些桶的元素注定会多一些。一旦某个桶里的链表变得很长get的复杂度就会退化成O(n)这就没法接受了。所以JDK 8做了优化当链表长度达到阈值8并且数组长度达到64时链表会转换成红黑树利用红黑树O(log n)的查找性能避免极端情况下性能劣化太严重。这里有个特别容易被误解的点树化之后退化条件是什么很多人以为只要链表长度降到6就会从红黑树转回链表其实源码里的判定条件是扩容时看树中节点数量是否小于等于6。而且元素的hash值没变时红黑树拆分过程中是直接按低位高位分组然后判断每组节点数决定保持树结构还是退化成链表。2.2 hashCode的“二次加工”扰动函数到底做了什么我们来看HashMap里最精髓的一段代码它出现在hash方法中static final int hash(Object key) { int h; return (key null) ? 0 : (h key.hashCode()) ^ (h 16); }这段代码很好理解先拿到key的hashCode然后让高16位和低16位做一个异或运算。为什么非要这么干这就要说到HashMap计算桶下标的逻辑了。在putVal里桶下标的计算方式是i (n - 1) hash这里n是数组长度而且HashMap保证n永远是2的幂所以(n - 1)的二进制表示是低位全为1。拿hash和(n - 1)做与运算本质上就是取hash的低位部分作为下标。问题来了如果HashMap容量只有16n - 1的二进制是1111那么下标只取决于hash值的低4位高28位参与运算等于白算这时候只要hashCode的低位分布不够均匀碰撞就会很严重。扰动函数的作用就是让高位也参与进低位的计算里来通过将高16位与低16位异或把高位的特征混入低位从而让映射结果更均匀。这也是为什么这个函数叫“扰动函数”它把原始hashCode的信息“打散”了。我实测过一些字符串key经过扰动之后碰撞率确实明显下降尤其在Map容量比较小的时候效果最明显。顺带一提链表法本身就是一个解决哈希冲突的通用方案Java中的HashMap用的是“链地址法”而ThreadLocalMap或者IdentityHashMap用的是“开放地址法”两者各有利弊这里不展开但你可以记住链地址法适合哈希冲突较多、负载因子较高的情况开放地址法更省内存但冲突处理起来成本更高。2.3 put和get的完整流程从头到尾走一遍先看put的流程。源码入口是put(K key, V value)它内部调用putVal(hash(key), key, value, false, true)我来把这几个参数解释一下hash(key)就是上面说的扰动后的哈希值false表示“不覆盖已有键值”true表示“允许修改”。putVal的完整流程是这样的我按步骤拆给你看如果table为空或者长度为0先调用resize()完成初始化。默认情况下第一次put时table为null所以首次put一定会触发一次resize。根据(n - 1) hash得到数组下标i取出tab[i]判断是否为null。如果为null说明这个桶是空的直接new一个Node放入tab[i]此时put操作完成没有发生哈希冲突。如果tab[i]不为null说明发生了哈希冲突需要分情况处理如果tab[i]的hash和key与当前要put的完全相等说明是“同一个键”记下这个节点准备替换value如果tab[i]是TreeNode类型说明这个桶已经树化了走红黑树的插入逻辑否则走链表逻辑——遍历链表逐个比较key如果找到了相同的key就准备替换value如果遍历到链表尾部还没找到就在尾部新增一个Node。链表插入新节点后如果链表长度已经大于等于8调用treeifyBin尝试树化。注意treeifyBin里还有一个判空条件如果数组长度小于64只是调用resize扩容并不会真正树化。最后判断本次操作是否产生了“新增”行为也就是没覆盖旧值如果新增了就更新modCount和size并检查size是否超过了扩容阈值超过就resize。get的流程就要简单不少。先计算出key的hash定位到数组下标如果桶首节点就是目标直接返回如果不是再根据桶首节点是TreeNode还是普通Node分别走红黑树查询或链表遍历查询。NULL是允许的HashMap允许null键hash(null)固定返回0所以null键会落到table[0]里。我在这里特别提醒一点一个常见的面试问法是“HashMap put的时候如何判断两个key相等”答案是先比较hash再比较equals。也就是说同一个桶里的两个节点hash一定相同但hash相同的节点key不一定相等还需要用equals进一步比较。这个设计逻辑直接影响了我们自定义对象作为key时为什么必须同时重写hashCode和equals。2.4 扩容、负载因子、树化的权衡接下来是HashMap最核心的机制——扩容。扩容不只是一个“数组变大了”这么简单它涉及所有键值对的重新分布开销非常大。HashMap的默认初始容量是16默认负载因子是0.75。扩容阈值是容量乘以负载因子也就是16乘以0.75等于12意思就是当Map里元素个数超过12个的时候就会触发扩容容量翻倍变成32阈值也变成24。为什么负载因子默认是0.75而不是0.5或者1这个数值是时间和空间的一个折中。负载因子越大比如1.0那么数组空间利用率更高但是哈希冲突会更严重链表变长查询性能下降负载因子越小比如0.5空间浪费严重但冲突少、查询快。0.75这个值在统计学上是一个比较均衡的选择。我个人的经验是如果你能预估Map要存放的数据量最好在创建时直接指定初始容量避免频繁扩容带来的性能损耗。比如你明确知道要放一万条数据那就应该用new HashMap(10000 / 0.75f 1)这样基本不会触发扩容。再看resize的源码重点有两个。第一个是旧数据迁移时的rehash逻辑扩容后数组长度n变成了2n每个元素新的下标只有两种可能——保持在原下标i或者变成i oldCap。为什么因为(n - 1) hash在n变成2n后相当于增加了一个高位参与计算这个高位的值恰好就是oldCap对应那一位的值。JDK 8对迁移过程也做了优化不用像JDK 7那样重新计算每个节点的hash而是直接根据(n - 1) hash新增的那一位是0还是1把链表一分为二分别放到原位置和“原位置oldCap”的位置这个优化大大提升了扩容效率。还有一个细节是扩容后可能发生“反树化”。当一个红黑树被拆分到两个新桶里时如果某个桶里节点数量比较少会按照节点数是否小于等于6来决定是否把红黑树退化成链表。这个6和树化阈值8之间留了缓冲就是为了避免元素在临界值附近反复横跳导致频繁树化和反树化白白浪费性能。3. LinkedHashMap与TreeMap有序Map的两种姿势3.1 LinkedHashMap双向链表怎么“记住”顺序LinkedHashMap继承自HashMap它并没有重写put、get这些核心方法而是通过在几个关键方法上做手脚实现了“有序”。具体来说HashMap里定义了三个钩子方法afterNodeInsertion(boolean evict)插入节点后回调afterNodeAccess(Node e)访问节点后回调afterNodeRemoval(Node n)移除节点后回调这三个方法在HashMap里都是空实现LinkedHashMap通过重写它们来维护一条贯穿所有节点的双向链表。当你put一个键值对时它会新创建一个Entry节点然后把这个节点加到双向链表的尾部。所以LinkedHashMap迭代时的顺序默认就是元素插入的顺序。看源码你会发现LinkedHashMap.Entry继承了HashMap.Node在原有的next指针基础上增加了before和after两个指针分别指向前一个和后一个节点。这里要注意区分next是解决hash冲突用的单链表指针只有在同一个桶里的节点之间才有关系而before/after是维护全局顺序用的双向链表指针所有节点都会串起来。更惊艳的是访问顺序模式。LinkedHashMap的构造方法有一个accessOrder参数默认是false按插入顺序如果设置为true那么每次get访问一个节点时afterNodeAccess会把该节点移动到链表尾部。这样一来链表头部的节点就是“最久没有被访问”的链表尾部的节点就是“最近刚被访问”的——“最近最少使用”这个语义就自然成立了。这也是为什么我前面说LinkedHashMap是实现LRU缓存最优雅的方式。网上那些LRU缓存代码核心思路无一例外都是继承LinkedHashMap并重写removeEldestEntry方法当缓存大小超过上限时让最老的节点被自动移除。我自己在项目里就实现过一个基于LinkedHashMap的本地缓存配合accessOrdertrue再重写一下removeEldestEntryclass LRUCacheK, V extends LinkedHashMapK, V { private final int maxSize; public LRUCache(int maxSize) { super(maxSize, 0.75f, true); this.maxSize maxSize; } Override protected boolean removeEldestEntry(Map.EntryK, V eldest) { return size() maxSize; } }这段代码简单到让我都有点不真实感但它确实就是LRU缓存的经典实现。用起来之后彻底理解了LinkedHashMap的作者为什么要在注释里说这个类“特别适合构建LRU缓存”。3.2 TreeMap红黑树与Comparator的排序逻辑TreeMap的底层是一棵红黑树它和HashMap的思路完全不一样HashMap通过hash散列实现“近乎O(1)”的查询TreeMap则通过树形结构实现“O(log n)”的查询而且天然支持有序遍历和范围查询。TreeMap的key必须是可以比较的要么key类型实现了Comparable接口要么在创建TreeMap时传入一个Comparator。它内部的插入、删除、查找都是标准的红黑树操作为了维持树的平衡在插入和删除之后会执行旋转和变色操作。我不建议你去硬背那些“左旋右旋”的细节你只需要理解红黑树的几条核心规则每个节点非红即黑根节点是黑色的红色节点的子节点必须是黑色任意节点到叶子节点的每条路径包含相同数量的黑色节点。这些规则保证了树的近似平衡使得树的高度控制在log n级别。有一个特别容易踩坑的场景你往TreeMap里放了自定义对象作为key但是又没有实现Comparable也没有传Comparatorput的时候会直接抛出ClassCastException。更隐蔽的坑是如果Comparator写得不严谨比如两个对象通过equals比较不相等但通过Comparator比较返回0那么在TreeMap看来它们就是同一个key后put的那个会直接覆盖前一个。所以使用TreeMap时Comparator的一致性和正确性一定要想清楚。TreeMap最有价值的应用场景是范围查询比如subMap(fromKey, toKey)、headMap(toKey)、tailMap(fromKey)这些方法可以很高效地取出一段连续范围的key。我做过一个基于时间戳排序的定时任务调度器就是用TreeMap存储“时间戳到任务”的映射然后通过tailMap拿到当前时间之后最早的一批任务比遍历HashMap再排序要优雅高效得多。3.3 源码对比迭代顺序的差异如何影响使用场景从源码实现对比这两种有序Map你会发现它们的差异非常大。LinkedHashMap的有序性是“记录顺序”得到的它本身还是一个哈希表查询还是O(1)TreeMap的有序性是“树结构自带的属性”每次查询都是O(log n)。所以选择它们时核心判断标准是你需要的是“保持插入顺序”还是“按键排序”。如果你只是希望Map迭代的时候不“乱跳”那就选LinkedHashMap如果你的业务逻辑依赖key的大小顺序比如要做区间查询、获取最小最大key那就选TreeMap。两者性能差异也值得注意LinkedHashMap因为额外维护了一条双向链表内存占用比普通HashMap高一些TreeMap的每个节点都要维护颜色标记和左右子节点指针内存占用更高。我在带团队做code review时看到过很多“用HashMap存储需要遍历展示的数据”结果线上数据一多展示顺序在有些机器上变得随机导致前端页面每次都不同的情况。这种需求其实用LinkedHashMap就能解决而且改动成本极低。4. ConcurrentHashMap并发环境下的Map源码演进4.1 为什么Hashtable会被淘汰Hashtable的线程安全策略简单粗暴所有公开方法全部加synchronized相当于给整个Map加了一把全局大锁。这样做的确保证了线程安全但代价是任何时刻只能有一个线程执行读写操作并发量稍微上来一点其它线程就只能阻塞等待性能急剧下降。我在一个高并发的统计场景里做过压测用Hashtable和ConcurrentHashMap分别做10万次并发写入Hashtable的耗时差不多是ConcurrentHashMap的好几倍。原因很简单Hashtable每写一个key都会把整张表都锁住而ConcurrentHashMap只锁一个桶其他线程依然可以操作别的桶。所以现代Java开发中如果没有特殊的兼容需求Hashtable基本没有存在意义。4.2 JDK 8的CAS synchronized锁粒度细到桶JDK 8的ConcurrentHashMap比JDK 7又做了一次大升级。JDK 7的方案是“分段锁”把整个Map分成16个Segment每个Segment内部是一个HashTable不同线程操作不同Segment时互不影响JDK 8彻底抛弃了Segment直接使用Node数组加CAS加synchronized锁粒度从“一段”细到了“一个桶”并发性能进一步大幅提升。具体来说put流程是这样的首先计算hash定位桶位置如果桶为空用CAS直接放入Node这一步不加锁是并发场景下最乐观的路径如果桶不为空给桶的首节点加synchronized锁然后走链表或红黑树的插入逻辑。因为synchronized锁的是单个桶的首节点所以不同线程同时写不同的桶时完全不会互相阻塞只有写同一个桶的时候才需要竞争锁。Deluxe扩容机制也是JDK 8 ConcurrentHashMap的亮点之一。它在扩容时不是一次性把所有数据迁移完而是“多线程协助扩容”——每个线程执行put操作前会检查当前是否正在扩容如果是就顺便帮一把分担一部分桶的迁移工作。这算是并发程序设计里一个比较巧妙的思路把高开销操作的时间分摊到多个线程上。4.3 并发场景下那些容易忽略的细节并发使用Map时有很多细节值得你注意。第一个是size()方法在多线程环境下它不保证返回一个绝对准确的实时值。这是出于性能考虑因为精确计数需要加锁遍历所有桶代价很高。JDK 8的size()返回的是一个估计值它通过sumCount方法把baseCount和CounterCell数组里的值累加起来但如果并发写入很频繁这个值可能是“有一定延迟的”。第二个是computeIfAbsent即使ConcurrentHashMap本身线程安全computeIfAbsent里的mappingFunction也可能会被并发地执行多次。不要在里面写有副作用的逻辑比如发送短信、扣减库存。源码里虽然对单桶加了synchronized但如果mappingFunction里面有网络调用会让锁持有时间变得很长反而放大阻塞。第三个细节是弱一致性问题。ConcurrentHashMap的get操作是弱一致的也就是说当一个线程往某个桶里写入了一个新Node但还没完全发布时另一个线程的get可能暂时看不到。这一点在绝大多数业务场景下是可以接受的但如果你需要绝对实时的可见性可能还是得用synchronized或者锁来兜底。5. 横向对比C map、Python dict、JavaScript Map实现的差异5.1 别看大家名字都一样底层思路天差地别C的std::map底层是一棵红黑树插入、删除、查找的复杂度都是O(log n)元素默认按键的大小排序。C标准库里还有另一个容器叫std::unordered_map它的底层是真正的哈希表查找近似O(1)但不保证有序。所以你看C里把“有序映射”和“无序映射”明确分成了两个类型这一点和Java的TreeMap、HashMap之分很像。Python的dict字典也是哈希表实现在Python 3.7之后官方保证dict的遍历顺序就是key的插入顺序这一点和Java的LinkedHashMap非常像。Python的dict底层使用了开放地址法而不是链地址法这意味着它在处理哈希冲突时的策略和Java完全不同如果某个槽被占用就继续探测下一个空闲槽位。因为开放地址法在负载因子过高时性能会迅速恶化所以Python的dict在负载因子超过2/3时就会触发扩容。JavaScript的Map是在ES6里引入的底层是由引擎自己实现的不同浏览器引擎的实现方式各有差异。但它有一个非常大的优势key不限于字符串或数字可以是任意对象同时它保持了插入顺序。与此对应的是普通对象Object它的key只能是字符串或者Symbol并不适合作为通用的键值对容器。5.2 从其他语言反观Java Map设计的优劣对比之后Java的Map设计其实有个可以改进的地方HashMap的遍历顺序完全不确定这在有些需要展示顺序的场景下会比较痛苦。虽然LinkedHashMap可以解决但它会带来额外的内存开销。Python的字典把“哈希表”和“插入有序”合二为一老实说在易用性上更胜一筹。不过Java的优势在于生态丰富针对不同场景提供了足够多的精细选择。比如要并发就选ConcurrentHashMap要排序就选TreeMap要LRU就选LinkedHashMap几乎每种数据结构需求都能找到对应的实现类。这种“一个接口、多个实现”的设计思路本身就是Java集合框架最值得学习的部分。你甚至可以借鉴这种设计思想在业务代码里定义好接口再按不同场景提供不同实现。6. 从源码到实战选型建议、读源码的方法与避坑经验6.1 业务场景下的Map选型建议根据我多年的实战经验我把选型建议整理成几句话你大概率能直接用上不确定用哪个就用HashMap大多数读写场景下它性价比最高。需要保持插入顺序用LinkedHashMap但要接受额外的内存开销。需要按key排序或做范围查询用TreeMap查询速度O(log n)可以接受。多线程环境优先选择ConcurrentHashMap。需要缓存且容量可控用LinkedHashMap的accessOrder模式实现LRU简单可靠。如果Map的key是自定义对象务必重写hashCode和equals否则非常容易出现“存进去却取不出来”的问题。6.2 读源码的几个实用技巧源码阅读这件事其实是有方法论的。直接打开HashMap.java硬啃很容易被各种位运算、边角逻辑劝退。我分享几个自己实践下来比较有效的方法。第一选定一个版本读。JDK 7和JDK 8的HashMap差异非常大JDK 7没有红黑树扩容可能产生环形链表所以读源码时一定要锁定版本不要来回跳。学习阶段我推荐直接读JDK 8这个版本同时有链表和红黑树结构更完整也更接近现代生产环境使用的版本。第二写最小Demo配合断点调试。在IDE里创建一个小项目放几个字符串进入HashMap在putVal和resize函数里打上断点然后一步步看变量变化。眼见为实比看几十篇源码分析文章都管用。我第一次真正搞懂resize的“链表一分为二”逻辑就是在Debug模式下看了几个小时变量的变化才想明白的。第三先画结构图后读逻辑。先把“数组链表红黑树”“双向链表”“红黑树”这些数据结构画出来再对照源码逐个方法去看会容易很多。看源码最忌讳的就是“逐行理解”因为有些代码是边界处理有些是性能优化都占用了大量篇幅但这些并不是主干。6.3 常见问题与排查技巧实录最后说一下我在实际项目中遇到的几个与Map有关的典型问题这些问题在面试中也经常被拿来考察候选人对源码的理解程度。第一个是高并发下HashMap扩容可能导致CPU飙升甚至死循环。这个问题在JDK 7中真实存在原因是扩容时头插法会让链表的引用关系在并发情况下形成环。JDK 8改成了尾插法这个问题得到了缓解但并发下HashMap仍然会丢失数据所以永远不要在多线程环境下使用HashMap。第二个问题是你可能会遇到ConcurrentHashMap的size()与实际元素数量不一致的情况。这不是bug前面已经说过了它本身就是一个弱一致性的估计值。如果业务上需要精确大小要么加分布式锁要么引入数据库计数等方案。第三个问题是自定义对象作为key时“明明内容一样却取不到值”。绝大多数情况都是因为只重写了equals而没有重写hashCode或者两者重写逻辑不一致。再次强调一遍HashMap判断key相等的底层逻辑先比较hash再比较equals两者必须同时满足。所以自定义key对象时equals和hashCode必须一起重写、逻辑一致这是最基础也是最重要的。还有一个容易被忽略的问题TreeMap中Comparator与equals不一致。前面提过TreeMap判断key是否相等依靠的是Comparator返回0而不是equals。如果你在TreeMap里放了一个“equals相等但compareTo返回非0”的对象实际上会导致两个键共存这通常不是你期望的行为。最后再分享一点我的个人体会源码读过一遍和没读过一遍对同一个API的掌控感是完全不一样的。拿我自己来说读完Map相关源码之后再排查线上问题时脑子里随时能浮现底层结构定位问题效率高了很多。比如那次线上偶发性的“加载数据顺序乱跳”问题我第一反应就是HashMap迭代顺序不可靠换成LinkedHashMap后立竿见影。Map集合的这些实现类每一个设计背后都有清晰的历史背景和性能考量。你把这些设计动机想明白了很多面试题、很多线上疑难杂症都不再是问题。如果你刚开始读源码完全可以照着这篇博客的思路从HashMap的put方法入手边Debug边画图花一个周末把几个核心方法理清楚。相信我这份时间投入绝对值得。