HashMap的擴容機制


復習散列數據結構之余重新研究了一下Java中的HashMap;本文主要針對:1、HashMap的初始化;2、HashMap的插入;3:HashMap的擴容這三個方面進行總結

1、HashMap的初始化

首先我們來看看代碼:

 1 public HashMap(int initialCapacity, float loadFactor) {
 2     if (initialCapacity < 0)
 3         throw new IllegalArgumentException("Illegal initial capacity: " +
 4                                            initialCapacity);
 5     if (initialCapacity > MAXIMUM_CAPACITY)
 6         initialCapacity = MAXIMUM_CAPACITY;
 7     if (loadFactor <= 0 || Float.isNaN(loadFactor))
 8         throw new IllegalArgumentException("Illegal load factor: " +
 9                                            loadFactor);
10     this.loadFactor = loadFactor;
11     this.threshold = tableSizeFor(initialCapacity);
12 }
13 
14 /**
15  * 返回一個等於指定容量的2的N次方的容量
16  * Returns a power of two size for the given target capacity.
17  */
18 static final int tableSizeFor(int cap) {
19     int n = cap - 1;
20     n |= n >>> 1;
21     n |= n >>> 2;
22     n |= n >>> 4;
23     n |= n >>> 8;
24     n |= n >>> 16;
25     return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
26 }
由此我們可知hashmap的容量總是2的N次方,而且這個值大於且最接近指定值大小的2次冪,比如就算我們指定new hashmap(1000),實際上構造出來的也是:hashmap(1024);

那問題來了:為什么JDK要這樣做呢?

要解決這個問題我們需要看看hashmap的是如何找到元素的存放位置的:
 1 方法一:
 2 static final int hash(Object key) {   //jdk1.8 & jdk1.7
 3      int h;
 4      // h = key.hashCode() 為第一步 取hashCode值
 5      // h ^ (h >>> 16)  為第二步 高位參與運算
 6      return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
 7 }
 8 方法二:
 9 static int indexFor(int h, int length) {  
10 //jdk1.7的源碼,jdk1.8沒有這個方法,取而代之的是在1.8中的putVal()方法中的第3行中:
11 //if ((p = tab[i = (n - 1) & hash]) == null))  
12 //原理都是一樣的,作用也是一樣的,都是定位元素位置
13      return h & (length-1);  //第三步 取模運算
14 },

看到這里我們自然會問:為什么hash()函數中要用對象的hashcode與自身的高16位進行異或運算(hashcode ^ (hashcode >>> 16))?

這是一個很精妙的設計:

a:其實我們大可以直接用對象的hashcode直接作為下標來存儲對象,這個值對於不同的對象必須保證唯一(JAVA規范),這也是大家常說的,重寫equals必須重寫hashcode的重要原因。但是對象的hashcode返回的是一個32位的int,那這個數組就有40億左右,大部分情況下我們不需要這么長的數組,我們只需要低位就行,比如只根據低16位創建數組,那數組長度大概就只需要6萬多,但是直接創建6萬多長度的數組肯定也不合理,而且只取低16位的隨機性肯定沒有取32位的隨機性大,沖突概率也更高,那JDK如何解決的呢?
b:JDK的處理非常巧妙,hashcode ^ (hashcode >>> 16) 該運算是用對象的hashcode與自己的高十六位進行異或運算,這樣計算出來的hash值同時具有高位和低位的特性,這樣算出來的hash值可以說就是一個增大了低十六位隨機性的hashcoede。這樣我們試想一下:只要對象的32位hashcode有一位發生了變化,那返回的hash值就會發生變化,更厲害的是不管這發生變化的那一位是高16位還是低16位,最后低十六位都會被影響到,這樣也使得后面取模運算下標時所截取的低位的隨機性增加,所計算出來的下標更加隨機和均勻;
為什么JDK中要用h & (length-1)來計算元素存儲位置下標?
計算元素的存放位置,我們首先想到的是根據對象的hash值對數組長度取模,這樣元素的分布也還算均勻,但是取模運算效率不算高,所以JDK采用了h & (table.length -1)來得到該對象的保存位,數組長度是2的整次冪時,(數組長度-1)正好相當於一個“低位掩碼”,“與”操作的結果就是散列值的高位全部歸零,只保留低位值,用來做數組下標訪問。以初始長度16為例,16-1=15。2進制表示是00000000 00000000 00001111。“與”操作的結果就是截取了最低的四位值。也就相當於取模操作,而且經過前面的hash()函數的的處理,低位的隨機性增加了,所以可知最后運算得到的存儲下標也會更加隨機更加均勻。
綜上:當length = 2^n時,不同的hash值發生碰撞的概率比較小,這樣就會使得數據在table數組中分布較均勻,查詢速度也較快。

2、HashMap的插入:

 1   public V put(K key, V value) {
 2         return putVal(hash(key), key, value, false, true);
 3     }
 4  
 5     final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
 6                    boolean evict) {
 7         Node<K, V>[] tab;
 8         Node<K, V> p;
 9         int n, i;
10         // 如果表為空則創建,這也體現了hashmap是懶加載的,構造完hashmap之后,如果沒有put操作,table是不會初始化的
11         if ((tab = table) == null || (n = tab.length) == 0)
12             n = (tab = resize()).length;
13         // 這一步是根據hash值對數組長度取模,找到元素應該存放的位置,
14         //JDk1.7中把該步驟寫成另一個方法,1.8中直接寫在此處
15         //如果為空則創建一個節點
16         if ((p = tab[i = (n - 1) & hash]) == null)
17             tab[i] = newNode(hash, key, value, null);
18         //不為空的情況
19         else {
20             Node<K, V> e;
21             K k;
22             // 節點已經存在,並且key一樣,直接覆蓋
23             if (p.hash == hash &&
24                     ((k = p.key) == key || (key != null && key.equals(k))))
25                 e = p;
26             //判斷是否是紅黑樹
27             else if (p instanceof TreeNode)
28                 e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
29             //執行到這里說明該位置存放的是鏈表
30             else {
31                 for (int binCount = 0; ; ++binCount) {
32                     if ((e = p.next) == null) {
33                         p.next = newNode(hash, key, value, null);
34                         //鏈表長度大於8轉換為紅黑樹進行處理 TREEIFY_THRESHOLD = 8
35                         if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
36                             treeifyBin(tab, hash);
37                         break;
38                     }
39                     // key已經存在直接覆蓋value
40                     if (e.hash == hash &&
41                             ((k = e.key) == key || (key != null && key.equals(k))))
42                         break;
43                     p = e;
44                 }
45             }
46             if (e != null) { // existing mapping for key
47                 V oldValue = e.value;
48                 if (!onlyIfAbsent || oldValue == null)
49                     e.value = value;
50                 afterNodeAccess(e);
51                 return oldValue;
52             }
53         }
54         ++modCount;
55         // 超過最大容量threshold 就擴容
56         if (++size > threshold)
57             resize();
58         afterNodeInsertion(evict);
59         return null;
60     }

3、HashMap的擴容:

有了前面的鋪墊,下面理解HashMap的擴容應該不會有太大的困難了:

我們先來看看JDK對擴容函數的注釋:
 1 /**
 2  * Initializes or doubles table size.  If null, allocates in
 3  * accord with initial capacity target held in field threshold.
 4  * Otherwise, because we are using power-of-two expansion, the
 5  * elements from each bin must either stay at same index, or move
 6  * with a power of two offset in the new table.
 7  *
 8  * @return the table
 9  */
10 final Node<K,V>[] resize() {...}
這段話后面描述到: 因為我們使用2的N次冪的擴容機制,所以元素在擴容后的數組中要么是留在原來的下標處,要么是在原位置基礎上再移動2的N次冪
這有點費解,我們看一下下面的尋址過程:
 
上圖的(a)(b)分別對應擴容前和擴容后的hash&(n-1)也就是尋找元素存放位置的過程,可以看到擴容后的n-1相比擴容前的n-1多了一高位1,則再進行&運算時,key1和key2也多了一高位參與運算:
所以,原hash值新增參與運算的的那一bit如果是0,則在新數組中的下標不變,如果原hash值新增參與運算的那一bit是1,則在新數組中的下標為:原索引+原數組容量。
因此現在JDK 只需要判斷每個元素的hash值新增參與運算的那一bit是1還是0就可以給每個元素確定新數組中的位置,這樣做可以巧妙的把原來處於同一個下標索引處的多個元素在新的數組中分散開來,如上面的(a)(b)過程,(a)過程中key1和key2雖然hash值不同,但是運算出了同一個索引值,所以存在同一個位置,但是在(b)過程中由於擴容的影響多了1bit參與運算,所以key1和key2就被分配到了不同的索引處!
下面看看JDK如何實現擴容,真是太巧妙了!
 1 final Node<K,V>[] resize() {
 2     Node<K,V>[] oldTab = table;
 3     int oldCap = (oldTab == null) ? 0 : oldTab.length;
 4     int oldThr = threshold;
 5     int newCap, newThr = 0;
 6     if (oldCap > 0) {
 7         if (oldCap >= MAXIMUM_CAPACITY) {
 8             threshold = Integer.MAX_VALUE;
 9             return oldTab;
10         }
11         else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
12                  oldCap >= DEFAULT_INITIAL_CAPACITY)
13             newThr = oldThr << 1; // double threshold
14     }
15     else if (oldThr > 0) // initial capacity was placed in threshold
16         newCap = oldThr;
17     else {               // zero initial threshold signifies using defaults
18         newCap = DEFAULT_INITIAL_CAPACITY;
19         newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
20     }
21     if (newThr == 0) {
22         float ft = (float)newCap * loadFactor;
23         newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
24                   (int)ft : Integer.MAX_VALUE);
25     }
26     threshold = newThr;
27     @SuppressWarnings({"rawtypes","unchecked"})
28         Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
29     table = newTab;
30     //移動數據
31     if (oldTab != null) {
32         for (int j = 0; j < oldCap; ++j) {
33             Node<K,V> e;
34             if ((e = oldTab[j]) != null) {
35                 oldTab[j] = null;
36                 if (e.next == null)
37                     newTab[e.hash & (newCap - 1)] = e;
38                 else if (e instanceof TreeNode)
39                     ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
40                 else { // preserve order
41                     Node<K,V> loHead = null, loTail = null;
42                     Node<K,V> hiHead = null, hiTail = null;
43                     Node<K,V> next;
44                     do {
45                         next = e.next;
46                         //把元素的hash值與舊的容量做&運算,便可得出元素的hash值
47                         //新增的參與運算的那一bit是1還是0
48                         //hash&(n-1) 與 hash & n  的區別:
49                         //加入n為16,則n-1為:1111 ,n為:10000
50                         //n比n-1高了一bit,且因為n為2的n次冪,
51                         //所以,hash&n 可以得出擴容后元素hash值多參與運算的那一bit是0還是1
52                         //新增參與運算的bit是0,則位置不變
53                         if ((e.hash & oldCap) == 0) {
54                             if (loTail == null)
55                                 loHead = e;
56                             else
57                                 loTail.next = e;
58                             loTail = e;
59                         }
60                         //新增參與運算的bit是1,位置變為: 原索引+原數組容量
61                         else {
62                             if (hiTail == null)
63                                 hiHead = e;
64                             else
65                                 hiTail.next = e;
66                             hiTail = e;
67                         }
68                     } while ((e = next) != null);
69                     if (loTail != null) {
70                         loTail.next = null;
71                         //位置不變
72                         newTab[j] = loHead;
73                     }
74                     if (hiTail != null) {
75                         hiTail.next = null;
76                         //位置變為: 原索引+原數組容量
77                         newTab[j + oldCap] = hiHead;
78                     }
79                 }
80             }
81         }
82     }
83     return newTab;
84 }

 第一次寫博客,有很多表述可能不是很清楚,望諒解。


免責聲明!

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



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