Java 集合底层深度剖析:Map 与 Set、二叉搜索树、哈希表全解
前言
日常开发中我们几乎天天在用 HashMap、TreeMap、HashSet、TreeSet,但很多人只停留在「会调用 API」的层面:
- 为什么
TreeMap有序,HashMap无序? - 哈希冲突到底是什么,Java 是怎么解决的?
- 二叉搜索树为什么会退化成链表,红黑树又起到什么作用?
Set和Map底层有什么关联?
本文结合底层数据结构,从二叉搜索树 到哈希表,完整拆解 Map/Set 的底层原理、性能差异与使用场景,一次性打通集合底层逻辑。
一、底层基石:二叉搜索树(BST)
1. 二叉搜索树定义
二叉搜索树(二叉排序树)满足三大核心规则:
- 左子树所有节点值 < 根节点值;
- 右子树所有节点值 > 根节点值;
- 左右子树同样是二叉搜索树。
示例数组 {5,3,4,1,7,8,2,6,0,9} 构建出上图搜索树,中序遍历会得到有序序列:0,1,2,3,4,5,6,7,8,9,这也是 TreeMap/TreeSet 能实现 Key 有序的根本原因。
2. 三大核心操作
(1)查找
从根节点开始对比:
- 相等:找到目标节点;
- 目标更小:去左子树查找;
- 目标更大:去右子树查找;
- 走到 null:不存在该元素。
(2)插入
- 树为空,直接作为根节点;
- 树不为空,按照查找逻辑找到待插入的父节点;
- 比父节点小挂左孩子,大则挂右孩子;
- 存在重复 key 直接插入失败(Set/Map 不允许重复键)。
(3)删除(难点)
分三种场景处理待删除节点 cur:
- 左孩子为空:直接用右孩子顶替 cur;
- 右孩子为空:直接用左孩子顶替 cur;
- 左右孩子都存在 :取右子树最小节点替换当前节点值,再删除该最小节点(替换法,保证二叉搜索树性质不变)。
3. 致命缺陷:性能两极分化
二叉搜索树的效率完全依赖树的高度:
- 最优:数据随机插入,近似完全二叉树,增删查复杂度
O(logN); - 最差:有序插入数据,树退化成单链表,复杂度退化到
O(N),和数组遍历无区别。
为了解决退化问题,平衡二叉树(红黑树)应运而生,Java 中 TreeMap/TreeSet 底层就是红黑树(平衡版二叉搜索树),保证无论插入顺序如何,操作稳定 O(logN)。
二、Map & Set:两种搜索模型
1. 两种搜索模型
- 纯 Key 模型(Set):只存储唯一关键字,用于去重、判断元素是否存在,例如判断单词是否在词典;
- Key-Value 键值模型(Map):关键字映射对应数值,例如「姓名-手机号」「单词-出现次数」。
底层关系:Set 本质是包装后的 Map ,TreeSet/HashSet 内部持有一个 Map,存入的元素全部作为 Map 的 Key,Value 固定填充一个空 Object。
2. Map 核心知识点
(1)接口特性
Map不继承Collection,独立接口;- Key 唯一不可重复,Value 允许重复;
- 内部静态内部类
Map.Entry<K,V>:封装单条键值对,提供getKey()、getValue()修改 Value(Key 不可修改)。
(2)常用 API 分类
- 增改:
put(K,V)存在则覆盖旧值,返回旧 value; - 查询:
get(key)、getOrDefault(key, 默认值); - 遍历:
keySet()获取所有键、values()获取所有值、entrySet()获取全部键值对; - 判断:
containsKey()(效率高)、containsValue()(遍历全部,O(N)); - 删除:
remove(key)。
(3)TreeMap VS HashMap 核心对比
| 特性 | TreeMap | HashMap |
|---|---|---|
| 底层结构 | 红黑树(平衡二叉搜索树) | 哈希桶(数组+链表/红黑树) |
| 时间复杂度 | O(logN) |
平均 O(1) |
| 有序性 | Key 自然有序 | 无序 |
| Null 支持 | Key 不能为 null,Value 可 null | Key/Value 均可为 null |
| 对象要求 | Key 实现 Comparable 或传入 Comparator | 自定义 Key 必须重写 hashCode() + equals() |
| 使用场景 | 需要按键排序、区间查找 | 追求极致查询性能,不关心顺序 |
3. Set 核心知识点
- 继承
Collection,只存储唯一 Key,天然去重; - 实现类:
TreeSet(红黑树,有序)、HashSet(哈希表,无序)、LinkedHashSet(哈希+双向链表,保留插入顺序); - 关键限制:
TreeSet不能存 null,HashSet允许存一个 null; - 核心方法:
add()重复返回 false、contains()判断存在、remove()删除元素。
三、哈希表:理想 O(1) 搜索结构
二叉搜索树存在性能波动,哈希表则实现了理论一次寻址找到元素 ,是 HashMap/HashSet 的底层。
1. 哈希表核心思想
通过哈希函数建立关键字与数组下标的映射关系:
- 插入:key 经过哈希函数算出数组下标,存入对应位置;
- 查询:相同哈希函数计算下标,直接定位元素;
理想状态无需任何比较,时间复杂度O(1)。
示例哈希函数 hash(key) = key % 数组容量,容量为10时,数字 1、4、5、6、7、9 会分别存入下标对应位置。
2. 哈希冲突(哈希碰撞)
不同 key 算出相同哈希下标,就是哈希冲突,例如 4 % 10 = 4、44 % 10 = 4。
冲突无法完全避免,只能通过合理设计哈希函数、控制负载因子降低冲突概率。
3. 冲突优化手段
(1)设计优质哈希函数
常用工业级方案:
- 除留余数法:取质数作为取模除数,分布更均匀;
- 直接定址法:适合连续小范围数字;
- 平方取中法、折叠法:处理长字符串、手机号等长关键字。
(2)负载因子(核心重点)
公式:负载因子 α = 表中元素个数 / 数组长度
- α 越大,数组越满,冲突概率指数上升;
- Java 哈希表默认阈值
0.75,超过阈值自动扩容数组为原来2倍,降低负载因子减少冲突。
4. 两种冲突解决方案
方案1:闭散列(开放地址法)
冲突时向后寻找空位存放元素,分为线性探测 、二次探测:
- 线性探测:冲突下标依次+1找空位,缺陷是数据扎堆聚集;
- 二次探测:
(原始下标 ± i²) % 容量,缓解聚集问题; - 致命缺点:不能直接删除元素,需伪删除标记,扩容开销大,Java 未采用。
方案2:开散列(哈希桶/链地址法,Java 采用)
数组每个下标对应一条单向链表,冲突元素挂载在同下标链表尾部:
- 寻址到数组下标;
- 遍历链表,通过
equals()匹配 key; - 链表过长(阈值8)自动转为红黑树,缩短遍历时间;
- 扩容后重新计算所有元素哈希下标,迁移到新数组。
5. 哈希桶简易实现核心逻辑
- 底层数组+链表节点存储 key-value;
put流程:计算哈希下标 → 遍历链表判断重复key,重复则覆盖value,无重复头插新节点;- 插入后校验负载因子 ≥0.75,执行二倍扩容;
get流程:计算下标,遍历链表匹配 key 返回 value,无匹配返回默认值;
6. Java 开发必记规范
自定义类作为 HashMap/HashSet 的 Key 时,必须同时重写 hashCode() 和 equals():
hashCode():确定数组下标,相等对象哈希值必须相同;equals():链表中精准匹配对象,区分哈希值相同但实际不同的对象;
若只重写其中一个,会出现「对象内容相等但集合判定为两个不同元素」的 bug。
四、Tree 系列 vs Hash 系列集合选型总结
- 需要有序、区间查询 :选
TreeMap/TreeSet,底层红黑树,稳定O(logN); - 追求最高读写性能,不关心顺序 :选
HashMap/HashSet,平均O(1); - 需要保留元素插入顺序 :
LinkedHashMap/LinkedHashSet; - 去重场景:统一使用 Set;键值映射场景使用 Map;
- 数据量大时,哈希表性能远优于二叉搜索树;数据有序遍历需求优先红黑树集合。
五、结语
Map 和 Set 是 Java 开发的高频容器,底层分别依托平衡二叉搜索树(红黑树)与哈希表两套数据结构。二叉搜索树解决有序查找问题,但存在退化风险;哈希表实现极致读写速度,但需要处理哈希冲突。
理解底层原理后,不再是只会调用 API 的开发者:遇到有序场景、大数据量查询、自定义对象作为 Key 等场景时,能精准选择合适集合,规避哈希冲突、空指针、去重失效等常见线上 Bug。