HashMap1.7和1.8,紅黑樹原理!

jdk 1.7

概述

HashMap基於Map介面實現,元素以鍵值對的方式存儲,並允許使用null鍵和null值,但只能有一個鍵作為null,因為key不允許重複,另外HashMap不能保證放入元素的數據,它是無序的,和放入的順序並不能相同,HashMap是執行緒不安全的。

繼承關係

public class HashMap<K,V>extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable

基本屬性

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //默認初始化大小 16 
static final float DEFAULT_LOAD_FACTOR = 0.75f;     //負載因子0.75
static final Entry<?,?>[] EMPTY_TABLE = {};         //初始化的默認數組
transient int size;     //HashMap中元素的數量
int threshold;          //判斷是否需要調整HashMap的容量 

HashMap的數據存儲結構

HashMap有數組和鏈表來實現對數據的存儲,HashMap採用Entry數組來存儲key-value對,每一個鍵值對組成了一個Entry實體,Entry類實際上是一個單向的鏈表結構,它具有Next指針,可以鏈接下一個Entry實體,以次來解決Hash衝突的問題。

數組存儲區間是連續的,佔用記憶體嚴重,故空間複雜的很大。但數組的二分查找時間複雜度小,為O(1);數組的特點是:定址容易,插入和刪除困難;

鏈表存儲區間離散,佔用記憶體比較寬鬆,故空間複雜度小,但時間複雜度很大,達 O(N) 。鏈表的特點是:定址困難,插入和刪除容易。

從上圖可以發現數組結構是由數組+鏈表組成,一個長度為16的數組中,每個元素存儲的是一個鏈表的頭節點。那麼這些元素是按照什麼樣的規矩存儲到數組中?

通過hash(key.hashCode())%length 獲得,也就是元素的key的哈希值對數組長度取模得到。比如上述哈希表中,12%16=12,28%16=12,108%16=12,140%16=12。所以12、28、108以及140都存儲在數組下標為12的位置。

取模運算的方式固然簡單,但是效率很低。為了實現高效的HashMao演算法,HashMap的發明者採用了位運算的方式。

公式: index = HashCode(Key) & (Length – 1)

以值為「book」的key來演示整個過程:

1.計算book的hashcode,結果為十進位的3029737,二進位的101110001110101110 1001。

2.假定HashMap長度是默認的16,計算Length-1的結果為十進位的15,二進位的1111。

3.把以上兩個結果做與運算,101110001110101110 1001 & 1111 = 1001,十進位是9,所以 index=9。

可以說,Hash演算法最終得到的index結果,完全取決於Key的Hashcode值的最後幾位。

這樣做不但效果上等同於取模,而且還大大提升了性能。

HashMap裡面實現一個靜態內部類Entry,其重要的屬性有 hash,key,value,next。

HashMap裡面用到鏈式數據結構的一個概念。上面我們提到過Entry類裡面有一個next屬性,作用是指向下一個Entry。打個比方, 第一個鍵值對A進來,通過計算其key的hash得到的index=0,記做:Entry[0] = A。一會後又進來一個鍵值對B,通過計算其index也等於0,現在怎麼辦?HashMap會這樣做:B.next = A,Entry[0] = B,如果又進來C,index也等於0,那麼C.next = B,Entry[0] = C;這樣我們發現index=0的地方其實存取了A,B,C三個鍵值對,他們通過next這個屬性鏈接在一起。

Put方法的原理

比如調用 hashMap.put(“apple”, 0) ,插入一個Key為「apple”的元素。這時候我們需要利用一個哈希函數來確定Entry的插入位置(index):

假定最後計算出的index是2,那麼結果如下:

但是,因為HashMap的長度是有限的,當插入的Entry越來越多時,再完美的Hash函數也難免會出現index衝突的情況。比如下面這樣:

可以利用鏈表來解決。

HashMap數組的每一個元素不止是一個Entry對象,也是一個鏈表的頭節點。每一個Entry對象通過Next指針指向它的下一個Entry節點。當新來的Entry映射到衝突的數組位置時,只需要插入到對應的鏈表即可:

需要注意的是,新來的Entry節點插入鏈表時,使用的是「頭插法」。

Get方法的原理

首先會把輸入的Key做一次Hash映射,得到對應的index:

由於剛才所說的Hash衝突,同一個位置有可能匹配到多個Entry,這時候就需要順著對應鏈表的頭節點,一個一個向下來查找。假設我要查找的Key是「apple」:

第一步,查看的是頭節點Entry6,Entry6的Key是banana,顯然不是我要找的結果。

第二步,查看的是Next節點Entry1,Entry1的Key是apple,正是我要找的結果。

之所以把Entry6放在頭節點,是因為HashMap的發明者認為,後插入的Entry被查找的可能性更大

高並發下的HashMap

HashMap的容量是有限的。當經過多次元素插入,使得HashMap達到一定飽和度時,Key映射位置發生衝突的幾率會逐漸提高。

這時候,HashMap需要擴展它的長度,也就是進行Resize

影響發生Resize的因素有兩個:

1.Capacity

HashMap的當前長度。HashMap的長度是2的冪。

2.LoadFactor

HashMap負載因子,默認值為0.75f。

衡量HashMap是否進行Resize的條件如下:

*HashMap.Size >= Capacity LoadFactor

HashMap的Rezie不是簡單的吧長度擴大,而是經過兩個步驟

1.擴容

創建一個新的Entry空數組,長度是原數組的2倍。

2.ReHash

遍歷原Entry數組,把所有的Entry重新Hash到新數組。為什麼要重新Hash呢?因為長度擴大以後,Hash的規則也隨之改變。

回顧一下Hash公式:

index = HashCode(Key) & (Length – 1)

當原數組長度為8時,Hash運算是和111B做與運算;新數組長度為16,Hash運算是和1111B做與運算。Hash結果顯然不同。

Resize前的HashMap:

Resize後的HashMap:

ReHash的Java程式碼如下:

/**
 * Transfers all entries from current table to newTable.
 */
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

單執行緒下執行沒有問題,多執行緒下的Rehash有問題!

假設一個HashMap已經到了Resize的臨界點。此時有兩個執行緒A和B,在同一時刻對HashMap進行Put操作:

此時達到Resize條件,兩個執行緒各自進行Rezie的第一步,也就是擴容:

這時候,兩個執行緒都走到了ReHash的步驟。回顧一下ReHash的程式碼:

假如此時執行緒B遍歷到Entry3對象,剛執行完紅框里的這行程式碼,執行緒就被掛起。對於執行緒B來說:

e = Entry3

next = Entry2

這時候執行緒A暢通無阻地進行著Rehash,當ReHash完成後,結果如下(圖中的e和next,代表執行緒B的兩個引用):

直到這一步,看起來沒什麼毛病。接下來執行緒B恢復,繼續執行屬於它自己的ReHash。執行緒B剛才的狀態是:

e = Entry3

next = Entry2

當執行到上面這一行時,顯然 i = 3,因為剛才執行緒A對於Entry3的hash結果也是3。

我們繼續執行到這兩行,Entry3放入了執行緒B的數組下標為3的位置,並且e指向了Entry2。此時e和next的指向如下:

e = Entry2

next = Entry2

整體情況如圖所示:

接著是新一輪循環,又執行到紅框內的程式碼行:

e = Entry2

next = Entry3

整體情況如圖所示:

接下來執行下面的三行,用頭插法把Entry2插入到了執行緒B的數組的頭結點:

整體情況如圖所示:

第三次循環開始,又執行到紅框的程式碼:

e = Entry3

next = Entry3.next = null

最後一步,當我們執行下面這一行的時候 !

newTable[i] = Entry2

e = Entry3

Entry2.next = Entry3

Entry3.next = Entry2

鏈表出現了環形!

整體情況如圖所示:

此時,問題還沒有直接產生。當調用Get查找一個不存在的Key,而這個Key的Hash結果恰好等於3的時候,由於位置3帶有環形鏈表,所以程式將會進入死循環

在高並發下通常使用ConcurrentHashMap,這個集合類兼顧了執行緒安全和性能。

總結:

1.Hashmap在插入元素過多的時候需要進行Resize,Resize的條件是

HashMap.Size >= Capacity * LoadFactor。

2.Hashmap的Resize包含擴容和ReHash兩個步驟,ReHash在並發的情況下可能會形成鏈表環。

JDK 1.8

HashMap採用數組+鏈表+紅黑樹實現。

在Jdk1.8中HashMap的實現方式做了一些改變,但是基本思想還是沒有變得,只是在一些地方做了優化,數據結構的存儲由數組+鏈表的方式,變化為數組+鏈表+紅黑樹的存儲方式,當鏈表長度超過閾值(8)時,將鏈錶轉換為紅黑樹。在性能上進一步得到提升。

執行構造函數,當我們看到這個new,第一反應應該是在堆記憶體里開闢了一塊空間。

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

構造方法:

 public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

初始化了一個負載因子

static final float DEFAULT_LOAD_FACTOR = 0.75f;

負載因子默認為0.75f

transient Node<K,V>[] table;

看到了數組,數組裡原對象是Node,來看下

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;		//key,value,用來存儲put的key,value值的
    Node<K,V> next; // next ,用來標記下一個元素
	
    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;			//構造函數
        this.next = next;
    }

put方法解析:

public V put(K key, V value) {
    //調用putVal()方法完成
    return putVal(hash(key), key, value, false, true);
}
 
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //判斷table是否初始化,否則初始化操作
    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) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                //key存在,直接覆蓋
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    //記錄修改次數
    ++modCount;
    //判斷是否需要擴容
    if (++size > threshold)
        resize();
    //空操作
    afterNodeInsertion(evict);
    return null;
}

如果存在key節點,返回舊值,如果不存在則返回Null。

紅黑樹

首先需要理解二叉查找樹(Binary Search Tree)

二叉查找樹(BST)具備的特性

1.子樹上所有結點的值均小於或等於它的根結點的值。

2.子樹上所有結點的值均大於或等於它的根結點的值。

3.左、右子樹也分別為二叉排序樹。

下圖中這棵樹,就是一顆典型的二叉查找樹:

比如我要查找值為10的節點:

1、查看根節點9

2、由於10 > 9,因此查看右孩子13

3、由於10 < 13,因此查看左孩子11

4.由於10 < 11,因此查看左孩子10,發現10正是要查找的節點:

這種方式正是二分查找的思想,查找所需的最大次數等同於二叉查找樹的高度。

在插入節點的時候也是利用類似的方法,通過一層一層比較大小,找到新節點適合插入的位置。

但二叉查找樹存在缺陷,如:

假設初始的二叉查找樹只有三個節點,根節點值為9,左孩子值為8,右孩子值為12:

接下來我們依次插入如下五個節點:7,6,5,4,3。依照二叉查找樹的特性,結果會變成如下這樣:

這樣的形態雖然也符合二叉查找樹的特性,但是查找的性能大打折扣,幾乎變成的線性。

如何解決二叉查找樹多次插入新節點而導致的不平衡?紅黑樹應運而生了。

紅黑樹(Red Black Tree) 是一種自平衡的二叉查找樹。除了符合二叉查找樹的基本特性外,它還具備下列的附加特性:

1、節點是紅色或黑色。

2、根節點是黑色。

3、每個葉子節點都是黑色的空節點(NIL節點)。

4 、每個紅色節點的兩個子節點都是黑色。(從每個葉子到根的所有路徑上不能有兩個連續的紅色節點)

5、從任一節點到其每個葉子的所有路徑都包含相同數目的黑色節點。

這張圖就是典型的紅黑樹!

正是因為這些規矩限制,才保證了紅黑樹的自平衡。紅黑樹從根到葉子的最長路徑不會超過最短路徑的2倍。

當插入或刪除節點的時候,紅黑樹的規則有可能被打破。這時候就需要做出一些調整,來繼續維持我們的規則。

什麼情況下會破壞紅黑樹的規則,什麼情況下不會破壞規則呢?舉兩個簡單的例子:

1、向原紅黑樹插入值為14的新節點:

由於父節點15是黑色節點,因此這種情況並不會破壞紅黑樹的規則,無需做任何調整。

2、向原紅黑樹插入值為21的新節點:

由於父節點22是紅色節點,因此這種情況打破了紅黑樹的規則4(每個紅色節點的兩個子節點都是黑色),必須進行調整,使之重新符合紅黑樹的規則。

調整有兩種方法:[變色]和[旋轉]。而旋轉又分成了兩種形式:[左旋轉]和[右旋轉]。

變色

為了重新符合紅黑樹的規則,嘗試把紅色節點變為黑色,或者把黑色節點變為紅色。

下圖所表示的是紅黑樹的一部分,需要注意節點25並非根節點。因為節點21和節點22連續出現了紅色,不符合規則4,所以把節點22從紅色變成黑色:

但這樣並不算完,因為憑空多出的黑色節點打破了規則5,所以發生連鎖反應,需要繼續把節點25從黑色變成紅色:

此時仍然沒有結束,因為節點25和節點27又形成了兩個連續的紅色節點,需要繼續把節點27從紅色變成黑色:

左旋轉:

逆時針旋轉紅黑樹的兩個節點,使得父節點被自己的右孩子取代,而自己成為自己的左孩子。看下圖:

圖中,身為右孩子的Y取代了X的位置,而X變成了自己的左孩子。此為左旋轉。

右旋轉:

順時針旋轉紅黑樹的兩個節點,使得父節點被自己的左孩子取代,而自己成為自己的右孩子。看下圖:

圖中,身為左孩子的Y取代了X的位置,而X變成了自己的右孩子。此為右旋轉。

紅黑樹的插入和刪除包含很多種情況,每一種情況都有不同的處理方式。在這裡舉個典型的例子,體會一下!

我們以剛才插入節點21的情況為例:

首先,我們需要做的是變色,把節點25及其下方的節點變色:

此時 節點17 和 節點25 是連續的兩個紅色節點,那麼把節點17變成黑色節點?恐怕不合適。這樣一來不但打破了規則4,而且根據規則2(根節點是黑色),也不可能把節點13變成紅色節點。

變色已無法解決問題,把節點13看著X,把節點17看著Y,想剛才的示意圖那樣進行左旋轉:

由於根節點必須是黑色節點,所以需要變色,變色結果如下:

這樣就結束了嗎?並沒有。因為其中兩條路徑(17 -> 8 -> 6 -> NIL)的黑色節點個數是4,其他路徑的黑色節點個數是3,不符合規則5。

這時候我們需要把節點13看做X,節點8看做Y,像剛才的示意圖那樣進行右旋轉

最後根據規則來進行變色

如此一來,紅黑樹變得重新符合規矩。 這一個例子的調整過程比較複雜,經歷了如下步驟: 變色 -> 左旋轉 -> 變色 -> 右旋轉 -> 變色

紅黑樹的應用有很多,除了HashMap,jdk 的集合類TreeMap和TreeSet 底層就是紅黑樹實現的。

幾點說明:

1、關於紅黑樹自平衡的調整,插入和刪除節點的時候都涉及到很多種Case,由於篇幅原因無法展開來一一列舉,有興趣的朋友可以參考維基百科,裡面講的非常清晰。

2、紅黑樹調整過程的示例是一種比較複雜的情形,沒太看明白的小夥伴也不必鑽牛角尖,關鍵要懂得紅黑樹自平衡調整的主體思想。

參考文章

//juejin.im/post/5a27c6946fb9a04509096248

//zhuanlan.zhihu.com/p/28501879

//blog.csdn.net/qq_41345773/article/details/92066554

Tags: