❤写在前面
❤博客主页:努力的小鳴人
❤系列专栏:JavaSE超详总结😋
❤欢迎小伙伴们,点赞👍关注🔎收藏🍔一起学习!
❤如有错误的地方,还请小伙伴们指正!🌹
强烈推荐:【10章Java集合】几张脑图带你进入Java集合的头脑风暴
文章目录
一、JDK1.8前 HashMap 的缺点
- JDK1.8前HashMap的实现是数组+链表,即便哈希函数取得再好,也难以达到元素百分百均匀分布
- 当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候HashMap 就相当于一个单链表,假如单链表有 n 个元素,遍历的时间复杂度就是 O(n),完全失去了它的优势
- 针对这种情况,
JDK 1.8 中引入了红黑树(查找时间复杂度为 O(logn))
来优化这个问题
二、JDK1.8中 HashMap 数据结构
HashMap 是数组+链表+红黑树实现的
🔥JDK1.8新增红黑树
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
}
🔥红黑树中三个关键参数
👌TREEIFY_THRESHOLD
一个桶的树化阈值
- 例:static final int TREEIFY_THRESHOLD = 8
- 当桶中元素个数超过这个值时,需要使用红黑树节点替换链表节点
👌UNTREEIFY_THRESHOLD
一个树的链表还原阈值
- 例:static final int UNTREEIFY_THRESHOLD = 6
- 当扩容时,桶中元素个数小于这个值就会把树形的桶元素还原为链表结构
👌MIN_TREEIFY_CAPACITY
哈希表的最小树形化容量
- 例:static final int MIN_TREEIFY_CAPACITY = 64
- 当哈希表中容量大于这个值时,表中的桶才能进行树形化否则桶内元素太多时会扩容,而不是树形化,为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
🔥JDK1.8中新增操作:桶的树形化
treeifyBin()
- 介绍:如果一个桶中的元素个数超过 TREEIFY_THRESHOLD(默认是 8 ),就使用红黑树来替换链表,从而提高速度,这个替换的方法叫 treeifyBin() 即树形化
- 以下步骤:
1.根据哈希表中元素个数确定是扩容或者树形化
2.如果是树形化遍历桶中的元素,创建相同个数的树形节点,复制内容,建立起联系
3.然后让桶第一个元素指向新建的树头结点,替换桶的链表内容为树形内容
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) {
//如果哈希表中的元素个数超过了 树形化阈值,进行树形化
// e 是哈希表中指定位置桶里的链表节点,从第一个开始
TreeNode<K,V> hd = null, tl = null; //红黑树的头、尾节点
do {
//新建一个树形节点,内容和当前链表节点 e 一致
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);
}
}
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
}
三、put 方法分析
图解:
- 判断键值对数组 table[i]是否为空或为 null,否则执行 resize()进行扩容
- 根据键值 key 计算 hash 值得到插入的数组索引 i,如果 table[i]==null,直接新建节点添加,转向6,如果 table[i]不为空,转向3
- 判断 table[i]的首个元素是否和 key 一样,如果相同直接覆盖 value,否则转向4,这里的相同指的是 hashCode 以及 equals
- 判断 table[i] 是否为 treeNode,即 table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向 5
- 遍历 table[i],判断链表长度是否大于 8,大于 8 的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现 key 已经存在直接覆盖 value
- 插入成功后,判断实际存在的键值对数量 size 是否超多了最大容量 threshold,如果超过,进行扩容
🔥put方法源码
源码加解析:
public V put(K key, V value) {
// 对 key 的 hashCode()做 hash
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 步骤①:tab 为空则创建
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 步骤②:计算 index,并对 null 做处理
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 步骤③:节点 key 存在,直接覆盖 value
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;
}
// key 已经存在直接覆盖 value
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;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 步骤⑥:超过最大容量 就扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
🔥JDK1.8中新增操作:红黑树中查找元素
getTreeNode()
查找方法:
- 通过计算指定 key 的哈希值后,调用内部方法 getNode()
- getNode() 方法是根据哈希表元素个数与哈希值求模(使用的公式是 (n - 1)&hash)得到 key 所在的桶的头结点,如果头节点恰好是红黑树节点,就调用红黑树节点的 getTreeNode() 方法,否则就遍历链表节点。
- getTreeNode 方法使通过调用树形节点的 find()方法进行查找:
final TreeNode<K,V> getTreeNode(int h, Object k) {
return ((parent != null) ? root() : this).find(h, k, null);
} - 由于之前添加时已经保证这个树是有序的,因此查找时基本就是折半查找,效率很高。
- 如果对比节点的哈希值和要查找的哈希值相等,就会判断 key 是否相等,相等就直接返回;不相等就从子树中递归查找
源码:
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
四、扩容机制 JDK1.8 VS JDK1.7
假设了hash 算法是简单的用 key mod 一下表的大小(也就是数组的长度)其中的哈希桶数组 table 的 size=2, 所以 key = 3、7、5,put 顺序依次为 5、7、3,在 mod 2 以后都冲突在 table[1]这里了,这里假设负载因子 loadFactor=1,即当键值对的实际大小 size 大于 table 的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize 成 4,然后所有的 Node 重新 rehash 的过程
🔥JDK1.8 优化:
经过观察可以发现,我们使用的是 2 次幂的扩展(指长度扩为原来 2 倍),所以元素的位置要么是在原位置,要么是在原位置再移动 2 次幂的位置。看下图可以明白,n 为 table 的长度,图(a)表示扩容前的 key1 和 key2 两种key 确定索引位置的示例,图(b)表示扩容后 key1 和 key2 两种 key 确定索引位置的示例,其中 hash1 是 key1 对应的哈希与高位运算结果
元素在重新计算 hash 之后,因为 n 变为 2 倍,那么 n-1 的 mask 范围在高位多 1bit(红色),因此新的 index 就会发生这样的变化:
因此,我们在扩充 HashMap 的时候,不需要像 JDK1.7 那样重新计算 hash,只需要看看原来的 hash 值新增的那个 bit 是 1 还是 0 就好了,是 0 的话索引没变,是 1 的话索引变成“原索引+oldCap”,可以看看下图为 16 扩充为 32 的 resize 示意图:
既省去了重新计算 hash 值的时间,而且同时,由于新增的 1bit 是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了
🎁总结:写代码的时候必然会用到一些数据结构,其中尤为经典的就是HashMap,我还是多看看吧
👌 作者算是一名Java初学者,文章如有错误,欢迎评论私信指正,一起学习~~
😊如果文章对小伙伴们来说有用的话,点赞👍关注🔎收藏🍔就是我的最大动力!
🚩不积跬步,无以至千里,书接下回,欢迎再见🌹