大多數JAVA開發人員正在使用Maps,尤其是HashMaps。HashMap是一種簡單而強大的方式來存儲和獲取數據。但是有多少開發人員知道HashMap在內部工作?前幾天,我已經閱讀了java.util.HashMap的源代碼(Java 7中的Java 8)的很大一部分,以便深入瞭解這個基礎數據結構。在這篇文章中,我將解釋java.util.HashMap的實現,介紹JAVA 8實現中的新功能,並討論使用HashMaps時的性能,內存和已知問題。
內容[ 顯示 ]
內部存儲器
JAVA HashMap類實現了接口Map <K,V>。這個接口的主要方法是:
- V put(K鍵,V值)
- V get(Object key)
- V刪除(對象鍵)
- Boolean containsKey(Object key)
HashMaps使用內部類來存儲數據:Entry <K,V>。此條目是一個帶有兩個額外數據的簡單鍵值對:
- 引用另一個條目,以便HashMap可以存儲像單鏈表的條目
- 表示密鑰的哈希值的哈希值。存儲此哈希值以避免每次HashMap需要時計算哈希值。
這是JAVA 7中Entry 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 <K,V> []數組)的數組中,該內部數組的默認容量爲16。
下圖顯示了具有可空條目數組的HashMap實例的內部存儲。每個條目可以鏈接到另一個條目以形成鏈表。
具有相同散列值的所有密鑰都放在相同的鏈表(bucket)中。具有不同哈希值的密鑰可以在同一個桶中結束。
當用戶調用put(K key,V value)或get(Object key)時,該函數計算Entry應該在哪個bucket的索引。然後,該函數遍歷列表以查找具有相同鍵的Entry(使用鍵的equals())。
在get()的情況下,函數返回與條目相關聯的值(如果條目存在)。
在put(K key,V value)的情況下,如果條目存在,則函數用新值替換它,否則它將在單鏈表的頭部創建一個新條目(來自參數中的鍵和值)。
桶的這個索引(鏈表)由地圖分三步生成:
- 它首先獲取密鑰的哈希碼。
- 它重新創建哈希碼,以防止從將所有數據放在內部數組的相同索引(bucket)中的密鑰的錯誤散列函數
- 它需要重新排列的散列哈希碼,並將其與數組的長度(減1)進行位掩碼。此操作確保索引不能大於數組的大小。您可以將其視爲非常計算優化的模函數。
這是處理索引的JAVA 7和8源代碼:
//
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(大小-1)。16的二進制表示爲0 ... 010000,因此對於任何散列值H,按位公式“H AND 16”生成的索引將爲16或0.這意味着大小爲17的數組將僅用於2個桶:索引爲0,索引16爲1,效率不高
但是,如果現在取大小爲16的冪,則按位指數公式爲“H AND 15”。15的二進制表示爲0 ... 001111,因此索引公式可以輸出從0到15的值,並且大小爲16的數組被完全使用。例如:
- 如果H = 952,其二進制表示爲0..0111011 1000,相關索引爲0 ... 0 1000 = 8
- 如果H = 1576,其二進制表示爲0..01100010 1000,則相關索引爲0 ... 0 1000 = 8
- 如果H = 12356146,其二進制表示爲0..010111100100010100011 0010,相關索引爲0 ... 0 0010= 2
- 如果H = 59843,其二進制表示爲0..0111010011100 0011,相關索引爲0 ... 0 0011 = 3
這就是爲什麼陣列大小是二的冪。這個機制對於開發人員來說是透明的:如果他選擇一個大小爲37的HashMap,Map將自動爲37(64)之後的內部數組大小自動選擇2的下一個冪。
自動調整大小
獲取索引後,函數(get,put或remove)訪問/重複相關鏈接列表,以查看給定鍵是否存在現有條目。沒有修改,這種機制可能會導致性能問題,因爲函數需要遍歷整個列表來查看該條目是否存在。想象一下,內部數組的大小是默認值(16),您需要存儲2百萬個值。在最佳情況下,每個鏈表的大小將爲125 000個條目(2,16百萬)。所以,每個get(),remove()和put()將導致125 000次迭代/操作。爲了避免這種情況,HashMap有能力增加其內部數組,以保持非常短的鏈表。
創建HashMap時,可以使用以下構造函數指定初始大小和loadFactor:
public
HashMap( int
initialCapacity, float
loadFactor) |
如果不指定參數,則默認的initialCapacity爲16,默認的loadFactor爲0.75。initialCapacity表示鏈表的內部數組的大小。
每次在put(...)的Map中添加新的鍵/值時,函數將檢查是否需要增加內部數組的容量。爲了做到這一點,地圖存儲2個數據:
- 映射的大小:它表示HashMap中的條目數。每次添加或刪除條目時,此值都會更新。
- 一個閾值:它等於(內部數組的容量)* loadFactor,並且在內部數組的每個調整大小之後刷新它
在添加新條目之前,put(...)檢查size> threshold,如果是這樣,它將重新創建一個雙倍大小的新數組。由於新數組的大小已更改,索引函數(返回按位運算“hash(key)AND(sizeOfArray-1)”)會發生更改。因此,調整數組大小會創建兩倍的桶(即鏈表),並將 所有現有條目重新分配到桶(舊版和新創建)中。
這種調整大小操作的目的是減少鏈表的大小,以便put(),remove()和get()方法的時間成本保持在較低水平。密鑰具有相同哈希值的所有條目在調整大小後將保留在相同的存儲桶中。但是,具有不同哈希鍵的2個條目在之前的相同的桶中可能在轉換後可能不在同一個桶中。
該圖顯示了在內部數組調整大小之前和之後的表示。在增加之前,爲了獲得Entry E,地圖必須遍歷5個元素的列表。調整大小後,相同的get()只是遍歷2個元素的鏈接列表,get()在調整大小之後快2倍!
注意:HashMap只增加了內部數組的大小,它不提供減少它的方法。
線程安全
如果你已經知道HashMaps,你知道這不是線程安全的,但是爲什麼?例如,假設您有一個Writer線程,只將新數據放入Map中,還有一個Reader線程從Map中讀取數據,爲什麼不運行?
因爲在自動調整大小的機制中,如果線程嘗試放置或獲取對象,則映射可能會使用舊的索引值,並且不會找到條目所在的新存儲桶。
最糟糕的情況是當2個線程同時放置一個數據時,2個put()調用可以同時調整Map的大小。由於兩個線程同時修改鏈接列表,所以Map可能會在其鏈接列表之一中出現內循環。如果您嘗試使用內部循環獲取列表中的數據,則get()將永遠不會結束。
該哈希表的實現是線程安全的實現,從這種情況可以防止。但是,由於所有的CRUD方法都是同步的,所以實現速度非常慢。例如,如果線程1調用get(key1),線程2調用get(key2)和線程3調用get(key3),一次只能有一個線程能夠獲取其值,而其中3個可以訪問數據與此同時。
自JAVA 5:ConcurrentHashMap以來,線程安全的HashMap的更智能的實現。只有桶被同步,所以多個線程可以同時獲取get(),remove()或put()數據,如果它不意味着訪問同一個bucket或調整內部數組的大小。最好在多線程應用程序中使用此實現。
重要的不變性
爲什麼字符串和整數是HashMap的一個很好的實現鍵?主要是因爲它們是不變的!如果您選擇創建自己的Key類,並且不使其成爲不可變的,那麼可能會丟失HashMap中的數據。
看下面的用例:
- 你有一個內部值爲“1”的鍵
- 您使用此鍵將對象放在HashMap中
- HashMap從Key的哈希碼生成哈希(所以從“1”)
- 地圖 將此哈希存儲 在新創建的條目中
- 您將密鑰的內部值修改爲“2”
- 密鑰的哈希值被修改,但是HashMap不知道(因爲舊的哈希值被存儲)
- 您嘗試使用修改的密鑰獲取對象
- 該地圖計算您的密鑰的新哈希(所以從“2”)查找條目在哪個鏈接列表(桶)
-
- 情況1:由於您修改了密鑰,地圖會嘗試在錯誤的桶中找到該條目,但找不到
- 情況2:幸運的是,修改的密鑰生成與舊密鑰相同的桶。然後,映射遍歷鏈表以查找具有相同鍵的條目。但要找到密鑰,地圖首先比較哈希值,然後調用equals()比較。由於修改的密鑰與舊的哈希值(存儲在條目中)沒有相同的哈希值,所以地圖將不會在鏈表中找到該條目。
這是Java中的一個具體例子。我在我的Map中放置了2個鍵值對,我修改了第一個鍵,然後嘗試獲取2個值。只有第二個值從地圖返回,第一個值在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檢索字符串1。
JAVA 8改進
HashMap的內部表示在JAVA 8中發生了很大的變化。實際上,JAVA 7中的實現需要1k行代碼,而在JAVA 8中的實現需要2k行。除了條目的鏈接列表之外,我之前說過的大部分內容都是正確的。在JAVA8中,您仍然有一個數組,但它現在存儲包含與Entries完全相同的信息的節點,因此也是鏈接列表:
這是JAVA 8中Node實現的一部分:
static
class
Node<K,V> implements
Map.Entry<K,V> { final
int
hash; final
K key; V
value; Node<K,V>
next; |
那麼JAVA 7有什麼不同呢?那麼,節點可以擴展到TreeNodes。TreeNode是一個紅黑樹結構,可以存儲更多的信息,以便它可以添加,刪除或獲取O(log(n))中的元素。
FYI,這裏是存儲在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)中。使用這些樹的主要優點是在許多數據位於內表的相同索引(桶)的情況下,樹中的搜索將花費 O(log(n)),而它將具有成本O(n)有一個鏈表。
正如你所看到的,樹比鏈表具有更多的空間(我們將在下一部分中討論它)。
通過繼承,內表可以包含 Node(鏈表)和 TreeNode(紅黑樹)。Oracle決定使用以下規則的兩個數據結構:
- 如果對於內部表中的給定索引(存儲桶),存在多於8個節點,鏈表將轉換爲紅色黑色樹
- 如果爲給定索引(桶)在內表中有少於6個節點,樹被轉換成一個鏈表
這張照片顯示了一個JAVA 8 HashMap的內部數組,其中包含樹(桶0)和鏈表(在1,2和3桶)。Bucket 0是一個Tree,因爲它有8個以上的節點。
內存開銷
JAVA 7
使用HashMap在內存方面是有代價的。在JAVA 7中,一個HashMap包含條目中的鍵值對。一個條目有:
- 引用下一個條目
- 預先計算的散列(整數)
- 參考的關鍵
- 參考價值
此外,JAVA 7 HashMap使用Entry的內部數組。假設一個JAVA 7 HashMap包含N個元素,其內部數組具有容量CAPACITY,則額外的內存成本約爲:
sizeOf(integer)* N + sizeOf(reference)*(3 * N + C)
哪裏:
- 整數的大小等於4字節
- 引用的大小取決於JVM / OS / Processor,但通常爲4個字節。
這意味着開銷通常是16 * N + 4 * CAPACITY字節
提醒:在自動調整地圖大小後,內部陣列的CAPACITY等於N之後的下一個功率。
注意:由於JAVA 7,HashMap類有一個懶惰的init。這意味着即使您分配了HashMap,在第一次使用put()方法之前,內部數組的條目(將花費4 * CAPACITY字節)也不會在內存中分配。
JAVA 8
使用JAVA 8實現,獲取內存使用情況變得有點複雜,因爲Node可以包含與條目相同的數據或相同的數據加上6個引用和布爾值(如果它是一個TreeNode)。
如果所有節點只是節點,則JAVA 8 HashMap的內存消耗與JAVA 7 HashMap相同。
如果所有節點都是TreeNodes,則JAVA 8 HashMap的內存消耗將變爲:
N * sizeOf(integer)+ N * sizeOf(boolean)+ sizeOf(reference)*(9 * N + CAPACITY)
在大多數標準JVM中,它等於44 * N + 4 * CAPACITY字節
性能問題
傾斜的HashMap和平衡的HashMap
在最佳情況下,get()和put()方法的時間複雜度爲O(1)。但是,如果您不關心該鍵的哈希功能,則可能會導致非常慢的put()和get()調用。put()和get的良好性能取決於將數據重新分配到內部數組(桶)的不同索引中。如果您的密鑰的散列函數設計不當,您將會有一個偏斜重新分區(無論內部數組的容量有多大)。所有使用最大的條目列表的put()和get()將很慢,因爲它們需要遍歷整個列表。在最壞的情況下(如果大多數數據在同一個桶中),則可能會導致O(n)時間複雜度。
這是一個視覺示例。第一張照片顯示了一個傾斜的HashMap,第二張照片是平衡的。
在這種傾斜的HashMap的情況下,桶0上的get()/ put()操作是昂貴的。獲得條目K將花費6次迭代
在這種平衡良好的HashMap的情況下,獲得Entry K將花費3次迭代。兩個HashMaps都存儲相同數量的數據,並且具有相同的內部數組大小。唯一的區別是分配桶中條目的哈希(key)函數。
這是JAVA中的一個極端例子,其中我創建一個哈希函數,將所有數據放在同一個數據桶中,然後添加200萬個元素。
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它需要超過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秒鐘。
我希望你意識到哈希函數的重要性。如果在JAVA 7中運行相同的測試,則第一和第二種情況的結果會更糟(由於JAVA 7中的put的時間複雜度爲JA(7)中的O(n),而在JAVA 8中爲0(log(n)))
當使用HashMap時,您需要爲您的密鑰找到一個散列函數,將鍵擴展到最可能的存儲區。爲此,您需要避免哈希衝突。String對象是一個很好的鍵,因爲它具有很好的散列函數。整數也很好,因爲它們的哈希碼是他們自己的價值。
調整開銷大小
如果您需要存儲大量數據,則應創建一個初始容量接近預期卷的HashMap。
如果不這樣做,地圖將使用默認大小16,因子加法爲0.75。第11個put()將非常快,但第12個(16 * 0.75)將重新創建一個新的內部數組(與其相關聯的列表/樹),新的容量爲32.第13到第23個將很快,但第24個(32 * 0.75)將重新創建一個昂貴的新表示,使內部數組的大小加倍。內部調整大小的操作將出現在第48,第96,第192,...的調用put()。在低容量下,內部陣列的完全娛樂是快速的,但是在高音量下可能需要幾分鐘到幾分鐘。通過初始設置您的預期大小,您可以避免這些 昂貴的操作。
但是有一個缺點:如果你設置一個非常高的數組大小像2 ^ 28,而你只在陣列中使用2 ^ 26個桶,你將浪費大量的內存(在這種情況下約爲2 ^ 30個字節)。
結論
對於簡單的用例,您不需要知道HashMaps如何工作,因爲您不會看到O(1)和O(n)或O(log(n))操作之間的差異。但是,瞭解最常用的數據結構之一的底層技術總是更好。而且,對於java開發人員來說,這是一個典型的面試問題。
在高音量下,重要的是知道它的工作原理,並瞭解鍵的散列函數的重要性。
我希望這篇文章幫助您深入瞭解HashMap實現。