1. 程式人生 > >【簡單瞭解系列】從基礎的使用來深挖HashMap

【簡單瞭解系列】從基礎的使用來深挖HashMap

HashMap定義

說的專業一點,HashMap是常用的用於儲存key-value鍵值對資料的一個集合,底層是基於對Map的介面實現。每一個鍵值對又叫Entry,這些Entry分散的儲存在一個由陣列和連結串列組成的集合中。當然在Java8中,Entry變成了Node。

說的通俗一點,就像你去住酒店,你下單提供了你的手機號,然後到酒店了給你一個房卡,你知道了你的房號之後再根據這個房號去找對應的房間一樣。

房號就是key,房間裡就是value。你通過手機號下單到酒店給你房號可以理解為對key雜湊的過程。你找的過程就是HashMap根據key取到對應value的過程

HashMap底層結構

table陣列

首先我們要知道,我們存在HashMap中的資料最終是存了什麼地方,就是如下的結構。

transient HashMap.Node<K, V>[] table;

可能有人看到transient有些陌生,被這個關鍵字修飾的變數將不會被序列化。簡單來說,就是序列化之後這個欄位的值就會被幹掉,用於一些不需要傳遞給第三方的欄位。

例如一個矩形,在本地使用的時候,有長、寬和麵積三個屬性,但是你要把這個物件給第三方用,但是由於面積可以通過另外兩個屬性推匯出來,這個key就不需要傳遞給第三方了。

這種情況就可以用transient關鍵字修飾。總的來說就是,被transient修飾的變數將不再參與序列化。

Node節點

下面是Node節點的定義。

static class Node<K, V> implements Entry<K, V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

              ......

        public final V getValue() {
            return value;
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

              ......
    }

上面的程式碼省略了一些Getter和Setter,結構還是非常清晰和簡單。可以看到這個節點儲存了下一個節點的物件的引用,形成了一個連結串列的結構。

為什麼要用連結串列?用陣列不行嗎?剛剛上面提到過,這個集合是由連結串列和陣列組成的。因為再完美的hash演算法都有可能產生雜湊衝突,所以兩個不同key的元素可以被放在同一個地方。

而單用陣列明顯不能滿足這個需求,而在陣列的槽位上存一個連結串列就可以解決這個問題。

HashMap的使用

上面簡單瞭解了HashMap的定義和基本的底層資料結構,接下來通過HashMap在平常開發中的使用來具體看看怎麼實現的。

Map<String, String> map = new HashMap<>();

map.put("搜尋關注公眾號", "SH的全棧筆記"); // 設定值
map.get("搜尋關注公眾號");               // SH的全棧筆記 

賦值

put函式

上面的Put方法,我們傳入了兩個引數,Key和Value,函式的定義如下。

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

應該跟大多數人YY的put方法差不多,put方法再呼叫了putVal 方法。

首先經過了hash之後的key,是一個整型的hashcode,其次是我們傳入的key和value。最後兩個布林值,後面會提到。

首先一進入putVal就會宣告存放資料的table,如果這個HashMap是首次設定值,就會被初始化一個預設size的table,且所有元素的初始值都是NULL,下面是初始化這塊的核心程式碼,我省略掉了一些無關的變數宣告。

有趣的是,初始化呼叫的是resize方法。

Node<K,V>[] tab; 
int n;
if ((tab = table) == null || (n = tab.length) == 0) {
  n = (tab = resize()).length;
}

newCap = 16; // 預設容量
newThr = 12; // 預設閾值

預設值為啥是16

上面初始化table的預設size給的是16,當然我們也可以自己定義,但是建議是最好是2的冪。有的朋(槓)友(精)就要問了,為什麼是16呢?我13,14不他不香嗎?我們接下來就要分析為什麼不香。

當我們放元素進入map的時候,它是如何確定元素在table陣列中的位置的呢?我們拿搜尋關注公眾號這個key舉例。

hash = (h = key.hashCode()) ^ h >>> 16
p = tab[i = n - 1 & hash]

可以看到,是將hash之後key和陣列的length-1做與運算得到了一個數組下標。而且,hash值的二進位制的位數,大多數情況下都會比table的長度的二進位制位數多。換句話說,與運算之後得到的陣列下標index完全取決於hash值的後幾位。

16 // n   10000
15 // n-1 1111
14 //     1110
13 //     1101
12 //     1100
11 //     1011
10 //     1010

從13、14的二進位制值可以看出來,存在0和1在二進位制位數上分佈不均勻的情況,這樣一來就會造成一個問題,那就是會存在某些不同的hash值經過與運算得到的值是一樣的。這樣就會導致hash到的index不均勻,換句話說有些index可能永遠都不會被hash到,而有些index也被頻繁的hash到。

本來hash演算法是要求計算的結果要均勻分佈的,但是上述的結果明顯不符合均勻分佈的要求。用n-1而不用n也是因為同樣的道理。如果這個值是2的冪,那麼2的冪的值-1的所有二進位制位數都是1,這樣有利於hash計算的均勻分佈。

綜上所述,不一定是16,2的冪都可以,16只是一個經驗值。

自動擴容

除了size,初始化的時候還會設定一個閾值,值為12,newThr = 12,這裡需要提到一個概念負載因子,HashMap的實現裡預設給的是0.75。

public HashMap() {
  this.loadFactor = 0.75F; // 12/16=0.75
}

負載因子是用來幹嘛的呢?最開始我們提到了,最開始儲存的資料結構是陣列,這種基礎結構是有size設定的。當我們不停的往map裡存資料的時候,總會存滿,當元素快存滿的時候,我們就需要擴大map的容量,來容納更多的元素,這就需要一個自動擴容的機制了。

不是擴容彈匣,想啥呢

在當資料量大於超過設定的閾值的時候(容量*負載因子),自動對map進行擴容,以存放更多的資料。

自動擴容做了什麼事情呢?總結來說就是兩件事。

  • 建立新的陣列,大小是原來陣列的一倍。
  • 將元素rehash到新的陣列

為什麼要rehash呢?上面我們提到過了,當元素被放進map時,確認下標的方法是table的長度-1和hash值做與運算,現在table的長度發生了變化,那麼自然而然,元素獲取下標的運算結果也就跟之前的不一樣了, 所以需要將老的map中的元素再按照新的table長度rehash到擴容後的table中。

所以在當你對效能有一定要求,且你知道你建立map的時候size的時候,可以指定size,這樣一來就不會因為資料量持續的增大而去頻繁的自動擴容了

put的過程中到底發生了什麼

瞭解了底層資料結構和自動擴容機制,接下來我們來看一下put過程中究竟發生了什麼。我們上面說過了,會通過陣列的長度-1和hash值與運算得到一個數組下標。

如果該位置沒有元素,那麼就很簡單,直接新建一個節點即可然後放置在資料的具體位置即可。

tab[i] = this.newNode(hash, key, value, (HashMap.Node)null);

但是如果該下標已經有元素了,這種情況HashMap是怎麼處理的呢?這也要看情況。

  • 如果是跟當前槽位相同的key,就直接覆蓋。這就是我們修改某個key的值會發生的情況。那HashMap怎麼來判斷是不是同一個key呢?就像下面這樣。p就是當前槽位上已經有的元素,如果新、老元素的key的hashCode和值都相同且key不為空,那麼就能證明這兩個key是相同的,那麼此時只需要覆蓋即可。

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

  • 而如果p是TreeNode的例項,那麼就代表當前槽位已經是一個紅黑樹了,此時只需要往這個樹裡putTreeVal即可。至於為什麼是紅黑樹,哪兒來的紅黑樹,下面馬上就要講到了。

  • 最後一種情況就是,既不是已經存在的元素也不是TreeNode的例項,也不是紅黑樹。這種情況下,它就是一個普通的Node。你可以理解為連結串列,如果hash衝突了,就把這個Node放到該位置的連結串列末尾。Java8之前採用的頭插法,而Java8換成了尾插法,至於為什麼要換,後面會講。

當該位置的連結串列中的元素超過了TREEIFY_THRESHOLD所設定的數量時,就會觸發樹化,將其轉化為紅黑樹。Java8裡給的預設值是8。

為啥要轉化成紅黑樹

首先我們要知道為什麼要樹化。當大量的資料放入Map中,Hash衝突會越來越多,某些位置就會出現一個很長的連結串列的情況。這種情況下,查詢時間複雜度是O(n) ,刪除的時間複雜度也是O(n),查詢、刪除的效率會大大降低。而同樣的資料情況下,平衡二叉樹的時間複雜度都是O(logn)。

有的朋(槓)友(精)看到這個小標題不樂意了,怎麼就直接用紅黑樹了?我用二叉查詢樹它不香嗎?

不瞭解二叉查詢樹的,我把它的特點列在了下面。

  • 左子樹上的所有節點的值都小於根節點的值

  • 右子樹上的所有節點的值都大於根節點的值

再精簡一下就是,左小右大

但是,如果資料大量的趨近於有序,例如所有的節點都比根節點大,那這個時候二叉查詢樹就退化成了連結串列,查詢效率就會急劇下降。看到這是不是覺得有點不對,我才從連結串列樹化,你這又給我退化成了連結串列?

朋友看到這又不樂意了,好好好,就算二叉查詢樹不行,那AVL樹它也不行?用了AVL樹就不會出現上面所描述的效率急劇退化的情況了不是嗎?

的確是這樣,AVL也可以叫平衡二叉搜尋樹。AVL樹會在其有退化成連結串列的趨勢的時候(左右子樹的高度差超過某個閾值)調整樹的結構,也就是通過左旋和右旋來使其左右子樹的高度儘量平衡。

OK,OK,就算你解釋清楚了為什麼要樹化,那為什麼一定要用紅黑樹?

具體的細節也就不在這裡贅述,不知不覺已經寫了這麼多了,直接說結論吧。AVL樹的查詢速度更快,但是相應的插入和修改的速度較慢。而紅黑樹則在插入和修改操作較為密集的時候表現更好。

而總結我們日常的HashMap使用,大多數情況下插入和修改應該是比查詢更頻繁一些的。而在這種情況下,紅黑樹的綜合表現會更好一些。

至於紅黑樹的相關細節,涉及的東西還是挺多,我之後會單獨拿一個篇幅來講。

為什麼要用尾插法

我們目前用的最多的是Java8,在Java8中採用的是尾插法,Java8之前採用的是頭插法。

那為什麼後面又變成了尾插法呢?放心,肯定不是設計者閒的蛋疼,沒事來改個設計。這樣做一定是有一定的道理的。在解釋這個問題之前,我們先來看看,如果採取頭插法在多執行緒下的情況下會出現什麼問題。

我們講過,假設陣列中index=1的位置已經有了元素A,之後又有元素B被分配到了index=1的位置。那麼在下標為1的槽位上的連結串列就變成了B -> A。

此時再分配了一個新元素C,連結串列又被更新成了C -> B -> A。這也是為什麼叫頭插法,新的元素會被放在連結串列的頭節點,因為當時設計的時候考慮到後被放入map的元素被訪問的可能性更大。

上面講到了在當不停的往map中放置元素後,超過了設定的閾值,就會觸發自動擴容。此時會觸發兩個操作,一是建立一個容量為之前兩倍的底層陣列,並且將老的陣列中的元素rehash到新的陣列中。

而由於陣列的長度發生了變化,這就導致了元素的rehash結果跟之前在老陣列中的位置不一樣。

首先我們來模擬一下rehash的過程,假設新的陣列中下標為2的槽位是空的。

  • 首先元素C,被放置在了其他位置。

  • 然後元素B,被rehash到了下標為2的槽位, 至此都沒有問題。

  • 最後元素A,同樣被rehash到了下標為2的槽位,此時連結串列變成了A -> B。到這就有問題了,最開始B的next指向的是A節點。但是rehash之後A的next又指向B,看到這你應該就能明白髮生了什麼。

我看到很多的對JDK1.7版的HashMap在多執行緒的情況下擴容會出現死鎖的解釋都只到了環形連結串列。但是其實就算是環形連結串列,只要找到了對應的元素,就會直接退出迴圈的邏輯,也不會造成死迴圈。

實際情況是,當自動擴容形成了環形連結串列後,當你去Get了一個在entry鏈上不存在的元素時,就會出現死迴圈的情況。

取值

上面聊了給HashMap賦值的大概過程,接下來聊一下從HashMap獲取值會發生什麼。get方法的開始,跟put一樣很簡單。

public V get(Object key) {
  Node<K,V> e;
  return (e = getNode(hash(key), key)) == null ? null : e.value;
}

可以看到,取值的核心操作是getNode來負責完成的。

首先第一件事就是去check的第一個元素是不是當前查詢的元素。

如果不是,而且當前槽位已經被樹化成了紅黑樹,就走紅黑樹的getTreeNode方法。

如果還沒有被樹化,只是普通的連結串列,則順著next一路找下去。

由於get方法邏輯和實現都比較容易理解,就不貼太多原始碼了。

結尾

由於最近太忙了,工作和生活中的事都巨多,這篇文章是幾周利用零零散散的時間寫出來的,如果有什麼問題,歡迎大家在評論區討論。

如果你覺得這篇文章對你有幫助,還麻煩點個贊,關個注,分個享,留個言

也可以微信搜尋公眾號【SH的全棧筆記】,當然也可以直接掃描二維碼關注

拜了個拜