gitbook/Java性能调优实战/docs/99052.md
2022-09-03 22:05:03 +08:00

231 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 07 | 深入浅出HashMap的设计与优化
你好,我是刘超。
在上一讲中我提到过Collection接口那么在Java容器类中除了这个接口之外还定义了一个很重要的Map接口主要用来存储键值对数据。
HashMap作为我们日常使用最频繁的容器之一相信你一定不陌生了。今天我们就从HashMap的底层实现讲起深度了解下它的设计与优化。
## 常用的数据结构
我在05讲分享List集合类的时候讲过ArrayList是基于数组的数据结构实现的LinkedList是基于链表的数据结构实现的而我今天要讲的HashMap是基于哈希表的数据结构实现的。我们不妨一起来温习下常用的数据结构这样也有助于你更好地理解后面地内容。
**数组**采用一段连续的存储单元来存储数据。对于指定下标的查找时间复杂度为O(1),但在数组中间以及头部插入数据时,需要复制移动后面的元素。
**链表**:一种在物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
链表由一系列结点(链表中每一个元素)组成,结点可以在运行时动态生成。每个结点都包含“存储数据单元的数据域”和“存储下一个结点地址的指针域”这两个部分。
由于链表不用必须按顺序存储所以链表在插入的时候可以达到O(1)的复杂度但查找一个结点或者访问特定编号的结点需要O(n)的时间。
**哈希表**根据关键码值Key value直接进行访问的数据结构。通过把关键码值映射到表中一个位置来访问记录以加快查找的速度。这个映射函数叫做哈希函数存放记录的数组就叫做哈希表。
**树**由nn≥1个有限结点组成的一个具有层次关系的集合就像是一棵倒挂的树。
## HashMap的实现结构
了解完数据结构后我们再来看下HashMap的实现结构。作为最常用的Map类它是基于哈希表实现的继承了AbstractMap并且实现了Map接口。
哈希表将键的Hash值映射到内存地址即根据键获取对应的值并将其存储到内存地址。也就是说HashMap是根据键的Hash值来决定对应值的存储位置。通过这种索引方式HashMap获取数据的速度会非常快。
例如存储键值对x“aa”哈希表会通过哈希函数f(x)得到"aa"的实现存储位置。
但也会有新的问题。如果再来一个(y“bb”)哈希函数f(y)的哈希值跟之前f(x)是一样的,这样两个对象的存储地址就冲突了,这种现象就被称为哈希冲突。那么哈希表是怎么解决的呢?方式有很多,比如,开放定址法、再哈希函数法和链地址法。
开放定址法很简单当发生哈希冲突时如果哈希表未被装满说明在哈希表中必然还有空位置那么可以把key存放到冲突位置后面的空位置上去。这种方法存在着很多缺点例如查找、扩容等所以我不建议你作为解决哈希冲突的首选。
再哈希法顾名思义就是在同义词产生地址冲突时再计算另一个哈希函数地址,直到冲突不再发生,这种方法不易产生“聚集”,但却增加了计算时间。如果我们不考虑添加元素的时间成本,且对查询元素的要求极高,就可以考虑使用这种算法设计。
HashMap则是综合考虑了所有因素采用链地址法解决哈希冲突问题。这种方法是采用了数组哈希表+ 链表的数据结构当发生哈希冲突时就用一个链表结构存储相同Hash值的数据。
## HashMap的重要属性
从HashMap的源码中我们可以发现HashMap是由一个Node数组构成每个Node包含了一个key-value键值对。
```
transient Node<K,V>[] table;
```
Node类作为HashMap中的一个内部类除了key、value两个属性外还定义了一个next指针。当有哈希冲突时HashMap会用之前数组当中相同哈希值对应存储的Node对象通过指针指向新增的相同哈希值的Node对象的引用。
```
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
```
HashMap还有两个重要的属性加载因子loadFactor和边界值threshold。在初始化HashMap时就会涉及到这两个关键初始化参数。
```
int threshold;
final float loadFactor;
```
LoadFactor属性是用来间接设置Entry数组哈希表的内存空间大小在初始HashMap不设置参数的情况下默认LoadFactor值为0.75。**为什么是0.75这个值呢?**
这是因为对于使用链表法的哈希表来说查找一个元素的平均时间是O(1+n)这里的n指的是遍历链表的长度因此加载因子越大对空间的利用就越充分这就意味着链表的长度越长查找效率也就越低。如果设置的加载因子太小那么哈希表的数据将过于稀疏对空间造成严重浪费。
那有没有什么办法来解决这个因链表过长而导致的查询时间复杂度高的问题呢?你可以先想想,我将在后面的内容中讲到。
Entry数组的Threshold是通过初始容量和LoadFactor计算所得在初始HashMap不设置参数的情况下默认边界值为12。如果我们在初始化时设置的初始化容量较小HashMap中Node的数量超过边界值HashMap就会调用resize()方法重新分配table数组。这将会导致HashMap的数组复制迁移到另一块内存中去从而影响HashMap的效率。
## HashMap添加元素优化
初始化完成后HashMap就可以使用put()方法添加键值对了。从下面源码可以看出当程序将一个key-value对添加到HashMap中程序首先会根据该key的hashCode()返回值再通过hash()方法计算出hash值再通过putVal方法中的(n - 1) & hash决定该Node的存储位置。
```
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
```
```
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
```
```
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//通过putVal方法中的(n - 1) & hash决定该Node的存储位置
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
```
如果你不太清楚hash()以及(n-1)&hash的算法就请你看下面的详述。
我们先来了解下hash()方法中的算法。如果我们没有使用hash()方法计算hashCode而是直接使用对象的hashCode值会出现什么问题呢
假设要添加两个对象a和b如果数组长度是16这时对象a和b通过公式(n - 1) & hash运算也就是(16-1)a.hashCode和(16-1)b.hashCode15的二进制为0000000000000000000000000001111假设对象 A 的 hashCode 为 1000010001110001000001111000000对象 B 的 hashCode 为 0111011100111000101000010100000你会发现上述与运算结果都是0。这样的哈希结果就太让人失望了很明显不是一个好的哈希算法。
但如果我们将 hashCode 值右移 16 位h >>> 16代表无符号右移16位也就是取 int 类型的一半刚好可以将该二进制数对半切开并且使用位异或运算如果两个数对应的位置相反则结果为1反之为0这样的话就能避免上面的情况发生。这就是hash()方法的具体实现方式。**简而言之就是尽量打乱hashCode真正参与运算的低16位。**
我再来解释下(n - 1) & hash是怎么设计的这里的n代表哈希表的长度哈希表习惯将长度设置为2的n次方这样恰好可以保证(n - 1) & hash的计算得到的索引值总是位于table数组的索引之内。例如hash=15n=16时结果为15hash=17n=16时结果为1。
在获得Node的存储位置后如果判断Node不在哈希表中就新增一个Node并添加到哈希表中整个流程我将用一张图来说明
![](https://static001.geekbang.org/resource/image/eb/d9/ebc8c027e556331dc327e18feb00c7d9.jpg)
**从图中我们可以看出:**在JDK1.8中HashMap引入了红黑树数据结构来提升链表的查询效率。
这是因为链表的长度超过8后红黑树的查询效率要比链表高所以当链表超过8时HashMap就会将链表转换为红黑树这里值得注意的一点是这时的新增由于存在左旋、右旋效率会降低。讲到这里我前面我提到的“因链表过长而导致的查询时间复杂度高”的问题也就迎刃而解了。
以下就是put的实现源码:
```
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
//1、判断当table为null或者tab的长度为0时即table尚未初始化此时通过resize()方法得到初始化的table
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
//1.1、此处通过n - 1 & hash 计算出的值作为tab的下标i并另p表示tab[i]也就是该链表第一个节点的位置。并判断p是否为null
tab[i] = newNode(hash, key, value, null);
//1.1.1、当p为null时表明tab[i]上没有任何元素那么接下来就new第一个Node节点调用newNode方法返回新节点赋值给tab[i]
else {
//2.1下面进入p不为null的情况有三种情况p为链表节点p为红黑树节点p是链表节点但长度为临界长度TREEIFY_THRESHOLD再插入任何元素就要变成红黑树了。
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//2.1.1HashMap中判断key相同的条件是key的hash相同并且符合equals方法。这里判断了p.key是否和插入的key相等如果相等则将p的引用赋给e
e = p;
else if (p instanceof TreeNode)
//2.1.2现在开始了第一种情况p是红黑树节点那么肯定插入后仍然是红黑树节点所以我们直接强制转型p后调用TreeNode.putTreeVal方法返回的引用赋给e
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//2.1.3接下里就是p为链表节点的情形也就是上述说的另外两类情况插入后还是链表/插入后转红黑树。另外上行转型代码也说明了TreeNode是Node的一个子类
for (int binCount = 0; ; ++binCount) {
//我们需要一个计数器来计算当前链表的元素个数并遍历链表binCount就是这个计数器
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1)
// 插入成功后要判断是否需要转换为红黑树因为插入后链表长度加1而binCount并不包含新节点所以判断时要将临界阈值减1
treeifyBin(tab, hash);
//当新长度满足转换条件时调用treeifyBin方法将该链表转换为红黑树
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;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
```
## HashMap获取元素优化
当HashMap中只存在数组而数组中没有Node链表时是HashMap查询数据性能最好的时候。一旦发生大量的哈希冲突就会产生Node链表这个时候每次查询元素都可能遍历Node链表从而降低查询数据的性能。
特别是在链表长度过长的情况下性能将明显降低红黑树的使用很好地解决了这个问题使得查询的平均复杂度降低到了O(log(n)),链表越长,使用黑红树替换后的查询效率提升就越明显。
我们在编码中也可以优化HashMap的性能例如重写key值的hashCode()方法,降低哈希冲突,从而减少链表的产生,高效利用哈希表,达到提高性能的效果。
## HashMap扩容优化
HashMap也是数组类型的数据结构所以一样存在扩容的情况。
在JDK1.7 中HashMap整个扩容过程就是分别取出数组元素一般该元素是最后一个放入链表中的元素然后遍历以该元素为头的单向链表元素依据每个被遍历元素的 hash 值计算其在新数组中的下标,然后进行交换。这样的扩容方式会将原来哈希冲突的单向链表尾部变成扩容后单向链表的头部。
而在 JDK 1.8 中HashMap对扩容操作做了优化。由于扩容数组的长度是 2 倍关系,所以对于假设初始 tableSize = 4 要扩容到 8 来说就是 0100 到 1000 的变化(左移一位就是 2 倍),在扩容中只用判断原来的 hash 值和左移动的一位newtable 的值)按位与操作是 0 或 1 就行0 的话索引不变1 的话索引变成原索引加上扩容前数组。
之所以能通过这种“与运算“来重新分配索引,是因为 hash 值本来就是随机的而hash 按位与上 newTable 得到的 0扩容前的索引位置和 1扩容前索引位置加上扩容前数组长度的数值索引处就是随机的所以扩容的过程就能把之前哈希冲突的元素再随机分布到不同的索引中去。
## 总结
HashMap通过哈希表数据结构的形式来存储键值对这种设计的好处就是查询键值对的效率高。
我们在使用HashMap时可以结合自己的场景来设置初始容量和加载因子两个参数。当查询操作较为频繁时我们可以适当地减少加载因子如果对内存利用率要求比较高我可以适当的增加加载因子。
我们还可以在预知存储数据量的情况下,提前设置初始容量(初始容量=预知数据量/加载因子。这样做的好处是可以减少resize()操作提高HashMap的效率。
HashMap还使用了数组+链表这两种数据结构相结合的方式实现了链地址法,当有哈希值冲突时,就可以将冲突的键值对链成一个链表。
但这种方式又存在一个性能问题如果链表过长查询数据的时间复杂度就会增加。HashMap就在Java8中使用了红黑树来解决链表过长导致的查询性能下降问题。以下是HashMap的数据结构图
![](https://static001.geekbang.org/resource/image/c0/6f/c0a12608e37753c96f2358fe0f6ff86f.jpg)
## 思考题
实际应用中我们设置初始容量一般得是2的整数次幂。你知道原因吗
期待在留言区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。