HashMap
本文主要是用于记录我在阅读Java1.8的 HashMap 源码所做的笔记。对于源码中的注释会进行翻译下来,并且会对其中部分源码进行注释。
这篇文章主要是关于插入键值对、扩容、树化和去树化方法的阅读。
putVal 插入新值方法
/**
* 实现 Map.put 和相关方法。
*
* @param hash 键的哈希值
* @param key 键
* @param value 要放入的值
* @param onlyIfAbsent 如果为 true,则不改变现有值
* @param evict 如果为 false,则表处于创建模式。
* @return 之前的值,如果没有则为 null
*/
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;
// 检查该位置上的第一个节点是否与新插入的键相同
// 如果该位置已有节点,并且该节点与要插入的键相同
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) {
// 遍历找到尾结点 p,e为待插入节点的位置
if ((e = p.next) == null) {
// 尾插法
p.next = newNode(hash, key, value, null);
// 如果链表长度达到阈值,则将其转换为红黑树
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;
// 如果 onlyIfAbsent 为 false 或者旧值为 null,则更新值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e); // 执行节点访问后的操作
return oldValue; // 返回旧值
}
}
// 修改HashMap 结构修改的次数,并检查是否需要扩容
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
方法解读
Java8之前的HashMap是采用头插法来解决hash冲突的,这意味着新插入的元素会被插入到链表的头部,而不是尾部。而Node数组存储的是链表的头节点。
Java8中将HashMap的插入方法改为了尾插法,也就是新插入的元素会被插入到链表的尾部。**这样做的主要原因是尾插法对于后续元素的查找和删除操作更加高效,因为新插入的元素在链表的尾部,因此只需要遍历链表一次就可以找到链表的尾部。**另外,Java8中还引入了红黑树来优化HashMap,在链表过长时会自动转换为红黑树,提高了查找的效率。
Java 8中将HashMap的插入方法改为了尾插法,提高了HashMap的性能,主要表现在以下两个方面:
- 避免了链表破坏带来的性能问题
在Java 8之前,HashMap的插入方法是基于头插法实现的,即在链表的头部添加新元素,这样可以保证新添加的元素总是位于链表的头部,而旧的元素则位于链表的尾部。但是,在多线程并发情况下,可能会出现链表破坏的情况,即两个线程在链表的同一位置插入元素,导致链表数据结构被破坏,出现环形链表或链表断裂等问题。这样会导致HashMap的性能严重下降,甚至可能出现死循环等问题。
而将HashMap的插入方法改为了尾插法,可以避免链表破坏带来的性能问题。因为在多线程并发情况下,新元素总是会被添加到链表的末尾,不会影响链表中已有元素的位置,从而避免了链表破坏带来的性能问题。 - 提高了查询性能
在Java 8中,HashMap的插入方法改为了尾插法,这种实现方式可以提高HashMap的查询性能。具体来说,由于新元素总是被添加到链表的末尾,查询时可以更快地找到需要的元素。因为在遍历链表时,新元素总是位于链表的尾部,查询时可以更快地找到需要的元素。这样可以提高HashMap的查询效率,从而提高HashMap的性能。
1.7和1.8有哪些区别
- JDK1.7的时候使用的是节点数组+ 单链表的数据结构。但是在JDK1.8及之后时,使用的是节点数组+链表+红黑树的数据结构(当链表的深度达到8的时候,也就是默认阈值,并且数组size大于64,就会自动扩容把链表转成红黑树的数据结构来把时间复杂度从O(n)变成O(logN)提高了效率)。因为当 table 数组容量比较小时,键值对节点 hash 的碰撞率可能会比较高,进而导致链表长度较长。这个时候应该优先扩容,而不是立马树化。
- JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法。因为JDK1.7是用单链表进行的纵向延伸,当采用头插法时会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。
- 扩容后数据存储位置的计算方式也不一样:在JDK1.7的时候是直接用hash值和需要扩容的二进制数进行&(hash值 & length-1)。而在JDK1.8的时候通过 hash & oldCap 的值来判断,并不需要重新如上一样再次计算。这种方式就相当于只需要判断Hash值的新增参与运算的位是0还是1就直接迅速计算出了扩容后的储存方式。
resize 重新哈希方法
高效的重新哈希:
当 HashMap 扩容时,它会尽量保持元素在新数组中的位置不变,或者只移动到新位置的固定偏移量处。这种做法减少了重新哈希的开销,并且保证了即使在扩容后也能快速定位元素。
/**
* 初始化或翻倍哈希表的大小。如果哈希表为空,则根据初始容量目标分配新表。
* 否则,因为使用的是二的幂次方扩展,所以每个桶中的元素要么保持在相同的位置,
* 要么移动到新表中相应位置,偏移量为二的幂次方。
*
* @return 新的哈希表
*/
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) { // 如果旧表不为空
if (oldCap >= MAXIMUM_CAPACITY) { // 如果旧表已经达到最大容量
threshold = Integer.MAX_VALUE; // 设置阈值为整型的最大值
return oldTab; // 返回旧表
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && // 如果旧表容量翻倍后不超过最大容量
oldCap >= DEFAULT_INITIAL_CAPACITY) { // 并且旧表容量大于等于默认初始容量
newThr = oldThr << 1; // 阈值翻倍
}
}
else if (oldThr > 0) { // 如果旧表为空但阈值不为零(即设置了初始容量)
newCap = oldThr; // 使用阈值作为新的容量
}
else { // 如果旧表为空并且阈值也为零(即使用默认设置)
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; // 更新阈值
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; // 创建新的哈希表
table = newTab; // 将新表赋值给table
if (oldTab != null) { // 如果旧表不为空
for (int j = 0; j < oldCap; ++j) { // 遍历旧表
Node<K,V> e;
if ((e = oldTab[j]) != 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 { // 如果是链表
Node<K,V> loHead = null, loTail = null; // 低索引部分的头尾
Node<K,V> hiHead = null, hiTail = null; // 高索引部分的头尾
Node<K,V> next;
do { // 遍历链表
next = e.next;
if ((e.hash & oldCap) == 0) { // 如果哈希值的低位为0
if (loTail == null) // 如果低索引部分还没有节点
loHead = e; // 设置头节点
else
loTail.next = e; // 连接到尾部
loTail = e; // 更新尾部
}
else { // 如果哈希值的低位为1
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; // 返回新表
}
resize() 方法是 HashMap 中用于初始化或扩容存储桶数组的核心方法。它的主要作用是根据当前的容量和负载因子,计算出新的容量和阈值,并创建一个新的存储桶数组,将原有的键值对重新散列到新的数组中。以下是对这个方法的详细解读:
- 首先获取当前的存储桶数组 oldTab、当前容量 oldCap 和当前阈值 oldThr。
- 根据 oldCap 的不同情况,计算新的容量 newCap 和新的阈值 newThr。
- 如果 oldCap 大于 0,说明已经初始化过了:
- 如果 oldCap 已经达到最大容量 MAXIMUM_CAPACITY,则将阈值设置为 Integer.MAX_VALUE,并返回原数组。
- 否则,新容量 newCap 为 oldCap 的两倍,新阈值 newThr 也是 oldThr 的两倍。
- 如果 oldCap 为 0,但 oldThr 不为 0,说明初始化时将初始容量设置在了 threshold 字段,此时 newCap 就等于 oldThr。
- 如果 oldCap 和 oldThr 都为 0,说明是第一次使用 HashMap,此时 newCap 为默认初始容量 DEFAULT_INITIAL_CAPACITY,newThr 为默认初始容量乘以默认负载因子 DEFAULT_LOAD_FACTOR。
- 如果计算出的 newThr 为 0,则根据 newCap 和 loadFactor 重新计算 newThr。
- 创建一个新的存储桶数组 newTab,容量为 newCap。
- 如果 oldTab 不为空,则遍历 oldTab,将原有的键值对重新散列到 newTab 中。
- 如果当前节点是单节点,则直接重新计算索引,将节点放入 newTab 对应的位置。
- 如果当前节点是树节点(TreeNode),则调用 split 方法对树节点进行拆分,将拆分后的节点重新放入 newTab 中。
- 如果当前节点是链表节点,则遍历链表,根据节点的哈希值是否大于等于 oldCap,将链表一分为二,分别放入 newTab 中对应的位置。
- 返回新的存储桶数组 newTab。
这个方法的主要逻辑是:
1. 根据当前容量和负载因子计算新的容量和阈值。
2. 创建新的存储桶数组。
3. 将原有的键值对重新散列到新的数组中,利用了 2 的幂次方扩容的特性,可以避免大量的节点位置重新计算。
resize() 方法保证了 HashMap 在元素数量增长时可以动态地调整存储桶的容量,从而保持较好的性能。同时,它也利用了红黑树来优化链表过长的情况,进一步提高性能。
treeifyBin 树化方法
/**
* 将给定哈希值对应的链表转换为红黑树,除非哈希表太小,此时先进行扩容。
*/
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 {
// replacementTreeNode 方法被用来将链表中的每个节点转换为 TreeNode。
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); // 转换为红黑树
}
}
replacementTreeNode 将链表中的每个节点转换为 TreeNode
// For treeifyBin
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
}
treeify 树化方法
/**
* 形成以当前节点为根的红黑树。
*/
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null; // 初始化根节点为 null
// 遍历从当前节点开始的链表
for (TreeNode<K,V> x = this, next; x != null; x = next) {
next = (TreeNode<K,V>)x.next; // 获取下一个节点
x.left = x.right = null; // 清空当前节点的左右子树
// 如果根节点为空
if (root == null) {
x.parent = null; // 设置当前节点的父节点为空
x.red = false; // 设置当前节点为黑色
root = x; // 设置当前节点为根节点
}
else {
K k = x.key; // 获取当前节点的键
int h = x.hash; // 获取当前节点的哈希值
Class<?> kc = null; // 初始化比较类为 null
// 寻找插入位置
for (TreeNode<K,V> p = root;;) {
int dir, ph;
K pk = p.key; // 获取当前遍历到的节点的键
if ((ph = p.hash) > h) // 如果当前节点的哈希值大于待插入节点的哈希值
dir = -1; // 左转
else if (ph < h) // 如果当前节点的哈希值小于待插入节点的哈希值
dir = 1; // 右转
else if ((kc == null && // 如果比较类未初始化
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) // 如果键相等
dir = tieBreakOrder(k, pk); // 解决键相等的情况
TreeNode<K,V> xp = p; // 保存当前节点作为父节点
if ((p = (dir <= 0) ? p.left : p.right) == null) { // 如果找到了插入位置
x.parent = xp; // 设置当前节点的父节点
if (dir <= 0) // 插入到左子树
xp.left = x;
else // 插入到右子树
xp.right = x;
root = balanceInsertion(root, x); // 平衡红黑树
break; // 结束循环
}
}
}
}
moveRootToFront(tab, root); // 将根节点移到链表的前端
}
这段代码实现了将一个链表转换为红黑树的过程。具体来说,它首先检查是否需要创建一个新的红黑树(即根节点是否为空),然后遍历链表并将每个节点插入到红黑树中。在插入过程中,它会根据哈希值和键的比较结果来确定插入的方向,并在插入后对红黑树进行必要的平衡操作。最后,它会将根节点移到链表的前端,以便于后续的操作。
untreeify 链化方法
/**
* 将从当前节点开始的红黑树转换回链表形式。
* @param map 当前的 HashMap 实例
* @return 返回一个链表形式的节点列表
*/
final Node<K,V> untreeify(HashMap<K,V> map) {
Node<K,V> hd = null, tl = null; // 初始化头节点和尾节点为 null
// 遍历红黑树中的节点
for (Node<K,V> q = this; q != null; q = q.next) {
Node<K,V> p = map.replacementNode(q, null); // 将树节点转换为链表节点
// 如果尾节点为空,说明这是第一个节点
if (tl == null)
hd = p; // 设置头节点
else
tl.next = p; // 将新节点连接到尾节点之后
tl = p; // 更新尾节点
}
return hd; // 返回头节点
}
这段代码实现的是将红黑树转换回链表的功能。当一个桶中的节点数量减少到一定阈值以下时(例如,当删除操作导致桶中的节点数量低于某个阈值时),HashMap 会调用此方法将红黑树转换回简单的链表形式。这样可以节省空间并减少维护红黑树所需的额外开销。