1、HashMap核心结构
HashMap = 数组 + 链表 + 红黑树
-
数组(哈希桶):通过哈希值快速定位,时间复杂度O(1)
-
链表:解决哈希冲突(拉链法)
-
红黑树:当链表过长时保证查询效率O(logn)
2、HashMap参数设计
初始容量:16
#设计思想:2的幂次方,便于位运算替代取模,提升计算效率
负载因子:0.75
#设计思想:基于泊松分布和数学统计,是冲突概率与空间利用的最佳平衡点
树化阈值:链表长度 > 8 且数组长度 >= 64
退化阈值:红黑树节点 <= 6时,退化为链表
#注意:6与8的差距是防止频繁转换的缓冲区间
3、put方法执行流程
1. 计算key的hash值
2. 如果数组为空,调用resize()初始化
3. 计算索引位置 i = (n-1) & hash
4. 查找该位置:
a. 无节点:直接插入Node
b. 有节点但key相同:覆盖value
c. 红黑树节点:调用putTreeVal
d. 链表节点:遍历链表
- 找到相同key:覆盖
- 未找到:尾插法添加
* 链表长度≥8:尝试树化
* 满足树化条件:转为红黑树
5. 判断size > threshold:调用resize()扩容
4、get方法执行流程
1. 计算key的hash值
2. 计算索引位置 i = (n-1) & hash
3. 查找该位置:
a. 第一个节点匹配:直接返回
b. 红黑树:调用getTreeNode,O(log n)
c. 链表:遍历查找,O(n)
4. 未找到返回null
5、扩容过程(resize)
1. 计算新容量:旧容量×2(保持2的幂)
2. 创建新数组
3. 重新哈希(rehash)所有元素
- JDK 1.8优化:元素在新数组的位置只有两种可能
* 保持原索引 j
* 新索引 = j + 旧容量
- 判断依据:hash & 旧容量 == 0 ?
原理:由于新容量是旧容量×2,只需看hash值在旧容量位上的值
6、原理追问
- 为什么HashMap线程不安全?
-
多线程put可能导致死循环(JDK1.7头插法,多线程扩容可能形成循环链表)或数据覆盖
-
JDK1.8 尾插法,解决了循环链表问题,依然存在数据丢失等线程安全问题
- 为什么用红黑树?
- 链表查询O(n),红黑树O(log n),性能提升
7、面试问题
-
HashMap底层数据结构演变
-
数组 → 链表 → 红黑树
-
为什么链表长度超过8才树化
-
泊松分布,概率极低
-
性能平衡,较低阈值树化导致不必要的内存消耗和损失,红黑树还是适用于大表
-
哈希冲突解决方法
-
拉链法
-
红黑树优化
-
扩容机制和重新哈希过程
-
元素数量超过负载因子0.75所允许的上限时触发扩容
-
参照 5、扩容过程(resize)
-
为什么重写equals必须重写hashCode
-
一致性要求:Java 的约定,如果两个对象通过
equals方法相等,那么这两个对象的hashCode方法也必须返回相同的整数值。 -
哈希表的完整性:只重写
equals而不重写hashCode,可能会导致在哈希表中相等的对象被分配到不同的位置,从而无法找到这些对象,最终破坏哈希表的完整性。 -
JDK 1.7和1.8的主要区别
-
处理冲突的方法:
JDK 1.7:使用链表处理冲突,所有冲突的元素都存储在同一桶的链表中。
JDK 1.8:在链表长度超过 8 时,链表会转化为红黑树,以提高查找性能。
-
扩容机制变化:
JDK 1.7:在扩容时,所有旧元素都会被重新计算并分散到新的桶中,扩容过程可能比较耗时。
JDK 1.8:在扩容时,使用了新的逻辑来处理元素的移动,保持了扩容的效率。
-
性能优化:
JDK 1.8:进行了许多性能优化,比如使用
Node和TreeNode类来表示不同的数据结构,使得在链表和红黑树之间的转换更加高效。