我自己在带项目的时候被问过太多次HashMap,也看过不少同事背了“数组+链表+红黑树”九个字,结果一追问“为什么容量必须是2的幂”“为什么树化阈值是8”“并发时到底哪里不安全”就当场卡壳。这篇是Java集合系列的第三篇,我把HashMap从数据结构、put/get完整流程、容量与负载因子设计、扩容机制、树化与退化、并发隐患,到日常开发里的容量预估和排序,全部串一遍。建议你手边有IDE的话直接打开JDK8的HashMap源码对着看,JDK11、17的核心逻辑跟8差别不大,并不影响理解。如果你是准备面试的Java开发,这篇文章能帮你把八股文背成真正讲得出的原理;如果你只是想把HashMap用对,可以直接跳到后面的开发实践部分,那里有不少实战踩坑点。
1. 别只背“数组+链表+红黑树”,HashMap到底是怎样组织的
1.1 三层数据结构各自负责什么
HashMap在JDK8之后,底层是“数组 + 链表 + 红黑树”的复合结构。很多文章把这九个字挂在开头,但实际要理解的是:这三层不是并列关系,而是一层套一层的引用关系。
最外层是一个Node<K,V>[] table数组,我们通常叫哈希桶数组,默认长度是16。数组的每个位置,叫做一个桶(bucket)。如果两个key经过hash计算后落到了同一个桶,并且它们又不相等,就会用链表把多个Node串起来。当某个桶的链表长度越来越长,查询效率从O(1)退化成O(n),这时如果满足条件,链表会转换成红黑树,把单桶查询复杂度压回O(log n)。
这里有个容易混淆的点:数组中每个桶存的不是完整链表数据,而是链表的头节点引用。桶位置上可以放四种东西:null、单个Node节点、链表头、TreeNode根节点。为什么能用TreeNode当根节点?因为TreeNode继承了Node,只是在Node的基础上增加了parent、left、right、prev、red这几个红黑树专用字段,所以同一个桶位既能串链表,也能组织成红黑树。
各层职责可以概括为:
- 数组:负责快速定位,通过
(n - 1) & hash算出key所在的桶下标,这是HashMap能实现平均O(1)的基础。 - 链表:解决哈希冲突,多个hash结果相同的key,顺序挂在同一个桶后面。链表适合节点少的情况,插入删除都很快。
- 红黑树:解决“某个桶内节点过多”的极端情况,把单桶查找从O(n)降到O(log n),相当于给hash冲突加了一道保险。
我经常打一个比方:HashMap就像一栋楼,数组是楼层,hash函数负责告诉你住几层,但同层可能住了好多人,于是每层有一条走廊(链表),住的人太多时走廊改成了带索引的树状结构(红黑树),找人就快多了。
1.2 put 一条数据:从哈希到查找的完整链路
JDK8的put流程大概是下面这个顺序:
- 对key计算扰动hash:
key.hashCode() ^ (key.hashCode() >>> 16)。如果key为null,扰动结果直接是0。 - 如果底层table数组还没初始化,先触发一次resize()。HashMap的table是懒加载的,new对象时并不会真的创建16长度的数组,要等第一次put才创建。
- 通过
(table.length - 1) & hash算出桶下标。 - 如果目标桶是空的,直接new一个Node放进去。
- 如果目标桶非空,先判断桶头节点的hash和key是否和传入key相等。相等就标记为待替换。
- 如果头节点是TreeNode类型,走红黑树的插入逻辑。
- 如果是普通链表节点,从头开始遍历链表:找到相等key就停下来准备替换;找不到就追加到链表尾部,追加后如果链表长度达到树化阈值,调用
treeifyBin尝试把链表转成红黑树。 - 最后,如果发生了key替换,返回旧value;否则
size++,并判断size是否超过threshold,超过就resize扩容。
源码中这一段很经典,建议自己精读一遍putVal方法。我精简摘录核心判断逻辑:
java复制final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab;
Node<K,V> p;
int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 目标桶是空的,直接插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e;
K k;
// 头节点key相等,直接替换
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 已经是红黑树,走树插入
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 遍历链表,尾插法插入
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) {
V oldValue = e.value;
e.value = value;
return oldValue;
}
}
++modCount;
// 超过阈值扩容
if (++size > threshold)
resize();
return null;
}
很多人看到binCount >= TREEIFY_THRESHOLD - 1会困惑,为什么不是大于等于8。原因是binCount从0开始计数,当binCount等于7时,说明当前这个链表已经遍历到了第8个节点,也就是这次新插入节点后链表长度变成8,所以才触发树化尝试。注意我这里说的是“尝试”,因为treeifyBin内部还会检查数组长度是否小于64,小于64时会先扩容而不是转树。
1.3 get 比 put 简单在哪
get的流程比put简单很多,因为不需要考虑扩容和插入,只需要定位和查找:
- 先对key计算同样的扰动hash。
- 判断table是否为空,为空直接返回null。
- 通过
(n - 1) & hash定位到桶。 - 桶为空返回null。
- 桶头节点匹配则直接返回。
- 否则看是TreeNode还是链表节点,分别走树的查找或链表遍历。
很多新手会以为HashMap的get是“从数组下标0开始遍历整个数组”,这是完全错误的。get是先通过hash直接跳到可能存放该key的桶,再在桶内做精细查找。这也是HashMap能维持高性能的原因。整个HashMap的查找耗时,绝大多数情况下只取决于hash函数是否均匀,而不是数据总量有多大。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 容量、负载因子与hash函数:三个最容易忽略的细节
2.1 容量为什么必须对齐到2的幂
先说结论:HashMap保证数组容量永远是2的n次方,是为了用位运算代替取模,并让扩容时数据迁移变得非常快。
如果要让一个hash值落在[0, capacity - 1]区间内,普通做法是取模:hash % capacity。但取模指令相对慢,HashMap的做法是使用hash & (capacity - 1)。这个优化能成立,前提就是capacity必须是2的幂。假设capacity是16,二进制是10000,capacity-1是01111,那么hash & 15等同于hash % 16,但运算快得多。
如果capacity不是2的幂,比如15,capacity-1是01110,二进制低位有0,那么hash & 14得到的结果永远不会出现最后一位是1的情况,也就是某些桶永远分配不到数据,整个数组利用率下降,冲突会集中在剩下的桶里。这是设计上不能接受的。
当你调用new HashMap<>(initialCapacity)传入一个不是2的幂的初始容量时,HashMap也不会直接使用这个值,而是调用tableSizeFor把它变成“大于等于传入值的最小2的幂”。比如传入10,实际容量会被修正为16。源码如下:
java复制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 >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
这个方法做的事情就是不断把最高位的1向右复制,最终让低位全是1,再加1得到2的幂。有一点很反直觉:tableSizeFor(1)返回1,但table扩容时如果容量是1会继续扩容,不是最终数组。另外,构造函数里算出的这个容量会被临时放在threshold字段上,第一次put时才真正创建数组,这是看过源码的人才能答出来的细节。
2.2 负载因子0.75:数学与工程的折中
负载因子默认是0.75f,它决定了扩容阈值:threshold = capacity * loadFactor。默认容量16时,threshold是12,意思是HashMap中元素达到13个之前不会扩容,一旦size超过12,就扩容到32。
0.75不是一个拍脑袋拍出来的数字,它是在“空间利用率”和“查询效率”之间取的折中:
- 负载因子太大,比如1.0,意味着要等数组快塞满了才扩容,空间利用率高,但哈希冲突会显著增加,链表变长,查询效率下降。
- 负载因子太小,比如0.5,冲突少,但数组一半空间都是空的,浪费内存,而且扩容频繁,迁移成本高。
0.75正好是四分之三,在大多数场景下既可以保证数组有一定空闲槽位去分散冲突,又不会让内存空置太多。
更严谨的数学依据藏在HashMap源码注释的泊松分布计算里。假设负载因子0.75,桶中节点数达到8的概率已经小于一千万分之一。也就是说,在正常随机hash情况下,一个链表很难长到8个节点,红黑树主要是为了应对恶意构造的哈希冲突攻击或极端糟糕的hashCode实现。这个概率表可以背下来,面试时能拿原理解释清楚:
| 链表节点数 | 概率 |
|---|---|
| 0 | 0.60653066 |
| 1 | 0.30326533 |
| 2 | 0.07581633 |
| 3 | 0.01263606 |
| 4 | 0.00157952 |
| 5 | 0.00015795 |
| 6 | 0.00001316 |
| 7 | 0.00000094 |
| 8 | 0.00000006 |
注意,这个概率建立在负载因子0.75、扩容机制正常的前提下。实际代码中如果某个key的hashCode实现极差,比如所有对象都返回同一个hash值,那所有节点都会堆到同一个桶,链表会无限变长,这时候红黑树就是最后一道防线,把O(n)拉回O(log n)。
2.3 扰动函数为什么用右移16位异或
源码里对key的hash处理非常短:
java复制static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
它把key的hashCode无符号右移16位,再和原hashCode做异或。16正好是32位int的一半,所以这个操作本质上是让哈希值的高16位和低16位进行混合,把高位的特征也扩散到低位去。
为什么要这样做?因为桶下标计算用的是hash & (capacity - 1),在容量比较小的时候,比如默认16,capacity-1只有低4位是1,这意味着真正参与桶下标计算的只有hash的低4位。如果一个对象的hashCode本身设计得不好,高位变化很丰富,但低位几乎不变,那么大量对象会落在同一个桶里,造成严重冲突。
通过h ^ (h >>> 16),高16位的信息被打散到了低16位,即使数组容量很小,最后参与下标计算的低位也包含了高位的信息。这不需要高深的数学知识,核心就是为了在容量有限时让桶分布更均匀。这也是面试时很常见的追问点,只看结论不看实现原理的人很难答好。
3. 扩容到底做了什么:JDK8的resize流程解读
3.1 resize的触发时机和源码分支
HashMap的扩容方法是整个类里最长的代码块之一,也是面试官最爱让你讲的部分。扩容触发时机有几种:
- 第一次put时,table为null,触发resize创建初始数组。
- size超过threshold时,触发扩容。
- 链表转树前发现数组长度小于64,触发扩容。
JDK8的resize在不同情况下的逻辑是通过判断oldCap和oldThr的状态分支实现的:
java复制final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 达到最大容量,threshold设置为Integer.MAX_VALUE,不再扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 容量翻倍,threshold翻倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
else if (oldThr > 0) // 构造函数传了initialCapacity时,oldThr暂时存的是容量
newCap = oldThr;
else { // 默认无参构造第一次put
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY
? (int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 下面把旧数据迁移到新数组
// ...
}
平时我们用无参构造时,根本没有走oldCap > 0分支,因为第一次put时oldCap是0,oldThr也是0,直接走else分支,创建容量16的数组,threshold设为12。如果用了new HashMap<>(32),构造阶段threshold会被临时设置为tableSizeFor(32)的结果,也就是32,而oldCap还是0,所以第一次resize会走到else if (oldThr > 0)分支,把newCap设成oldThr,再在后面计算newThr,也就是32 * 0.75 = 24。
很多资料讲“默认容量16,扩容阈值12”只讲了无参构造这条路,一旦问到带初始容量构造,旧式回答就不够用了。
3.2 新桶下标为什么只用 oldCap 就能判断
JDK7老版HashMap扩容时,会对每个key重新计算hash和下标,成本比较高。JDK8做了很聪明的优化:因为容量扩容是翻倍,原来在index位置的元素,扩容后要么待在原index,要么移动到index + oldCap,判断依据就是(e.hash & oldCap)是否为0。
我来解释为什么。设oldCap等于16,二进制是10000,oldCap-1是01111。原来计算下标时只用低4位:hash & 01111。扩容后newCap等于32,newCap-1是11111,下标计算变成hash & 11111,相当于取低5位。区别就在于原来的第4位(从低往高数)到第5位的变化。如果hash在oldCap对应那一位上是0,比如hash的这一位是0,那它和11111相与的结果,低5位和原来低4位的值完全一样,索引不变;如果这一位是1,新的索引就比原来多了一个oldCap。
判断某一位是0还是1,只需要hash & oldCap,oldCap的二进制只有在oldCap那一位上是1,其他位都是0。所以整条链遍历时,在同一个桶里的所有node会被拆分成两个链表:
(e.hash & oldCap) == 0,进lo链表,说明扩容后位置不变。(e.hash & oldCap) != 0,进hi链表,说明扩容后位置是j + oldCap。
迁移完后,直接newTab[j] = loHead、newTab[j + oldCap] = hiHead,不需要重新计算hash,也不需要对数组中每个桶重新散列。整个迁移过程效率很高。
3.3 扩容期间链表和红黑树是怎么处理的
仍然以resize后半段为例,代码中通过loHead、loTail、hiHead、hiTail四个临时节点,把原桶的链表拆分成两条:
java复制do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null) loHead = e;
else loTail.next = e;
loTail = e;
} else {
if (hiTail == null) hiHead = e;
else hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
这段代码同时解决了两个问题。第一个是顺序问题:JDK8用尾插法维护了同一条链拆出来的节点在原链表中的相对顺序,不会像JDK7那样因为头插法导致链表反转。第二个问题是当原桶节点是红黑树节点时,由于TreeNode的next指针仍然存在,拆分逻辑可以沿用同样的lo/hi拆分。拆分后如果某条链长度小于等于6,就把TreeNode链转回普通Node链表;如果仍然满足树化条件,则重新构建红黑树。
这里要特别注意红黑树和链表不是“一劳永逸”的关系,而是会动态转化的:put导致链表过长时会尝试转树,扩容或删除导致树节点减少时又可能退化回链表。这也是为什么会有两个看起来矛盾的阈值:树化阈值是8,退化阈值是6。中间留了7这个缓冲带,目的是防止元素数量在7、8之间波动时,频繁在链表和红黑树之间来回切换,白白浪费转换成本。
4. 并发问题:HashMap不能用于多线程的本质原因
4.1 JDK7:头插法引起的并发死循环
早期版本的HashMap扩容使用的是头插法,每次把旧链表里的节点取出来,插入到新数组的同位置桶的头部。单线程下没问题,多线程并发扩容时,两个线程可能同时执行transfer方法,互相覆盖对方的引用,最终在某个桶内形成环形链表。
环形链表的危害是致命的:当get一个不存在的key且它的hash定位到这个环上时,遍历链表永远找不到结尾,进入死循环,CPU直接飙到100%。这个问题在JDK8里通过尾插法和拆分链已经基本解决了,因为扩容迁移时不再反转链表,天然不会形成环。
不过要说明一点,JDK7的死循环问题并不是100%必现,它依赖多个线程在同一时刻对同一个桶做扩容迁移的交错时序,但一旦在高负载下触发,整个服务就会瘫痪。在我早年的项目里就出现过一次线上事故,多个线程同时往一个无锁HashMap里写数据并触发扩容,最终一台机器CPU打满,应用完全卡死,查了半天才定位到是HashMap并发写入导致。
4.2 JDK8:死循环问题缓解,但数据丢依旧是常态
JDK
