HashMap实现原理

前言

今天我们来聊聊HashMap。

Java集合类里的HashMap,实现Map接口,是个非线程安全的类。HashMap允许key和value有null值,且循环遍历为无序的,HashMap底层主要是通过数组+链表实现的,同时JDK8引入红黑树优化,提高HashMap的性能。

要了解HashMap,我们可以从几方面下手。

分析

我们先来看下HashMap的两个参数:

initialCapacity:初始容量,默认16
loadFactor:负载因子,默认0.75

现在我们执行了下面一段代码,根据代码来分析HashMap:

1
2
Map<String,String> map=new HashMap<>();
map.put("0","0");

创建一个HashMap,其会初始化以下数据:

1
2
3
4
5
6
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
  1. DEFAULT_INITIAL_CAPACITY: 初始容量,也就是默认会创建 16 个箱子,箱子的个数不能太多或太少。如果太少,很容易触发扩容,如果太多,遍历哈希表会比较慢。
  2. MAXIMUM_CAPACITY: 哈希表最大容量,一般情况下只要内存够用,哈希表不会出现问题。
  3. DEFAULT_LOAD_FACTOR: 默认的负载因子。因此初始情况下,当键值对的数量大于 16 * 0.75 = 12 时,就会触发扩容。
  4. TREEIFY_THRESHOLD: 如果哈希函数不合理,即使扩容也无法减少箱子中链表的长度,因此处理方案是当链表太长时,转换成红黑树。这个值表示当某个箱子中,链表长度大于 8 时,有可能会转化成树。
  5. UNTREEIFY_THRESHOLD: 在哈希表扩容时,如果发现链表长度小于 6,则会由树重新退化为链表。
  6. MIN_TREEIFY_CAPACITY: 在转变成树之前,还会有一次判断,只有键值对数量大于 64 才会发生转换。这是为了避免在哈希表建立初期,多个键值对恰好被放入了同一个链表中而导致不必要的转化。

根据HashMap源码,可以看到源码中有两个static final class Node < K,V > 和 TreeNode < K,V >分别为链表和红黑树链表。

本文不对红黑树链表的实现做过多分析。

我们来看下HashMap的put方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
//put方法,调用putVal方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
//HashMap放值方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//构建一个链表数组tab,链表p,长度n,索引i
Node<K,V>[] tab; Node<K,V> p; int n, i;
//把table的值赋给tab,如果tab是空或者长度为0
if ((tab = table) == null || (n = tab.length) == 0)
//调用resize方法,并获得tab长度
n = (tab = resize()).length;
//计算索引并获得tab索引下的值,如果为空直接将值添加
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;
//如果key不相同,判断p是不是TreeNode,是的话就执行红黑树放入值操作
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) {
//如果key上没值就放入普通链表
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;
}
}
//如果节点value不为空,即key上有值,把这个值返回去
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//记录改变次数(fast-fail机制)
++modCount;
//如果长度超过当前,就进行扩容操作
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}

我们再来看下,resize方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
final Node<K,V>[] resize() {
//获取旧的tab
Node<K,V>[] oldTab = table;
//旧的容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//旧的阀值
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
//如果容量超了MAXIMUM_CAPACITY,最大阀值定为Integer.MAX_VALUE
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//如果新容量赋值后小于MAXIMUM_CAPACITY并且旧容量不小于初始值
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//如果定义了初始容量
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
//否则为初始化,所有均为默认值
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;
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 { // preserve order
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;
}

HashMap的get方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public V get(Object key) {
Node<K,V> e;
//调用getNode方法
return (e = getNode(hash(key), key)) == null ? null : e.value;
}

//获取HashMap Value值
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;
}

我们看一下hash获取方法:

1
2
3
4
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

代码的意思是:如果Key值为null,返回0;如果Key值不为空,返回原hash值和原hash值无符号右移16位的值按位异或的结果。可以看到当key=null时,hash为0.

问题

  1. 为什么HashMap的初始长度默认16,负载因子默认0.75,且长度建议取2的倍数?

         通过以上的代码我们可以知道这两个值主要影响的threshold的大小,这个值的数值是当前桶数组需不需要扩容的边界大小,我们都知道桶数组如果扩容,会申请内存空间,然后把原桶中的元素复制进新的桶数组中,这是一个比较耗时的过程。既然这样,那为何不把这两个值都设置大一些呢,threshold是两个数的乘积,设置的大些不就减小了扩容次数吗?
         原因是这样的,如果桶初始化桶数组设置太大,就会浪费内存空间,16是一个折中的大小,既不会像1,2,3那样放几个元素就扩容,也不会像几千几万那样可以只会利用一点点空间从而造成大量的浪费。
         加载因子设置为0.75而不是1,是因为设置过大,桶中键值对碰撞的几率就会越大,同一个桶位置可能会存放好几个value值,这样就会增加搜索的时间,性能下降,设置过小也不合适,如果是0.1,那么10个桶,threshold为1,你放两个键值对就要扩容,太浪费空间了。
         HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法;这个算法实际就是取模,hash%length,计算机中直接求余效率不如位移运算,源码中做了优化hash&(length-1),hash%length==hash&(length-1)的前提是length是2的n次方;为什么这样能均匀分布减少碰撞呢?
         2的n次方实际就是1后面n个0,2的n次方-1 实际就是n个1;例如长度为9时候,3&(9-1)=0 2&(9-1)=0 ,都在0上,碰撞了;例如长度为8时候,3&(8-1)=3 2&(8-1)=2 ,不同位置上,不碰撞;

  2. HashMap的fast-fail机制?

         在put方法里,我们注意到一个参数,modCount,每当HashMap改变,modCount都会改变,在HashMap的remove,clear等对HashMap的变动操作中,都可以看到对此参数的操作,同时我们还能看到如下代码:

    1
    2
    if (modCount != expectedModCount)
    throw new ConcurrentModificationException();

         这就是HashMap的fast-fail机制,HashMap会记录Map的改变次数,如果多个线程操作HashMap,会导致modCount和expectedModCount不一致,就会抛出ConcurrentModificationException异常,说明你的代码里有多线程对HashMap的操作,这是不被允许的,这样也可以快速检索程序错误,但是我们不能指望HashMap的fast-fail机制来检索错误,我们更应该在编码中注意HashMap是线程不安全的,多线程情况可以考虑ConcurrentHashMap,Hashtable或者使用Collections.synchronizedMap(map)等操作。

深度分析

  1. 关于hash桶索引计算函数 i=(n-1)&hash 好处。

         观察HashMap将元素放入tab操作如下图,n在1处被赋值为tab.length,在2处,有一个关键算法,i=(n-1)&hash,i为tab下标,这样做有什么好处呢?

    (1)保证不会发生数组越界

         首先我们要知道的是,在HashMap,数组的长度按规定是2的幂。因此,数组的长度的二进制形式是:10000…000, 1后面有偶数个0。 那么,length - 1 的二进制形式就是01111…111, 0后面有偶数个1。最高位是0, 和hash值相“与”,结果值一定不会比数组的长度值大,因此也就不会发生数组越界。

    (2)保证元素尽可能的均匀分布

         由上边的分析可知,length若是一个偶数,length - 1一定是一个奇数。假设现在数组的长度length为16,减去1后length - 1就是15,15对应的二进制是:1111。现在假设有两个元素需要插入,一个哈希值是8,二进制是1000,一个哈希值是9,二进制是1001。和1111“与”运算后,结果分别是1000和1001,它们被分配在了数组的不同位置,这样,哈希的分布非常均匀。那么,如果数组长度是奇数呢?减去1后length - 1就是偶数了,偶数对应的二进制最低位一定是 0,例如14二进制1110。对上面两个数子分别“与”运算,得到1000和1000。结果都是一样的值。那么,哈希值8和9的元素都被存储在数组同一个index位置的链表中。在操作的时候,链表中的元素越多,效率越低,因为要不停的对链表循环比较。

    upload successful

  2. 关于”扰动函数”,(h = key.hashCode()) ^ (h >>> 16) ?

         在对数据进行hash计算时,可以看到,不仅仅是取了数据的hashCode,而是将hashCode和hashCode无符号右移16位的值进行异或运算。

    upload successful

         我们知道,key.hashCode返回一个int值,这个值一般比hash桶数组长度要大,比如一个长度为16的hash桶,放入String abc (hashCode为96354),直接进行桶索引计算,i=(n-1)&hashCode 可以得出(15&96354)=2,索引值为2,如果是abcd,计算(15&2987074)=2,索引值也为2。

    upload successful

         可以看出,即使hashCode散列再离散,计算索引值时低位才是主要影响原因,而特征较大的高位(96354和2987074高位特征较大)根本不参与运算,这样hash冲突也会较高。而右移16位(32位的一半,int最大32位),正好为32位一半,这样可以把前16位认为高位,后16位认为低位,然后进行异或操作,高16位的信息被变相保存了下来,增大了随机性。

    upload successful

         可以看出这样操作后abc的下标为3(二进制11),abcd的下标为15(二进制1111)。

         Peter Lawrey有一篇关于hash冲突率比较的文章《An introduction to optimising a hashing strategy》,大家可以看看。

         https://www.javacodegeeks.com/2015/09/an-introduction-to-optimising-a-hashing-strategy.html

    upload successful

  3. 为什么引入负载因子这个概念?

         负载因子的引入,可以来说是时间复杂度和空间复杂度的折中。(大数据统计下)负载因子越低,一般认为空间开销越大,查询时间开销越低(hash碰撞低),大量hash数组,少量链表;负载因子越高,一般认为空间开销越低,查询时间开销越高(hash碰撞高),少量hash桶数组,大量链表。负载因子的引入恰可以增加HashMap不同场景使用的灵活性。

结语

欢迎光临我的博客

https://www.sakuratears.top

我的GitHub地址

https://github.com/javazwt




-------------文章结束啦 ~\(≧▽≦)/~ 感谢您的阅读-------------

您的支持就是我创作的动力!

欢迎关注我的其它发布渠道