HashMap 實現了 Map 接口。HashMap 使用的很普遍,但不是線程安全的,若是在多線程中使用,必須須要額外提供同步機制(多線程狀況下推薦使用 ConCurrentHashMap)。java
HashMap 的類圖相對簡單,主要就是繼承了 AbstractMap,有一點須要注意,雖然沒有實現 Iterable
接口,但 HashMap 自己仍是實現了迭代器的功能。數組
本文基於 JDK1.8緩存
HashMap 是一個 Node[]
數組,每個下標稱之爲一個桶。安全
每個鍵值對都是使用 Node
來存儲,這是一個單鏈表的數據結構。每一個桶上能夠經過鏈表來存儲多個鍵值對。微信
HashMap 中用到的常量及其意義以下:數據結構
// 初始容量(桶的個數) 2^4
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量(桶的個數) 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默認的裝載因子(load factor),除非特殊緣由,不然不建議修改
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 單個桶上的元素個數大於這個值從鏈表轉成樹(樹化操做)
static final int TREEIFY_THRESHOLD = 8;
// 單個桶上元素少於這個值從樹轉成鏈表
static final int UNTREEIFY_THRESHOLD = 6;
// 只有桶的個數大於這個值時,樹化操做纔會真正執行
static final int MIN_TREEIFY_CAPACITY = 64;
複製代碼
HashMap 中用到的成員變量以下:多線程
// HashMap 中的 table,也就是桶
transient Node<K,V>[] table;
// 緩存全部的鍵值對
transient Set<Map.Entry<K,V>> entrySet;
// 鍵值對的個數
transient int size;
// HashMap 被修改的次數,用於 fail-fast 檢查
transient int modCount;
// 進行 resize 操做的臨界值,threshold = capacity * loadFactor
int threshold;
// 裝載因子
final float loadFactor;
複製代碼
table 是一個 Node 數組,length
一般是 ,但也能夠爲 0。函數
HashMap 的初始化其實就只幹了兩件事:源碼分析
用戶能夠經過傳入初始的容量和裝載因子。HashMap 的容量老是 ,若是傳入的參數不是 ,也會被轉化成 :post
// HashMap.tableSizeFor()
int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
複製代碼
Integer.numberOfLeadingZeros()
返回一個 int 類型(32位)在二進制表達下最後一個非零數字前面零的個數。好比 2:
0000 0000 0000 0000 0000 0000 0000 010
複製代碼
因此 Integer.numberOfLeadingZeros(3) 返回 30。
-1 在用二進制表示爲:
1111 1111 1111 1111 1111 1111 1111 1111
複製代碼
>>>
表示無符號右移,-1 右移 30 位則獲得:
0000 0000 0000 0000 0000 0000 0000 011
複製代碼
獲得 3。
因此通過了 -1 >>> Integer.numberOfLeadingZeros(cap - 1)
返回的值必定是 ,因此最後返回的值必定是 ,感興趣的能夠去驗證一下。
HashMap 在初始化的時候也能夠接受一個 Map 對象,而後把傳入的 Map 對象中的元素放入當前的容器中。
除了傳入 Map 對象的實例化方式,都不會實際去建立桶數組,這是一種延遲初始化的方式,在插入第一個鍵值對的時候,會調用 resize()
方法去初始化桶。
下面來詳細看看 resize()
操做。
與 ArrayList 不一樣,HashMap 沒有手動擴容的過程,只會根據容器當前的狀況自動擴容。
擴容操做由 resize()
方法來完成,擴容操做主要幹三件事:
參數說明
- oldCap: 擴容前桶的個數
- oldThr: 擴容前 threshold 的值
- newCap: 擴容後桶的個數
- newThr: 擴容後 threshold 的值
擴容流程以下:
擴容時會新建一個 Node(桶)數組,而後把原容器中的鍵值對從新做 hash 操做,而後放到新的桶中。
HashMap 的容量有上限,爲 ,也就是 1073741824,桶的個數不會超過這個數,threshold 的最大值是 2147483647,是最大容量的兩倍少1。
這樣設置表明這個若是桶的個數達到了最大容量,就不會再進行擴容操做了。
HashMap 的結構圖如上,每一個桶都是一個鏈表的頭結點,對於 hash 值相同(哈希衝突)的 key,會放在同一個桶上。這也是 HashMap 解決哈希衝突的方法稱之爲拉鍊法。在 JDK1.8 之後,在插入鍵值對時,使用的是尾插法,而再也不是頭插法。
HashMap 與 Hashtable 的功能大體上一致。HashMap 的 key 和 value 均可覺得 null。下面是主流 Map 的鍵值對是否能夠爲 null 的對比:
Map | key 是否能夠爲null | value 是否能夠爲 null |
---|---|---|
HashMap | 是 | 是 |
Hashtable | 否 | 否 |
ConcurrentHashMap | 否 | 否 |
TreeMap | 否 | 是 |
HashMap 不是線程安全的。在多線程環境中,須要使用額外的同步機制,好比使用 Map m = Collections.synchronizedMap(new HashMap(...));
。
HashMap 也支持 fail-fast 機制。
hash 方法對 HashMap 很是重要,直接會影響到性能。鍵值對插入位置由 hash 方法來決定。假設 hash 方法可讓元素在桶上均勻分佈,基本操做如 get
和 put
操做就是常量操做時間()。
hash 方法須要有兩個特色:
HashMap 中具體實現以下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
複製代碼
>>>
是無符號右移操做,上面已經說到。假設如今有個 key 是 "name",在我電腦上計算出來的值是:3373707,轉變成二進制就是:
0000 0000 0011 0011 0111 1010 1000 1011
複製代碼
右移 16 位後:
0000 0000 0000 0000 0000 0000 0011 0011
複製代碼
而後進行異或運算:
0000 0000 0011 0011 0111 1010 1011 1000
複製代碼
最後拿這個值與 HashMap 的長度減 1 進行與操做,由於 n 必定是 ,因此 (n-1) 的二進制所有是由 1 組成,下面這個操做至關於取 hash 值的後幾位:
index = (n - 1) & hash
複製代碼
index 就是鍵值對的插入位置。
hash() 函數其實就是用來使鍵值對的插入位置足夠隨機,稱之爲擾動函數,若是對具體的策略感興趣,能夠參考這篇文章。
注:Object.hashcode() 是一個本地方法,返回對象的內存地址。Object.equals() 方法默認比較對象的內存地址,若是某個類修改了 equals 方法,那麼 hashcode 方法也須要修改,要讓 equals 和 hascode 的行爲是一致的。否在在查找鍵值對的過程當中就會出現 equals 結果是 true, hashcode 卻不同,這樣就沒法找到鍵值對。
使用 HashMap 時,有兩個參數會影響它的性能:初始容量和裝載因子。
容量是指 HashMap 中桶的個數,初始容量是在建立實例時候所初始化桶的個數。
裝載因子用來決定擴容的時機,進行擴容操做時,會把桶的數量設爲原來的兩倍,容器中全部的元素都會從新分配位置,擴容的代價很大,應該儘量減小擴容操做。
裝載因子的默認值是 0.75,這是權衡時間性能和空間開銷的一個值。裝載因子設置的越大,那麼空間的開銷就會下降,但查找等操做的性能就會降低,反之亦然。
在初始化 HashMap 的時候,初始容量和裝載因子的值必須仔細衡量,以便儘量減小擴容操做,若是沒有特殊的狀況,使用默認的參數就能夠。
遍歷 HashMap 所需的時間與容器的容量(桶的個數)及元素的數量成正比。若是迭代的時間性能很重要,就不要把初始容量設置的太大,也不要把裝載因子設置的很小。
在講解具體的方法前,須要瞭解 HashMap 中一個重要的內部操做:樹化。
HashMap 使用拉鍊法來解決哈希衝突問題。多個鍵值對被分配到同一個桶的時候,是以鏈表的方式鏈接起來。但這樣會面臨一個問題,若是鏈表過長,那麼 HashMap 的不少操做就沒法保持 的操做時間。
極端狀況下,全部的鍵值對在一個桶中。那麼 get、remove 等操做的時間複雜度度就都是 。HashMap 的解決方法是用紅黑樹來替代鏈表,紅黑樹查詢的時間複雜度穩定在 。
HashMap 在單個桶的的元素的個數超過 8(TREEIFY_THRESHOLD) 且桶的個數大於 64(MIN_TREEIFY_CAPACITY) 時,會把桶後面的鏈表轉成樹(相似於 TreeMap
),這個操做稱之爲樹化操做。
須要注意的是,當單個桶上的元素超過了8個,但桶的個數少於 64 時,不會進行樹化操做,而是會進行擴容操做,代碼以下:
// HashMap.treeifyBin() method
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// other code...
}
複製代碼
樹化的過程是把鏈表的全部節點都替換成 TreeNode,而後再組成一棵紅黑樹(紅黑樹的具體構建過程能夠查看這篇文章)。並且在鏈表轉成樹的過程當中,每一個節點之間的相對關係不會變化,經過節點的 next
變量來保持這個關係。
當樹上的節點樹少於 6(UNTREEIFY_THRESHOLD) 時,樹結構會從新轉化成鏈表。把樹的每個節點換成鏈表的節點,經過 next 從新組成一個鏈表:
// HashMap.ubtreeify()
final Node<K,V> untreeify(HashMap<K,V> map) {
Node<K,V> hd = null, tl = null;
for (Node<K,V> q = this; q != null; q = q.next) {
Node<K,V> p = map.replacementNode(q, null);
if (tl == null)
hd = p;
else
tl.next = p;
tl = p;
}
return hd;
}
複製代碼
即便遇到極端狀況(全部的鍵值對在一個桶上),樹化操做也會保證 HashMap 的性能也不會退化太多。
get 方法: get 方法的實際操做是使用 getNode 方法來完成的。
// HashMap.getNode()
final Node<K,V> getNode(int hash, Object key) {
// 首先檢查容器是否爲 null 以及 key 在容器中是否存在
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 找到相應的桶,從第一個節點開始查找,若是第一個節點不是要找的,後續節點就分紅鏈表或者紅黑樹進行查找
if (first.hash == hash &&
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
// 若是鏈表已經轉成了紅黑樹,則在紅黑樹中查找
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
// 若是不是樹,則在鏈表中查找
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
}
複製代碼
put 方法: 用於插入或者更新鍵值對,實際使用的是 HashMap.putVal()
方法來實現。若是是第一次插入鍵值對,會觸發擴容操做。
// HashMap.putVal() 刪減了部分代碼
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 若是是第一次插入鍵值對,首先會進行擴容操做
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 若是一個桶的尚未插入鍵值對,則對第一個節點進行初始化
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 若是是紅黑樹的結構,則按照紅黑樹的方式插入或者更新節點
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 不然按照鏈表的方式插入或者更新節點
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// 若是沒有找到鍵值對,則新建一個節點,把鍵值對插入
p.next = newNode(hash, key, value, null);
// 若是鏈表的長度大於等於 8,就會嘗試進行樹化操做
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
// 若是找到了 key,則跳出循環
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 若是 key 已經存在,則把 value 更新爲新的 value
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
return oldValue;
}
}
// fail-fast 版本號更新
++modCount;
// 若是容器中元素的數量大於擴容臨界值,則進行擴容
if (++size > threshold)
resize();
return null;
}
複製代碼
remove 方法的實現與 get 方法相似。
clear 方法會將 map 中全部的桶都置爲 null 來清空鍵值對。
其餘的操做都是組合這幾個基本的操做來完成。
在 JDK8 中,Map 中增長了一些新的方法,HashMap 對這些方法都進行了重寫,加入了對 fail-fast 機制的支持。
這些方法是用上面的增刪改查方法來實現的。
getOrDefault 方法,在值不存在的時候,返回一個默認值:
HashMap map = new HashMap<>();
map.put("name", "xiaomi");
map.getOrDefault("gender","genderNotExist"); // genderNotExist
複製代碼
forEach 方法,遍歷 map 中的鍵值對,能夠接收 lambda 表達式:
HashMap<String, Object> map = new HashMap<>();
map.put("name", "xiaomi");
map.forEach((k, v) -> System.out.println(k +":"+ v));
複製代碼
putIfAbsent 方法,只有在 key 不存在時纔會插入鍵值對:
HashMap<String, Object> map = new HashMap<>();
map.put("name", "xiaomi");
map.putIfAbsent("gender", "man");
複製代碼
computeIfAbsent 方法用來簡化一些操做,下面方法1和方法2功能同樣,都是在 key 不存在的狀況下,經過某些處理後而後把鍵值對插入 map:
HashMap<String, Object> map = new HashMap<>();
map.put("name", "xiaomi");
// 方法1:
Integer age = (Integer)map.get("key");
if (age == null) {
age = 18;
map.put("key", age);
}
// 方法2:
map.computeIfAbsent("age", k -> {return 18;});
複製代碼
computeIfPresent 方法則是在鍵值對存在的狀況下,對鍵值對進行處理,而後再更新 map,下面方法1和方法2功能徹底同樣:
HashMap<String, Object> map = new HashMap<>();
map.put("name", "xiaomi");
// 方法1:
Integer age = (Integer)map.get("key");
Integer age = 18 + 4;
map.put("key", age);
// 方法2:
map.computeIfPresent("age", (k,v) -> {return 18 + 4;});
複製代碼
merge 方法用來對相同的 key 的 value 進行合併,如下方法1和方法2的功能一致:
HashMap<String, Object> map = new HashMap<>();
map.put("name", "xiaomi");
// 方法1:
Integer age = (Integer)map.get("key");
age += 14;
map.put("key", age);
// 方法2:
map.merge("age", 18, (oldVal, newVal) -> {return (Integer)oldVal + (Integer)newVal;});
複製代碼
HashMap 一樣也實現了迭代功能,HashMap 中有三個具體 Iterator 的實現:
可是這個三個迭代器都不會直接使用,而是經過調用 HashMap 方法來間接獲取。
Spliterator 的實現與迭代器的相似,分別對於 key、value 和 key + value 分別實現了 Spliterator。
相關文章
關注微信公衆號,聊點其餘的