JAVA 7和JAVA 8裏HashMap的工作原理

這篇文章主要講解了JAVA 7和JAVA 8裏HashMap的工作原理。

ps:譯文具體沒有說明是JDK8點幾。所以博主會在JDK1.8.0_77的源碼中重新粘貼在代碼塊中

大多數Java程序員都使用過或者正在使用Map,特別是HashMap。HashMap雖然簡單但卻能夠非常高效地存取數據。可是有多少人知道HashMap的內部原理呢?爲了深入理解HashMap,幾天前,我讀了java.util.HashMap(Java7和Java8)的大部分源碼。在這篇文章中,我將解釋HashMap的實現和Java8中HashMap的新特性,並對性能、內存方面以及在使用HashMap時的注意點進行說明。
Contents
1.內部存儲
2.自動調整
3.線程安全
4.鍵的不可變性
5.Java8 優化
6.內存開銷
    6.1  JAVA 7
    6.2 JAVA 8
7.性能問題
    7.1 “傾斜”的HashMap vs 平衡HashMap
    7.2 自動調整的開銷
8.總結

內部存儲
HashMap類實現了Map<K,V>接口。該接口的主要方法如下:
  • V put(K key,V value)
  • V get(Object key)
  • V remove(Object key)
  • Boolean containsKey(Object key)
HashMap使用內部類來存儲數據:Entry<K,V>。一個Entry是一個簡單的鍵值對加上兩個附加的數據項:
  • 一個指向另一個Entry的引用,因此HashMap能像單鏈表一樣存儲多個Entry.
  • 鍵的哈希值,將該值存儲起來避免每次HashMap需要時的重新計算.
以下是JAVA7裏Entry類的部分實現:

	//jdk1.7.0_80
    static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;
        int hash;
		…
}

HashMap將數據存儲在多個Entry單鏈表中(稱作bucket或者bins)。HashMap內部有一個內部數組,保存每一個Entry鏈表的表頭,數組的默認大小是16.


這張圖表明瞭HashMap的內存存儲結構,Entry鏈表可以容納null值。每個Entry能夠指向另一個Entry以形成一個單鏈表。
所有哈希值相同的鍵的鍵值對都被存在同一個Entry鏈表中(bucket)。哈希值不同的鍵的鍵值對也可以出現在同一個鏈表中。

當用戶調用put(K key,V value)或者get(Object key)方法的時候,該方法會計算出對應Entry所在bucket在內部數組中的下標。然後,該方法會繼續遍歷找到的bucket,以找出含有相同鍵的Entry對象(通過equal()方法)。

在調用get()的時候,方法返回找到的Entry的value值(如果對應的Entry存在的話)
在調用put(K key,V value)的時候,如果key對應的Entry存在,該方法就將其value值進行替換,否則就在鏈表的頭部創建一個新的Entry(以傳入的key和value爲參數)。


bucket(Entry單鏈表)的下標通過以下三個步驟產生:
  • 首先獲取鍵的哈希值
  • 對哈希值進行再一次哈希操作(rehash)以避免鍵本身的hashCode()方法導致將所有數據存入了相同的bucket,即得到的內部數組下標相同
  • 使用再一次哈希操作得到的哈希值和數組的長度減一進行“”操作。這個操作保證了得到的數組下標不會大於數組的大小。

JAVA7和JAVA8裏對數組下標的處理的代碼如下:

	// the "rehash" function in JAVA 7 that takes the hashcode of the key
	static int hash(int h) {
	    h ^= (h >>> 20) ^ (h >>> 12);
	    return h ^ (h >>> 7) ^ (h >>> 4);
	}
	
	// the "rehash" function in JAVA 8 that directly takes the key
	static final int hash(Object key) {
	    int h;
	    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
	    }
	    
	// the function that returns the index from the rehashed hash
	static int indexFor(int h, int length) {
	    return h & (length-1);
	}

爲了提高運行效率,內部數組的大小需要是2的次方,原因如下:
假設數組大小是17,長度減一爲16,。16的二進制表示是0....010000,所有對於任意的哈希值H,對應的內部數組下標爲”H and 16“,其結果就只能是0或者16,。這意味着大小爲17的內部數組會被使用到的就只有2個bucket:下標爲0的和下標爲16的,十分低效。

但,如果取大小爲2的次方,如16,那麼對於任意哈希值H,數組下標就是"H and 15"。因爲15的二進制表示是0....001111,所以能夠得到0-15之間的任意一個值,因此大小爲16的數組能夠被充分使用。例子如下:

  • 如果 H = 952 ,它的二進制表示是 0..01110111000, 對應的數組下標爲 0…01000 = 8
  • 如果 H = 1576,它的二進制表示是 0..011000101000對應的數組下標爲 0…01000 = 8
  • 如果 H = 12356146, 它的二進制表示是 0..0101111001000101000110010對應的數組下標爲 0…00010 = 2
  • 如果 H = 59843, 它的二進制表示是 0..01110100111000011對應的數組下標爲 0…00011 = 3
這就是爲什麼要將數組的大小設爲2的次方。這個過程對於開發者是透明的:如果爲HashMap指定了大小爲37,HashMap將自動選擇大於37的最小的2的次方(64)作爲內部數組的大小。

自動調整
獲取了對應的內部數組下標之後,被調用的方法(get、put或者remove)會訪問/遍歷下標所在的鏈表以查看是否有對應給定鍵的Entry存在。這個過程會造成性能問題,因爲被調用的方法需要對整個鏈表進行遍歷來找到對應的Entry。假設內部數組爲默認大小(16),並且你需要在HashMap中存儲2百萬個數據。在最好的情況下,每個鏈表會有12萬5000個Entry對象(2百萬/16)。因此,每次調用get()、put()、remove()將會導致12萬5000次的遍歷。爲了防止這種情況,HashMap內部有一種機制能夠增大內部數組的大小以使得鏈表的長度維持在一個較短的範圍。

當創建HashMap對象的時候,我們能夠指定一個初始大小(initial size)和一個裝載因子(loadFactor):

public HashMap(int initialCapacity, float loadFactor)

如果不指定這兩個參數,默認的初始容量爲16,裝載因子爲0.75.初始容量代表了內部數組的大小。


每一個調用put(...)方法加入一個新的鍵值對時,該方法會先檢查是否需要增大內部數組的大小。爲此,HashMap需要存儲兩個數據項:
  • HashMap的大小size:即HashMap存儲的所有Entry的數目。每次HashMap添加或者刪除一個Entry,就更新該值。
  • 閾值threshold:大小等於內部數組長度*裝載因子,並且在每次自動調整後更新。
在加入新的Entry之前,put(...)方法會先檢查size是否大於threshold,如果是的話,它就以原來大小的2倍新建一個內部數組。因爲數組的大小改變了,定位數組下標的方法(返回“hash(key) AND (數組長度-1))一樣也改變了。所以,新創建的數組會比原來擁有多一倍的bucket(單鏈表),然後將原有的Entry重新分佈到所有的bucket(老的bucket和新建的bucket)中去。

自動調整的目的在於減小鏈表的長度,以使得消耗在put(),remove(),get()方法上的時間保持在低水平。鍵的哈希值相同的所有Entry在自動調整後都會被分佈到同一個bucket中。但是,在調整前存在於同一個bucket的2個擁有不同哈希值的鍵的Entry在調整後不一定仍在同一個bucket中。

這張圖片演示了自動調整前後內部數組的狀況。在調整之前,要獲取Entry E,需要遍歷5個Entry對象;而在調整後,相同的get()操作只需要遍歷2個Entry對象,比之前的效率提升了2倍。

注意:HashMap只能夠增大內部數組的大小,而沒有方法能夠減小它。

線程安全

對於瞭解HashMap的人,應該都知道它是線程不安全的,但,這是爲什麼呢?舉個例子,假設有一個寫線程只向HashMap裏放數據,和一個讀線程從HashMap中讀出數據,想一想爲什麼這樣子並不能正常運行?

因爲在自動調整的過程中,如果一個線程嘗試放或者讀取一個對象,HashMap可能仍會使用舊的數組下標,因此將不會找到Entry所在的新的bucket。

最壞的情況是當2個線程同時向Map放數據,並且這兩個put()都引起Map的自動調整。因爲兩個線程同時對Map裏的鏈表進行修改,Map可能最終在某個鏈表上出現迴路。之後如果我們嘗試獲取該鏈表中的數據,get()方法將會一直運行下去。

HashTable的實現是線程安全的,因此避免了以上的狀況。但,因爲所有的CRUD(新建、獲取、更新、刪除)都需要進行同步,所以效率很低。例如,如果線程1調用了get(key1),線程2調用了get(key2),同時線程3調用了get(key3),在同一時間點只有一個線程能夠獲取到需要的數據,儘管三個線程本來能夠同時訪問對應的數據。(譯者注:不存在同步問題)

線程安全的HashMap更高效的實現出現在JAVA:也就是ConcurrentHashMap。被同步的只有bucket,所以多個線程在不訪問同一個bucket或者引起自動調整時能夠同時進行get()、remove()、put()。在多線程應用中,最好使用ConcurrentHashMap

鍵不可變性
爲什麼字符串和整數適合當做Map的鍵呢?主要是因爲它們是不可變的!如果你決定要創建自己的鍵類(Key class)並且不將它設爲不可變的,你可能會丟失存儲在HashMap裏的數據。

看下列例子:
  • 我們有一個鍵,它的值是“1”
  • 使用該鍵向HashMap中存入一個Object對象
  • HashMap爲該鍵的hashcode重新進行哈希運算(從"1"產生哈希值)
  • 將重新計算得到的哈希值存入Map的Entry中
  • 修改鍵的值爲"2"
  • 鍵的hashcode被修改了,因此對該鍵的哈希值重新進行哈希操作的結果也改變了,但HashMap並不知道這一點(因爲舊的哈希值已經被存儲)
  • 使用修改過的鍵獲取數據
  • Map對鍵(“2”)計算出最終的哈希值,確定對應的bucekt:
    • 可能性1:因爲鍵被修改了,所以Map會從錯誤的bucket尋找Entry,並最終失敗;
    • 可能性2:幸運地,被修改的鍵和舊鍵對應到了相同的bucket,於是Map遍歷該bucket尋找對應的Entry.但爲了找到對應的鍵,Map會對新鍵的哈希值和Entry原先存儲的哈希值調用equals()進行比較。因爲新鍵的哈希值與舊鍵的哈希值不同,所以Map一樣會查找失敗。
以下是一個具體的例子。我向Map裏放了兩個鍵值對,修改了第一個的鍵,然後嘗試獲取兩個鍵值對的值,只有第二個鍵值對的值能被成功獲取,第一個的值“丟失”在了HashMap裏:

public class MutableKeyTest {

	public static void main(String[] args) {

		class MyKey {
			Integer i;

			public void setI(Integer i) {
				this.i = i;
			}

			public MyKey(Integer i) {
				this.i = i;
			}

			@Override
			public int hashCode() {
				return i;
			}

			@Override
			public boolean equals(Object obj) {
				if (obj instanceof MyKey) {
					return i.equals(((MyKey) obj).i);
				} else
					return false;
			}

		}

		Map<MyKey, String> myMap = new HashMap<>();
		MyKey key1 = new MyKey(1);
		MyKey key2 = new MyKey(2);

		myMap.put(key1, "test " + 1);
		myMap.put(key2, "test " + 2);

		// modifying key1
		key1.setI(3);

		String test1 = myMap.get(key1);
		String test2 = myMap.get(key2);

		System.out.println("test1= " + test1 + " test2=" + test2);

	}

}

輸出結果爲:“test1= null test2=test 2”。正如預期的一樣,Map不能夠對修改過的鍵1返回原先的值。


JAVA 8 優化
HashMap的內部實現在JAVA8中有很大的變化。實際上,在JAVA7裏這個類只有1000多行代碼,而在JAVA8裏卻有2000多行。除了Entry鏈表,我以上所講的大部分在JAVA8裏都是一樣的。在JAVA8裏我們仍然需要一個數組,但現在這個數組被用來存儲Node對象,它含有與Entry相同的信息,因此,實際上也是一個鏈表。
以下是Node類在JAVA8裏的部分實現代碼:

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

那麼,JAVA8裏的HashMap和7裏的有什麼大不一樣的嗎?實際上,Node類擁有一個子類:TreeNode。TreeNode是紅黑樹的數據結構實現,它存儲了更多的信息,因此能夠在O(log(n))的時間複雜度之內完成增加、刪除或者獲取元素的操作。

下面是TreeNode裏存儲信息的詳細列表:

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
	final int hash; // inherited from Node<K,V>
	final K key; // inherited from Node<K,V>
	V value; // inherited from Node<K,V>
	Node<K,V> next; // inherited from Node<K,V>
	Entry<K,V> before, after;// inherited from LinkedHashMap.Entry<K,V>
	TreeNode<K,V> parent;
	TreeNode<K,V> left;
	TreeNode<K,V> right;
	TreeNode<K,V> prev;
	boolean red;
	...

紅黑樹是自平衡的二叉搜索樹。無論是向其增加還是刪除元素,其內部結構都能保證它的深度總是log(n)。使用紅黑樹的主要好處在於但大量數據被放在了同一個bucket中的時候,對樹的搜索總能在對數時間內完成而不是使用鏈表時的O(n)複雜度。

就像見到的一樣,紅黑樹會比鏈表消耗更多空間。(我們將在之後討論這個問題)

通過繼承機制,HashMap的內部數組能夠同時存放Node對象(作爲鏈表)和TreeNode對象(作爲紅黑樹)。Oracle決定按以下規則使用這兩種數據結構:
  如果一個bucket裏有多於8個元素(即鍵值對,譯者注),就將這個bucket對應的鏈表轉化爲一棵紅黑樹
 如果一個bucket裏的元素少於6個,就將這個bucket對應的紅黑樹轉化爲鏈表


上面是JAVA8的HashMap如何同時使用紅黑樹(bucket 0)和鏈表(bucket 1,2,3)存儲數據。Bucket 0是一棵紅黑樹,因爲它的元素數目大於8。

內存開銷
JAVA 7
HashMap的使用代價取決於它的內存開銷。在JAVA7裏,HashMap將鍵值對“包裝”在Entry對象中。一個Entry對象含有:
  • 一個指向下一個Entry對象的引用
  • 已計算出的哈希值(整數)
  • 指向鍵的引用
  • 指向值的引用
除此之外,JAVA7還要使用一個內部數組來存儲Entry。假設HashMap裏有N個元素,它的數組大小爲默認值CAPACITY,其內存消耗爲:
sizeOf(integer)* N + sizeOf(reference)* (3*N+C)
其中:
  • 整數大小爲4字節
  • 引用的大小取決於虛擬機/操作系統/處理器,大多數情況下爲4字節
則內存消耗爲:16 * N + 4 * CAPACITY個字節。
提醒:在HashMap的自動調整後,內部數組的大小等於大於N的最小的2的次方。
注意:從JAVA7開始,HashMap使用了lazy init。所以即使實例化了一個HashMap,它的內部數組(佔用4 * CAPACITY個字節)直到第一次調用了put()方法之前都不會被初始化。

JAVA8
在JAVA8的實現中,計算內存開銷比較麻煩,因爲一個Node對象可以擁有和Entry一樣多想信息,或者比Entry多出6個引用和一個布爾值(當是紅黑樹節點時)。

如果所有元素都是Node對象,內存開銷就和JAVA7的一樣。
如果所有元素都是TreeNode對象,內存開銷爲:
N * sizeOf(integer) + N * sizeOf(boolean) + sizeOf(reference)* (9*N+CAPACITY )
在大多數虛擬機上,該值等於44 * N + 4 * CAPACITY個字節。

性能問題
“傾斜”的HashMap vs 平衡的HashMap
在最好的情況下,get()、set()方法的時間複雜度是O(1)。但是,如果不考慮鍵的哈希方法,就有可能出現put()、get()的性能問題。put()、get()的高效率取決於數據如何分佈到內部數組的不同下標(bucket)。如果鍵的哈希方法有問題的話,就會使得分佈的過程十分不平均(無論內部數組有多大)。此時所有的put()、get()方法都會變得非常慢,因爲它們都要對整個鏈表進行遍歷。在最壞的情況下(大多數的數據都分佈到了同一個bucket),時間複雜度就會變成O(n)。
以下是一個例子。第一張圖片是一個“傾斜”的HashMap,第二張圖片是一個平衡的HashMap。

在上面這個HashMap中,在bucket 0裏的get、put操作會十分費時。獲取Entry K需要查找6次。
在平衡的HashMap中,獲取Entry K只需要查找3次。這兩個HashMap擁有一樣多的元素、一樣大小的內部數組。唯一的不同在於用來分配Entry到一個bucket的哈希方法。

下面這個例子中,我實現了自己的hash方法,它會將所有數據都分配到同一個bucket,然後我向HashMap中加入2百萬個數據。
public class Test {

	public static void main(String[] args) {

		class MyKey {
			Integer i;
			public MyKey(Integer i){
				this.i =i;
			}

			@Override
			public int hashCode() {
				return 1;
			}

			@Override
			public boolean equals(Object obj) {
			…
			}

		}
		Date begin = new Date();
		Map <MyKey,String> myMap= new HashMap<>(2_500_000,1);
		for (int i=0;i<2_000_000;i++){
			myMap.put( new MyKey(i), "test "+i);
		}

		Date end = new Date();
		System.out.println("Duration (ms) "+ (end.getTime()-begin.getTime()));
	}
}

在配置爲i5-2500k @ 3.6Ghz的主機上,這段代碼在JDK8上的運行時間超過了45分鐘。(我在45分鐘後將進程關閉了)。


現在,改爲使用下面的hash方法:

	@Override
	public int hashCode() {
		int key = 2097152-1;
		return key+2097152*i;
}

它僅耗費了46秒!這個hash方法比之前的能更好的對元素進行分配所以put()的調用要快得多。


如果我使用下面這個更好的hash方法:

 @Override
 public int hashCode() {
 	return i;
 }

現在僅僅需要2秒


我希望你能夠明白hash方法的重要性。如果我們在JDK7上運行以上的測試,第一個和第二個的結果會更差(因爲在JAVA7裏put()方法的時間複雜度是O(n),而JAVA8裏是O(log(n)))。

在使用HashMap的時候,我們需要爲鍵找到一個好的hash方法,使得它能夠將鍵分配到大部分的bucket上去。因此,需要避免哈希衝突。String對象適合作爲鍵因爲它有很好的hash方法。整數也一樣因爲它們的hashcode就是它們的值。

自動調整的代價
如果需要存儲大量的數據,我們應該在創建HashMap的時候指定一個接近預期的初始容量。

如果不指定的話,Map就會使用默認的16作爲初始大小和默認的裝載因子0.75。前11次的put()調用會很快的執行完,但在第12(16*0.75)次的put()時,一個新的內部數組會被創建,其大小爲32.第13到23次put()會很快執行完,但是第24(32*0.75)次(原文爲23th,譯者注)時又會進行一次自動調整。以此類推,自動調整將在第48、96、192....次調用put()的時候進行。如果數據量較小的話,重建整個數組會很快。但是在數據量很大的情況下,可能要耗費數秒到數分鐘的時間。而通過設定一個初始的容量大小,就能否避免這些耗時的操作

但這個方法也有其缺點:如果將數組大小設定爲一個很大的數如2^28,而你只會用到2^26個bucket,那就會有大量的內存浪費掉。

總結
對於簡單的使用,我們並不需要了解HashMap內部是如何工作的,畢竟我們不會察覺到O(1)O(n)O(log(n))複雜度的操作間的區別。但瞭解最常使用的數據結構之一的底層原理總是有好處的。另外,對於JAVA程序員,這也是在面試時經常被問到的問題。

在數據量很大的時候,瞭解HashMap是如何工作的以及hash方法的重要性是非常重要的。

希望讀完這篇文章以後,你們能夠對HashMap有更深的瞭解。

轉發:https://blog.csdn.net/chowforcsdn/article/details/51393375

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