一文搞懂HashMap底层原理
前言
HashMap是Java集合核心,本文从底层结构、哈希算法、读写流程、扩容、红黑树、线程安全、衍生集合全方位拆解。
一、底层数据结构(JDK1.7 vs JDK1.8)
1. JDK1.7
数组 + 单向链表(Entry节点)
冲突元素全部挂链表,采用头插法,并发扩容会产生环形链表死循环。
2. JDK1.8(主流生产版本)
数组(Node[]桶) + 单向链表(Node) + 红黑树(TreeNode)
规则:
- 同一桶内链表长度 ≥ 8 且数组容量≥64:链表转为红黑树;
- 红黑树节点 ≤ 6:退化为普通链表;
- 数组容量不足64时,链表过长优先扩容,不树化。
节点结构
// 普通链表节点
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
// 红黑树节点,继承Node,新增左右、父、颜色指针
static final class TreeNode<K,V> extends Node<K,V> {
TreeNode<K,V> parent;
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev;
boolean red;
}
核心常量
- 默认初始容量:
16(2的4次幂) - 默认负载因子:
0.75 - 树化阈值:
TREEIFY_THRESHOLD=8 - 退化阈值:
UNTREEIFY_THRESHOLD=6 - 最小树化容量:
MIN_TREEIFY_CAPACITY=64
二、哈希与扰动函数(解决哈希碰撞)
1. 底层hash方法
static final int hash(Object key) {
int h;
// key.hashCode() 高16位 ^ 低16位,混合高低位
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
2. 为什么需要扰动?
数组长度永远是2的幂,hash & (n-1) 只会保留hash低位;
如果hashCode高低位差异大、低位重复,极易大量碰撞。
右移16位异或,把高位特征混入低位,大幅提升低位随机性,减少碰撞。
3. 为什么容量必须是2的整数幂?
计算下标公式:i = hash & (table.length - 1)
- 位运算
&替代取模%,性能更高; - length=2^n时,
length-1二进制全1,hash所有低位都能参与散列,元素均匀分布; - 扩容时仅判断
hash & oldCap即可快速拆分节点,无需重算hash。
4. 初始化传入非2次幂怎么处理?
调用tableSizeFor()向上取最近的2次幂。
示例:new HashMap(17),实际容量32。
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1; n |= n >>> 2; n |= n >>> 4;
n |= n >>> 8; n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAX) ? MAX : n + 1;
}
逻辑:把数字所有低位全部填充1,最后+1得到2的幂。
三、put插入完整流程
- 计算key扰动hash值;
- 判断table数组是否为空,为空执行
resize()初始化; - 计算桶下标
i = hash & (n-1); - 桶为空:直接新建Node放入桶,结束;
- 桶不为空:
- 桶首节点key完全相等(hash+equals):直接覆盖value;
- 桶是TreeNode红黑树节点:执行树插入;
- 桶是普通链表:尾插法遍历链表,找到相同key则覆盖;遍历到末尾无匹配则追加新节点;
- 链表新增节点后,长度达到8:调用
treeifyBin();- 数组<64:仅扩容,不树化;
- 数组≥64:链表转红黑树;
- size自增,判断
size > threshold,满足则触发扩容resize。
JDK1.7头插法,JDK1.8改为尾插法,避免并发环形链表。
四、get查询流程
- 扰动计算hash,定位桶下标;
- 桶首节点匹配hash+key.equals,命中直接返回;
- 判断节点是TreeNode,遍历红黑树查找;
- 否则循环遍历单向链表匹配key;
- 未匹配返回null。
五、负载因子0.75与扩容机制
1. 负载因子为什么是0.75?
负载因子=元素数/数组容量,平衡空间开销和哈希碰撞概率:
- 过小:提前扩容,占用更多内存;
- 过大:桶填充密集,碰撞激增,链表/树查询变慢;
泊松分布计算下,0.75是综合最优平衡点。
阈值计算公式:threshold = capacity * 0.75
默认16容量,阈值12,第13个元素插入触发扩容。
2. resize扩容核心优化(JDK1.8重点)
扩容后容量 = 原容量 * 2;
旧节点不需要重新计算hash,仅判断 hash & oldCap:
- 结果=0:新下标 = 原下标;
- 结果≠0:新下标 = 原下标 + oldCap;
链表拆分为高低两条链表,分别放入新桶,无需遍历重hash,性能大幅提升。
3. 树节点扩容
红黑树同样按hash & oldCap拆分两条树链;
拆分后单链节点≤6,自动退化为普通链表。
六、红黑树相关面试问题
1. 为什么链表转红黑阈值是8?
哈希随机分布下,桶内节点数量符合泊松分布,链表长度达到8的概率仅千万分之六,属于极端碰撞场景,树化作为兜底方案。
红黑树节点占用内存约为普通Node两倍,正常短链表查询更快,不会提前树化浪费内存。
2. 退化阈值为什么是6不是8?
防止节点数量在7/8反复增减,频繁树化、退化,产生大量转换开销,设置缓冲区间。
3. 为什么不用平衡二叉树(AVL)?
AVL严格平衡,插入删除频繁旋转;红黑树弱平衡,最多2次旋转即可恢复,读写综合性能更高。
4. 红黑树五大规则
- 节点只能红/黑;
- 根节点黑色;
- 所有叶子空节点黑色;
- 红色节点子节点必为黑色;
- 任意节点到所有叶子,黑色节点数量相等。
平衡依靠变色、左旋、右旋维护。
七、哈希冲突解决四大方案(拓展)
- 链地址法(HashMap采用):冲突元素挂链表/红黑树;
- 开放寻址法:线性探测、二次探测;
- 再哈希法:冲突换哈希函数重算下标;
- 公共溢出区:单独数组存放冲突数据。
八、HashMap线程不安全问题(高频面试)
HashMap完全非线程安全,无任何同步控制:
- 数据覆盖丢失:多线程同时put同一桶,后插入节点覆盖前者;
- JDK1.7特有:头插法并发扩容,链表倒置形成环形链表,get无限循环CPU100%;
- JDK1.8尾插消除死链,但仍存在size计数错乱、红黑树结构破坏、扩容数据丢失;
并发替代方案对比
- HashTable:全方法
synchronized锁整张表,并发性能极差; - Collections.synchronizedMap:全局对象锁,等同于HashTable;
- ConcurrentHashMap(推荐):细粒度锁。
九、ConcurrentHashMap实现原理
JDK1.7:分段锁Segment
- 底层:Segment数组+链表,Segment继承ReentrantLock;
- 默认16个分段,最多16线程并发写,仅锁住当前Segment;
- get无锁,value用volatile保证可见性。
JDK1.8:CAS + synchronized(锁单个桶头节点)
结构和HashMap一致(数组+链表+红黑树):
- 桶为空:CAS无锁插入;
- 当前桶正在扩容:协助迁移数据;
- 桶有元素:
synchronized(f)锁住桶首节点,仅锁单个桶,粒度极细; - 不允许key/value为null,防止并发歧义;
- get全程无锁,依靠volatile数组、节点可见性读取。
十、衍生集合底层原理
1. LinkedHashMap
继承HashMap,额外维护双向链表(before/after),实现有序:
- 插入有序(默认);
- 访问有序(LRU缓存核心,重写removeEldestEntry淘汰旧数据)。
2. TreeMap
底层纯红黑树,按键排序;key需实现Comparable或传入自定义Comparator。
3. HashSet
底层封装HashMap,存入元素作为key,value统一固定Object常量PRESENT;add、remove、contains全复用HashMap方法。
十一、高频面试简答汇总
- 初始化new HashMap(10)实际容量?16,tableSizeFor向上取2次幂;
- 允许null键值?HashMap允许1个null key,ConcurrentHashMap不允许;
- 链表查询时间复杂度O(n),红黑树O(logn);
- 扩容时机:size > capacity * 0.75;
- 1.7与1.8核心差异:红黑树、尾插、扩容优化、扰动简化;
- 热key大量碰撞解决方案:自定义哈希、扩容、分段存储。

浙公网安备 33010602011771号