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方法外加擴容機制嗎?這就完了?是的,我想說的就這些,這部分是最難理解的,至於其余自己看看都能理解的差不多了。

最關鍵是一定要理解擴容機制,那幾行最難理解的代碼設計的真是巧妙,大神的思想真是無法企及啊!!!

本篇到此結束,希望對你有用。

聲明:文章將會陸續搬遷到個人公眾號,以后文章也會第一時間發布到個人公眾號,及時獲取文章內容請關注公眾號

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM