HashMap 是 Java 中最核心的集合类之一,其设计精妙且高效。下面我们直接深入其源码实现,探究其内部工作机制。
⚙️ 核心结构与初始化
理解源码前,先掌握HashMap的三个基石:核心数据结构、关键静态常量与实例变量。
类别 | 名称 | 说明 |
---|---|---|
核心静态常量 | DEFAULT_INITIAL_CAPACITY |
默认初始容量 16 。 |
MAXIMUM_CAPACITY |
最大容量 2^30 。 | |
DEFAULT_LOAD_FACTOR |
默认负载因子 0.75f ,是空间和时间成本的折中 。 | |
TREEIFY_THRESHOLD |
链表转红黑树的阈值 8 。 | |
UNTREEIFY_THRESHOLD |
红黑树转链表的阈值 6 。设置6是为了防止在阈值附近频繁地进行转换 。 | |
MIN_TREEIFY_CAPACITY |
触发树化的最小数组容量 64 。链表长度长但数组容量小的情况下,会优先扩容 。 | |
核心实例变量 | Node<K,V>[] table |
哈希桶数组,作为HashMap的骨架 。 |
int size |
当前键值对的数量 。 | |
int threshold |
扩容阈值,计算公式为 capacity * loadFactor 。当size 超过此值,触发扩容 。 |
|
float loadFactor |
负载因子,可手动指定 。 |
HashMap采用了经典的数组 + 链表 + 红黑树的复合结构 。
- 数组(桶数组):作为主干,默认初始长度为16 。
- 链表(
Node
):用于解决哈希冲突。当多个键的哈希值映射到数组同一位置时,会以链表形式存储 。 - 红黑树(
TreeNode
):当链表长度超过阈值(默认8)且数组长度达到一定值(默认64)时,链表会转换为红黑树,以将查询效率从O(n)提升至O(log n) 。
HashMap在创建时并不会立即初始化桶数组 table
,而是在第一次插入元素时,通过 resize()
方法完成初始化 。这是一种懒加载机制。
🔍 哈希计算与定位
要操作一个元素,第一步是确定它在数组中的位置。这个过程非常巧妙。
java
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这个方法计算键的哈希值,其核心是一个扰动函数 。它将键的原始哈希码 h
的高16位与低16位进行异或运算 (h = key.hashCode()) ^ (h >>> 16)
。这样做的目的是将高位的特征融入到低位中,从而增加哈希值的随机性,减少后续出现哈希冲突的概率 。
得到哈希值后,通过以下位运算确定其在数组中的下标:
index = (table.length - 1) & hash
由于数组长度 n
总是2的幂次方,n-1
的二进制就是一串连续的 1
(比如长度为16时,15的二进制是 1111
)。这个操作实质上是 hash % n
的等效优化,但位运算的效率远高于取模运算 。
📬 元素插入(put)全过程
put
方法是HashMap最核心的方法,它直接调用了 putVal
方法。这里对关键步骤进行说明:
-
计算哈希值 (
hash(key)
):插入过程始于对键(key)的哈希码进行优化计算。HashMap 并没有直接使用键对象原始的
hashCode()
返回值,而是通过一个"扰动函数"进行处理:(h = key.hashCode()) ^ (h >>> 16)
。此操作将哈希码的高 16 位与低 16 位进行异或运算,目的是增加低位的随机性,从而减少后续发生哈希冲突的概率 。如果键为null
,其哈希值被定义为 0 。 -
初始化数组 (
resize()
):HashMap 采用懒加载机制。在首次调用
put
方法时,底层的存储数组table
通常是空的。putVal
方法首先会检查table
是否为空或其长度是否为 0。如果是,则调用resize()
方法对数组进行初始化 。默认情况下,初始容量为 16,扩容阈值 (threshold
) 为 16 * 0.75 (负载因子) = 12 。 -
计算索引并检查空桶:
通过公式
i = (n - 1) & hash
计算键值对在数组中的索引位置i
(其中n
是数组当前长度)。这是一个高效的位运算,等效于取模运算hash % n
。如果计算出的位置table[i]
为空(即null
),则直接在此处创建一个新节点存储键值对,本次插入的主要工作就完成了 。 -
处理哈希冲突(桶非空):
如果
table[i]
不为空,意味着发生了哈希冲突。此时的处理逻辑取决于该位置已存在节点的数据结构:-
情况一:键已存在(首节点匹配)
首先检查该桶的第一个节点
p
的键是否与待插入的键相同。判断依据是:两者的哈希值相等,并且(引用相同或equals
方法返回true
)。如果匹配,则记录下该现有节点e
,后续将用新值覆盖其原有的值 。 -
情况二:红黑树节点 (
TreeNode
)如果第一个节点是
TreeNode
类型,说明该桶的数据结构已经是一棵红黑树。此时会调用putTreeVal
方法,按照红黑树的规则进行插入或查找。如果在树中找到了相同键的节点,则返回该节点用于值覆盖;否则,将新节点插入到树中 。 -
情况三:链表结构
如果第一个节点是普通链表节点,则开始遍历链表。
- 在遍历过程中,会检查每个节点的键是否与待插入键相同(判断逻辑同情况一)。如果找到,则跳出循环,记录该节点
e
用于值覆盖 。 - 如果一直遍历到链表尾部(
p.next == null
)仍未找到相同键,则在尾部创建一个新节点进行插入(JDK 8 采用尾插法)。同时,会记录当前链表的长度(binCount)。 - 树化检查 :插入新节点后,如果链表长度达到或超过了树化阈值(
TREEIFY_THRESHOLD
,默认为 8),即binCount >= 7
(因为计数从 0 开始),则会调用treeifyBin
方法 。注意 :在treeifyBin
方法内部,还会检查当前数组的总体容量是否达到最小树化容量(MIN_TREEIFY_CAPACITY
,默认为 64)。只有数组长度 >= 64,才会真正将链表转换为红黑树;否则,将优先选择对数组进行扩容(resize()
),以期减少链表长度 。
- 在遍历过程中,会检查每个节点的键是否与待插入键相同(判断逻辑同情况一)。如果找到,则跳出循环,记录该节点
-
-
处理值覆盖:
如果在上述第 4 步的任意情况中找到了已存在的键(即
e != null
),则表示是更新操作而非新增。此时,会用新的value
替换掉节点e
中旧的value
(除非onlyIfAbsent
为true
且旧值不为null
),然后返回旧值 。 -
扩容检查:
如果本次操作是新增 了一个节点(而非覆盖),那么会修改
modCount
(结构性修改次数),并且将 HashMap 的大小size
加 1。之后,会检查size
是否超过了当前的扩容阈值threshold
。如果超过,则调用resize()
方法进行扩容,新容量通常是旧容量的 2 倍 。
希望这次详细的文字说明能够帮助您清晰理解 HashMap 的插入全过程。
🚀 扩容机制(resize)
扩容是HashMap保证性能的关键。
- 触发条件 :当 HashMap 中的元素数量超过
threshold
(容量 × 负载因子)时触发扩容 。例如,默认情况下,当元素数量超过16 * 0.75 = 12
时,容量会扩大为原来的2倍(如16变为32)。 - JDK 8 的优化 :在迁移链表元素时,JDK 8 采用了一个巧妙的算法。由于新容量是旧容量的2倍,每个元素在新数组中的新位置要么是原索引位置 ,要么是原索引 + 旧容量 。通过判断
(e.hash & oldCap) == 0
这个条件,可以快速将一条链表拆分成两条(一条放在原索引,一条放在原索引+旧容量的位置),并保持原有顺序,避免了 JDK 7 中头插法可能导致死循环的问题,同时提升了效率 。
⚠️ 线程安全问题
HashMap 不是线程安全的 。在多线程环境下并发修改可能导致:
- 数据覆盖 :多个线程同时执行
put
可能导致数据被覆盖 。 - 死循环(JDK 7):在扩容过程中,头插法可能导致链表形成环,引起死循环 。
- 状态不一致。
解决方案:
- **
ConcurrentHashMap
**:推荐使用,它采用了更细粒度的锁机制(JDK 7使用分段锁,JDK 8及之后使用CAS + synchronized
锁住桶的头节点)来保证线程安全和高并发性能 。 - **
Collections.synchronizedMap
**:可以返回一个线程安全的包装类,但性能相对较低 。
💎 总结与最佳实践
- 正确重写
hashCode()
和equals()
:如果你自定义的类要作为 HashMap 的 Key,必须正确重写这两个方法,且要保证:如果两个对象通过equals
方法比较是相等的,那么它们的hashCode
返回值也必须相等 。 - 合理指定初始容量 :如果能预估 HashMap 最终要存放的元素数量,应该在创建时指定一个合适的初始容量,以避免或减少扩容次数。建议初始容量 =
(预计元素数量 / 负载因子) + 1
。 - 理解树化与扩容的权衡:树化阈值8和退化阈值6的设定,是为了在时间和空间复杂度之间取得平衡,并避免在临界点附近频繁转换 。