JDK 1.8 中 HashMap 的源码解读
HashMap 是 Java 中最常用的集合类之一,它实现了 Map 接口,提供了键值对的存储和查询功能。HashMap 的底层数据结构是数组和链表的结合,也称为哈希桶(hash bucket)。在 JDK 1.8 中,HashMap 进行了一些优化,引入了红黑树来解决链表过长的问题,提高了查询效率。本文将从 HashMap 的源码出发,逐段解读其主要的实现细节,包括:
- 基本属性和构造方法
- 哈希函数和索引计算
- put 方法和扩容机制
- get 方法和查找流程
- remove 方法和删除操作
- 红黑树的转换和平衡调整
基本属性和构造方法
首先,我们来看一下 HashMap 的基本属性,它们定义了 HashMap 的容量、负载因子、阈值、链表和红黑树的转换条件等。
java
// 默认初始容量为 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 最大容量为 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认负载因子为 0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 链表转换为红黑树的阈值为 8
static final int TREEIFY_THRESHOLD = 8;
// 红黑树转换为链表的阈值为 6
static final int UNTREEIFY_THRESHOLD = 6;
// 转换为红黑树时,哈希桶数组的最小长度为 64
static final int MIN_TREEIFY_CAPACITY = 64;
// 哈希桶数组,存放节点(链表或红黑树)
transient Node<K,V>[] table;
// 存放所有键值对的集合,用于迭代
transient Set<Map.Entry<K,V>> entrySet;
// 键值对的数量
transient int size;
// 修改次数,用于迭代器的快速失败机制
transient int modCount;
// 扩容的阈值,等于容量乘以负载因子
int threshold;
// 负载因子
final float loadFactor;
然后,我们来看一下 HashMap 的构造方法,它们可以指定初始容量、负载因子、或者根据另一个 Map 来初始化。
java
// 默认构造方法,使用默认的初始容量(16)和负载因子(0.75)
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
// 指定初始容量的构造方法,使用默认的负载因子(0.75)
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 指定初始容量和负载因子的构造方法
public HashMap(int initialCapacity, float loadFactor) {
// 初始容量不能为负数
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 初始容量不能超过最大容量(2^30)
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 负载因子不能为负数或者非数(NaN)
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
// 设置负载因子
this.loadFactor = loadFactor;
// 计算扩容阈值,取大于等于初始容量的最小2次幂(例如10 -> 16)
this.threshold = tableSizeFor(initialCapacity);
}
// 根据另一个 Map 来初始化的构造方法,使用默认的负载因子(0.75)
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
// 将另一个 Map 中的所有键值对复制到当前 Map 中
putMapEntries(m, false);
}
哈希函数和索引计算
在了解 HashMap 的增删改查方法之前,我们先来看一下它是如何计算哈希值和索引位置的。哈希值是根据键对象的 hashCode 方法和一个扰动函数来计算的,目的是为了让哈希值更加均匀分布,减少碰撞的概率。索引位置是根据哈希值和哈希桶数组的长度来计算的,使用了一个简单而高效的位运算。
java
// 计算键对象的哈希值
static final int hash(Object key) {
int h;
// 如果键对象为 null,哈希值为 0
// 否则,调用键对象的 hashCode 方法,并将高 16 位和低 16 位进行异或运算
// 这样做的目的是为了让高位和低位都参与到哈希值的计算中,增加随机性
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 根据哈希值和数组长度计算索引位置
static int indexFor(int h, int length) {
// h & (length-1) 等价于 h % length,但是效率更高
// 因为 length 是 2 的幂次方,h & (length-1) 只需要进行一次与运算即可
// 而 h % length 需要进行除法和取余运算,开销更大
return h & (length-1);
}
put 方法和扩容机制
put 方法是 HashMap 中最核心的方法之一,它实现了键值对的插入功能。put 方法的逻辑比较复杂,可以分为以下几个步骤:
- 判断哈希桶数组是否为空或者长度为 0,如果是,就进行扩容操作,分配一个初始容量(默认为 16)的数组。
- 根据键对象的哈希值和数组长度计算索引位置,判断该位置是否为空,如果是,就创建一个新节点放在该位置。
- 如果该位置不为空,说明发生了哈希碰撞,就需要根据不同的情况进行处理:
- 如果该位置的节点的键和要插入的键相同(通过 equals 方法判断),就直接覆盖该节点的值,并返回旧值。
- 如果该位置的节点是一个红黑树节点,就按照红黑树的方式插入节点,并判断是否需要进行平衡调整。
- 如果该位置的节点是一个链表节点,就遍历链表,如果找到和要插入的键相同的节点,就覆盖其值,并返回旧值;如果没有找到,就在链表尾部插入一个新节点,并判断链表长度是否超过阈值(默认为 8),如果超过了,就把链表转换为红黑树。
- 如果插入了新节点,就判断键值对数量是否超过阈值(等于容量乘以负载因子,默认为 16 * 0.75 = 12),如果超过了,就进行扩容操作。
put 方法的源码:
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;
// 如果哈希桶数组为空或者长度为 0,进行扩容操作
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;
// 如果该位置的节点的键和要插入的键相同(通过 equals 方法判断)
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);
// 判断链表长度是否超过阈值(默认为 8)
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 如果超过了,就把链表转换为红黑树
treeifyBin(tab, hash);
break;
}
// 如果找到和要插入的键相同的节点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 就跳出循环,准备覆盖其值
break;
// 否则,继续遍历下一个节点
p = e;
}
}
// 如果找到了和要插入的键相同的节点
if (e != null) { // existing mapping for key
// 保存旧值
V oldValue = e.value;
// 如果不是只有在键不存在时才插入,或者旧值为 null
if (!onlyIfAbsent || oldValue == null)
// 就覆盖新值
e.value = value;
// 执行一些后处理操作,例如 LinkedHashMap 中的访问顺序调整等
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
// 增加修改次数,用于迭代器的快速失败机制
++modCount;
// 如果键值对数量超过阈值(等于容量乘以负载因子,默认为 16 * 0.75 = 12)
if (++size > threshold)
// 就进行扩容操作
resize();
// 执行一些后处理操作,例如 LinkedHashMap 中的插入顺序调整等
afterNodeInsertion(evict);
// 返回 null,表示没有覆盖旧值
return null;
}
这里我们重点关注一下 resize 方法和 treeifyBin 方法,它们分别实现了扩容机制和链表转换为红黑树的功能。
resize 方法的作用是根据当前的容量和键值对数量来调整哈希桶数组的长度,使其能够适应不同的数据量。resize 方法的逻辑如下:
- 如果哈希桶数组为空或者长度为 0,就分配一个初始容量(默认为 16)的数组,并计算阈值(等于容量乘以负载因子)。
- 如果哈希桶数组已经达到最大容量(2^30),就不再扩容,只是将阈值设为最大整数(Integer.MAX_VALUE),这样就相当于取消了扩容机制。
- 否则,就将容量翻倍,分配一个新的数组,并将阈值也翻倍。然后,遍历旧数组中的所有节点,将它们复制到新数组中。复制的过程中,需要根据节点的类型进行不同的处理:
- 如果节点是一个单节点,就直接计算其在新数组中的索引位置,并放入新数组。
- 如果节点是一个红黑树节点,就按照红黑树的方式拆分节点,分别放入新数组的两个位置(原索引和原索引加旧容量)。如果拆分后的红黑树节点数量小于阈值(默认为 6),就将其转换为链表。
- 如果节点是一个链表节点,就按照链表的方式拆分节点,分别放入新数组的两个位置(原索引和原索引加旧容量)。
下面我们来看一下 resize 方法的源码:
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;
// 如果旧容量大于 0
if (oldCap > 0) {
// 如果旧容量已经达到最大容量(2^30)
if (oldCap >= MAXIMUM_CAPACITY) {
// 就将阈值设为最大整数(Integer.MAX_VALUE)
threshold = Integer.MAX_VALUE;
// 返回旧数组,不再扩容
return oldTab;
}
// 否则,将新容量设为旧容量的两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 将新阈值设为旧阈值的两倍
newThr = oldThr << 1; // double threshold
}
// 如果旧阈值大于 0,说明是根据另一个 Map 来初始化的
else if (oldThr > 0)
// 就将新容量设为旧阈值
newCap = oldThr;
else { // zero initial threshold signifies using defaults
// 否则,使用默认的初始容量(16)和负载因子(0.75)
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 如果新阈值为 0
if (newThr == 0) {
// 计算临时阈值,等于新容量乘以负载因子
float ft = (float)newCap * loadFactor;
// 如果新容量小于最大容量,并且临时阈值小于最大容量
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
// 就将新阈值设为临时阈值
(int)ft : Integer.MAX_VALUE);
}
// 设置新阈值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
// 分配一个新数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
// 设置新数组
table = newTab;
// 如果旧数组不为空
if (oldTab != null) {
// 遍历旧数组中的所有节点
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 如果节点不为空,就将其复制到新数组中
if ((e = oldTab[j]) != null) {
// 将旧数组中的节点置为 null,方便垃圾回收
oldTab[j] = null;
// 如果节点是一个单节点,没有后续节点
if (e.next == null)
// 就直接计算其在新数组中的索引位置,并放入新数组
newTab[e.hash & (newCap - 1)] = e;
// 如果节点是一个红黑树节点
else if (e instanceof TreeNode)
// 就按照红黑树的方式拆分节点,分别放入新数组的两个位置(原索引和原索引加旧容量)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 如果节点是一个链表节点,就按照链表的方式拆分节点,分别放入新数组的两个位置(原索引和原索引加旧容量)
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 如果节点的哈希值和旧容量的按位与运算结果为 0
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;
}
}
}
}
}
// 返回新数组
return newTab;
}
treeifyBin 方法的作用是将一个过长的链表转换为红黑树,提高查询效率。treeifyBin 方法的逻辑如下:
- 判断哈希桶数组的长度是否小于转换为红黑树时的最小长度(默认为 64),如果是,就先进行扩容操作,而不是转换为红黑树。这样做的目的是为了避免在容量较小时就进行红黑树的转换,因为这样可能会导致多个链表同时转换为红黑树,反而降低效率。
- 否则,就创建一个红黑树的根节点,并遍历链表中的所有节点,将它们按照红黑树的方式插入到根节点中,并进行平衡调整。最后,将哈希桶数组中的原链表节点替换为红黑树的根节点。
下面我们来看一下 treeifyBin 方法的源码:
java
// 将链表转换为红黑树
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 如果哈希桶数组为空或者长度小于转换为红黑树时的最小长度(默认为 64)
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
// 就先进行扩容操作
resize();
// 否则,找到对应索引位置的节点
else if ((e = tab[index = (n - 1) & hash]) != null) {
// 创建一个红黑树的根节点和尾节点
TreeNode<K,V> hd = null, tl = null;
do {
// 将链表节点转换为红黑树节点
TreeNode<K,V> p = replacementTreeNode(e, null);
// 如果尾节点为空,说明是第一个节点,就将其设为根节点
if (tl == null)
hd = p;
else {
// 否则,将其链接到尾节点后面
p.prev = tl;
tl.next = p;
}
// 更新尾节点
tl = p;
} while ((e = e.next) != null);
// 如果根节点不为空,就将其放入哈希桶数组中,并遍历所有节点,按照红黑树的方式插入和平衡调整
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}