Android版数据结构与算法(四):基于哈希表实现HashMap核心源码彻底分析
版权声明:本文出自汪磊的博客,未经作者允许禁止转载。
存储键值对我们首先想到HashMap,它的底层基于哈希表,采用数组存储数据,使用链表来解决哈希碰撞,它是线程不安全的,并且存储的key只能有一个为null,在安卓中如果数据量比较小(小于一千),建议使用SparseArray和ArrayMap,内存,查找性能方面会有提升,如果数据量比较大,几万,甚至几十万以上还是使用HashMap吧。本篇只详细分析HashMap的源码,SparseArray和ArrayMap不在本篇讨论范围内,后续会单独分析。
HashMap的理解,最最核心就是扩容那二十几行代码,可以说是HashMap的核心所在了,然而网上绝大部分博客只是一带而过,大体说了一下结论,让人十分失望,本篇将会彻底分析扩容机制,源码分析基于android-23。
好了,直入主题吧。
一、HashMap中成员变量
1 private static final int MINIMUM_CAPACITY = 4;//约定hashmap中最小容量,也可以是0,如果不为0,那么最小容量限制为4 2 private static final int MAXIMUM_CAPACITY = 1 << 30;约定hashmap中最大容量,为2的30次方
3 static final float DEFAULT_LOAD_FACTOR = .75F;//扩容因子:主要用于扩容时机,后续会细讲
4
5 transient HashMapEntry<K, V>[] table;//盛放数据的table,数据每一项key不为null,每一项都是一个HashMapEntry对象
6
7 transient HashMapEntry<K, V> entryForNullKey;盛放key为null的数据项
8
9 transient int size;//hashmap中已经盛放数据的大小
10
11 private transient int threshold;//用来判断是否需要扩容,其值为DEFAULT_LOAD_FACTOR * hashmap的容量,当盛放数据达到hashmap的四分之三时,急需要考虑扩容了
主要成员变量已经有所标注,后续分析的时候会再次提及,此处不做过多解释。
二、HashMap中数据项HashMapEntry
HashMap中每个数据项都是HashMapEntry对象,HashMapEntry是HashMap的内部类,我们先来看看其结构:
1 static class HashMapEntry<K, V> implements Entry<K, V> { 2 final K key; 3 V value; 4 final int hash; 5 HashMapEntry<K, V> next; 6 7 HashMapEntry(K key, V value, int hash, HashMapEntry<K, V> next) { 8 this.key = key; 9 this.value = value; 10 this.hash = hash; 11 this.next = next; 12 } 13 14 public final K getKey() { 15 return key; 16 } 17 18 public final V getValue() { 19 return value; 20 } 21 22 public final V setValue(V value) { 23 V oldValue = this.value; 24 this.value = value; 25 return oldValue; 26 } 27 28 @Override public final boolean equals(Object o) { 29 if (!(o instanceof Entry)) { 30 return false; 31 } 32 Entry<?, ?> e = (Entry<?, ?>) o; 33 return Objects.equal(e.getKey(), key) 34 && Objects.equal(e.getValue(), value); 35 } 36 37 @Override public final int hashCode() { 38 return (key == null ? 0 : key.hashCode()) ^ 39 (value == null ? 0 : value.hashCode()); 40 } 41 42 @Override public final String toString() { 43 return key + "=" + value; 44 } 45 }
主要信息就是每一个数据项都包含了我们存储的key,value以及根据key算出来的hash值,next用于发生哈希碰撞的时候指向其下一个数据项。
三、HashMap构造方法
HashMap构造方法有如下:
1 public HashMap() 2 public HashMap(int capacity) 3 public HashMap(int capacity, float loadFactor) 4 public HashMap(Map<? extends K, ? extends V> map)
构造方法有如上4种方式,我们平时最常用的就是第一种方式,直接初始化然后不停往里面仍数据就可以了,第二种初始化的时候可以指定容量大小,第三,四中估计大部分人没用过,第三种除了指定容量大小我们还可以指定扩容因子,不过我们还是不要动扩容因子为好,指定为0.75是时间和空间的权衡,平时我们使用就用默认的0.75就可以了。
我们看一下HashMap()这种构造方式:
1 public HashMap() { 2 table = (HashMapEntry<K, V>[]) EMPTY_TABLE; 3 threshold = -1; // Forces first put invocation to replace EMPTY_TABLE 4 }
太简单了,就是初始化table为空的数组EMPTY_TABLE,这个EMPTY_TABLE的初始化容量可不为0,源码如下:
private static final Entry[] EMPTY_TABLE = new HashMapEntry[MINIMUM_CAPACITY >>> 1];
看到了吧,初始化容量为MINIMUM_CAPACITY的一半,也就是2。
此外threshold初始化的时候置为-1。
接下来我们在看下HashMap(int capacity) 这种构造方式:
1 public HashMap(int capacity) { 2 if (capacity < 0) { 3 throw new IllegalArgumentException("Capacity: " + capacity); 4 } 5 6 if (capacity == 0) { 7 @SuppressWarnings("unchecked") 8 HashMapEntry<K, V>[] tab = (HashMapEntry<K, V>[]) EMPTY_TABLE; 9 table = tab; 10 threshold = -1; // Forces first put() to replace EMPTY_TABLE 11 return; 12 } 13 14 if (capacity < MINIMUM_CAPACITY) { 15 capacity = MINIMUM_CAPACITY; 16 } else if (capacity > MAXIMUM_CAPACITY) { 17 capacity = MAXIMUM_CAPACITY; 18 } else { 19 capacity = Collections.roundUpToPowerOfTwo(capacity); 20 } 21 makeTable(capacity); 22 } 23 24 25 private HashMapEntry<K, V>[] makeTable(int newCapacity) { 26 @SuppressWarnings("unchecked") HashMapEntry<K, V>[] newTable 27 = (HashMapEntry<K, V>[]) new HashMapEntry[newCapacity]; 28 table = newTable; 29 threshold = (newCapacity >> 1) + (newCapacity >> 2); // 3/4 capacity 30 return newTable; 31 }
6-12行逻辑大体就是我们调用hashmap()空参数的构造函数初始化一样。
14-17行,就是对我们设置的容量capacity进行检查了,如果小于MINIMUM_CAPACITY那么就重置为MINIMUM_CAPACITY,如果大于MAXIMUM_CAPACITY则重置为MAXIMUM_CAPACITY。
19行,Collections.roundUpToPowerOfTwo这个方法就是找出一个2^n的数,使其不小于给出的数字,并且最近接给出的数字。
比如:
Collections.roundUpToPowerOfTwo(3)返回4,22.
Collections.roundUpToPowerOfTwo(4)返回4,22.
Collections.roundUpToPowerOfTwo(100)返回128,27.
明白了吧?也就是说返回的数肯定是2的几次方,也就是说hashmap的容量肯定是2的几次方形式,这里很重要,一定要记住,后续分析的时候还会用到。
接下来就是调用makeTable了。
26,27就是根据给定的容量创建newTable数组。
28行,成员变量table指向新创建的newTable数组。
29行,计算threshold的值,也就是我们指定的容量的四分之三了。
好了,以上就是构造方法逻辑,其余两种方法可自行查看一下,比较核心的就是19行代码,对capacity数据的转换,约束hashmap容量大小肯定为2的n次方。
四、HashMap中put方法分析
接下来就是HashMap核心所在了,我们一点点分析,先看下put方法源码:
1 @Override 2 public V put(K key, V value) { 3 if (key == null) { 4 return putValueForNullKey(value); 5 } 6 int hash = Collections.secondaryHash(key); 7 HashMapEntry<K, V>[] tab = table; 8 int index = hash & (tab.length - 1); 9 for (HashMapEntry<K, V> e = tab[index]; e != null; e = e.next) { 10 if (e.hash == hash && key.equals(e.key)) { 11 preModify(e); 12 V oldValue = e.value; 13 e.value = value; 14 return oldValue; 15 } 16 } 17 // No entry for (non-null) key is present; create one 18 modCount++; 19 if (size++ > threshold) { 20 tab = doubleCapacity(); 21 index = hash & (tab.length - 1); 22 } 23 addNewEntry(key, value, hash, index); 24 return null; 25 }
3-5行,如果我们放入的数据key为null,那么执行4行代码逻辑并且直接返回,putValueForNullKey源码如下:
1 private V putValueForNullKey(V value) { 2 HashMapEntry<K, V> entry = entryForNullKey; 3 if (entry == null) { 4 addNewEntryForNullKey(value); 5 size++; 6 modCount++; 7 return null; 8 } else { 9 preModify(entry); 10 V oldValue = entry.value; 11 entry.value = value; 12 return oldValue; 13 } 14 }
大体逻辑很简单,就是对成员变量entryForNullKey操作,其就是HashMapEntry对象实例,3-8行如果entry为null,则代表之前没有放入过key为null的数据,则只需要创建即可。8-12行表示之前放入锅key为null的数据,那么只需要将value替换为新的value即可,这里说明HashMap只会有一个数据的key为null,重复放入只会将value替换为最新value.好了,这里就只是简单分析一下。
回到put方法,如果我们放入的key不为null,则继续向下执行:
6行,根据key计算二次哈希值,源码如下:
1 public static int secondaryHash(Object key) { 2 return secondaryHash(key.hashCode()); 3 } 4 5 private static int secondaryHash(int h) { 6 // Spread bits to regularize both segment and index locations, 7 // using variant of single-word Wang/Jenkins hash. 8 h += (h << 15) ^ 0xffffcd7d; 9 h ^= (h >>> 10); 10 h += (h << 3); 11 h ^= (h >>> 6); 12 h += (h << 2) + (h << 14); 13 return h ^ (h >>> 16); 14 }
就是将key的hashCode方法返回的值传入secondaryHash(int h) 再次计算一次返回一个值,这里最重要的一点就是我们传入的key必须有hashCode()方法并且每次返回的值一样,如果HashMap Key的哈希值在存储键值对后发生改变,Map可能再也查找不到这个Entry了,所以HashMap中key我们需要使用不可变对象,比如经常使用的String,Integer对象,其HashCode()方法分别如下:
1 @Override 2 public int hashCode() {//String中HashCode()方法 3 int hash = hashCode; 4 if (hash == 0) { 5 if (count == 0) { 6 return 0; 7 } 8 for (int i = 0; i < count; ++i) { 9 hash = 31 * hash + charAt(i); 10 } 11 hashCode = hash; 12 } 13 return hash; 14 } 15 16 @Override 17 public int hashCode() {//Integer中HashCode()方法 18 return value; 19 }
回到put方法,则继续向下执行:
7行定义局部变量tab指向全局变量table数组。
8行,计算放入的数据在tab中的位置,计算方式为key的hash值按位与tab的长度减1,这样确保了计算出的index不会超出数组角标,比如:
key的hash值为11111111111111111111111111111111,tab容量为8,则tab.length-1为7,其数组角标范围为0~7。
hash & (tab.length - 1)按位与计算如下:
也就是经过上述计算最大值为7,不会超过数组角标。
为什么要进行二次哈希值得计算呢?
比如我们放入三个数据,key的HashCode值分别为:31,63,95。tab容量为8
如果不进行二次哈希值计算索引index,也就是key.hashcode() & (tab.length - 1),计算如下:
31=00011111 & 00000111 = 0111 = 7
63=00111111 & 00000111 = 0111 = 7
95=01011111 & 00000111 = 0111 = 7
进行二次哈希值后再计算索引index,也就是源码中secondaryHash(key.hashCode())& (tab.length - 1),计算如下:
31=00011111 =>secondaryHash=> 00011110 & 00000111= 0110 = 6
63=00111111 ==secondaryHash=> 00111100 & 00000111= 0100 = 4
95=01011111 ==secondaryHash=> 01011010 & 00000111= 0010 = 2
如上不经过二次哈希值计算最终计算出的index值均为7,也就是我们放入数组中都处于同一位置。而经过二次哈希值计算之后再计算index值分别为6,4,2也就是在数组中处于了三个不同的位置,这样就达到了更加散列的效果。但是即使经过二次哈希值计算也不能保证计算出的index值都不相同,这里只是尽可能的散列化,不能保证避免哈希碰撞。
回到put方法,继续向下分析:
我们知道HashMap存储数据结构如下:
简单说就是我们放入一个数据的时候会先根据数据项的key计算出其在table数组中的索引,如果索引位置已经有元素了,那么则与已经放入的数据形成链表的关系,相信稍有经验的都明白,这里只是稍微提一下。
9-16行的for循环逻辑就是挨个遍历所在数组行链表中每一个数据项,然后将每个数据项的hash值和key与将要放入的key及其hash值比较,如果二者均相等则表明HashMap中已经存在此数据。
12-14行就是将对应数据项的value值替换为新的value值,并将之前value返回。
如果整个for循环都没有找到则表明HashMap中没有将要存储的数据项,继续向下执行。
19行,判断是否需要扩容,threshold上面说过值为table容量的四分之三,size记录我们HashMap中存入数据的大小,我们放入数据时如果超过容量的四分之三那么就需要扩容了。
20行,如果需要扩容那么调用doubleCapacity()方法进行扩容(后续会仔细分析扩容机制),扩容完此方法会返回扩容后的数组。
21行,由于数组已经扩容,容量发生了变化,所以这里需要重新计算一下将要放入数据的index索引。
23行调用addNewEntry方法将数据放入数组中。addNewEntry源码如下:
1 void addNewEntry(K key, V value, int hash, int index) { 2 table[index] = new HashMapEntry<K, V>(key, value, hash, table[index]); 3 }
这里就是根据我们传入的key,value,hash值新建HashMapEntry数据节点,此数据节点的next指向原table[index],最后将新数据节点赋值给table[index],这里说的有点蒙圈,用图来解释一下,又要展示我强大的画图能力了:
这里通过阅读源码可以发现新添加的数据项是放在链表头部的,而不是直接放在尾部。
好了,以上就是put方法主要逻辑了,不再做其余分析,下面我们着重看一下HashMap的扩容机制。
五、HashMap中扩容机制分析
好了,如果你看到这里那么清理一下大脑吧,下面的有点烧脑了。
废话少说,直接看扩容方法源码;
1 private HashMapEntry<K, V>[] doubleCapacity() { 2 HashMapEntry<K, V>[] oldTable = table; 3 int oldCapacity = oldTable.length; 4 if (oldCapacity == MAXIMUM_CAPACITY) { 5 return oldTable; 6 } 7 int newCapacity = oldCapacity * 2; 8 HashMapEntry<K, V>[] newTable = makeTable(newCapacity); 9 if (size == 0) { 10 return newTable; 11 } 12 for (int j = 0; j < oldCapacity; j++) { 13 /* 14 * Rehash the bucket using the minimum number of field writes. 15 * This is the most subtle and delicate code in the class. 16 */ 17 HashMapEntry<K, V> e = oldTable[j]; 18 if (e == null) { 19 continue; 20 } 21 int highBit = e.hash & oldCapacity; 22 HashMapEntry<K, V> broken = null; 23 newTable[j | highBit] = e; 24 for (HashMapEntry<K, V> n = e.next; n != null; e = n, n = n.next) { 25 int nextHighBit = n.hash & oldCapacity; 26 if (nextHighBit != highBit) { 27 if (broken == null) 28 newTable[j | nextHighBit] = n; 29 else 30 broken.next = n; 31 broken = e; 32 highBit = nextHighBit; 33 } 34 } 35 if (broken != null) 36 broken.next = null; 37 } 38 return newTable; 39 }
2-6行oldTable,oldCapacity记录原来数组,数组长度以及检查原数组长度是否已经达到MAXIMUM_CAPACITY,如果已经达到最大长度,那么不好意思了,直接返回原数组了,老子无法给你扩容了,都那么长了,还扩什么容,自己继续在原数组玩吧,管你哈希碰撞导致链表多长我都不管了。
7行,定义newCapacity也就是新数组长度为原数组长度的2倍。
8行,就是执行makeTable()逻辑,创建新的数组newTable了,至于makeTable方法上面说过,就不再分析了。
9-11行,检查size是否为0,如果为0那么表明HashMap中没有存储过数据,不用执行下面的数据拷贝逻辑了,直接返回newTable就可以了。
12-37行,这可就是HashMap整个类的精华所在了,这几行代码看不懂这个类你就没有真正理解,看懂了其余扫一下就明白了。
假设原HashMap如图:
12行很简单就是遍历原数组中每个位置的数据,也可以说每个链表的头数据。
13-16行,风骚的注释:直白翻译就是下面这几行代码是这个类中最风骚的几行代码。
17-20行代码,就是检查取出的数组中每个数据项是否为null,为null则表明此行没有数据,继续循环就可以了。
在继续向下讲请大家思考一个问题:HashMap中同一个链表中每一个数据项的哈希值有什么相同点?比如原数组大小是8,那么同一链表中每一个数据项的哈希值有什么相同点?
思考。。。。。
这里直接说了:能在同一链表说明计算出来的index值相同,在看计算公式为int index = hash & (tab.length - 1),这里在扩容之前tab.length-1的值是相同的,比如数组长度为8,那么tab.length - 1的二进制表示为00000111,不同hash值计算出的index又相同,那么这里同一链表中每一个数据项的hash值得最后三位一定相同,只有这样计算出的index值才相同,如下:
如上图 两个hash值不同的数据项,经过运算后得出index均为2,原因就是虽然整体的hash值不同,但是最后三位均为010,所以计算出index值是相同的(此处假设数组长度为8)。
进而得出结论:如果HashMap中数组长度为2的n次方,那么同一链表中不同数据项的hash值的最后n位一定相同。
好了,到这里第一个难点通过,我们继续分析doubleCapacity()方法。
21行,int highBit = e.hash & oldCapacity计算出highBit位,翻译过来就是高位,这他妈又是什么玩意?仔细看计算方式与的oldCapacity,而不是oldCapacity-1,所以这里取得是数据项hash值得第n+1位(hashmap数组长度为2的n次方)是0还是1,这里一定要知道HashMap数组长度一定为2的n次方,二进制形式就是第n+1位为1其余为均为0。这里先记住这个highBit是哪一位,后面会用到。
22行,定义一个broken,知道有这么个玩意,后面也会用到。
23行,newTable[j | highBit] = e,将我们从原数组取出的数据项放入新数组中,也就是数据的拷贝了,注意这里e是每一个链表的头部,也就是处于数组中的数据。链表其余数据是通过24行for循环挨个遍历再放入新数组中的。但是这里有个疑问原数组放入数据是按照hash & (tab.length - 1)计算其在数组中位置的,这里怎么成了j | highBit这样计算了呢?这里真是卡住我了,一开始我是怎么想怎么想不通,但是我觉得二者之间一定有什么关系,不可能用两个完全不相关的算法来计算同一数据项在数组中的位置,绝不可能,一定有内在联系,我查啊查,算啊算,在经过如下计算我终于想明白了:hash & (tab.length - 1)与j | highBit这二者逻辑是完全相同的,TMD,逻辑竟然是相同的。
接下来咱们推导分析一下:
j | highBit
= j | (e.hash & oldCapacity) 第一步
= (e.hash & (oldCapacity-1)) | (e.hash & oldCapacity) 第二步
= e.hash & ( (oldCapacity-1) | oldCapacity) 第三步
= e.hash & (newCapacity- 1) 第四步
从开始到第一步很简单了,highBit的计算方式就是e.hash & oldCapacity这里只是替换回来。
第一步到第二步,j怎么就成了e.hash & (oldCapacity-1)呢?还记得index的计算方式吗?就是e.hash & (oldCapacity-1),那就是说j就是index了,在看看j是什么?j就是从0开始到oldCapacity的值,这里我们想一下啊,e就是通过oldTable[j]获取的,我们想想put方法怎么放入的呢,不就是oldTable[e.hash & (oldCapacity-1)] = e吗,想到了什么?想到了什么?对,通过j获取的元素e,这个j就是e.hash & (oldCapacity-1),所以这里可以替换的。
第二步到第三步就是数学方面的,记住就可以了。
第三步到第四步怎么来的呢?也就是(oldCapacity-1) | oldCapacity与newCapacity- 1相等,还记得上面说的HashMap数组容量一定是2的n次方吗?并且newCapacity = oldCapacity * 2 。
oldCapacity为2的n次方,也就是n+1位为1,其余都为0,oldCapacity-1也就是0到n位为1其余都为0,二者或运算后0到n+1为1其余位0。
newCapacity= oldCapacity * 2 也就是n+2位为1其余位0,newCapacity- 1也就是0到n+1位为1其余为0.
所以(oldCapacity-1) | oldCapacity与newCapacity- 1相等。
到此,我们就证明了j | highBit = e.hash & (newCapacity- 1) 其计算数据项在新数组中位置与原数组的计算逻辑是一样的,只不过十分巧妙的运用了位运算,好了,想明白这里恭喜你通过了第二个难点,我们继续向下分析。
24-34行就是遍历链表中数据项了,把他们挨个放入新数组中,这里思考一个问题?在原数组中同一链表的数据项在新数组中还处于同一链表吗?如果不是那么是什么决定它们不在同一链表了?
在上面分析的时候我们得出一个结论:如果HashMap中数组长度为2的n次方,那么同一链表中不同数据项的hash值的最后n位一定相同。
扩容后数组容量为原来的2倍了,根据index的计算方式e.hash & (newCapacity- 1)每个数据项的hash值是不变的,但是长度变了,所以同一链表中不同数据项在新数组中不一定还处于同一链表,那么具体是什么决定在新数组中二者在不在同一链表呢?
原数组长度为2的n次方,新数组长度扩容后为原数组2倍也就是2的n+1次方,原数组中同一链表中不同数据项的hash值的最后n位一定相同,所以新数组同一链表中不同数据项的hash值的最后n+1位一定相同。如果上面讲的你真的理解了,这里就不难理解,不过多解释。
在原数组中同一链表的数据项已经确保了hash值最后n位相同,按照计算方式新数组中处于同一链表的数据项需要确保hash值最后n+1位相同即可,既然原链表中的数据项最后n位已经相同了,在新数组中是否处于同一链表那么只需要比较同链表数据项hash值的第n+1位即可,如果相同则表明在新数组中依然处于同一链表,如果不同那么就处于不同链表了,上面的高位highBit就是取的是每一数据项的第n+1位,后面比较也只是比较每个数据项的highBit是否相同。
好了,这里我认为解释的已经很清楚了,这里你要是明白了,恭喜你,HashMap中最难理解的部分你已经完全掌握了。
至于24-34行具体逻辑我就不一一分析了,静下心来,自己试着分析,难度不大。
六、总结
好了,到这里本篇就要结束了,咦?这不就分析了一个put方法外加扩容机制吗?这就完了?是的,我想说的就这些,这部分是最难理解的,至于其余自己看看都能理解的差不多了。
最关键是一定要理解扩容机制,那几行最难理解的代码设计的真是巧妙,大神的思想真是无法企及啊!!!
本篇到此结束,希望对你有用。
声明:文章将会陆续搬迁到个人公众号,以后文章也会第一时间发布到个人公众号,及时获取文章内容请关注公众号