资讯详情

Java Map与Set底层实现:哈希表与红黑树深度解析

📅 2026/10/13 23:03:08 | 华诺云谱 👁 阅读
Java Map与Set底层实现:哈希表与红黑树深度解析
1. 为什么Map和Set用起来顺手面试却总翻车说实话Java里Map和Set是平时写业务代码最离不开的两个接口。统计字符串出现次数用HashMap给列表去重用HashSet按分数排序取前几名用TreeMap这些都属于“条件反射式”的操作。但真要是被人追问一句“HashMap的key是怎么存进去的”、“TreeMap凭什么能排序”、“HashSet去重到底去的是什么”不少写了两年业务代码的同学会突然卡壳。这篇文章不是面试八股文背诵而是想把二叉搜索树、哈希表、Map和Set这几条线串起来讲清楚。我会从JDK源码和实际调试的角度出发把“底层结构如何决定上层行为”这条逻辑链捋一遍。适合几类人看准备Java后端面试的、刚接触数据结构的在校生、以及虽然天天用Map但从来没看过源码的CRUD工程师。核心结论先放在这里Map和Set不是凭空出现的两个容器Map的本质是“键值对容器”它的底层要么是哈希表HashMap要么是二叉搜索树TreeMapSet的本质是“只有键没有值的Map”HashSet底层就是一个HashMapTreeSet底层就是一个TreeMap。所以题目并列写“二叉搜索树哈希表Map和Set”本质上是同一件事的两副面孔。我见过很多人在学习时习惯把这四样东西分开背——二叉搜索树背遍历方式哈希表背负载因子Map背源码Set背特性——结果到了实际场景就乱套。比如线上出现OOM日志指向HashMap扩容压根想不到这背后是哈希表在重建数组又比如需求要按key的自然顺序输出有人居然用HashMap然后自己写排序而不知道TreeMap天生就是干这个的。这种割裂感根源就在于没把“数据结构”和“容器类”对应起来看。所以这篇博文的核心主线是先看结构本身再看JDK怎么用这个结构最后落到代码和排错经验上。每个部分都会有可验证的代码片段和特定的坑点不只是原理层面。2. 二叉搜索树Map和Set的数值排序底座以及它藏起来的短板2.1 节点结构与插入逻辑从零手写一个最简版本二叉搜索树Binary Search Tree的定义从名字里就能拆出来二叉一棵树最多分两个叉搜索左子树的节点都小于根节点右子树的节点都大于根节点。这个“左小右大”的不变量决定了它查找时每一步都能砍掉一半的搜索空间。先感受一下最核心的节点结构public class BSTNodeK extends ComparableK, V { K key; V value; BSTNodeK, V left; BSTNodeK, V right; public BSTNode(K key, V value) { this.key key; this.value value; } }这里有个关键设计K extends ComparableK。这意味着key必须具备可比较性才能决定插入到左子树还是右子树。这是TreeMap能排序的根本前提——它的key必须实现Comparable接口或者在构造TreeMap时传入一个Comparator。插入逻辑的递归写法非常简洁我当年第一次看到时也觉得这玩意优雅得不像代码public BSTNodeK, V insert(BSTNodeK, V node, K key, V value) { if (node null) { return new BSTNode(key, value); } int cmp key.compareTo(node.key); if (cmp 0) { node.left insert(node.left, key, value); } else if (cmp 0) { node.right insert(node.right, key, value); } else { node.value value; // key已存在更新value } return node; }判断逻辑就是三件事比当前节点小往左走比当前节点大往右走相等就覆盖旧值。这段代码放在TreeMap的put方法里就是同一个思路只是JDK的实现加上了红黑树的平衡逻辑和迭代器支持。搜索逻辑同理最坏情况下比较的次数等于树的高度。在平衡状态下n个节点的树高度约为log₂n所以查找复杂度是O(log n)。这就是为什么TreeMap的get、put、remove都是O(log n)——因为它根本不是数组结构而是链式的树形结构。2.2 退化成链表的极端情况以及JDK为什么选红黑树二叉搜索树有个臭名昭著的短板如果插入顺序是升序或降序树会退化成一条直链。比如依次插入1、2、3、4、5结构就是根节点1右孩子2右孩子的右孩子3……查找5要挨个比较五次O(log n)直接退化成O(n)。我实际测试过一个自己手写的BST往里面塞100万个升序整数再随机查询一个key耗时竟然比HashMap慢了两个数量级。这个实验结果太直观了直接导致我明白了为什么JDK不用裸BST来实现TreeMap。JDK的解决方案是红黑树它本质上是一棵“自动保持平衡”的二叉搜索树。红黑树通过给节点着色红/黑并维护五条规则保证从根到叶子的最长路径不超过最短路径的两倍。这个约束让树的高度始终维持在O(log n)量级即使插入的是严格升序的数据树也会自发作左旋、右旋、变色等调整。红黑树的五条规则理解即可不需要背 1. 每个节点要么红色要么黑色 2. 根节点是黑色 3. 所有叶子节点NIL是黑色 4. 红色节点的两个子节点必须是黑色即不存在两个连续的红色节点 5. 从任一节点到其每个叶子的所有路径包含相同数目的黑色节点正因为红黑树的平衡性有数学保障TreeMap才能在任意插入顺序下都维持O(log n)的查询效率。同时它不像AVL树那样要求左右子树高度差不超过1这种极其严格的平衡所以插入删除时的旋转次数相对少综合性能更均衡。从源码角度说java.util.TreeMap内部有一个EntryK,V它就是红黑树节点里面除了key、value之外还有left、right、parent三个指针和一个boolean类型的color字段。代码里大量出现fixAfterInsertion和fixAfterDeletion这两个方法就是旋转和换色的实现。初看会头晕但把它理解成“约束红黑规则不变量”的修复函数就通了。2.3 遍历顺序的特性直接决定TreeMap的适用场景BST的中序遍历结果是“左子树、根、右子树”恰好是升序排列。这个特性被TreeMap完整继承了它的迭代器按key升序输出subMap可以截取某个key区间firstKey和lastKey分别是最小和最大键。我实际项目中遇到过一个典型场景需要维护一个用户的积分排行榜要求按分数从低到高遍历还要频繁查询“某个分数段有哪些人”。用TreeMap的subMap方法一步到位TreeMapInteger, String scoreMap new TreeMap(); scoreMap.put(85, A同学); scoreMap.put(92, B同学); scoreMap.put(70, C同学); // 获取[80, 90)区间内的所有记录 SortedMapInteger, String sub scoreMap.subMap(80, 90);这就是“数据结构决定接口行为”的活例子。如果没有树结构的排序特性subMap这种范围查询根本没法高效实现。3. 哈希表从数组加链表到红黑树Java 8改了什么3.1 哈希函数的职责与哈希冲突的必然性哈希表的核心思想用一个生活类比就能说明白假设有100个储物柜每个柜子上贴着编号存东西时先根据一个固定规则算出编号然后把东西放进对应柜子。这个“固定规则”就是哈希函数。理想情况下每个key算出不同的桶下标查找一步到位。但现实是哈希函数的输出空间有限比如HashMap的数组长度默认16而key的数量远大于这个空间必然有多个key算到同一个下标。这就是哈希冲突collision。处理冲突的方式有很多种开放定址法、再哈希法、链地址法。JDK的HashMap采用的是链地址法——数组的每个下标位置挂一个链表冲突节点依次挂在链表后面。我要强调一个容易被忽略的细节hashCode()返回的是int范围是正负21亿多但HashMap的数组长度远没有这么大。所以计算下标时不能直接用hashCode而是要经过两步// 第一步扰动函数让高位也参与计算 static final int hash(Object key) { int h; return (key null) ? 0 : (h key.hashCode()) ^ (h 16); } // 第二步与运算取模等价于hash % nn为2的幂 i (n - 1) hash;第二步里(n - 1) hash能成立的前提是n必须是2的幂。这就是为什么HashMap扩容后容量总是翻倍而初始容量传入时会被tableSizeFor方法修正为不小于传入值的2的幂。这个设计决定了HashMap的性能边界。3.2 扩容机制的代价为什么负载因子默认是0.75HashMap不是数组装满才扩容而是有一个“负载因子”loadFactor概念。阈值计算公式是扩容阈值 数组长度 x 负载因子比如默认数组长度16负载因子0.75那么阈值是12。也就是说当键值对数量超过12个时数组会扩容到32然后所有已有节点重新计算下标并迁移到新数组。这个0.75不是随便拍脑袋定的它是“空间利用率”和“查冲突概率”之间的一个折中。负载因子越小比如0.5空间浪费严重但冲突少负载因子越大比如1.0空间利用率高但链表长度可能暴涨。0.75在实测中能在大多数数据分布下兼顾两方面所以成了默认值。扩容的代价在于全量rehash不是简简单单复制元素而是每个节点要重新计算桶下标。我在压测时看到过HashMap扩容期间耗时暴涨的现象尤其是从几十万扩到百万级时明显出现一个延迟尖刺。这也是为什么如果事先能预估数据量最好在构造时指定初始容量// 预计存1000条数据为了避免频繁扩容直接给定容量 // 这里要注意扩容阈值是容量*0.75所以容量要给到1340左右 // 最稳妥的做法是给1000/0.75向上取整的2次幂编译器不省心就手算 MapString, Integer map new HashMap(2048);3.3 Java 8之后链表转红黑树的阈值为什么是8Java 8的HashMap引入了一个重要改动当链表长度超过8且数组长度不小于64时链表会转成红黑树。反之当树的节点数低于6时红黑树退化为链表。为什么是8JDK的注释里面给了统计学依据在随机哈希码下桶中节点数呈现泊松分布链表长度达到8的概率约等于千万分之六。换句话说正常情况下链表很难涨到8个节点真涨到了8说明哈希函数大概率出了问题比如很多key的hashCode一样此时用红黑树补救把查询从O(n)降为O(log n)。这里有个面试高频点链表转红黑树除了长度到8还必须满足数组长度不小于64。如果数组长度还不够64即使某个桶的链表到了8HashMap也不会转树而是先扩容。原因是在数组太小时多个key挤在一个桶里往往是“容量不够”导致的扩容一倍后冲突大概率自然缓解没必要直接上红黑树。Java 8之后HashMap的性能衰减曲线比Java 7平滑不少但你要记得转到红黑树再退化中间有额外的维护开销。所以尽量让自己的hashCode分布均匀才是治本之策。4. 打开源码看Map和Set的选型差异底层决定行为4.1 HashMap与TreeMap的选择依据实际写代码时Map选型经常被忽略。很多人随手一个HashMap用到天荒地老直到某天发现需要按序输出才手忙脚乱。从底层结构出发两者特性对比如下对比项HashMapTreeMap底层结构哈希表数组链表红黑树红黑树自平衡二叉搜索树键的顺序无保证按key自然顺序或Comparator排序核心操作复杂度O(1)平均最坏O(log n)O(log n)是否允许null键允许一个null键不允许null键无法比较适用的核心场景快速查找、插入、删除需要有序遍历、范围查询关于null键这点我要特别提醒TreeMap的put方法内部会调用key.compareTo如果key是null就会抛NullPointerException而HashMap将null键特殊处理放在table[0]位置。所以如果业务数据里可能出现null键用HashMap会安全得多。还有一点经常被忽略HashMap的迭代顺序不稳定。同一个HashMap在JDK版本升级或者扩容行为改变后输出顺序可能不同。所以千万不要依赖HashMap的遍历顺序做业务逻辑比如“取第一个元素”。这种代码在某次扩容后就会突然改变行为线上bug的经典来源。4.2 HashSet、TreeSet与LinkedHashSet对照Set接口的三个常见实现直接映射到对应的Map上// HashSet的构造器直接new了一个HashMap public HashSet() { map new HashMap(); } // TreeSet的构造器直接new了一个TreeMap public TreeSet() { this(new TreeMap()); }HashSet添加元素时底层是map.put(e, PRESENT)其中PRESENT是一个全局共享的Object占位符。所以HashSet“去重”的本质是HashMap的key去重——key唯一值无所谓。这直接解释了为什么HashSet要求元素正确重写hashCode和equals。TreeSet在遍历时输出升序底层也是依赖TreeMap的key排序。LinkedHashSet则更特殊一点它继承自HashSet底层是LinkedHashMap额外用一条双向链表记录了插入顺序所以迭代顺序是“插入顺序”。我在一个导出报表的项目里踩过LinkedHashSet的坑需求是按导入顺序去重输出但中间有人把HashSet当成“就是无序的Set”来用结果输出的行顺序全乱了。后来换成LinkedHashSet一行代码解决。这种教训说明Set的选型不是随便挑一个要明确你到底需不需要“顺序”这个维度。4.3 常用API的复杂度对比写代码前先心里有数操作HashMapTreeMapHashSetTreeSet插入O(1)平均O(log n)O(1)平均O(log n)查询O(1)平均O(log n)O(1)平均O(log n)删除O(1)平均O(log n)O(1)平均O(log n)遍历O(n)O(n)升序O(n)O(n)升序范围查找不支持O(log n k)不支持O(log n k)注意TreeMap的范围查找通过subMap、headMap、tailMap实现它利用树中序遍历的特性能高效地输出某个区间内的所有键值对这是哈希表做不到的。你要是遇到“按日期范围统计订单”的需求优先考虑TreeMap让树结构替你搞定排序和范围筛选。5. hashCode与equals一套契约引发的线上事故级问题5.1 契约的完整逻辑链HashMap和HashSet的正确性都建立在hashCode()和equals()的契约之上。整套逻辑链是这样的调用hashCode()计算桶下标如果该桶位置为空直接放入如果该桶位置非空遍历链表先用或equals()逐个比对找到相同key则覆盖value找不到则追加到链表末尾这里有个致命细节同一个对象在不同时刻hashCode变了会导致它存在HashMap里后找不到。举一个实际栽过的跟头。我曾写过一个实体类重写了equals但忘了重写hashCode结果这个类被塞进HashSet。remove时怎么都删不掉contains一直返回false排查了一个多小时才意识到问题。原因是两个对象的equals返回true但hashCode不同导致它们被分配到了不同的桶HashSet在remove时先按新对象的hashCode定位桶在那个桶里根本找不到旧对象。正确的重写方式要遵循三条规则equals判断相等的两个对象hashCode必须相等重写equals时必须重写hashCodehashCode计算用到的字段必须包含在equals比较的字段里5.2 为什么可变对象不能直接当Map的Key这是另一个大坑如果把一个可变对象放进HashMap做key然后修改了这个对象的字段会导致hashCode变。但桶的位置已经确定了再次查找时用新hashCode计算出的桶位置大概率不是原来那个。MapListInteger, String map new HashMap(); ListInteger key new ArrayList(); key.add(1); map.put(key, value1); key.add(2); // 修改了key的内容hashCode变化 System.out.println(map.get(key)); // 输出null输出null的瞬间真的让人血压飙升。因为ArrayList的hashCode会随元素变化修改后hashCode变了get时按新hashCode找桶自然找不到。避免这个问题的实操经验作为Map的key对象要尽量不可变。String已经不可变了所以它是使用频率最高、最安全的key类型。如果一定要用自定义对象把参与hashCode和equals的字段设为final或者在设计上保证这些字段不会被修改。5.3 动手验证hashCode分布对HashMap性能的影响为了更直观地理解hashCode质量对哈希表性能的影响我曾经写过一个对比实验// 场景AhashCode固定为常数模拟劣质哈希 class BadHash { int id; Override public int hashCode() { return 0; } // 所有对象都映射到同一个桶 Override public boolean equals(Object o) { ... } } // 场景BhashCode分布均匀 class GoodHash { int id; Override public int hashCode() { return id; } Override public boolean equals(Object o) { ... } }分别往两个HashMap里put十万条数据坏哈希的插入过程几乎退化成了往同一个链表上追加节点耗时是均匀哈希的百倍以上。这个实验虽然极端但很能让人直观理解“哈希分布哈希表的生命线”这句话。提示如果你的自定义类经常用作Map的key建议在重写hashCode时参考Objects.hash(...)方法JDK内置的实现已经考虑到了组合多个字段的分布质量。6. 一个练手型实现带给我的调试心得6.1 拼接一个用BST实现Map的完整链条光看不练等于白看。我建议动手实现一个“用二叉搜索树作为底层的简单Map”把前面讲的所有概念串起来。核心代码如下public class SimpleTreeMapK extends ComparableK, V { private BSTNodeK, V root; public V get(K key) { BSTNodeK, V node get(root, key); return node null ? null : node.value; } private BSTNodeK, V get(BSTNodeK, V node, K key) { if (node null) return null; int cmp key.compareTo(node.key); if (cmp 0) return get(node.left, key); if (cmp 0) return get(node.right, key); return node; } public void put(K key, V value) { root insert(root, key, value); } private BSTNodeK, V insert(BSTNodeK, V node, K key, V value) { if (node null) return new BSTNode(key, value); int cmp key.compareTo(node.key); if (cmp 0) node.left insert(node.left, key, value); else if (cmp 0) node.right insert(node.right, key, value); else node.value value; return node; } }自己实现一遍后你会对TreeMap的代码有完全不同的理解。比如为什么TreeMap的put要返回旧值为什么AccessOrder会影响遍历顺序为什么旋转逻辑那么绕——因为没有平衡机制的裸BST会让你在测试时立刻感受到性能退化的痛苦。6.2 调试中发现的删减痛点递归返回值的接住问题自己写这个练手项目时我还踩过一个非常典型的错删除节点时忘记接收递归返回值。// 错误写法子树的修改没有传回父节点 public void delete(K key) { deleteNode(root, key); } private BSTNodeK, V deleteNode(BSTNodeK, V node, K key) { if (node null) return null; int cmp key.compareTo(node.key); if (cmp 0) { deleteNode(node.left, key); // 这里丢了返回值 } ... }在BST中删除可能改变子树的结构比如被删节点有右孩子需要找到后继顶替修改后的子树必须通过返回值接住否则父节点的指针还是指向旧节点删除等于白做。我把代码改成node.left deleteNode(node.left, key)后问题立刻消失。这种错误在业务代码里不常见但一旦遇到排查思路能训练出来树结构的递归操作返回值往往不只是“结果”而是“新子树的根”。理解了这一点看红黑树的fixAfterInsertion也不容易迷路。6.3 使用Java Collections工具类时的几个实用细节除了直接操作Map和Set日常开发还会用到Collections工具类里的一些辅助方法。有几个细节值得多说一句。Collections.synchronizedMap(map)返回的同步Map它只是在外层套了一个互斥锁所有方法都会走mutex对象同步。想要遍历它仍然需要自己加锁MapString, Integer syncMap Collections.synchronizedMap(new HashMap()); synchronized (syncMap) { for (String key : syncMap.keySet()) { // 如果不锁遍历过程中其他线程修改map会抛ConcurrentModificationException } }Collections.unmodifiableMap(map)返回的不可变视图对它的任何修改操作都会抛UnsupportedOperationException。但它只保证引用不可变不保证内部对象不可变。如果map的value是一个可变的List仍然可以list.add()。还有一个容易混淆的SetK keySet()返回的set是Map的“视图”而不是副本。对keySet做remove操作会同步影响原Map但add操作会抛UnsupportedOperationException。很多人在做缓存清理时想“从这个Map的keyset里加一个key”结果直接异常表现就是这个原因。6.4 从数据结构视角重新看待日常Map的“怪现象”掌握了底层结构之后很多日常中遇到的“怪现象”都能想明白了。为什么HashMap扩容后迭代顺序和原来不一样——因为rehash时每个key的桶下标变了链表的排列顺序也随之改变。为什么同一个HashMap在Java 7和Java 8下遍历顺序可能不同——因为Java 8引入了红黑树链表的结构和迁移方式都有变化。为什么TreeMap的keySet遍历总是有序的——因为它的迭代器基于中序遍历实现。为什么HashSet的迭代顺序“看起来随机”——因为它在内部用一个HashMap的keySet而HashMap的顺序由hashCode和数组长度共同决定既不稳定也无语义。理解了这些“为什么”写代码时就不会再把Map当黑盒使用。遇到性能问题、并发问题、顺序问题时心里就有了排查方向。我个人的体会是数据结构的知识背定义是最没用的学法。真正有效的路径是先理解结构特性树的排序性、哈希表的散列性再对照JDK源码看它怎么利用这些特性最后在实战中通过异常现象反推结构原理。这三步走完面试题随便问线上问题也能更快定位。最后再分享一个实用的小技巧如果你在调试时不确定当前用的Map到底走的是什么桶、有没有转红黑树可以通过反射打印内部table数组的长度和每个桶里的节点数量来观察。虽然实际线上代码不要这么干但对于理解哈希表的行为这比纯看文档来得直观太多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑