深入理解HashMap(一)

目錄
導讀
(一)定義及構造函數
1.1 什麼是HashMap?
1.2 HashMap的成員變量
1.3 HashMap的四個構造函數
1.3.1 容量是什麼
1.3.2 加載因子
1.3.3 擴容臨界點
(二)HashMap的數據結構
2.1 Entry數組
(三)HashMap的存儲實現put方法
3.1 實現過程簡介
3.2 hash()詳解
3.2.1 hash算法與擾動函數
3.3 Java中的hashcode、equals、==
3.4 如何讓數據在table數組中均勻分佈
3.4.1 均勻分佈的必要性
3.4.2 取模與取餘的區別
3.5 addEntry方法
3.5.1 鏈表的產生
3.5.2 擴容問題
3.6 總結
3.6.1 put方法的幾個關鍵步驟
(四)HashMap的讀取實現get方法

(五)HashMap鍵的遍歷


導讀
我們都知道,數組是在內存當中連續開闢的一段空間,這樣只要知道了數組的首位地址,在數組當中尋找某一元素將會非常容易,時間複雜度爲O(1),但是插入和刪除則需要O(n);而鏈表呢,在內存當中是離散的,依靠結點之間的指向,來儲存一組數據元素,這樣插入和刪除操作就很方便,複雜度爲O(1),但是查詢就非常費時,需要O(n)。這樣HashMap就應運而生,將數組和鏈表的優勢相結合,無論尋址、刪除、插入都變的很快了。
這篇文章介紹的是Java8以前的HashMap對於Java8中關於HashMap的實現方式和變動,會在基於此篇文章之後,另行整理。這篇文章中會全面剖析HashMap中的主要內容,並且會詳細介紹其中用到的一些算法思想,儘可能減少另外查詢其他資料的情況。

(一)定義及構造函數
1.1什麼是HashMap?

public class HashMap<k,v>
	extends AbstractMap<k,v>
	implements Map<k,v>, Cloneable, Serializable</k,v></k,v></k,v>

這是jdk1.7官方文檔中HashMap類的定義,可以看出來HashMap繼承自AbstractMap,實現了Map接口。其中Map接口定義了鍵映射到值的規則,而AbstractMap類提供了Map接口的骨幹實現,從而減少HashMap實現Map接口的壓力。

1.2HashMap的成員變量

int DEFAULT_INITIAL_CAPACITY = 16:默認的初始容量爲16 
int MAXIMUM_CAPACITY = 1 << 30:最大的容量爲 2 ^ 30 
float DEFAULT_LOAD_FACTOR = 0.75f:默認的加載因子爲 0.75f 
Entry< K,V>[] table:Entry類型的數組,HashMap用這個來維護內部的數據結構,它的長度由容量決定 
int size:HashMap的大小 
int threshold:HashMap的極限容量,擴容臨界點(容量和加載因子的乘積)

1.3HashMap的四個構造函數

public HashMap():構造一個具有默認初始容量 (16) 和默認加載因子 (0.75) 的空 HashMap 
public HashMap(int initialCapacity):構造一個帶指定初始容量和默認加載因子 (0.75) 的空 HashMap 
public HashMap(int initialCapacity, float loadFactor):構造一個帶指定初始容量和加載因子的空 HashMap 
public HashMap(Map< ? extends K, ? extends V> m):構造一個映射關係與指定 Map 相同的新 HashMap

1.2和1.3介紹了HashMap的基本結構,其中的成員變量和相關參數的定義含義如下:

1.3.1容量

哈希表中桶的數量,初始容量只是哈希表在創建時的容量,實際上就是Entry< K,V>[] table數組的容量,大小必須爲2的n次方(一定是合數),這是一種非常規的設計,常規的設計是把桶的大小設計爲素數。相對來說素數導致衝突的概率要小於合數,具體證明可以參考http://blog.csdn.net/liuqiyao_01/article/details/14475159,Hashtable初始化桶大小爲11,就是桶大小設計爲素數的應用(Hashtable擴容後不能保證還是素數)。HashMap採用這種非常規設計,主要是爲了在取模和擴容時做優化,同時爲了減少衝突,HashMap定位哈希桶索引位置時,也加入了高位參與運算的過程。之後會舉例說明使用2的n次方的原因,以便更好的理解取模和避免衝突之類的定義以及他們的關係。

1.3.2加載因子

加載因子是哈希表在其容量自動增加之前可以達到多滿的一種尺度,它衡量的是一個散列表的空間的使用程度,負載因子越大表示散列表的裝填程度越高,反之愈小。因此如果負載因子越大,對空間的利用更充分,然而後果是查找效率的降低;如果負載因子太小,那麼散列表的數據將過於稀疏,對空間造成嚴重浪費。至於原因在1.3.3中給出了答案,往下看就能更好的理解上面這句話了。

1.3.3擴容臨界點

threshold是HashMap所能容納的最大數據量的Entry(鍵值對)個數,其實也就是成員變量size(實際數量<=threshold)的最大值。threshold = length(容量) * Load factor(加載因子)。也就是說,在數組定義好長度之後,負載因子越大,所能容納的鍵值對個數越多。
結合負載因子的定義公式可知,threshold就是在此Load factor和length(數組長度)對應下允許的最大元素數目,超過這個數目就重新resize(擴容),擴容後的HashMap容量是之前容量的兩倍。默認的負載因子0.75是對空間和時間效率的一個平衡選擇,建議大家不要修改,除非在時間和空間比較特殊的情況下,如果內存空間很多而又對時間效率要求很高,可以降低負載因子Load factor的值;相反,如果內存空間緊張而對時間效率要求不高,可以增加負載因子loadFactor的值,這個值可以大於1。

1.4小結

通過以上介紹,對HashMap的定義以及其中的一些變量參數都進行了詳細的解釋,接下來將分析這些變量和參數在HashnMap中發揮的作用,這裏簡單提一下,這篇文章中不會詳細說明Java8中對HashMap的優化,但是會進行簡單引入,以便過渡到深入理解HashMap(二),也就是Java8中對HashMap的優化。
我們必須明確一點,即使負載因子和Hash算法設計的再合理,也免不了會出現拉鍊過長的情況,一旦出現拉鍊過長,則會嚴重影響HashMap的性能。於是,在JDK1.8版本中,對數據結構做了進一步的優化,引入了紅黑樹。而當鏈表長度太長(默認超過8)時,鏈表就轉換爲紅黑樹,利用紅黑樹快速增刪改查的特點提高HashMap的性能,其中會用到紅黑樹的插入、刪除、查找等算法。本文不再對紅黑樹展開討論,想了解更多紅黑樹數據結構的工作原理可以參考http://blog.csdn.net/v_july_v/article/details/6105630。

(二)HashMap的數據結構
Java中最常用的兩種結構是數組和模擬指針(引用),幾乎所有的數據結構都可以利用這兩種來組合實現,HashMap也是如此。實際上HashMap是一個“鏈表散列”,如下是它數據結構:

從上圖我們可以看出HashMap底層實現還是數組,只是數組的每一項都是一條鏈。其中參數initialCapacity就代表了該數組的長度。下面爲HashMap構造函數的源碼:

public HashMap(int initialCapacity, float loadFactor) {
        //初始容量不能<0
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: "
                    + initialCapacity);
        //初始容量不能 > 最大容量值,HashMap的最大容量值爲2^30
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        //負載因子不能 < 0
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: "
                    + loadFactor);
 
        // 計算出大於 initialCapacity 的最小的 2 的 n 次方值。
        int capacity = 1;
        while (capacity < initialCapacity)
            capacity <<= 1;
         
        this.loadFactor = loadFactor;
        //設置HashMap的容量極限,當HashMap的容量達到該極限時就會進行擴容操作
        threshold = (int) (capacity * loadFactor);
        //初始化table數組
        table = new Entry[capacity];
        init();
    } 
可以看到,這個構造函數主要做的事情就是:  
  1.  對傳入的 容量 和 加載因子進行判斷處理 
  2. 設置HashMap的容量極限 
  3.  計算出大於初始容量的最小 2的n次方作爲哈希表table的長度,然後用該長度創建Entry數組(table),這個是最核心的

從源碼中可以看出,每次新建一個HashMap時,都會初始化一個table數組。table數組的元素爲Entry節點。 Entry[] table是HashMap類中非常重要的字段,即哈希桶數組,明顯它是一個Entry的數組。我們來看Entry是何物。

2.1 Entry數組

tatic class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;
        int hash;


        /**
         * Creates new entry.
         */
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }

Entry爲HashMap的內部類,它包含了鍵key、值value、下一個節點next(該引用指向當前table位置的鏈表),以及hash值(用來確定每一個Entry鏈表在table中位置),這是非常重要的,正是由於Entry才構成了table數組的項爲鏈表。


(三)HashMap的存儲實現put方法


3.1 實現過程簡介

 public V put(K key, V value) {
//如果key爲空的情況
if (key == null)
return putForNullKey(value);
//計算key的hash值
int hash = hash(key);
//計算該hash值在table中的下標
int i = indexFor(hash, table.length);
//對table[i]存放的鏈表進行遍歷
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//判斷該條鏈上是否有hash值相同的(key相同)  
//若存在相同,則直接覆蓋value,返回舊value
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}


//修改次數+1
modCount++;
//把當前key,value添加到table[i]的鏈表中
addEntry(hash, key, value, i);
return null;
}
通過源碼我們可以清晰看到HashMap保存數據的過程爲:首先判斷key是否爲null,若爲null,則直接調用putForNullKey方法。若不爲空則先計算key的hash值,然後根據hash值搜索在table數組中的索引位置,如果table數組在該位置處有元素,則通過比較是否存在相同的key,若存在則覆蓋原來key的value,否則將該元素保存在鏈頭(最先保存的元素放在鏈尾)。若table在該處沒有元素,則直接保存。

1、如果爲null,則調用putForNullKey:這就是爲什麼HashMap可以用null作爲鍵的原因,來看看HashMap是如何處理null鍵的: 
private V putForNullKey(V value) {
//查找鏈表中是否有null鍵
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
//如果鏈中查找不到,則把該null鍵插入
addEntry(0, null, value, 0);
return null;
}
關於addEntry方法下面會詳細說明。

2、如果鏈中存在該key,則用傳入的value覆蓋掉舊的value,同時把舊的value返回:這就是爲什麼HashMap不能有兩個相同的key的原因。
有了這樣的介紹,接下來就一一剖析其中的奧妙。

3.2hash()詳解
3.2.1 hash算法及tanle索引確定與擾動函數

首先是hash算法,hash算法將對象的hashcode值作爲參數,重新進行哈希計算,對於hash操作,最重要也是最困難的就是如何通過確定hash的位置,我們來看看HashMap的
做法: 首先求得key的hash值:hash(key)

final int hash(Object k) {
int h = 0;
if (useAltHashing) {
if (k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h = hashSeed;
}


h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}

計算該hash值在table中的下標
static int indexFor(int h, int length) {  
return h & (length-1);  
}  


在這裏明顯可以看到將hashcode進行多次移位後多次異或運算,爲什麼要這樣計算,這裏就要提到一個概念,叫做擾動函數,在介紹擾動函數之前,我們先來搞清楚hashmap中的hash算法的過程,另外我們必須要清楚,計算hash的目的是什麼,前面提到過,hashmap是數組-鏈表結構的,爲了避免大量的數組對象,即k-v鍵值對存放在數組中的分佈產生過於密集的情況,也就是每個數組元素存放位置上儘量不要產生長鏈表,使得整個數組表中的空間得到充分利用,也同樣避免了大量的鏈表遍歷過程,從而提高效率。正是出於這樣的目的,才需要進行數組索引定位,其中定位的算法用到了hash值,關於如何確定索引值,在後面的小節中會詳細說明。

現在假設key.hashCode()的值爲:0x7FFFFFFF,table.length爲默認值16。 
上面算法執行如下:



得到i=15 
其中h^(h>>>7)^(h>>>4) 結果中的位運行標識是把h>>>7 換成 h>>>8來看。 

即最後h^(h>>>8)^(h>>>4) 運算後hashCode值每位數值如下: 

8=8 
7=7^8 
6=6^7^8 
5=5^8^7^6 
4=4^7^6^5^8 
3=3^8^6^5^8^4^7 
2=2^7^5^4^7^3^8^6 
1=1^6^4^3^8^6^2^7^5 
結果中的1、2、3三位出現重複位^運算 
3=3^8^6^5^8^4^7     ->   3^6^5^4^7 
2=2^7^5^4^7^3^8^6   ->   2^5^4^3^8^6 
1=1^6^4^3^8^6^2^7^5 ->   1^4^3^8^2^7^5 
 
算法中是採用(h>>>7)而不是(h>>>8)的算法,應該是考慮1、2、3三位出現重複位^運算的情況。使得最低位上原hashCode的8位都參與了^運算,所以在table.length爲默認值16的情況下面,hashCode任意位的變化基本都能反應到最終hash table 定位算法中,這種情況下只有原hashCode第3位高1位變化不會反應到結果中,即:0x7FFFF7FF的i=15。

那麼究竟什麼是擾動函數呢,這裏稍微提一下java8中關於hash值的計算,然後以java8爲例,進行解釋,因爲java8對這個算法進行了簡化,其原理相同,也同同樣是擾動函數的思想,在java8中hash()方法不再進行四次位運算,而簡化成一次:

static final int hash(Object key) {   /
int h;
// h = key.hashCode() 爲第一步 取hashCode值
// h ^ (h >>> 16)  爲第二步 高位參與運算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

大家都知道上面代碼裏的key.hashCode()函數調用的是key鍵值類型自帶的哈希函數,返回int型散列值。理論上散列值是一個int型,如果直接拿散列值作爲下標訪問HashMap主數組的話,考慮到2進制32位帶符號的int表值範圍從-2147483648到2147483648。前後加起來大概40億的映射空間。只要哈希函數映射得比較均勻鬆散,一般應用是很難出現碰撞的。但問題是一個40億長度的數組,內存是放不下的。你想,HashMap擴容之前的數組初始大小才16。所以這個散列值是不能直接拿來用的。用之前還要先做對數組的長度取模運算,得到的餘數才能用來訪問數組下標。源碼中模運算是在這個indexFor( )函數裏完成的。bucketIndex = indexFor(hash, table.length);
indexFor的代碼也很簡單,就是把散列值和數組長度做一個"與"操作,
static int indexFor(int h, int length) {
return h & (length-1);
}

順便說一下,這也正好解釋了爲什麼HashMap的數組長度要取2的整次冪。因爲這樣(數組長度-1)正好相當於一個“低位掩碼”。“與”操作的結果就是散列值的高位全部歸零,
只保留低位值,用來做數組下標訪問。以初始長度16爲例,16-1=15。2進製表示是00000000 00000000 00001111。和某散列值做“與”操作如下,結果就是截取了最低的四位值。

10100101 11000100 00100101
& 00000000 00000000 00001111
----------------------------------
00000000 00000000 00000101    //高位全部歸零,只保留末四位

但這時候問題就來了,這樣就算我的散列值分佈再鬆散,要是隻取最後幾位的話,碰撞也會很嚴重。更要命的是如果散列本身做得不好,分佈上成等差數列的漏洞,恰好使最後幾個低位呈現規律性重複,就無比蛋疼。這時候“擾動函數”的價值就體現出來了,說到這裏大家應該猜出來了。看下面這個圖,



右位移16位,正好是32bit的一半,自己的高半區和低半區做異或,就是爲了混合原始哈希碼的高位和低位,以此來加大低位的隨機性。而且混合後的低位摻雜了高位的部分特徵,這樣高位的信息也被變相保留下來。最後我們來看一下Peter Lawley的一篇專欄文章《An introduction to optimising a hashing strategy》裏的一個實驗:他隨機選取了352個字符串,在他們散列值完全沒有衝突的前提下,對它們做低位掩碼,取數組下標。

結果顯示,當HashMap數組長度爲512的時候,也就是用掩碼取低9位的時候,在沒有擾動函數的情況下,發生了103次碰撞,接近30%。而在使用了擾動函數之後只有92次碰撞。碰撞減少了將近10%。看來擾動函數確實還是有功效的。但明顯Java 8覺得擾動做一次就夠了,做4次的話,多了可能邊際效用也不大,所謂爲了效率考慮就改成一次了。

3.3Java中的hashcode、equals、==
這裏插入一個小知識點,關於hashcode、equals、==,他們的區別,三者在日常中經常遇到,其中的概念對於像我一樣的初學者非常迷惑,經常將他們混淆
java中==、equals()、hashCode()都和對象的比較有關,在java中這三者各有什麼用處呢,即java中爲什麼需要設計這三種對象的比較方法呢?

1、關於==

==是容易理解的。java設計java就是要比較兩個對象是不是同一個對象。
對於引用變量而言,比較的時候兩個引用變量引用的是不是同一個對象,即比較的是兩個引用中存儲的對象地址是不是一樣的。
對於基本數據類型而言,比較的就是兩個數據是不是相等,沒什麼歧義。
由於對於基本數據類型而言,沒有方法,所以不存在equal()和hashCode()的問題,下面的討論都是針對引用類型而言的。

2、關於equals()
爲什麼java會設計equals()方法?

==比較的是兩個對象是否是同一個對象,這並不能滿足很多需求。有時候當兩個對象不==的時候,我們仍然會認爲兩者是“相等”的,比如對於String對象,當兩個對象的字符串序列是一直的,我們就認爲他們是“相等”的。對於這樣的需求,需要equals()來實現。對於有這種需求的對象的類,重寫其equals()方法便可,具體的“相等”邏輯可以根據需要自己定義。

需要注意的地方

Object中equals()的默認實現是比較兩個對象是不是==,即其和==的效果是相同的。java提供的某些類已經重寫了equals()方法。自己寫的類,如果需要實現自己的“相等”邏輯,需要重寫equals()方法。
   
3、關於hashCode()

爲什麼會設計hashCode()方法?

hashCode()方法返回的就是一個數值,我們稱之爲hashCode吧。從方法的名稱上就可以看出,其目的是生成一個hash碼。hash碼的主要用途就是在對對象進行散列的時候作爲key 輸入,據此很容易推斷出,我們需要每個對象的hash碼儘可能不同,這樣才能保證散列的存取性能。事實上,Object類提供的默認實現確實保證每個對象的hash碼不同(在對象的內存地址基礎上經過特定算法返回一個hash碼)。
分析到這個地方,看似沒什麼問題,三者的作用很清晰,好像他們之間也沒什麼關係。在java的規範上,hashCode()方法和equals()方法確實可以沒有關係。
但是!!!!!!!!有一個問題。
問題如下:
對於集合類HashSet、HashMap等和hash有關的類(以HashSet爲例),是通過hash算法來散列對象的。對HashSet而言,存入對象的流程爲:根據對象的hash碼,經過hash算法,找到對象應該存放的位置,如果該位置爲空,則將對象存入該位置;如果該位置不爲空,則使用equals()比較該位置的對象和將要入的對象,如果兩個相等,則不再插入,如果不相等,根據hash衝突解決算法將對象插入其他位置。而java規定對於HashSet判斷是不是重複對象就是通過equals() 方法來完成,這就需要在兩個對象equals()方法相等的時候,hash碼一定相等(即hashCode()返回的值相等)。
假設兩個對象equals()方法相等的時候,hash碼不相等,會出現equals()相等的兩個對象都插入了HashSet中,這時不允許的。從而我們有了一下的結論:

結論:對於equals()相等的兩個對象,其hashCode()返回的值一定相等

通過上面的分析,對於這個結論是沒有異議的。結合前面關於hash碼要儘可能不同的要求,現在變成了對於equals()相等的對象hash碼要一定相等,而對於equals()不同的對象要儘量做到hash碼不同。那麼怎麼才能保證這一點呢?
答案就是重寫hashCode()
首先,如何保證“對於equals()相等的對象hash碼要一定相等”。
equals()方法中對於對象的比較是通過比較對象中全部或者部分字段來完成的,這些字段集合記爲集合A,如果我們計算hash碼的時候,如果只是從集合A中選取部分字段或者全部字段來完成便可,因爲輸入相同,不管經過什麼算法,輸出一定相同(在方法中調用隨機函數?這屬於吃飽了撐的!)。如此設計便保證滿足了第一個要求。
其次,對於equals()不同的對象要儘量做到hash碼不同。
對於這一點的保證就是在設計一個好的算法,讓不同的輸入儘可能產生不同的輸出。
下面就詳細介紹一下如何設計這個算法。這個算法是有現成的參考的,算法的具體步驟就是:
[1]把某個非零常數值(一般取素數),例如17,保存在int變量result中;
[2]對於對象中每一個關鍵域f(指equals方法中考慮的每一個域):
  [2.1]boolean型,計算(f ? 0 : 1);

  [2.2]byte,char,short型,計算(int)f;

  [2.3]long型,計算(int) (f ^ (f>>>32));

  [2.4]float型,計算Float.floatToIntBits(afloat);

  [2.5]double型,計算Double.doubleToLongBits(adouble)得到一個long,再執行[2.3];

  [2.6]對象引用,遞歸調用它的hashCode方法;

  [2.7]數組域,對其中每個元素調用它的hashCode方法。

[3]將上面計算得到的散列碼保存到int變量c,然後執行 result=37*result+c;

[4]返回result。

其實其思路就是:先去一個基數,然後對於equals()中考慮的每一個域,先轉換成整數,再執行result=37*result+c;

3.4 如何讓數據在table數組中均勻分佈
3.4.1 均勻分佈的必要性

對於HashMap的table而言,數據分佈需要均勻(最好每項都只有一個元素,這樣就可以直接找到),不能太緊也不能太鬆,太緊會導致查詢速度慢,太鬆則浪費空間。計算hash值後,怎麼才能保證table元素分佈均與呢?我們會想到取模,但是由於取模的消耗較大,而HashMap是通過&運算符(按位與操作)來實現的:h & (length-1)。其實這裏與上面對於hash的介紹內容上有很大重合了,關於最初爲什麼要讓數組的長度爲合數,也就是2的n次方的答案,也在擾動函數中做了相關的解釋,這裏不再一一贅述。

3.4.2取模與取餘的區別

這裏也插入一個知識點,既然均勻分佈用到了取模運算,取模運算是很消耗資源的,所以我們使用了位運算&來解決,那麼關於取餘和取模有什麼關係又有什麼區別呢,其實這是一個純粹的數學問題,一般來說,在大多數情況下,取餘和取模在數值上是相同的,這樣的情況下,我們也無需去進行區分,但是他們的本質上是完全不同的。
首先,無論取餘還是取模,都是除數被除數還有商之間的關係,由於一個數字除以一個數字得到商,這種初等計算的大多數情況我們是不考慮商的結果的,無法整除時,就商到取餘數爲止,但是不僅可以去餘數,我們也可以取模數,這樣同樣能夠表示一個非整除的式子的結果。
先說一下定義,然後結合幾個簡單的例子說上面的話是什麼意思:
對於整數a,b來說,取模運算或者求餘運算的方法要分如下兩步:
1.求整數商:c=a/b
2.計算模或者餘數:r=a-(c*b)
求模運算和求餘運算在第一步不同
取餘運算在計算商值向0方向捨棄小數位
取模運算在計算商值向負無窮方向捨棄小數位
例如:4/(-3)約等於-1.3
在取餘運算時候商值向0方向捨棄小數位爲-1
在取模運算時商值向負無窮方向捨棄小數位爲-2

3.5addEntry方法

接下來看看計算了hash值,並用該hash值來求得哈希表中的索引值之後,如何把該key-value插入到該索引的鏈表中: 
首先addEntry方法:

void addEntry(int hash, K key, V value, int bucketIndex) {
//如果size大於極限容量,將要進行重建內部數據結構操作,之後的容量是原來的兩倍,並且重新設置hash值和hash值在table中的索引值
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
//真正創建Entry節點的操作
createEntry(hash, key, value, bucketIndex);
}

void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}

首先取得bucketIndex位置的Entry頭結點,並創建新節點,把該新節點插入到鏈表中的頭部,該新節點的next指針指向原來的頭結點 。

3.5.1鏈表的產生

系統總是將新的Entry對象添加到bucketIndex處。如果bucketIndex處已經有了對象,那麼新添加的Entry對象將指向原有的Entry對象,形成一條Entry鏈,
但是若bucketIndex處沒有Entry對象,也就是e==null,那麼新添加的Entry對象指向null,也就不會產生Entry鏈了

3.5.2擴容問題

隨着HashMap中元素的數量越來越多,發生碰撞的概率就越來越大,所產生的鏈表長度就會越來越長,這樣勢必會影響HashMap的速度,爲了保證HashMap的效率,系統必須要在某個臨界點進行擴容處理。該臨界點在當HashMap中元素的數量等於table數組長度*加載因子。但是擴容是一個非常耗時的過程,因爲它需要重新計算這些數據在新table數組中的位置並進行復制處理。所以如果我們已經預知HashMap中元素的個數,那麼預設元素的個數能夠有效的提高HashMap的性能。

擴容(resize)就是重新計算容量,向HashMap對象裏不停的添加元素,而HashMap對象內部的數組無法裝載更多的元素時,對象就需要擴大數組的長度,以便能裝入更多的元素。當然Java裏的數組是無法自動擴容的,方法是使用一個新的數組代替已有的容量小的數組,就像我們用一個小桶裝水,如果想裝更多的水,就得換大水桶。

void resize(int newCapacity) {   //傳入新的容量
     Entry[] oldTable = table;    //引用擴容前的Entry數組
     int oldCapacity = oldTable.length;         
     if (oldCapacity == MAXIMUM_CAPACITY) {  //擴容前的數組大小如果已經達到最大(2^30)了
         threshold = Integer.MAX_VALUE; //修改閾值爲int的最大值(2^31-1),這樣以後就不會擴容了
         return;
 }

Entry[] newTable = new Entry[newCapacity];  //初始化一個新的Entry數組
transfer(newTable);                         //!!將數據轉移到新的Entry數組裏
table = newTable;                           //HashMap的table屬性引用新的Entry數組
threshold = (int)(newCapacity * loadFactor);//修改閾值
}

這裏就是使用一個容量更大的數組來代替已有的容量小的數組,transfer()方法將原有Entry數組的元素拷貝到新的Entry數組裏。
void transfer(Entry[] newTable) {
Entry[] src = table;                   //src引用了舊的Entry數組
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) { //遍歷舊的Entry數組
Entry<K,V> e = src[j];             //取得舊Entry數組的每個元素
if (e != null) {
src[j] = null;//釋放舊Entry數組的對象引用(for循環後,舊的Entry數組不再引用任何對象)
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity); //!!重新計算每個元素在數組中的位置
e.next = newTable[i]; //標記[1]
newTable[i] = e;      //將元素放在數組上
e = next;             //訪問下一個Entry鏈上的元素
} while (e != null);
}
}
}

newTable[i]的引用賦給了e.next,也就是使用了單鏈表的頭插入方式,同一位置上新元素總會被放在鏈表的頭部位置;這樣先放在一個索引上的元素終會被放到Entry鏈的尾部(如果發生了hash衝突的話),這一點和Jdk1.8有區別,在(二)中將會詳解。在舊數組中同一條Entry鏈上的元素,通過重新計算索引位置後,有可能被放到了新數組的不同位置上。

下面舉個例子說明下擴容過程。假設了我們的hash算法就是簡單的用key mod 一下表的大小(也就是數組的長度)。其中的哈希桶數組table的size=2,
所以key = 3、7、5,put順序依次爲 5、7、3。在mod 2以後都衝突在table[1]這裏了。這裏假設負載因子 loadFactor=1,即當鍵值對的實際大小size 大於 table的實際大小時進行擴容。接下來的三個步驟是哈希桶數組 resize成4,然後所有的Entry重新rehash的過程。
3.6總結

3.6.1put方法的幾個關鍵步驟

  1.  傳入key和value,判斷key是否爲null,如果爲null,則調用putForNullKey,以null作爲key存儲到哈希表中; 
  2.  然後計算key的hash值,根據hash值搜索在哈希表table中的索引位置,若當前索引位置不爲null,則對該位置的Entry鏈表進行遍歷,如果鏈中存在該key,則用傳入的value覆蓋掉舊的value,同時把舊的value返回,結束; 
  3.  否則調用addEntry,用key-value創建一個新的節點,並把該節點插入到該索引對應的鏈表的頭部
  4. HashMap的讀取實現get方法
public V get(Object key) {
        //如果key爲null,求null鍵
        if (key == null)
            return getForNullKey();
        // 用該key求得entry
        Entry<K,V> entry = getEntry(key);


        return null == entry ? null : entry.getValue();
    }


    final Entry<K,V> getEntry(Object key) {
        int hash = (key == null) ? 0 : hash(key);
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }

讀取的步驟比較簡單,調用hash(key)求得key的hash值,然後調用indexFor(hash)求得hash值對應的table的索引位置,然後遍歷索引位置的鏈表,如果存在key,則把key對應的Entry返回,否則返回null

5HashMap鍵的遍歷

private abstract class HashIterator<E> implements Iterator<E> {
        Entry<K,V> next;        // next entry to return
        int expectedModCount;   // For fast-fail
        int index;              // current slot
        Entry<K,V> current;     // current entry


        //當調用keySet().iterator()時,調用此代碼
        HashIterator() {
            expectedModCount = modCount;
            if (size > 0) { // advance to first entry
                Entry[] t = table;
                //從哈希表數組從上到下,查找第一個不爲null的節點,並把next引用指向該節點
                while (index < t.length && (next = t[index++]) == null)
                    ;
            }
        }


        public final boolean hasNext() {
            return next != null;
        }


        //當調用next時,會調用此代碼
        final Entry<K,V> nextEntry() {
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            Entry<K,V> e = next;
            if (e == null)
                throw new NoSuchElementException();


            //如果當前節點的下一個節點爲null,從節點處罰往下查找哈希表,找到第一個不爲null的節點
            if ((next = e.next) == null) {
                Entry[] t = table;
                while (index < t.length && (next = t[index++]) == null)
                    ;
            }
            current = e;
            return e;
        }


        public void remove() {
            if (current == null)
                throw new IllegalStateException();
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            Object k = current.key;
            current = null;
            HashMap.this.removeEntryForKey(k);
            expectedModCount = modCount;
        }
    }

從這裏可以看出,HashMap遍歷時,按哈希表的每一個索引的鏈表從上往下遍歷,由於HashMap的存儲規則,最晚添加的節點都有可能在第一個索引的鏈表中,這就造成了HashMap的遍歷時無序的。

關於Java8中對HashMap的優化以及改變,請看《深入理解HashMap(二)》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章