弄懂HashMap,這一篇就夠了
如果你點開了這篇博客,請一定要讀完,可能會花費你20分鍾,因為它真的可以幫助你了解到hashmap的底層實現以及使用hashmap的注意事項,聲明:這篇博文是摘抄至國外的一個大牛的博客,地址在博文底端。
大多數JAVA開發人員都在使用Maps,尤其是HashMaps。 HashMap是一種簡單而強大的數據存儲和獲取方法。 但是,有多少開發人員知道HashMap在內部如何工作? 在本文中,我將解釋java.util.HashMap的實現,介紹JAVA 8實現中的新增功能,並討論使用HashMaps時的性能,內存和已知問題。
內部存儲
Java中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是一個簡單的鍵值對,其中包含兩個額外的數據:
- 對另一個條目的引用,以便HashMap可以存儲諸如單個鏈接列表之類的條目
- 表示鍵的哈希值的哈希值。存儲此哈希值是為了避免每次HashMap需要哈希時都進行哈希計算。
這是JAVA 7中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實例的內部存儲。每個條目可以鏈接到另一個條目以形成鏈接列表。
具有相同哈希值的所有鍵都放在相同的鏈表(存儲桶)中。具有不同哈希值的鍵可以最終出現在同一存儲桶中。
當我們調用put(K key,V value)或get(Object key)時,該函數將計算條目應在其中的存儲區的索引。然后,該函數遍歷列表以查找具有相同鍵的Entry(使用鍵的equals()函數)。
在get()的情況下,該函數返回與該條目關聯的值(如果該條目存在)
對於put(K,V),如果該條目存在,則該函數將其替換為新值,否則它將在單鏈接列表的開頭創建一個新條目(根據鍵和參數中的值。這也就是在HashMap中不能存儲相同鍵值的原因,因為這回導致原有的數據被覆蓋。
該存儲桶的索引(鏈結列表)一般分3步生成:
- 它首先獲取密鑰的哈希碼。
- 接着會重新哈希,以防止將所有數據放入內部數組的相同索引(存儲桶)的鍵中,造成不均勻的散列值。
- 它采用經過重整的哈希哈希碼,並使用數組的長度(負1)對其進行掩碼。此操作可確保索引不能大於數組的大小。您可以將其視為在計算上經過優化的模函數。
下面是Java 7 和Java 8 處理索引的源碼:
// Java7 哈希值的計算方法
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
// Java8 哈希值的計算方法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//從重新哈希返回索引的函數
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的數組將僅用於2個存儲桶:索引為0的一個存儲桶和索引16的一個存儲桶,效率不高…
但是,如果您現在使用的是2的冪(例如16),則按位索引公式為``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的冪的原因。這種機制對開發人員是透明的:如果他選擇大小為37的HashMap,則Map將為其內部數組的大小自動選擇37(64)之后的下一個2的冪。
自動擴容
獲取索引后,函數(get, put or remove)訪問/迭代關聯的鏈表,以查看給定鍵是否存在現有的Entry。 如果不進行修改,則此機制可能會導致性能問題,因為該功能需要遍歷整個列表以查看條目是否存在。想象一下,內部數組的大小是默認值(16),您需要存儲2百萬個值。在最佳情況下,每個鏈接列表的大小將為125000個條目(2/16百萬),因此,每個get(),remove()和put()都會導致125000次迭代/操作。為了避免這種情況,HashMap可以自動增加其內部數組以保留非常短的鏈表。創建HashMap時,可以使用以下構造函數指定初始大小和loadFactor:
public HashMap(int initialCapacity, float loadFactor)
如果未指定參數,則默認數組的初始大小為16,默認loadFactor(加載因子)為0.75。 initialCapacity表示鏈表內部數組的大小。
每次使用put(…)在映射中添加新的鍵/值時,該函數都會檢查是否需要增加內部數組的容量。為此,map存儲了2個數據:
- map的size大小:它表示HashMap中的條目數。每次添加或刪除條目時都會更新此值。
- 一個閾值:等於(內部數組的容量)* loadFactor,並在每次調整內部數組的大小后刷新
在添加新條目之前,put(...)方法之前,如果size> threshold,它將重新創建一個大小加倍的新數組。由於新數組的大小已更改,因此索引函數(返回按位運算“ hash(key)AND(sizeOfArray-1)”)更改。因此,調整數組大小會再創建兩個存儲桶(即鏈表),並將所有現有條目重新分配到存儲桶中(舊的和新創建的)。
調整大小操作的目的是減小鏈接列表的大小,以使put(),remove()和get()方法的時間成本保持較低。調整大小后,所有具有相同哈希值的鍵的條目將保留在同一存儲桶中。但是,轉換后,位於同一存儲桶中的兩個具有不同哈希鍵的條目可能不在同一存儲桶中。
該圖顯示了調整內部數組大小之前和之后的表示。 在增加之前,為了獲得條目E,地圖必須迭代5個元素的列表。 調整大小后,相同的get()會遍歷2個元素的鏈接列表,調整大小后,get()的速度提高了2倍。
注意:HashMap僅增加內部數組的大小,而沒有提供減小內部數組的方法。
線程安全
如果您已經了解HashMaps,那么您知道這不是線程安全的,但是為什么呢?例如,假設您有一個Writer線程僅將新數據放入Map中,而一個Reader線程則從Map中讀取數據,為什么它不起作用?
因為在自動調整大小機制期間,如果線程嘗試放置或獲取對象,則地圖可能會使用舊的索引值,而不會找到條目所在的新存儲桶。
最壞的情況是兩個線程同時放置一個數據,而兩個put()調用則同時調整Map的大小。 由於兩個線程同時修改鏈表,因此Map可能在其鏈表之一中以一個內循環結束。 如果您嘗試通過內部循環獲取列表中的數據,則get()將永遠不會結束。
HashTable實現是線程安全的實現,可以防止這種情況發生。但是,由於所有CRUD方法都是同步的,因此此實現非常慢。例如,如果線程1調用get(key1),線程2調用get(key2),線程3調用get(key3),且這三個線程可以同時訪問數據,就只能有一個線程獲得該值,其他兩個線程只能等待。
自Java 5以來,存在一個更安全的線程安全HashMap實現:ConcurrentHashMap。只有存儲桶是同步的,因此多個線程可以同時get(),remove()或put()數據(如果這並不意味着訪問同一存儲桶或調整內部數組的大小)。這是因為ConcurrentHashMap只給一個方法的一個片段加鎖,所以在並發的效率下也能維持一個高的性能。下面是ConcurrentHashMap的部分源碼。
else {
V oldVal = null;
// 只對這個片段加鎖
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
Key的不變性
為什么Strings和Integers是HashMap的鍵的良好實現?主要是因為它們是一成不變的!如果您選擇創建自己的Key類並且不使其保持不變,則可能會丟失HashMap中的數據。
看下面的示例:
- 您有一個內部值為1的鍵,
- 並使用此鍵將一個對象放入HashMap中.
- HashMap根據該鍵的哈希碼生成一個哈希值(因此從``1''開始)
- .Map將此哈希值存儲在新創建的Entry中
- 您如果將鍵的內部值修改為“ 2”。
- 鍵的哈希值被修改,但HashMap不知道(因為存儲了舊的哈希值)。您嘗試使用修改后的鍵獲取對象 密鑰的新哈希值(因此從“ 2”開始)以查找條目在哪個鏈表(存儲桶)中
這就會出現你存儲的值丟失,具體原因如下:
- 情況1:由於您修改了密鑰,因此Map會嘗試在錯誤的存儲桶中找到該條目,但找不到它
- 情況2:幸運的是,修改后的密鑰生成與舊密鑰相同的存儲桶。 然后,映射將迭代鏈接列表,以查找具有相同鍵的條目。 但是要找到鍵,映射首先比較哈希值,然后調用equals()比較。 由於修改過鍵的哈希值與舊哈希值(存儲在條目中)的哈希值不同,因此map不會在鏈接列表中找到該條目。
這是Java中的具體示例。我將2個鍵值對放入地圖中,我修改了第一個鍵,然后嘗試獲取2個值。從地圖僅返回第二個值,第一個值在HashMap中“丟失”:
public class MapTest {
public void test(){
Map<String,String> map = new HashMap<>();
map.put("1","hello");
map.put("2","chen");
for (Map.Entry<String,String> entry: map.entrySet()) {
System.out.println(entry.getKey());
}
}
public static void main(String[] args) {
// 檢驗map的key值被改了以后的情況
class Mykeys{
private int i;
public Mykeys(int i){
this.i = i;
}
public int getI() {
return i;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Mykeys)) return false;
Mykeys mykeys = (Mykeys) o;
return i == mykeys.i;
}
@Override
public int hashCode() {
return Objects.hash(i);
}
public void setI(int i) {
this.i = i;
}
}
new MapTest().test();
Map<Mykeys,String> map = new HashMap<>();
Mykeys key1 = new Mykeys(1);
Mykeys key2 = new Mykeys(2);
map.put(key1,"test"+1);
map.put(key2,"test2"+2);
// 改變key的值
key1.setI(3);
String val1 = map.get(key1);
String val2 = map.get(key2);
System.out.println("test1="+val1+" "+"test2="+val2);
}
}
輸出:
1
2
test1=null test2=test2
如預期的那樣,map無法使用已修改的鍵1檢索字符串1,所以test1的值為null,造成了數據的丟失。
JAVA 8改進
HashMap的內部表示在JAVA 8中發生了很大變化。確實,JAVA 7中的實現需要1k行代碼,而JAVA 8中的實現則需要2k行。在JAVA8中,您仍然有一個數組,但是它現在存儲的Node包含與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))中添加,刪除或獲取元素。
這是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處)。值區0是一棵樹,因為它有8個以上的節點。
內存開銷
Java7
HashMap的使用在內存方面要付出一定的代價。在JAVA 7中,HashMap將鍵值對包裝在Entries中。條目具有:
- 對下一個entry的引用
- 預先計算的哈希(整數)
- Key的參考
- 對值的引用
此外,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個字節
提醒:在自動調整Map大小之后,內部數組的CAPACITY等於N之后的下一個2的冪。注意:自JAVA 7起,HashMap類具有一個惰性的init。 這意味着即使您分配了HashMap,條目的內部數組(花費4 * CAPACITY字節)也不會在第一次使用put()方法之前在內存中分配。
Java8
使用JAVA 8實施方案時,獲取內存使用情況變得有些復雜,因為Node可以包含與Entry相同的數據或相同的數據以及6個引用和一個布爾值(如果是TreeNode)。
如果所有節點均為Nodes,則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 *容量字節
性能問題
傾斜的HashMap與平衡良好的HashMap
在最佳情況下,get()和put()方法的時間復雜度為O(1)。 但是,如果您不注意密鑰的哈希函數,則可能會以非常慢的put()和get()調用結束。 put()和get的良好性能取決於將數據重新分配到內部數組(存儲桶)的不同索引中。 如果密鑰的哈希函數設計不當,您將有一個傾斜的分區(無論內部數組的容量有多大)。 所有使用最大鏈接條目列表的put()和get()都會變慢,因為它們需要迭代整個列表。 在最壞的情況下(如果大多數數據都在同一個存儲桶中),最終可能會導致O(n)時間復雜度。
這是一個視覺示例。第一張圖片顯示了傾斜的HashMap,第二張圖片顯示了均衡的圖片。
在這種偏斜的HashMap的情況下,存儲區0上的get()/ put()操作成本很高。獲得條目K將花費6次迭代
在這種平衡良好的HashMap的情況下,獲取條目K將花費3次迭代。 兩個HashMap都存儲相同數量的數據,並且具有相同的內部數組大小。 唯一的不同是(項的)哈希(hash)功能,該功能在存儲桶中分配條目。
這是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()));
}
}
在我的電腦上,i7第八代,使用Java 8u40花費的時間超過45分鍾(我在45分鍾后停止了該過程)。
如果我使用以下哈希函數運行相同的代碼,則可以提供更好的哈希重新分區
@Override
public int hashCode() {
return i;
}
現在需要2秒
以上示例可以看到哈希函數的重要性。 如果在JAVA 7上進行了相同的測試,則在第一種情況和第二種情況下結果會更糟(因為put的時間復雜度在JAVA 7中為O(n)而在Java 8中為O(log(n))。 使用HashMap,您需要為您的密鑰找到一個哈希函數,以將密鑰散布到盡可能多的存儲桶中。 為此,您需要避免哈希沖突。 字符串對象是一個很好的鍵,因為它具有良好的哈希功能。 整數也很好,因為它們的哈希碼是它們自己的值。
怎么減小內存消耗
如果需要存儲大量數據,則應創建初始容量接近預期容量的HashMap。
如果您不這樣做,map將采用默認大小16,factorLoad為0.75。第11個put()會非常快,但是第12個(16 * 0.75)將重新創建一個新的內部數組(及其關聯的鏈表/樹),其新容量為32。
從13號到23號很快,但是24號(32 * 0.75)將重新創建(再次)昂貴的新表示,將內部數組的大小加倍。內部調整大小操作將出現在put()的第48、96、192等處。在低容量下,內部陣列的完全恢復速度很快,但在高容量下,可能需要幾秒鍾到幾分鍾。通過最初設置您的預期大小,您可以避免這些昂貴的操作。
對於簡單的用例,您不需要了解HashMap的工作原理,因為您不會看到O(1)和O(n)或O(log(n))操作之間的區別。 但是,最好了解最常用的數據結構之一的底層機制。 此外,對於Java開發人員來說,這是一個典型的面試問題。 在高容量下,了解其工作原理並了解密鑰的哈希函數的重要性變得很重要。 我希望本文能幫助您對HashMap實現有一個深刻的了解。
參考至: http://coding-geek.com/how-does-a-hashmap-work-in-java/
追本溯源,方能闊步前行。