我自己在带项目的时候被问过太多次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 = ta
