大家好,又见面了,我是你们的朋友全栈君。
概述
HashMap 是 Map 接口下一个线程不安全的,基于哈希表的实现类。由于他解决哈希冲突的方式是分离链表法,也就是拉链法,因此他的数据结构是数组 链表,在 JDK8 以后,当哈希冲突严重时,HashMap 的链表会在一定条件下转为红黑树以优化查询性能,因此在 JDK8 以后,他的数据结构是数组 链表 红黑树。
对于 HashMap ,作为集合容器,我们需要关注其数据的存储结构,迭代方式,能否存放空值;作为使用了数组作为底层结构的集合,我们还需要关注其扩容的实现;同时,针对哈希表的特性,我们还需要关注它如何通过哈希算法取模快速定位下标。
这是关于 java 集合类源码的第六篇文章。往期文章:
- java集合源码分析(一):Collection 与 AbstractCollection
- java集合源码分析(二):List与AbstractList
- java集合源码分析(三):ArrayList
- java集合源码分析(四):LinkedList
- java集合源码分析(五):Map与AbstractMap
一、HashMap 的数据结构
在 JDK8 之前,HashMap 的数据结构是数组 链表。在 JDK8 以后是数组 链表 红黑树。
在 HashMap 中,每一个 value 都被存储在一个 Node 或 TreeNode 实例中,容器中有一个 Node[] table
数组成员变量,数组中的每一格称为一个“桶”。当添加元素时,根据元素的 key 通过哈希值计算得到对应下标,将 Node 类的形式存入“桶”中。如果 table 容量不足时,就会发生扩容,同时对容器内部的元素进行重哈希。
当发生哈希冲突,也就是不同元素计算得到了相同的下标时,会将节点接到“桶”的中的第一个元素后,后续操作亦同,最后就会形成链表。
在 JDK8 以后,由于考虑到哈希冲突严重时,“桶”中的链表会影响查询效率,因此在一定条件下,链表元素多到一定程度,Node 就会转为 TreeNode,也就是把链表转为红黑树。
对于红黑树,可以简单理解为不要求严格平衡的平衡二叉树,他保证了查找效率的同时,又保持了较低的的旋转次数。通过这种数据结构,保证了哈希冲突严重的情况下的查找效率。
二、HashMap的成员变量
由于 HashMap 本身继承了 AbstractMap 抽象类的成员变量,再加上自身的成员变量,以及由于扩容时的重哈希需要的参数,因此 HashMap 的成员变量比较复杂。按照来源以及用途,我们将他的成员变量分为三类:
1.来自父类的变量
代码语言:javascript复制/**
* 1.存放key的Set集合视图,通过 keySet()方法获取
*/
transient Set<K> keySet;
/**
* 1.存放value的Collection集合视图,通过values()方法获取
*/
transient Collection<V> values;
2.自己的变量
代码语言:javascript复制/**
* 1.结构更改次数。用于实现并发修改情况下的fast-fail机制,同AbstractList
*/
transient int modCount;
/**
* 2.集合中的元素个数
*/
transient int size;
/**
* 3.存放集合中键值对对象Entry的Set集合视图,通过entrySet()获取
*/
transient Set<Map.Entry<K,V>> entrySet;
/**
* 4.集合中的桶数组。桶即是当链表或者红黑树的容器
*/
transient Node<K,V>[] table;
3.扩容相关的变量和常量
代码语言:javascript复制/**
* 1.默认初始容量。必须为2的幂,默认为16
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
/**
* 2.最大容量。不能超过1073741824,即Integer.MAX_VALUE的一半
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 3.扩容阈值。负载系数与容量的乘积,当元素个数超过该值则扩容。默认为0
*/
int threshold;
/**
* 4.负载系数。当容器内元素数量/容器容量大于等于该值时发生扩容
*/
final float loadFactor;
/**
* 5.默认负载系数。未在构造函数中指定则默认为0.75
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 6.容器中桶的最小树化阈值。当容器中元素个数大于等于该值时,桶才会发生树化。
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* 7.桶的树化阈值。当容器元素个数大于等于MIN_TREEIFY_CAPACITY,并且桶中元素个数大于等于该值以后,将链表转为红黑树
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 8.桶的链化阈值。当桶中元素个数,或者说链表长度小于等于该值以后,将红黑树转为链表
*/
static final int UNTREEIFY_THRESHOLD = 6;
三、构造方法
HashMap 一共提供了四个构造方法:
1.指定容量和负载系数
代码语言:javascript复制public HashMap(int initialCapacity, float loadFactor) {
// 指定初始容量是否小于0
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: "
initialCapacity);
// 若指定初始容量大于最大容量,则初始容量为最大容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 初始容量是否为小于0或未初始化
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: "
loadFactor);
// 指定初始容量
this.loadFactor = loadFactor;
// 下一扩容大小为loadFactor或最接近的2的幂
this.threshold = tableSizeFor(initialCapacity);
}
这里涉及到一个取值的方法 tableSizeFor()
:
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n 1;
}
这个方法的用于得到指定容量最接近的2的幂,比如传入1会得到2,传入7会得到8。
2.只指定容量
代码语言:javascript复制public HashMap(int initialCapacity) {
// 使用默认负载系数0.75
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
3.不指定任何系数
代码语言:javascript复制public HashMap() {
// 下一扩容大小为默认大小16,其负载系数默认为0.75,初始容量默认为16
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
4.根据指定Map集合构建
代码语言:javascript复制public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
这里涉及到一个将合并集合的方法 putMapEntries()
,putAll()
方法也是基于这个方法实现的。由于添加还涉及到扩容以及其他方法,这里暂不介绍,等下面再详细的了解。
四、HashMap的内部类
基于前文java集合源码分析(五):Map与AbstractMap中第五部分 “AbstractMap 的视图”里对 AbstractMap 的分析,我们知道,HashMap 作为继承了 AbstractMap 的子类,因此它内部会拥有三个集合视图
- 存放 key 的 Set 集合:
Set<K> keySet
- 存放 value 的 Collection 集合:
Collection<V> valuse
- 存放 Entry 对象的 Set 集合:
Set<Map.Entry<K,V>> entrySet
同时还需要一个实现了 Entry 接口的内部类作为 entrySet 的元素使用。
因此 HashMap 作为 AbstractMap 的子类,他最少需要 3种集合视图 3种结合视图的迭代器 Entry 实现类
7种内部类。
实际上,由于 JDK8 以后红黑树和并行迭代的需求,他还需要新增 1种Entry红黑树节点实现 3种视图容器对应的并行迭代器
2种内部类。
由于针对迭代器和并行迭代器又各提取了一个抽象类,所以 HashMap 中一共会有 :
3种视图容器 1种迭代器抽象类 3种视图容器的迭代器 1种并行迭代器抽象类 3种视图容器对应的并行迭代器 1种Entry实现类 1种Entry的红黑树节点实现类
总计 13 种内部类
1. Node / TreeNode
Node 是 HashMap 中的节点类,在 JDK8 之前对应的是 Entry 类。他是 Map 接口中 Entry 的实现类。
在 HashMap 中数组的每一个位置都是一个“桶”,而“桶”中存放的就是带有数据的节点对象 Node。当哈希冲突时,多个 Node 会在同一个“桶”中形成链表。
代码语言:javascript复制static class Node<K,V> implements Map.Entry<K,V> {
// 节点的hashcode
final int hash;
// key
final K key;
// value
V value;
// 下一节点
Node<K,V> next;
}
在 JDK8,当容器中元素数量大于等于64,并且桶中节点大于等于8的时候,会在扩容前触发红黑树化,Node 类会被转变为 TreeNode ,链表会变成:
代码语言:javascript复制static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
// 父节点
TreeNode<K,V> parent;
// 左子节点
TreeNode<K,V> left;
// 右子节点
TreeNode<K,V> right;
// 前驱节点
TreeNode<K,V> prev;
// 是否为红色节点
boolean red;
}
值得一提的是,TreeNode 继承了 LinkedHashMap.Entry 类,但是 LinkedHashMap.Entry 类又继承了 HashMap.Node,因此,实际上 TreeNode 也是 Node 类的子类,这是 Node 转变为 TreeNode 的结构基础。
另外,TreeNode 尽管是树,但是他仍然通过 prev 维持了隐式的链表结构,理论上每一个节点都可以获取他上一次插入的节点,这仍然可以理解为单向链表。
2. KeySet / KeyIterator
Set<K> keySet
是在 AbstractMap 中已经定义好了变量,它是一个存放 key 的集合,HashMap 的哈希算法保证了 key 的唯一性,这恰好也符合 Set 集合的特征。在 HashMap 中,为其提供了实现类 KeySet 。
KeySet 继承了 AbstractSet 抽象类,并且直接使用 HashMap 中的方法去实现了抽象类中的大多数抽象方法。值得一提的是,他实现的 iterator()
返回的也是 HashMap 的一个内部类 KeyIterator。
3. Values / ValueIterator
和 KeySet 类一样,Values 也是给 AbstractMap 中的 Collection<V> values
提供的实现类,他继承了 AbstractCollection 抽象类,并且使用 HashMap 的方法实现了大部分抽象方法。
同样的,它的iterator()
返回的也是 HashMap 的一个内部类 ValueIterator。
4. EntrySet / EntryIterator
AbstractMap 中有一个留给子类去实现的核心抽象方法 entrySet()
,而 EntrySet 就是为了实现该方法而创建的类。它继承了 AbstractSet<Map.Entry<K,V>>
,表示的是容器中的一对键值对对象。在注释中,作者将其称为视图。
通过 EntrySet 类,我们就可以像 Collection 的 toArray 一样,将 Map 以 Set 集合视图的形式表现出来。
同样的,作为一个 AbstractSet 的实现类,HashMap 也专门为其实现了一个内部迭代器类 EntryIterator 。EntrySet 的iterator()
方法返回的就是该类。
5. HashIterator
HashIterator 类是一个用于迭代 Node 节点的迭代器抽象类,他也是上述 KeyIterator,ValueIterator,EntryIterator 三种内部迭代器类的父类。
代码语言:javascript复制abstract class HashIterator {
Node<K,V> next; // next entry to return
Node<K,V> current; // current entry
int expectedModCount; // for fast-fail
int index; // current slot
public final boolean hasNext() {}
final Node<K,V> nextNode() {}
public final void remove() {}
}
虽然它叫 HashIterator ,但是它并没有实现 Iterator
接口,而是让他的子类自己去实现接口。并且只值提供迭代和删除两种功能的三个方法。
此外,他的子类 KeyIterator,ValueIterator,EntryIterator 也非常朴素,只在它的基础上重写包装了一下 nextNode()
作为自己的 next()
方法,这里不妨也看成适配器的一种。
final class KeyIterator extends HashIterator
implements Iterator<K> {
public final K next() { return nextNode().key; }
}
final class ValueIterator extends HashIterator
implements Iterator<V> {
public final V next() { return nextNode().value; }
}
final class EntryIterator extends HashIterator
implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() { return nextNode(); }
}
6. Spliterator
跟 Iterator 一样,HashMap 也提供了 HashMapSpliterator,KeySpliterator,ValueSpliterator,EntrySpliterator 四种并行迭代器。后面三者都是 HashMapSpliterator 的子类。
五、HashMap 获取插入下标
HashMap 是基于哈希表实现的,因此添加元素和扩容时通过哈希算法获取 key 对应的数组下标是整整个类进行添加操作的基础。
代码语言:javascript复制public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
1. 计算哈希值
这里涉及到两个方法,一个是计算哈希值的 hash()
方法:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这是来自知乎大佬一个非常详细的回答:JDK 源码中 HashMap 的 hash 方法原理是什么? – 知乎;
这里我简单的概括一下:
该方法实际上是一个“扰动函数”,作用是对Object.hashCode()
获取到的 hash 值进行高低位混淆。
我们可以看到,符号右移16位后,新二进制数的前16位都为0,后16位就是原始 hashcode 的高16位。
将原始 hashcode 与 位运算得到的二进制数再进行异或运算以后,我们就得到的 hash 前16全部都为1,后16位则同时混淆了高16位和低16位的特征,进一步增加了随机性。
现在我们得到了 key 的 hashcode,这是计算下标的基础。
2. 计算下标
接下来进入putVal()
方法,实际上包括 putAll()
在内,所有添加/替换元素的方法,都依赖于 putVal()
实现。putVal()
需要传入 key 的 hash 作为参数,它将根据 hash 值和 key 进行进一步的计算,获取实际 value 要插入的下标。
我们先忽略计算下标以外的其他方法:
代码语言:javascript复制final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
... ...
// n 即为当前数组长度
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 根据 n 与 hash 计算插入下标
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
... ...
}
也就是说,当通过扰动函数hash()
获取到了已经混淆高低位的 key 的 hashcode 以后, 会将其与数组长度-1进行与运算:(n - 1) & hash
。
以默认是容量16为例,它转换为二进制数是 10000,而 (16-1)转换为二进制数就是 1111,补零以后它与 hash()
计算得到的 hashcode 进行与运算过程如下:
在这个过程,之前留下的两个问题就得到了解答:
为什么容量需要是2的幂?
我们可以看到,按位的与运算只有 1&1 = 1,由于数组长度转为二进制只有4位,所有高于4位的位数都为0,因此运算结果高于4位的位置也都会是0,这里巧妙的实现了取模的效果,数组长度起到了低位掩码的作用。这也整是为什么 HashMap 的容量要是2的幂的原因。
为什么要hash()
要混淆高低位?
再回头看看 hash()
函数,他混合了原始 hashcode 的高位和低位的特征,我们说他增加了随机性,在点要怎么理解呢?
我们举个例子:
key | hashCode | 不混淆取后四位 | 混淆后取后四位 |
---|---|---|---|
808321199 | 110000001011100000000010101111 | 1111 | 0001 |
7015199 | 11010110000101100011111 | 1111 | 0100 |
9999 | 10011100001111 | 1111 | 1111 |
实际上,由于取模运算最终只看数组长度转成的二进制数的有效位数,也就是说,数组有效位是4位,那么 key 的 hash 就只看4位,如果是18位,那么 hash 就只看18位。
在这种情况下,如果数组够长,那么 hash 有效位够多,散列度就会很好;但是如果有效位非常短,比如只有4位,那么对于区分度在高位数字的值来说就无法区分开,比如表格所示的 808321199,7015199,461539999 三个低位相同的数字,最后取模的时候都会被看成 1111,而混合高低位以后就是 0001,0100,1111,这就可以区分开来了。
六、HashMap 添加元素
在之前获取下标的例子中,我们知道 put()
方法依赖于 putVal()
方法,事实上,包括 putAll()
在内,所有添加元素的方法都需要依赖于 putVal()
。
由于添加元素涉及到整个结构的改变,因而 putVal()
中除了需要计算下标,还包含扩容,链表的树化与树的链表化在内的多个过程。
1. putVal
代码语言:javascript复制final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// 当前table数组
Node<K,V>[] tab; Node<K,V> p;
// 当前数组长度,当前要插入数组位置的下标
int n, i;
// 若集合未扩容,则进行第一次扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 获取key对应要插入的下标(即桶)
if ((p = tab[i = (n - 1) & hash]) == null)
// 若是桶中没有元素,则添加第一个
tab[i] = newNode(hash, key, value, null);
else {
// 若已经桶中已经存在元素
Node<K,V> e; K k;
// 1.插入元素与第一个元素是否有相同key
if (p.hash == hash && ((k = p.key) == key ||
(key != null && key.equals(k))))
e = p;
// 2.桶中的链表是否已经转换为红黑树
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 3.遍历链表,添加到尾端
else {
for (int binCount = 0; ; binCount) {
// 将节点添加到队尾
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 是否需要转换为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 是否遇到了key相同的节点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 如果key已经存在对应的值
if (e != null) { // existing mapping for key
V oldValue = e.value;
// 是否要覆盖value
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 空方法,用于LinkedHashMap插入后的回调
afterNodeAccess(e);
return oldValue;
}
}
modCount;
// 是否需要扩容
if ( size > threshold)
resize();
// 空方法,用于LinkedHashMap插入后的回调
afterNodeInsertion(evict);
return null;
}
2.链表的树化
在上述过程,涉及到了判断桶中是否已经转为红黑树的操作:
代码语言:javascript复制else if (p instanceof TreeNode)
// 将Node转为TreeNode,并且添加到红黑树
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
以及将链表转为红黑树的操作:
代码语言:javascript复制if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
其中,putTreeVal()
是添加节点到红黑树的方法,而 treeifyBin()
是一个将链表转为红黑树的方法。我们暂且只看看 HashMap 链表是如何转为红黑树的:
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// table是否小于最小树化阈值64
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
// 如果不到64就直接扩容
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
// 否则看看桶中是否存在元素
TreeNode<K,V> hd = null, tl = null;
// 将桶中链表的所有节点Node转为TreeNode
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);
}
}
上面过程实现了将链表的节点 Node 转为 TreeNode 的过程,接下来 TreeNode.treeify()
方法会真正将链表转为红黑树:
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = 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;
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);
}
以上是链表树化的过程,虽然实现过程不简单,但是流程很简单:
- 判断是否链表是否大于8;
- 判断元素总数量是否大于最小树化阈值64;
- 将原本链表的Node节点转为TreeNode节点;
- 构建树,添加每一个子节点的时候判断是否需要再平衡;
- 构建完后,若原本链表的头结点不是树的根节点,则再平衡确保头节点变为根节点
链表转为红黑树的条件
这里我们也理清楚了链表树化的条件:一个是链表添加完元素后是否大于8,并且当前总元素数量大于64。
当不满足这个条件的时候,再添加元素就会直接扩容,利用扩容过程中的重哈希来缓解哈希冲突,而不是转为红黑树。
3.为什么key可以为null
我们回顾一下 hash()
方法:
(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
可以看到,这里对 key == null
的情况做了处理,当 key 是 null 的时候,哈希值会直接被作为 hash 为 0 的元素看待,在 putVal()
中添加元素的时候还会判断:
if (p.hash == hash && ((k = p.key) == key ||
(key != null && key.equals(k))))
由于除了比较 hash 值,还会比较内存地址并调用 equals 比较,所以 null 会被筛出来,作为有且仅有一个的 key 使用。
七、HashMap 的扩容
现在我们知道了 HashMap 是如何计算下标的,也明白了 HashMap 是如何添加元素的,现在我们该了解添加元素过程中,扩容方法 resize()
的原理了。
1. resize
resize()
是 HashMap 的扩容方法:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
// 当前容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 上一次的扩容阈值(在上一次扩容时指定)
int oldThr = threshold;
// 新容量,下一次扩容目标容量
int newCap, newThr = 0;
//======一、计算并获取扩容目标大小======
// 1.若当前容量大于0(即已经扩容过了)
if (oldCap > 0) {
// 是否大于理论允许最大值
if (oldCap >= MAXIMUM_CAPACITY) {
// 扩容阈值设置为Integer.MAX_VALUE,本次以后不会再触发扩容
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 若未达到理论允许最大值,并且:
// (1)本次扩容目标容量的两边小于理论允许最大值
// (2)当前容量大于默认初始容量16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 新扩容阈值为当前扩容阈值的两倍
newThr = oldThr << 1;
}
// 2.若本次扩容容量大于0(即还是初始状态,指定了容量,但是是第一次扩容)
else if (oldThr > 0)
// 新容量为上一次指定的扩容阈值
newCap = oldThr;
// 3.若当前容量和上一次的扩容阈值都为0(即还是初始状态,未指定容量而且也没扩容过)
else {
// 使用默认值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//======二、根据指定大小扩容======
// 根据负载系数检验新容量是否可用
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
// 如果乘上负载系数大于理论允许最大容量,则直接扩容到Integer.MAX_VALUE
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) {
oldTab[j] = null;
if (e.next == null)
// 重新计算节点在新HashMap桶数组的下标
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) {
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;
}
上述过程代码一大串,其实就是确定容量和扩容两个步骤。
2.确认扩容大小
扩容的时机
在了解 HashMap 如何确认扩容大小之前,我们需要明白 HashMap 是什么时候会认为需要扩容。
我们在前面知道了,value 的下标由 key 的高低位混合后与数组长度-1进行与运算获得,也就是说,如果数组长度不够大——或者说容量不够大,就会导致与运算后得到的随机值范围受限,因此更可能造成哈希冲突。
为此,HashMap 引入负载系数 loadFactor
,当不指定时默认为0.75,则有扩容阈值 threshold = 容量*负载系数
,达到扩容阈值——而不是容量大小——的时候就会进行扩容。
假如我们都使用初始值,即默认容量16,默认负载系数0.75,则第一次扩容后,当元素个数达到 0.75*16=12
时,就会进行一次扩容变为原来的两倍,也就是32,并且将 threshold
更新为32*0.75=24
。如此反复。
扩容的大小
扩容的时候,分为两种情况:已经扩容过,还未扩容过。
我们仅针对获取新容量 newCap
与新扩容阈值 newThr
这段代码逻辑,画出大致流程图:
这里比较需要注意的是,当 oldCap 已经大于等于理论最大值的时候,会在设置 newThr=Integer.MAX_VALUE
后直接返回,不会执行后序扩容过程。
另外,当新扩容阈值被设置为 Integer.MAX_VALUE
以后,由于该值已经是最大的整数值了,所以设置为该值以后 HashMap 就不会再触发扩容了。
3.重哈希过程
我们知道,如果桶数组扩容了,那么数组长度也就变了,那么根据长度与哈希值进行与运算的时候计算出来的下标就不一样。在 JDK7 中 HashMap 扩容移动旧容器的数据的时候,会直接进行重哈希获得新索引,并且打乱所有元素的排布。而在JDK8进行了优化,只移动部分元素。
我们可以回去看看扩容部分的代码,其中有这两处判断:
代码语言:javascript复制// 判断扩容后是否需要移动位置
if ((e.hash & oldCap) == 0) {
//... ...
}else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
// 扩容后移动位置
if (hiTail != null) {
hiTail.next = null;
newTab[j oldCap] = hiHead;
}
前面有提到 HashMap 取下标,是通过将 key 的哈希值与长度做与运算,也就是 (n-1) & hash
,而这里通过计算 n & hash
是否为 0 判断是否需要位移。
他的思路是这样的:
假如从16扩容到32,扩容前通过(n-1) & hash
取模是取后4位,而扩容后取后5位,因为01111和1111没区别,所以如果多出来这一位是0,那么最后用新长度去与运算得到的坐标是不变的,那么就不用移动。否则,多出来这一位相当于多了10000,转为十进制就是在原基础上加16,也就是加上了原桶数组的长度,那么直接在原基础上移动原桶数组长度就行了。
以初始容量 oldCap = 16,newCap = 32
为例,我们先看看他的换算过程:
十进制 Cap | 二进制 Cap | 二进制 Cap-1 | 十进制 Cap-1 | |
---|---|---|---|---|
oldCap | 16 | 10000 | 1111 | 15 |
newCap | 32 | 100000 | 11111 | 31 |
以上述数据为基础,我们模拟下面三个 key 在扩容过程中的计算:
key | hash | (oldCap-1) & hash | oldCap & hash | (newCap-1) & hash |
---|---|---|---|---|
808321199 | 110000001011100000000010101111 | 1111(15) | 0 | 01111(15) |
7015199 | 11010110000101100011111 | 1111(15) | 10000 | 11111(31) |
9999 | 10011100001111 | 1111(15) | 0 | 01111(15) |
不难看出,只有当 oldCap & hash > 0
的时候元素才需要移动,而由于容量必然是2的冥,每次扩容新容量都是旧容量的两倍,换成二进制,相同的 hash 值与运算算出来的坐标总是多1,因此相当于每次需要移动的距离都是旧容量。
也就是说,如果 oldCap & hash > 0
,那么就有 新坐标=原下标 oldCap
,这个逻辑对应的代码就是 newTab[j oldCap] = hiHead;
这一行。
这样做的好处显而易见,少移动一些元素可以减少扩容的性能消耗,同时同一桶中的元素也有可能在重哈希之后被移动,使得哈希冲突得以在扩容后减缓,元素散列更均匀。
八、HashMap 获取元素
和put()
方法和 putVal()
的关系一样,get()
方法以及其他获取元素的方法最终都依赖于 getNode()
方法。
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
1. getNode
代码语言:javascript复制final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 如果桶数组不为空,并且桶中不为null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 如果桶中第一个元素的key与要查找的key相同,返回第一个元素
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;
}
2.为什么元素要同时重写equals和hashcode?
首先,不被原本的的hashCode和equals是这样的
hashCode()
是根据内存地址换算出来的一个值equals()
方法是判断两个对象内存地址是否相等
我们回顾一下上文,可以看到无论put()
还是get()
都会有类似这样的语句:
// putVal
p.hash == hash && ((k = p.key) == key ||
(key != null && key.equals(k)));
// getNode
p.hash == hash && (key != null && key.equals(k));
因为可能存在哈希冲突,或者为 null 的 key,因此所以光判断哈希值是不够的,事实上,当我们试图添加或者找到一个 key 的时候,方法会根据三方面来确定一个唯一的 key:
- 比较
hashCode()
是否相等:代码是比较内部hash()
方法算出来的值 hash 是否相等,但是由于该方法内部还是调用hashCode()
,所以实际上是比较的仍然是hashCode()
算出来的值; - 比较
equlas()
是否相等:Object.equlas()
方法在不重写的时候,默认比较的是内存地址; - 比较 key 是否为 null;
为什么要重写equals和hashcode方法?
当我们使用 HashMap 提供的默认的流程时,这三处校验已经足以保证 key 是唯一的。但是这也带来了一些问题,当我们使用一些未重写了 Object.hashCode()
或者 Object.equlas()
方法的类的实例作为 key 的时候,由于 Object 类中的方法默认比较的都是内存地址,因此必须持有当初作为 key 的实例才能拿到 value。
我们举个例子:
假设我们有一个 Student 类
代码语言:javascript复制public class Student {
String name;
Integer age;
public Student(String name, Integer age) {
this.name = name;
this.age = age;
}
}
现在我们使用 Student 的实例作为 key:
代码语言:javascript复制Map<Object,Object> map = new HashMap<>(2);
map.put(new Student("xx",16), "a");
map.put(new Student("xx",16), "a");
for(Map.Entry<Object, Object> entry : map.entrySet()){
System.out.print(entry.getValue());
};
// aa
因此,如果我们希望使用对象作为 key,那么大多数时候都需要重写equals()
和hashcode()
的。
为什么要同时重写两个方法?
这个也很好理解,判断 key 需要把 equals()
和hashcode()
两个的返回值都判断一遍,如果只重写其中一个,那么最后还是不会被认为是同一个 key。
当我们为 Student 重写 equals()
和hashcode()
以后,结果运行以后输出就是只有一个 a 了。
@Override
public int hashCode() {
return this.name.hashCode() age;
}
@Override
public boolean equals(Object obj) {
return obj instanceof Student &&
this.name.equals(((Student) obj).name);
}
九、HashMap 删除元素
在 HashMap 中,get,put 和 remove 行为各自都有一个统一的底层方法。在 remove()
中,这个方法就是 removeNode()
,所有的删除行为最终都要通过调用它来实现。
1. removeNode
代码语言:javascript复制final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
// 若集合不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
// 若桶中第一个就是目标元素
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
// 否则遍历链表/树删除节点
else if ((e = p.next) != null) {
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
// 若找到目标元素,并且确定要删除,就删除节点
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
// 若要删除为红黑树节点
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
// 若要删除节点为链表头结点
tab[index] = node.next;
else
p.next = node.next;
modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
2.红黑树在删除过程的链化
在扩容部分我们了解了链表是如何转为红黑树的,事实上红黑树也可以在必要的时候转化为链表。在 removeNode()
方法中,可以看到调用了 removeTreeNode()
以删除红黑树节点,实际上在这个过程中会发生红黑树的链化。
我们暂且只关注链化的判断条件,也就是在 removeTreeNode()
中的这一段代码:
// 根节点为null,根节点的左或右子节点为null,根节点左子节点的左子节点为null
if (root == null || root.right == null ||
(rl = root.left) == null || rl.left == null) {
tab[index] = first.untreeify(map); // too small
return;
}
也就是说,如果可能存在的最小红黑树如下:
可以看到,此时树共有四个及节点,需要再删除一个节点才会导致链化,也就是说,在 remove 中,触发链化的最小树可能只有3个节点,而最大树需要考虑到变色和平衡,是十个(待考证)。
也就是说,和网上所说的小于6就链化不同,在删除中,链化触发值是一个范围,在 [3,10] 之间。
3.红黑树在扩容过程的链化
我们知道,扩容经过重哈希有可能会拆分链表,树也一样。在扩容时, split()
方法会对红黑树进行拆分,以便重哈希后变更位置,在里头有这么一段逻辑:
// 左头结点不为空,并且长度小于链化阈值 6
if (lc <= UNTREEIFY_THRESHOLD)
// 将红黑树转为链表
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
在扩容时,是否链化的标准就是树中元素个数是否小于链化阈值6。
十、HashMap 的迭代
由于 Map 集合本质上表示的是一组键值对之间的映射关系,并且 HashMap 的数据结构是数组 链表/树,因此 HashMap 集合并无法直接像 Collection 接口的实现类那样直接迭代。
而在本文的第四部分,我们了解了 HashMap 中的几个主要内部类,其中四大视图类就是其中三个集合视图的 KeySet,Values,EntrySet,与一个键值对视图 Entry。当我们要迭代 HashMap 的时候,就需要通过迭代三个集合视图来实现,并且通过 key,value 或者 Entry 对象来接受迭代得到的对象。
值得一提的是,和 ArrayList 一样,HashMap 也实现了 fast-fail 机制,因此最好不要在迭代的时候进行结构性操作。
1.迭代器迭代
所有集合都可以通过迭代器迭代器(集合的增强 for 循环在编译以后也是迭代器跌迭代)。
所以,在 HashMap 中,三种视图集合都可以通过迭代器或增强 for 循环迭代器,但是 HashMap 本身虽然有迭代器,但是由于没有 iterator()
方法,所以无法通过迭代器或者增强 for 直接迭代,必须通过三种视图集合来实现迭代。
以 EntrySet 视图为例:
代码语言:javascript复制Map<Object,Object> map = new HashMap<>();
// 迭代器
Iterator<Map.Entry<Object, Object>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<Object, Object> entry = iterator.next();
System.out.print(entry.getValue());
}
// 增强for迭代,等价于迭代器迭代
for(Map.Entry<Object, Object> entry : map.entrySet()){
System.out.print(entry.getValue());
};
// forEach迭代
map.entrySet().forEach(s -> {
System.out.println(s.getValue());
});
KeySet
视图和 values
同理,但是 values 是 Collection 集合,所以写法会稍微有点区别。
2.视图集合中的数据从何处来
我们虽然通过 entrySet()
,values()
和 keySet()
三个方法获取了视图集合并且迭代成功了,但是回头看源码,却发现源码中返回的只是一个空集合,里面并没有任何装填数据的操作,但是当我们直接拿到视图集合的时候,却能直接遍历,原因在于他们的迭代器:
final class KeyIterator extends HashIterator
implements Iterator<K> {
// 获取迭代器返回的node的key
public final K next() { return nextNode().key; }
}
final class ValueIterator extends HashIterator
implements Iterator<V> {
// 获取迭代器返回的node的value
public final V next() { return nextNode().value; }
}
final class EntryIterator extends HashIterator
implements Iterator<Map.Entry<K,V>> {
// 获取迭代器返回的node
public final Map.Entry<K,V> next() { return nextNode(); }
}
而这个 nextNode()
方法来自于他们的父类HashIterator
,这里需要连着它的构造方法一起看:
// 构造方法
HashIterator() {
expectedModCount = modCount;
Node<K,V>[] t = table;
current = next = null;
index = 0;
// 获取第一个不为空的桶中的第一个节点
if (t != null && size > 0) { // advance to first entry
do {} while (index < t.length && (next = t[index ]) == null);
}
}
// nextNode
final Node<K,V> nextNode() {
Node<K,V>[] t;
Node<K,V> e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
// 接着构造方法获取到的第一个非空桶中的节点,遍历以该节点为头结点的链表
// 当遍历完,接着查看下一个桶中的节点
if ((next = (current = e).next) == null && (t = table) != null) {
do {} while (index < t.length && (next = t[index ]) == null);
}
return e;
}
可以看到,这个 HashIterator 就是 HashMap 真正意义上的迭代器,它会从桶数组中第一个非空桶的第一个节点开始,迭代完全部桶数组中的每一个节点。但是它并不直接使用,而是作为而三个视图集合的迭代器的父类。
三个视图集合自己的迭代器通过把HashIterator
的nextNode()
方法的基础重新适配为 next()
,分别把它从返回 Node 节点类变为了返回节点、节点的 key、节点的 value,这就是集合视图迭代的原理。
由于 Node 本身就携带了 key,value和 hash,因此删除或者添加就可以直接通过 HashMap 类的方法去操作,这就是迭代器增删改的原理。
3. forEach迭代
HashMap 重写了 forEach() 方法,三个视图集合也自己重写了各自的 forEach()
方法。
public void forEach(BiConsumer<? super K, ? super V> action) {
Node<K,V>[] tab;
if (action == null)
throw new NullPointerException();
// 若集合不为空
if (size > 0 && (tab = table) != null) {
int mc = modCount;
// 遍历桶
for (int i = 0; i < tab.length; i) {
// 遍历链表
for (Node<K,V> e = tab[i]; e != null; e = e.next)
action.accept(e.key, e.value);
}
if (modCount != mc)
throw new ConcurrentModificationException();
}
}
forEach()
逻辑与迭代器类似,但是写法更直白,就是遍历桶数组然后遍历桶数组中的链表。三个视图集合的 forEach()
写法与 HashMap 的基本一样,这里就不再赘述了。
十一、总结
结构与扩容
HashMap 底层结构是数组 链表/红黑树。
HashMap 在不指定初始容量和负载系数的时候,默认容量为16,默认负载系数为0.75,扩容阈值为当前容量*负载系数,当容器中的元素数量大于等于扩容阈值的时候就会扩容为原来的两倍。
树化与链化
红黑树的树化一般发生在添加元素的时候。由于扩容本身就可以缓解哈希冲突,因此要让 HashMap 选择树化而不是优先扩容,需要同时满足两个条件:
- 当容器中总元素的数量大于等于64;
- 添加元素后桶中链表长度大于等于8。
此时会将链表转为红黑树。
而红黑树的链化既发生在扩容过程,也发生在删除过程,扩容过程中的链化触发条件是树的节点数量小于链化阈值6,而删除过程中的链化触发条件要求是左子节点、左子节点的左子节点或右子节点为null。由于可能存在的最小树或者最大树,因此在删除时链化触发值的范围处于 [3,10] 之间。
哈希算法
HashMap 获取下标的过程分两步:
- 位运算混淆 hashCode 的高低位:
(h = key.hashCode()) ^ (h >>> 16)
,作用是保证取模后的随机性; - 与运算计算下标:
(n - 1) & hash
,作用是取模获取下标。
其中,长度之所以是2的冥,就是为了在此处将长度作为哈希值的低位掩码,巧妙实现取模效果。
扩容重哈希
假如从16扩容到32,扩容前通过(n-1) & hash
取模是取后4位,而扩容后取后5位,因为01111和1111没区别,所以如果多出来这一位是0,那么最后用新长度去与运算得到的坐标是不变的,那么就不用移动。否则,多出来这一位相当于多了10000,转为十进制就是在原基础上加16,也就是加上了原桶数组的长度,那么直接在原基础上移动原桶数组长度就行了。
迭代
HashMap 本身有迭代器 HashIterator
,但是没有 iterator()
方法,所以无法直接通过增强 for 循环或者获取迭代器进行迭代,只能借助三个视图集合的迭代器或增强 for 来迭代器。但是视图迭代器本身也是 HashIterator
子类,因此视图本身只是空集合,它的迭代能力来自于它们自己的迭代器的父类HashIterator
。
HashMap 和他的三个集合视图都重写了 forEach()
方法,所以可以通过 forEach()
迭代器。
HashMap 也实现了 fast-fail 机制,因此最好不要在迭代的时候进行结构性操作。
equals和hashCode方法
HashMap 在get()
和set()
的时候都会通过 Object.equals()
和 Object.hashCode()
方法来确定唯一 key。由于默认使用的 Object 的实现比较是内存地址,因此使用自建对象作为 key 会很不方便,因此需要重写两个方法。但是由于校验唯一性的时候两个方法都会用到,因此若要重写equals()
和hashCode()
必须同时重写两个方法,不能重写其中一个。
发布者:全栈程序员栈长,转载请注明出处:https://javaforall.cn/170775.html原文链接:https://javaforall.cn