你們都知道,HashMap的是key-value(鍵值對)組成的,這個key既能夠是基本數據類型對象,如Integer,Float,同時也能夠是本身編寫的對象,那麼問題來了,這個做爲key的對象是否可以改變呢?或者說key可否是一個可變的對象?若是能夠該HashMap會怎麼樣?html
可變對象java
可變對象是指建立後自身狀態能改變的對象。換句話說,可變對象是該對象在建立後它的哈希值(由類的hashCode()方法能夠得出哈希值)可能被改變。面試
爲了能直觀的看出哈希值的改變,下面編寫了一個類,同時重寫了該類的hashCode()方法和它的equals()方法【至於爲何要重寫equals方法能夠看博客:http://www.cnblogs.com/0201zcr/p/4769108.html】,在查找和添加(put方法)的時候都會用到equals方法。算法
在下面的代碼中,對象MutableKey的鍵在建立時變量 i=10 j=20,哈希值是1291。shell
而後咱們改變實例的變量值,該對象的鍵 i 和 j 從10和20分別改變成30和40。如今Key的哈希值已經變成1931。數組
顯然,這個對象的鍵在建立後發生了改變。因此類MutableKey是可變的。安全
讓咱們看看下面的示例代碼:數據結構
public class MutableKey { private int i; private int j; public MutableKey(int i, int j) { this.i = i; this.j = j; } public final int getI() { return i; } public final void setI(int i) { this.i = i; } public final int getJ() { return j; } public final void setJ(int j) { this.j = j; } @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + i; result = prime * result + j; return result; } @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (obj == null) { return false; } if (!(obj instanceof MutableKey)) { return false; } MutableKey other = (MutableKey) obj; if (i != other.i) { return false; } if (j != other.j) { return false; } return true; } }
測試:多線程
public class MutableDemo { public static void main(String[] args) { // Object created MutableKey key = new MutableKey(10, 20); System.out.println("Hash code: " + key.hashCode()); // Object State is changed after object creation. key.setI(30); key.setJ(40); System.out.println("Hash code: " + key.hashCode()); } }
結果:ide
Hash code: 1291
Hash code: 1931
只要MutableKey 對象的成員變量i或者j改變了,那麼該對象的哈希值改變了,因此該對象是一個可變的對象。
HashMap如何存儲鍵值對
HashMap底層是使用Entry對象數組存儲的,而Entry是一個單項的鏈表。當調用一個put()方法將一個鍵值對添加進來是,先使用hash()函數獲取該對象的hash值,而後調用indexFor方法查找到該對象在數組中應該存儲的下標,假如該位置爲空,就將value值插入,若是該下標出不爲空,則要遍歷該下標上面的對象,使用equals方法進行判斷,若是遇到equals()方法返回真的則進行替換,不然將其插入,源碼詳解可看:http://www.cnblogs.com/0201zcr/p/4769108.html。
查找時只須要查詢經過key值獲取獲取hash值,而後找到其下標,遍歷該下標下面的Entry對象便可查找到value。【具體看下面源碼及其解釋】
若是HashMap Key的哈希值在存儲鍵值對後發生改變,Map可能再也查找不到這個Entry了。
public V get(Object key) { // 若是 key 是 null,調用 getForNullKey 取出對應的 value if (key == null) return getForNullKey(); // 根據該 key 的 hashCode 值計算它的 hash 碼 int hash = hash(key.hashCode()); // 直接取出 table 數組中指定索引處的值, for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; // 搜索該 Entry 鏈的下一個 Entr e = e.next) // ① { Object k; // 若是該 Entry 的 key 與被搜索 key 相同 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) return e.value; } return null; }
上面是HashMap的get()方法源碼,經過上面咱們能夠知道,若是 HashMap 的每一個 bucket 裏只有一個 Entry 時,HashMap 能夠根據索引、快速地取出該 bucket 裏的 Entry;在發生「Hash 衝突」的狀況下,單個 bucket 裏存儲的不是一個 Entry,而是一個 Entry 鏈,系統只能必須按順序遍歷每一個 Entry,直到找到想搜索的 Entry 爲止——若是剛好要搜索的 Entry 位於該 Entry 鏈的最末端(該 Entry 是最先放入該 bucket 中),那系統必須循環到最後才能找到該元素。
同時咱們也看到,判斷是否找到該對象,咱們還須要判斷他的哈希值是否相同,假如哈希值不相同,根本就找不到咱們要找的值。
若是Key對象是可變的,那麼Key的哈希值就可能改變。在HashMap中可變對象做爲Key會形成數據丟失。
下面的例子將會向你展現HashMap中有可變對象做爲Key帶來的問題。
import java.util.HashMap; import java.util.Map; public class MutableDemo1 { public static void main(String[] args) { // HashMap Map<MutableKey, String> map = new HashMap<>(); // Object created MutableKey key = new MutableKey(10, 20); // Insert entry. map.put(key, "Robin"); // This line will print 'Robin' System.out.println(map.get(key)); // Object State is changed after object creation. // i.e. Object hash code will be changed. key.setI(30); // This line will print null as Map would be unable to retrieve the // entry. System.out.println(map.get(key)); } }
輸出:
Robin null
如何解決
在HashMap中使用不可變對象。在HashMap中,使用String、Integer等不可變類型用做Key是很是明智的。
咱們也能定義屬於本身的不可變類。
若是可變對象在HashMap中被用做鍵,那就要當心在改變對象狀態的時候,不要改變它的哈希值了。咱們只須要保證成員變量的改變能保證該對象的哈希值不變便可。
在下面的Employee示例類中,哈希值是用實例變量id來計算的。一旦Employee的對象被建立,id的值就不能再改變。只有name能夠改變,但name不能用來計算哈希值。因此,一旦Employee對象被建立,它的哈希值不會改變。因此Employee在HashMap中用做Key是安全的。
import java.util.HashMap; import java.util.Map; public class MutableSafeKeyDemo { public static void main(String[] args) { Employee emp = new Employee(2); emp.setName("Robin"); // Put object in HashMap. Map<Employee, String> map = new HashMap<>(); map.put(emp, "Showbasky"); System.out.println(map.get(emp)); // Change Employee name. Change in 'name' has no effect // on hash code. emp.setName("Lily"); System.out.println(map.get(emp)); } } class Employee { // It is specified while object creation. // Cannot be changed once object is created. No setter for this field. private int id; private String name; public Employee(final int id) { this.id = id; } public final String getName() { return name; } public final void setName(final String name) { this.name = name; } public int getId() { return id; } // Hash code depends only on 'id' which cannot be // changed once object is created. So hash code will not change // on object's state change @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + id; return result; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (getClass() != obj.getClass()) return false; Employee other = (Employee) obj; if (id != other.id) return false; return true; } }
輸出
Showbasky Showbasky
致謝:感謝您的耐心閱讀!
本文翻譯自 Coding Geek, 原文地址。英文水平有限,有些地方翻譯得不太精確
絕大多數Java開發者都在使用Map類,尤爲是HashMap。HashMap是一種簡單易用且強大的存取數據的方法。可是,有多少人知道HashMap內部是如何工做的?幾天前,爲了對這個基本的數據結構有深刻的瞭解,我閱讀大量的HashMap源碼(開始是Java7,而後是Java8)。在這篇文章裏,我會解釋HashMap的實現,介紹Java8的新實現,聊一聊性能,內存,還有使用HashMap時已知的一些問題。
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<k,v>
的引用,這樣HashMap能夠像單獨的鏈表同樣存儲數據hash
值,表明了key的哈希值,避免了HashMap每次須要的時候再來計算下面是Java7裏Entry的部分實現:
static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry<K,V> next; int hash; … }
HashMap存儲數據到多個單獨的entry鏈表裏,全部的鏈表都登記到一個Entry數組裏(Entry<K,V>[] array
),而且這個內部數組默認容量是16。
下面的圖片展現了一個HashMap實例的內部存儲,一個可爲null的Entry數組,每個Entry均可以連接到另外一個Entry來造成一個鏈表:
全部具備相同哈希值的key都會放到同一個鏈表裏,具備不一樣哈希值的key最終也有可能在同一個鏈表裏。
當調用 put(K key, V value)
或者get(Object key)
這些方法時,會先計算這個Entry應該存放的鏈表在內部數組中的索引(index),而後方法會迭代整個鏈表來尋找具備相同key的Entry(使用key的 equals()
方法)
get()
方法,會返回這個Entry關聯的value值(若是Entry存在)put(K key, V value)
方法,若是Entry存在則重置value值,若是不存在,則以key,value參數構造一個Entry並插入到鏈表的頭部。
獲取鏈表在數組內的索引經過三個步驟肯定:
下面是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(size-1),16的二進制表示是 0…010000
,那麼對於任何哈希值H經過位運算H AND 16
獲得的索引就只會是16或者0,這意味着17大小的鏈表數組只會使用到兩個:索引爲0的和索引爲16的,很是浪費。
可是,若是你取2的指數大小例如16,位運算是 H AND 15
,15的二進制表示是 0…001111
, 那麼取索引的運算就會輸出0~15之間的值,大小16的數據就能徹底使用到。舉例:
H = 952
二進制表示爲 0..0111011 1000, 相關的索引就是 0…01000 = 8
H = 1576
二進制表示爲 0..01100010 1000, 相關的索引就是 0…01000 = 8
H = 12356146
二進制表示爲 010111100100010100011 0010, 相關的索引就是 0…00010 = 2
H = 59843
二進制表示爲 0111010011100 0011, 相關的索引就是 0…00011 = 3
這就是爲何數組的大小必須是2的指數大小,這個機制對開發人員是透明的,若是選擇了一個37大小的HashMap,那麼Map會自動選擇37以後的一個2的指數大小(64)來作爲內部數組的容量。
咱們獲取到索引以後,函數(put,get或者remove) 訪問/迭代 關聯的鏈表,檢查是否有指定key對應的Entry。 不作改動的話,這個機制會帶來性能問題,由於這個函數會遍歷整個鏈表來檢查Entry是否存在。
想象一下若是內部數組大小是初始值16,咱們有兩百萬條數據須要存儲,最好的狀況下, 每一個鏈表裏平均有 125 000個數據(2000000/16).所以,每一個get(),remove(),put()
會致使125 000個迭代或者操做。爲了不出現這種狀況,HashMap會自動調整它的內部數組大小來保持每一個鏈表儘量的短。
當你建立一個HashMap時,你能夠指定一個初始化大小和一個載入因數:
public HashMap(int initialCapacity, float loadFactor)
若是不指定參數,缺省的initialCapacity
是16,loadFactor
是0.75,initialCapacity
即表明了Map內部數組的大小。
每次當你調用put()
方法加入一個新的Entry時,這個方法會檢測是否須要增長內部數組大小,所以map存儲了兩個數據:
添加一個新Entry時,put函數會檢查 map的大小 是否大於閾值 ,若是大於,則會建立一個雙倍大小的數組,當新數組的大小改變,索引計算函數(返回 哈希值 & (數組大小-1) 的位運算)也會跟着改變。所以,數組的從新調整新建了兩倍數量的鏈表,而且 從新分發現有的Entry到這些數組內(注:原文括號有下面一句補充,暫時不明白是什麼意思。看HashMap的源代碼,是全部的數據分發到新的數組內,舊的直接棄用)
(the old ones and the newly created).
自動調整的目的是減小鏈表的長度從而減少 put(),remove(),get()
等函數的時間開銷,全部具備相同哈希值的Entry在從新調整大小後還會在同一個鏈表內,原來在同一個鏈表內具備不一樣哈希值的Entry則有可能不在同一個鏈表內了。
上面這個圖展現了一個HashMap自動調整先後的狀況,在調整前,爲了拿到Entry E,必需要迭代5次,調整後,只須要兩次。速度快了兩倍!
注意:HashMap只會增長內部數組的大小,沒有提供方法變小。
若是你已經瞭解過HashMap,你知道它不是線程安全的,可是有沒有想過爲何?
想象一下這種場景:你有一個寫線程只往Map裏寫新數據,還有一個讀線程只往裏讀數據,爲何不能很好的運做?
由於在從新調整內部數組大小的時候,若是線程正在寫或者取對象,Map可能會使用調整前的索引,這樣就找不到調整後的Entry所在的位置了。
最壞的狀況是:兩個線程同時往裏面放數據,同時調用了調整內部數組大小的方法。當兩個線程都在修改鏈表時,Map其中的某個鏈表可能會陷入一個內部循環,若是你試圖在這個鏈表裏取數據時,可能會永遠取不到值。
HashTable 爲了不這種狀況,作了線程安全的實現。可是,全部的CRUD方法都是 同步阻塞的,因此會很慢。例如,線程1調用get(key1)
,線程2調用get(key2)
,線程3調用get(key3)
,同一時間只會有一個線程能拿到值,即便他們原本能夠同時獲取這三個值。
其實從Java5開始就有一個更高效的線程安全的HashMap的實現了:ConcurrentHashMap。只有鏈表是同步阻塞的,所以多線程能夠同時get,put,或者remove數據,只要沒有訪問同一個鏈表或者從新調整內部數組大小就行。在多線程應用裏,使用這種實現顯然會更好。
爲何字符串和整數是HashMap的Key的一種很好的實現呢? 大可能是由於他們的不變性。若是你選擇本身新建一個Key類而且不保證它的不變性的話,在HashMap裏面可能就會丟失數據,讓咱們來看下面一種使用狀況:
這裏有一個具體的例子,我存了兩個鍵值對到Map裏,我修改了第一個key而且試圖拿出這兩個值,只有第二個值有返回,第一個值已經丟失在Map裏:
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用改變後的key1找不回第一個字符串。
Java8裏,HashMap的內部表示已經改變了不少了。的確,Java7裏HashMap的實現有1K行代碼,而Java8裏有2K。我前面所說的大部分都是真的,除了Entry鏈表。在Java8裏,仍然存在一個內部數組不過裏面存儲的都是節點(Node),可是節點包含的信息和Entry徹底同樣,由於也能夠看作鏈表,下面是Java8裏節點實現的部分代碼:
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next;
那麼對比Java7最大的變化是什麼呢?節點(Nodes)能夠被樹節點(TreeNodes)繼承。樹節點是一種紅黑樹的數據結構,存儲了更多信息,可讓你以O(log(n))
的算法複雜度新增,刪除或者是獲取一個元素。
下面是一個樹節點內存儲的數據的詳細列表供參考:
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)內。使用這種樹的一個主要優勢是,當一個內部表有許多相同的數據在同一個容器內時,在樹中搜索會花費O(log(n))
的時間複雜度,而鏈表會花費log(n)
。
如你所見,樹比鏈表佔用了更多的空間(咱們稍後會談到這個)。
經過繼承,內部表能夠包含 節點(鏈表) 和 樹節點(紅黑樹)兩種節點。Oracle經過下面的規則,決定同時使用這兩種數據結構:
上圖展現了一個Java8 HashMap的內部數組的結構,具備樹(桶0),和鏈表(桶1,2,3) ,桶0由於有超過8個節點因此結構是樹。
使用HashMap會帶來必定的內存開銷,在Java7裏,一個HashMap用Entry包含了 許多鍵值對,一個Entry裏會有:
此外,Java7裏 HashMap使用一個 Entry的內部數組。假設 一個HashMap包含了N個元素,內部數組容量是 C, 額外內存開銷約爲:
sizeOf(integer) * N + sizeOf(reference) * (3 * N +C)
小貼士:從JAVA7起,HashMap類初始化的方法是懶惰的,這意味着即便你分配了一個HashMap,內部Entry數組在內存裏也不會分配到空間( 4 * 數組大小 個字節),直到你調用第一個put()方法
java8的實現裏,獲取內存用量變得稍微複雜了一點。由於 Entry 和 樹節點包含的數據是同樣的,可是樹節點會多6個引用和1個布爾值。
若是所有都是 普通鏈表節點,那麼內存用量和java7同樣。
若是所有都是 樹節點,內存用量變成:N * sizeOf(integer) + N * sizeOf(boolean) + sizeOf(reference)* (9*N+CAPACITY )
在大多數標準的 JVM裏,這個式子等於 44 * N + 4 * CAPACITY
字節
最好的狀況下,get/put方法只有 O(1)的時間複雜度。可是,若是你不關心key的哈希函數,調用put/get/方法可能會很是慢。
put/get的良好性能取決於如何分配數據到內部數組不一樣的索引。若是key的哈希函數設計不良,你會獲得一個傾斜的HashMap(和內部數組大小無關)。全部在最長鏈表上的put/get會很是慢,由於會遍歷整個鏈表。最壞的狀況下(全部數據都在同一個索引下), 時間複雜度是O(n).
下面是一個例子,第一個圖片展現了一個傾斜HashMap,第二個圖則是一個平衡的HashMap:
這個傾斜HashMap在索引0上的get/put很是耗時,獲取Entry K會進行6次迭代
在這個平衡HashMap內,獲取Entry K只要進行3次迭代。這兩個HashMap存儲的數據量相同,內部數組大小也同樣。惟一的區別,就是分發數據的key的哈希函數。
下面是一個極端的例子,我建立了一個哈希函數,把兩百萬的數據都放到同一個數組索引下:
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())); } }
在個人機器上(core i5-2500k @ 3.6Ghz
),這個程序跑了超過45分鐘(java 8u40
),45分鐘後我中斷了這個程序。
如今,我運行相同的代碼,只是使用下面的哈希函數:
@Override public int hashCode() { int key = 2097152-1; return key+2097152*i; }
結果只花了 46秒 !! 這個哈希函數比先前那一個有一個更好的數據分發因此put函數運行快得多。
若是我仍是運行這段代碼,可是換成下面這個更好的哈希函數:
@Override public int hashCode() { return i; }
如今,程序只須要2秒。
我但願你意識到哈希函數有多麼重要。若是上面的測試在java7上運行,第一個和第二個測試的性能甚至還會更差(java7的複雜度是 O(n),java8是 O(log(n)))
當你使用HashMap時,你須要找到一個哈希函數,能夠 把key分發到儘可能多的索引上,爲了作到這一點,你須要避免哈希碰撞。字符串是不錯的一種key,由於它有 很不錯的哈希函數。整數作key也不錯,由於它的哈希函數就是自己的值。
若是你須要存儲大量數據,你應該在建立HashMap時設置一個接近你預期值的初始化大小。若是你不這麼作,map會用默認的 16數組大小和0.75的 載入因數。 前面11個put會很快可是第12個(16*0.75)會建立一個容量爲32的新數組,第13~23個put也會很快可是第24個會再次建立一個雙倍大小的數組。這個內部重設大小的操做會出如今第48次,96次,192次……。在數據量較小時,這個操做很快,可是當數據量增大時,這個操做會費時數秒到數分鐘不等。經過指定預期初始化大小,你能夠避免這些操做開銷。
可是這也有一個弊端,若是你設置了一個很大的數組大小像 2^28
而你只用了2^26
,你會浪費掉大量的內存(這個例子裏大約是 2^30 字節)
對於簡單的使用,你不須要知道HashMap是如何工做的,由於你感受不出 O(1)、O(n)、O(log(n))的區別。可是瞭解這種最經常使用的數據結果的底層機制老是有好處的,況且,對於java開發者來講,這是一個很典型的面試問題。在大數據量時,知道它是若是工做的,知道哈希函數的重要性 就變得很是重要了。
但願這篇文章能幫助你加深對HashMap實現細節的瞭解。