目錄
一、 HashMap的數據結構
1.7版本與1.8版本數據結構的區別
1.7版本使用的數據結構是數組 + 鏈表的形式。對於新增的節點使用的是頭插法,新增的節點增加在離桶最近的地方。
1.8版本使用的是 數組 + 鏈表/紅黑樹的形式。新增節點使用的是尾插法,新增的節點在鏈表的尾部。當鏈表的長度>=8時,會轉換爲紅黑樹結構。
二、HashMap的功能實現源碼解析
1. hash方法
如果沒有指明HashMap的初始化大小值,則其默認初始化大小是16。
當我們有一個新的值被put方法放入HashMap時,它應該在0~15之間有一個具體的位置。那麼應該用什麼方法確定它的位置呢?
我們常想到的就是用隨機取模的方法來做,Random(16).nextInt(),簡單粗暴。但是如果我們對同一個key比如"hello",反覆地放入同一個HashMap,則其每次的位置都是隨機的且位置不同,這樣對於查找並不方便,最好用一種與key本身帶有某種關係的算法,同一個key往往放在同一個位置。我們看下HashMap的源碼是怎樣確定key的位置的。
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 {
....
}
}
源碼的算法是 tab[i = (n - 1) & hash]
tab是HashMap內部的Node數組,tab[i]就是第i個的位置。 i 的取值是(n-1) & hash。
n -1 是數組的長度 - 1, 那麼hash是什麼呢?
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
它的計算原理是: hashCode ^ (hashCode >>> 16)
每一個對象都有一個hashCode,是一個32位的int值。 算式的意思是將它的高16位與低16位相異或。
爲什麼要用異或?
我們用了hashCode的高16位與低16位來進行運算,我們當然可以取與(&)運算或者或( | )運算,但是這樣的結果與異或(^)相比較一下,就可以發現任意兩個數X和Y進行(&)或( | )運算之後,每一位取0和1 的概率都是不一樣的,也就是說某個操作數(即高16位或者低16位)被賦予的權重是不一樣的,這就會使hash計算後分佈不夠均勻。而異或(^)運算則沒有這個問題。
HashMap數組的長度n必須爲2的整數次方。這個n值爲什麼要這麼規定?
tab[i] 中位置 i 的位置是 (n-1) & hash。 我們明白了hash的計算結果,是一個16位的值。
n爲2的整數次方,比如說16,則n-1 = 15,換算成二進制就是 00001111。
長度必須爲2的整數次方的原因就是:
(1) n-1 與 hash相與,最大值爲15,最小值爲0,其結果值分佈在0 ~ 15之間,完美契合座標範圍。
(2)n-1的二進制,其值全部爲1,可以採樣到hashCode後面所有位的值。如果n-1中間有某幾位爲0,則該位與(&)的結果一定是0,取不到值,則tab[] 數組某些位置就會爲空,永遠也不會被存放值,造成內存浪費。
2. 由鏈表改爲紅黑樹
當鏈表的長度 >= TREEIFY_THRESHOLD (=8)時,就會將鏈表改爲紅黑樹。因爲鏈表查找的時間複雜度爲O(n),而紅黑樹的查找時間複雜度爲O(logn)。 (紅黑樹的內容在數據結構篇中查看)
當紅黑樹的節點 <= UNTREEIFY_THRESHOLD (=6)時,又會從紅黑樹轉換爲鏈表。
3.擴容
隨着Node數組存放的數據越來越多,達到 0.75 *f (f爲Node數組的長度)時,就會對HashMap進行擴容。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE; //如果到了 1 << 30,則擴容到Integer.MAX_VALUE
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 擴容一倍
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // 初始時用默認值16
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
...
}
如果是首次初始化則初始化爲16,如果 >= 2^30則擴容爲Integer.MAX_VALUE,其他情況則擴容爲原來的2倍。
4. 擴容後的新位置
擴容的過程中,容量變爲原來的2倍,原Node數組中的節點位置,就需要計算新的 hash & (n-1)來確定。
新的位置只可能在兩個位置:
- 原來的位置
- [原下標 + 原容量] 的位置
這個很容易理解:
下標就是 hash & (n-1), hash沒有變, 只有 n 擴大爲原來的2倍,則 n-1的二進制就是比原來的最左側多了一個 1 。那麼計算 hash & (n-1),如果最左側的1上hash碼爲0,則爲原來的數;如果最左側1上hash碼爲1,則爲原來的數 + 原來的容量。
5. 搬家
當HashMap擴容之後,hashMap的各個Node節點都要移動到新的位置上去。這個過程再去增刪改查一定是不安全的,因此就需要先禁止這些操作,等到各Node節點存放到新的位置之後才能操作。
三、 怎樣將HashMap升級爲線程安全的
與HashTable相比較,HashTable爲啥是線程安全的?
因爲HashTable的幾乎所有方法都加上了synchronized 關鍵字。
public synchronized V put(K key, V value) {
...
}
這樣是實現了安全性,但是性能也肯定被降低了,算是犧牲了性能來保證了安全性。
1. HashMap爲啥線程不安全呢?
我們來看一下HashMap中有哪些操作。
(1) 對於一個普通的put操作,步驟有:
hash(key)
數組初始化
將該key/value值存放入某個位置
(2)擴容
數組擴容
移動數據
這些步驟除了hash(key) 之外,其他都是線程不安全的。這就是HashMap線程不安全的原因。
2. HashMap應該怎樣實現線程安全呢?
除了像HashTable那樣low地爲每個方法用synchronized修改,我們可以根據每個步驟來對其進行優化。
(1) 對於一個普通的put/remove 操作,步驟有:
hash(key) --------- 線程安全
數組初始化 -------- 線程不安全,只能有一個線程在處理,可以用CAS來解決。
將該key/value值存放入某個位置 -------- 線程不安全,插入時如果爲null則用CAS 解決, 如果不爲null,可以使用synchronized(i i爲數組下標)的方式,儘量減小鎖的粒度。
(2)擴容
數組擴容 ------- 線程不安全,只能由一個線程操作,用CAS解決。
移動數據 ------- 線程不安全,必須禁止其他的增刪改查操作,之後借鑑ConcurrentHash中的方式,將每一個桶將由不同的線程去負責搬運它們的位置,將鎖的粒度減小到單個桶的範圍。
此外其他的方法都要仿照這種形式進行改造。所以對多線程來說,還是要用 ConcurrentHash 作爲更好的選擇。