1. 程式人生 > 其它 >HashMap 原始碼分析

HashMap 原始碼分析

瞭解 HashMap

簡單操作

Map<Integer, String> map = new HashMap<>();
map.put(1,"張三");
map.put(2,"李四");
System.out.println(map.get(1));

當進行如上操作的時候,即呼叫put()方法的時候,會對key進行一個hashCode()方法的運算,獲取key的hash值。常規的一個做法,就是用這個hash值對陣列的長度進行取模,根據取模的結果,將key-value對放在陣列中的某個元素上
map.get(1)這個方法,同理的,會對key獲取一個hash值,根據hash值對陣列長度的取模,就知道這個key對應的key-value對在哪裡,就可以直接根據hash值定位到陣列中的元素,然後就返回了,效能很高
如果說,某兩個key,對應的hash值,是一樣的,怎麼辦呢?


如果說兩個值的hash值是一樣的,但是這兩個key值不一樣,hash值一樣會導致他們放到同一個陣列的索引位置上,在 jdk1.8 以前,採用連結串列。如果說有很多的 hash 衝突,也就是說多個 key 的 hash 值是一樣的,或者也可能是多個 key 的 hash 值不一樣,但是不同的 hash 值對一個數組的 length 取模,獲取到的這個陣列的 index 位置,是一樣的比如說map.put(1,"zs")取模以後 index 的位置是 5,而map.get(4,"ls")取模後 index 同樣為 5,此時會導致它的這個元素掛在陣列上,形成一個連結串列;jdk1.8以後,優化了一下,如果一個連結串列的長度超過了8,就會自動將連結串列轉換為紅黑樹,查詢的效能O(logN),比連結串列O(N)要高,若長度小於了6,又會退化為連結串列

總結:jdk1.8之前,hashmap 的資料結構是 陣列 + 連結串列;jdk1.8及以後,hashmap的資料結構是 陣列 + 連結串列/紅黑樹

幾個關鍵點

  • static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
    陣列的預設初識大小,是16
  • static final float DEFAULT_LOAD_FACTOR = 0.75f;
    這個引數是負載因子,如果你在數組裡的元素個數達到了陣列大小(16) * 負載因子(0.75),預設是達到12個元素,就會進行陣列的擴容
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
}

這是一個很關鍵的內部內,它其實是代表了一個key-value對,裡面包含了 key 的hash 值,key,value,還有就是可以有一個 next 的指標指向下一個Node,也就是指向單向連結串列中的下一個節點,通過這個指標就可以形成一個連結串列

  • transient Node<K,V>[] table;
    Node<K,V>[],這個陣列就是所謂的 map 裡的核心資料結構的陣列,陣列的元素就可以看到是 Node 型別的,天然就可以掛成一個連結串列(單項鍊表),Node裡面只有一個 next 指標
  • transient int size;
    這個 size 代表的就是當前 hashmap 中有多少個 key-value 對,如果這個數量達到了指定大小 * 負載因子,那麼就會進行陣列的擴容
  • int threshold;
    threshold = capatity * loadFactory,threshold表示當HashMap的size大於threshold時會執行resize操作。
  • final float loadFactor;
    預設就是負載因子,預設的值是 0.75f,也可以直接指定,如果你指定的值越大,一般就越是拖慢擴容的速度,一般不要修改

hash 演算法

使用 map.put(key,value),對 key 進行 hash 演算法,通過 hash 獲取到對應的陣列中的 index 位置。hash 演算法是怎麼來玩的,是簡單的 key 的 hash值即 key.hashCode() 方法返回一個值嗎?答案是否定的。

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

hash(key),對 key 進行 hash 獲取一個對應的 hash 值,key、value傳入到 putVal() 方法裡面去,將 key-value 對根據其 hash 值找到對應的陣列位置
hash(key)方法,裡面的演算法是什麼?JDK 原始碼裡面,涉及到了大量的位運算,下面這個方法計算出了 key 的 hash 值

static final int hash(Object key) {
   int h;
   return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

eg:

1111 1111 1111 1111 1111 1010 0111 1100
# h >>> 16,這個是位運算的操作,這個東西是把 32 位的二進位制的數字,所有的 bit 往右側右移了 16 位

         1111 1111 1111 1111 1111 1010 0111 1100
         0000 0000 0000 0000 1111 1111 1111 1111
^ 得到:  1111 1111 1111 1111 0000 0101 1000 0011

怎麼做的目的,其實是考慮到,將它的高 16 位和低 16 位進行一個異或運算,結論:後面在用到這個 hash 值定位到陣列的 index 的時候,也有一個位運算,但是呢,一般那個後面的位運算,一般都是用低 16 位在進行運算,所以說如果你不把 hash 值的高16位和低16位進行運算的話,那麼就會導致你後面在通過 hash 值找到陣列 index 的時候,只有 hash 值的低16位參與了運算
一個結論:提前在 hash() 函式裡面,把高16位和低16位進行一下異或運算,就可以保證說,在hash值的低16位裡面,可以同時保留它的高16位和低16位的特徵。相當於是,在後面定位到陣列index的位運算的時候,哪怕只有低16位參與了運算,其實運算的時候,它的hash值的高16位和低16位的特徵都參與到了運算定位到那個陣列的index
這麼做的好處:為什麼要保證同時將高16位和低16位的特徵同時納入運算,考慮到陣列index的定位中去呢?因為這樣子可以保證降低hash衝突的概率,如果說直接用hash值的低16位去運算定位到陣列index的話,可能會導致一定的hash衝突。有很多的key,可能值不同,但是hash值可能是相同的,如果key不同,但是hash值相同,或者是hash值不同,但是到陣列的index相同,那麼會出現hash衝突,通過上面的這個操作,計算出來非hash值可以降級hash衝突概率

put操作原理&hash的定址演算法

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

假設 hashmap 是空的,陣列大小就是預設的16

if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;

剛開始 table陣列是空的,所以會分配一個預設大小的一個數組,陣列大小是16,負載因子是0.75,threshold是12

對於hash的定址演算法,並不是說用hash值陣列大小取模,取模就可以將任意一個hash值定位到陣列的一個index,取模的操作效能不是很高。對於hash的定址演算法,採用的是位運算,通過位運算來達到取模的效果,
對於 hashmap,它的初始值以及未來每次擴容的值,都是2的N次方,也就是說他後面每次擴容,陣列的大小就是2的N次方,只要保證陣列的大小是2的N次方,也就可以保證說,hash & (n-1) 與 hash % n 取模的效果是一樣的,也就是說,通過 hash&(n-1) 就可以將任意一個hash值定位到陣列的某個index裡去
因為不想用取模,取模的效能相對較低,所有采用了位運算,這是hashmap 的一個提升效能的優化點,這也是 hashmap 底層原理裡面的重要部分

if ((p = tab[i = (n - 1) & hash]) == null)
     tab[i] = newNode(hash, key, value, null);

i = (n - 1) & hash,i就是最後定址演算法獲取到的那個hash值對應的陣列的index,tab[i]直接定位到陣列的那個位置,對於hashmap,剛開始肯定是空的,就直接建立了一個 Node 出來,代表了一個 key-value 對,放在陣列的那個位置就可以了

hash衝突時,連結串列處理

假設說,某兩個key的 hash 值是一樣的,兩個key不同,hash值一樣,這個概率其實是很低的,除非你自己亂寫了 hashCode()方法,你自己人為的製造了兩個不同的key,但是hash值一樣。
兩個key的hash值不一樣,但是通過定址演算法定位到了陣列的同一個key上去,此時就會出現典型的hash衝突,預設情況下,會向單鏈表來處理

if ((p = tab[i = (n - 1) & hash]) == null)

這個分支,它的意思是說tab[i],i就是hash值定位到的陣列index,tab[i]如果為空,也就是hash定位到的這個位置是空的,之前沒有任何人在這裡,此時直接放進去一個Node在陣列的這個位置即可
如果進入了 else 分支,就說明通過hash定位到陣列的位置,是已經有了Node了。

if (p.hash == hash &&
     ((k = p.key) == key || (key != null && key.equals(k))))
      e = p;

// 如果滿足了上述條件,說明是相同的key,覆蓋舊的value
// map.put(1,"張三")
// map.put(1,"李四")

if (e != null) { // existing mapping for key
    // 張三就是 oldValue
    V oldValue = e.value;
    if (!onlyIfAbsent || oldValue == null)
        // value是新的值,是李四
        // e.value = value,也就是將陣列那個位置的Node的value設定為了新的李四這個值
        e.value = value;
    afterNodeAccess(e);
    return oldValue;
}

// 上述那塊程式碼,其實說白了,就是相同的key在進行value的覆蓋

如果上面那個 if 不成立,說明人家的key是不一樣的,hash值不一樣或者是key不一樣

// 這個分支是說,如果這個位置已經是一顆紅黑樹的話,會怎麼來處理
else if (p instanceof TreeNode)

進入到else 這個分支,才是說,key不一樣,出現了hash衝突,然後此時還不是紅黑樹的資料結構,還是連結串列的資料結構,在這裡會通過連結串列來處理

if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
      treeifyBin(tab, hash);
    break;

上述那串程式碼,就是說如果當前連結串列的長度(binCount),大於等於了 TREEIFY_THRESHOLD-1 的話,如果連結串列的長度大於等於8的話,那麼此時就需要將這個連結串列轉換為一個紅黑樹的資料結構了

jdk1.8引入紅黑樹

如果說出現了大量的hash衝突之後,假設給某個位置掛一個連結串列特別的長,就很噁心了,如果連結串列太長的話,會導致有一些 get() 操作的時間複雜度就是 O(n),正常來說,table[i]陣列索引直接定位的方式的話,O(1)。但是如果連結串列,大量的 key 衝突,會導致 get() 操作的效能急劇下降,導致很多問題。
所以說 jdk1.8 以後,人家優化了這塊東西,會判斷,如果連結串列的長度達到了8的時候,那麼就會將連結串列轉換為紅黑樹,如果用紅黑樹的話,get()操作,即使對一個很大的紅黑樹進行查詢,那麼時間複雜度會變成 O(logN),效能會比連結串列的 O(N)得到很大的提升
當你遍歷到第8個節點,此時binCount是7,同時你掛上第9個節點,然後會發現binCount>=7,達到了臨界值,也就是說,當你的連結串列節點的數量超過了8的時候,此時就會將連結串列轉換成紅黑樹

if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
     treeifyBin(tab, hash);

結論:先是掛連結串列,當長度超過了8,就將連結串列轉換成紅黑樹

rehash演算法

jdk1.8以後,為了提升 rehash 這個過程的效能,不是說簡單的用key 的 hash 值對新陣列.length 取模,由於取模的效能較低,jdk1.8以後 hash 定址這塊,統一都是用的位操作

n - 1	0000 0000 0000 0000 0000 0000 0000 1111
hash1   1111 1111 1111 1111 0000 1111 0000 0101
&結果    0000 0000 0000 0000 0000 0000 0000 0101	 = 5(index = 5的位置)

n - 1	0000 0000 0000 0000 0000 0000 0000 1111
hash2	1111 1111 1111 1111 0000 1111 0001 0101
&結果	0000 0000 0000 0000 0000 0000 0000 0101 = 5(index = 5的位置)

此時,上面兩個 hash 值會出現 hash 碰撞的問題,使用連結串列或者是紅黑樹來解決
如果陣列長度擴容到 32 之後,重新對每個 hash 值進行定址操作,也就是用每個 hash 值跟新陣列的 length-1 進行與操作

n-1 	0000 0000 0000 0000 0000 0000 0001 1111
hash1	1111 1111 1111 1111 0000 1111 0000 0101
&結果	0000 0000 0000 0000 0000 0000 0000 0101 = 5(index = 5的位置)

n-1 	0000 0000 0000 0000 0000 0000 0001 1111
hash2	1111 1111 1111 1111 0000 1111 0001 0101
&結果	0000 0000 0000 0000 0000 0000 0001 0101 = 21(index = 21的位置)

hash2 的位置由原來的 5 變成了 21
也就是說,jdk1.8,擴容一定是2的倍數,從16到32到64到128
就可以保證說,每次擴容之後,你的每個hash 值要麼是停留在原來的那個index的地方,要麼是變成了原來的index(5)+oldCap(16)=21
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
         oldCap >= DEFAULT_INITIAL_CAPACITY)
    newThr = oldThr << 1; // double threshold
# 如果要對 hashmap 進行擴容的話
# newThr = oldThr << 1;就是乘以2,新陣列是老陣列的2倍
# 如果 e.next 是 null 的話,這個位置的元素既不是連結串列也不是紅黑樹
# 那麼此時就是用 e.hash & (newCap - 1),進行與運算,直接定位到新陣列的某個位置,然後直接就放在新數組裡了
if (e.next == null)
	newTab[e.hash & (newCap - 1)] = e;
# 如果這個位置是一個紅黑樹的話
# 此時會呼叫 split() 方法,人家肯定會去裡面遍歷這顆紅黑樹,
# 然後將裡面每個節點都進行重新 hash 定址,找到新陣列的某個位置
else if (e instanceof TreeNode)
	((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
# 進入這個分支的話,證明是連結串列
# 這塊的原理,就說說會判斷一下,如果是一個連結串列裡的元素的話
# 那麼要麼是直接放在新陣列的原來的那個index
# 要麼是原來的index + oldCap
else { // preserve order
	Node<K,V> loHead = null, loTail = null;
	Node<K,V> hiHead = null, hiTail = null;
	Node<K,V> next;
	do {
		next = e.next;
		if ((e.hash & oldCap) == 0) {
			if (loTail == null)
				loHead = e;
			else
				loTail.next = e;
			loTail = e;
		}
		else {
			if (hiTail == null)
				hiHead = e;
			else
				hiTail.next = e;
			hiTail = e;
		}
	} while ((e = next) != null);
	if (loTail != null) {
		loTail.next = null;
		newTab[j] = loHead;
	}
	if (hiTail != null) {
		hiTail.next = null;
		newTab[j + oldCap] = hiHead;
	}
}