前言
Hash 表(散列表)
是一种高效的数据结构,查找效率达到了 O(1)
。它本质上是一个数组,根据 Hash 函数 将元素映射到数组的某个位置进行存储。
有可能不同的元素会映射到同一个位置,称为 哈希冲突。哈希冲突无法避免,解决哈希冲突的方法有很多,比如开放地址法和 链表法。
其中,链表法是本文介绍的 HashMap 采用的方法,此外,在 JDK8 及以后的版本中,对 HashMap 做了进一步的优化,引入了 红黑树 结构在适当时候替换链表结构。
本文基于 JDK11 。
原理解析
属性和框架
HashMap
中定义了一些属性,它们的含义如下:
int DEFAULT_INITIAL_CAPACITY = 1 << 4
:默认的初始容量为 16int MAXIMUM_CAPACITY = 1 << 30
:最大容量float DEFAULT_LOAD_FACTOR = 0.75f
:默认的负载因子int TREEIFY_THRESHOLD = 8
:当链表长度超过 8 时,使用红黑树存储int UNTREEIFY_THRESHOLD = 6
:当红黑树元素少于 6 时,转回链表存储int MIN_TREEIFY_CAPACITY = 64
:当 HashMap 的容量超过 64 时,才会允许转化为红黑树存储Node<K,V>[] table
:Hash 表数组int size
:键值对个数int modCount
:结构化修改次数
结构化修改是指元素数量的改变或者内部结构的变化,比如
rehash
操作。
int threshold
:超过该阈值会进行扩容,threshold = capacity * loadFactor
内部结构
Hash 表 table
是 Node
类型的数组。其中,链表存储的节点类型是 HashMap.Node
,红黑树存储的节点类型是 HashMap.TreeNode
,TreeNode
是 Node
的子类。
哈希函数和索引计算
HashMap 中的 hash 值
计算如下:
// HashMap.class
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
复制代码
使用异或操作保证了 32 位的哈希值只要有一位发生改变,整个返回值就会改变,一定程度上减少了哈希冲突的次数。
在 HashMap 中还需要计算 key
的索引 i
,这是通过它的哈希值 hash
和数组容量 n
计算的,这是一种优化的索引计算方式,相当于对 n
取余,公式如下:
i = (n-1) & hash
复制代码
扩容后原来在同一索引的元素只有如下两种情况:
- 保持原索引
- 数组原长度+原索引
例如,数组原长度 16,扩容后为 32。用二进制表示:
16-1:0000 1111
32-1:0001 1111
复制代码
假设 hash(key) = 0000 1010
(第 位为 0),此时,扩容前后索引相同。
假设 hash(key) = 0001 1010
(第 位为 1),此时,扩容前索引为 01010
,扩容后索引为 11010
,索引增加了 16。
那么如何判断某个 key
扩容后的索引是哪种情况呢?
我们假设数组扩容前的容量为 oldCap
,且一定是 2 次幂,所以 oldCap
的二进制第 位一定为 1,所以如果 hash(key) & oldCap
结果为 0,扩容情况索引相同,否则,索引增加 oldCap
。
这个特点很重要,在
resize()
函数中利用这个特点重新构建扩容后的数组。
重写 hashCode
HashMap 利用 hashCode 来计算索引,如果是引用类型,使用 equals()
方法判断 key 是否相等,为了保证 equals()
相同的对象返回相同的 hashCode()
,我们要在重写 equals()
时重写 hashCode()
。
扩容
当出现以下两种情况时,会进行扩容 resize()
操作:
- 哈希表为空,相当于是初始化操作,默认容量为 16
- 键值对数量超过阈值 ,将哈希表数组变为原来的 2 倍
扩容时,会更新阈值 threshold
,最后要重新计算索引,构建新的哈希表。
在 [哈希函数和索引计算] 中,我们知道某个索引的元素,扩容 2 倍后对应的新索引,要么保持原索引,要么增加原数组容量。以链表结构为例,遍历链表,根据 hash(key) & oldCap
,将链表节点分别加入两个不同的链表中,最后将两个链表的头节点赋值给新数组的两个位置。
保持原索引不变的节点构成一个链表,头尾节点分别为
loHead,loTail
。索引增加原长度的节点构成一个链表,头尾节点分别为hiHead,hiTail
。
这部分的源码如下:
/**
* 重新计算原索引 j 处的链表节点的新索引
* e 初始是该索引处的第一个节点
* oldCap 原数组容量
* newTab 扩容后的数组
*/
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;
}
// 增加 oldCap 的所有结点
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
复制代码
初始化
使用构造函数初始化时,容量和负载因子默认是 16 和 0.75,也可以由用户指定。但是 不会创建数组对象。
哈希表的数组容量必须是 2 的次幂。如果用户指定的容量不是 2 的次幂,调用 tableSizeFor(initialCapacity)
方法用最接近的 2 次幂的容量代替,并且此时的阈值 threshold
等于数组容量(默认构造函数不会初始化 threshold
)。
真正的创建数组对象并更新 threshold
的过程在 resize()
方法中。
红黑树化
为什么使用红黑树?
红黑树 RBT
是一种弱平衡的二叉查找树,不要求像 AVL 树
一样的严格平衡(严格平衡需要更多的旋转次数)。因此,从整体上看,红黑树 查找、删除、添加 更加稳定,性能也更好。
引入红黑树是为了解决 链表长度过长时,查询效率低 的问题。
红黑树和链表的转化
链表转为红黑树存储有两个条件:
- 数组容量
MIN_TREEIFY_CAPACITY
- 链表的长度
TREEIFY_THRESHOLD=8
如果数组容量不够,但是链表长度 8 时,会进行一次扩容操作,仍然使用链表存储。
为什么阈值默认为 8 呢?
一般来说,如果 hashCode 分布均匀,链表的长度不会过长,很少会使用到红黑树存储。在理想情况下,使用随机哈希码,节点在数组中的出现频率符合泊松分布,当加载因子为 0.75、链表长度等于 8 时,节点出现的概率仅为 0.00000006,即几乎不会出现链表长度超过 8 的情况。
红黑树转为链表存储的条件是:
- 红黑树节点数
UNTREEIFY_THRESHOLD=6
当删除元素或者 resize
操作时,可能导致红黑树节点减少,转化为链表存储。
添加 (put) 过程
put
方法实际上调用的是 putVal()
方法,
/**
* @param hash key的hash值
* @param onlyIfAbsent 如果为true,不改变重复key的值
* @return 新添加key之前的值
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict){}
复制代码
添加键值对 (key,value)
的具体过程:
- 如果哈希表为空,先进行
resize()
操作。 - 计算新元素的索引
i
,并记录table[i]
的节点为p
- 如果
p
为空(说明还没有存储任何元素),直接创建一个Node
新节点。否则,说明已经存储了元素,出现了 哈希冲突,进行如下操作:- 如果
p
是TreeNode
实例,调用putTreeVal()
方法插入到红黑树中 - 如果
p
是Node
实例,遍历链表插入到链表尾部,并根据链表的长度判断是否需要转为红黑树 - (插入过程中)如果发现出现了重复的
key
,就结束遍历,直接用新值覆盖旧值,并直接返回旧值
- 如果
- (能走到这里说明一定增加了一个元素)修改次数增加 1(
modCount++
),键值对个数加 1,如果哈希表新的容量达到了阈值,要进行resize()
操作
putVal() 方法的源码有点多了,这里就不贴了?。
获取 (get) 过程
get(key)
方法调用 getNode()
方法获取对应的 Node
,返回 value
。
// HashMap.class
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
复制代码
getNode(hash,key)
方法的过程如下:
- 计算索引
i
,并获取i
位置的第一个元素first
。如果哈希表为空或者first
为空,返回null
。 - 如果
first
是要找的元素节点,返回first
。 - 如果
first
是TreeNode
类型,调用getTreeNode(hash, key)
从红黑树结构中查找 - 如果
first
是Node
类型,顺序遍历链表查找 - (没找到返回
null
)
获得当前数组(链表)的首元素first,判断key值是否相等,如果相等,直接返回key的value
如果不是first,判断是否为红黑树结构,如果是,调用红黑树的getTreeNode()方法查询
如果不是红黑树结构,从first开始遍历链表,直到找到key值对应的元素,没有则返回null.
HashMap 的变化
存储结构的变化
JDK8 开始使用 数组+链表+红黑树 结构,之前是 数组+链表。
链表插入方式的变化
JDK8 开始出现冲突时,链表使用尾插入,之前是头插入。
头插入在多线程环境下可能导致 链表死循环 和 数据丢失 的问题。
hash 函数的变化
JDK8 之前的 hash
函数:
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
复制代码
JDK8 开始使用的 hash
函数:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
复制代码
(扩容时)索引计算的变化
JDK8 之前计算某个 key 的索引会调用 indexFor()
方法:
// <= JDK7 的索引计算
static int indexFor(int h, int length) {
return h & (length-1);
}
复制代码
这其实就相当于是对 length
取余操作。
而 JDK8 在扩容时使用 hash(key) & oldCap
直接判断(具体在 [哈希函数和索引计算] 中已经提到了)。
遍历元素
使用 EntrySet
获取键值对集合。Entry
是 Map
中用来描述键值对的接口,而 Node、TreeNode
都是它的实现类。
(如果使用 for 循环的话)必须使用增强 for
循环才能正确地获得键值对:
// 使用增加 for 循环遍历 HashMap
for(Map.Entry<Integer,String> entry : map.entrySet()){
System.out.println(entry.getKey());
System.out.println(entry.getValue());
}
复制代码
增强循环只是一种语法糖,本质上还是使用 迭代器 来实现的,HashMap
中有如下迭代器:
HashIterator
:迭代器父类,提供默认实现,有以下三个子类EntryIterator
:遍历 Entry 的迭代器,对应EntrySet
KeyIterator
:遍历 key 的迭代器ValueIterator
:遍历 value 的迭代器
遍历所有元素也可以使用 EntryIterator
,它通过 EntrySet.iterator()
方法获取到:
// 使用迭代器遍历
Iterator<Map.Entry<Integer, String>> iterator = map.entrySet().iterator();
while (iterator.hasNext()){
Map.Entry<Integer, String> next = iterator.next();
System.out.println(next.getKey());
System.out.println(next.getValue());
}
复制代码
迭代器遍历的过程中,如果要 删除元素,要使用迭代器本身的 remove()
方法 ,而不应使用 HashMap
的 remove()
方法。
类似的,使用
KeySet、KeyIterator
和Values、ValueIterator
也可以分别遍历 key 和 value。