喫透Java集合系列九:HashMap

一:HashMap的整體實現

HashMap是由Hash表來實現的,數組+鏈表(1.8加入紅黑樹)的方式實現的,通過key的hash值與數組長度取餘來獲取應插入數組的下標,如果產生Hash衝突,在原下標位置轉爲鏈表,當鏈表長度到達8並且數組長度大於等於64則轉爲紅黑樹。通過以上描述我們提以下問題:

1、什麼是Hash表
我們知道數組的特點是:尋址容易,插入和刪除困難。
鏈表的特點是:尋址困難,插入和刪除容易。
那麼我們能不能綜合兩者的特性,做出一種尋址容易,插入刪除也容易的數據結構?答案是肯定的,這就是我們要提起的哈希表,哈希表有多種不同的實現方法,HashMap中最常用的一種方法——拉鍊法,我們可以理解爲“鏈表的數組”,如圖:

 

 

 

2、JDK1.8爲什麼引入紅黑樹?
Hash算法設計的再合理,也免不了會出現拉鍊過長的情況,一旦出現拉鍊過長,則會嚴重影響HashMap的性能。於是,在JDK1.8版本中,對數據結構做了進一步的優化,引入了紅黑樹。而當鏈表長度太長(默認超過8)時,鏈表就轉換爲紅黑樹利用紅黑樹快速增刪改查的特點提高HashMap的性能,其中會用到紅黑樹的插入、刪除、查找等算法。
深入瞭解紅黑樹請移步這裏https://blog.csdn.net/v_july_v/article/details/6105630

3、用什麼方式解決Hash衝突?
解決Hash衝突方法有:開放地址法(線性探測再散列,二次探測再散列,僞隨機探測再散列)、再哈希法、鏈地址法、建立公共溢出區。
HashMap使用鏈地址法來解決Hash衝突。

 

 

 

二:字段信息

transient Node<K,V>[] table;
transient int size;
transient int modCount;
int threshold;
final float loadFactor;

table是Hash表的數組結構,初始默認大小爲16,裏面存儲Node信息

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;    //用來定位數組索引位置
        final K key;
        V value;
        Node<K,V> next;   //鏈表的下一個node

        Node(int hash, K key, V value, Node<K,V> next) { ... }
        public final K getKey(){ ... }
        public final V getValue() { ... }
        public final String toString() { ... }
        public final int hashCode() { ... }
        public final V setValue(V newValue) { ... }
        public final boolean equals(Object o) { ... }
}

Node是HashMap的一個內部類,實現了Map.Entry接口,本質是就是一個映射(鍵值對)。
Load factor爲負載因子(默認值是0.75),threshold是HashMap所能容納的最大數據量的Node(鍵值對)個數。threshold = length * Load factor。也就是說,在數組定義好長度之後,負載因子越大,所能容納的鍵值對個數越多。

threshold就是在此Load factor和length(數組長度)對應下允許的最大元素數目,超過這個數目就重新resize(擴容),擴容後的HashMap容量是之前容量的兩倍。默認的負載因子0.75是對空間和時間效率的一個平衡選擇,建議不要修改,除非在時間和空間比較特殊的情況下,如果內存空間很多而又對時間效率要求很高,可以降低負載因子Load factor的值;相反,如果內存空間緊張而對時間效率要求不高,可以增加負載因子loadFactor的值,這個值可以大於1。

size是HashMap中實際存在的鍵值對數量,而modCount字段主要用來記錄HashMap內部結構發生變化的次數,主要用於迭代的快速失敗。強調一點,內部結構發生變化指的是結構發生變化,例如put新鍵值對,但是某個key對應的value值被覆蓋不屬於結構變化。

三:HashMap的hash方法原理

 

 

 

 

 

 

四:put方法

判斷鍵值對數組table[i]是否爲空或爲null,否則執行resize()進行擴容。
根據鍵值key計算hash值得到插入的數組索引i,如果table[i]==null,直接新建節點添加,轉向步驟6,如果table[i]不爲空,轉向步驟3。
判斷table[i]的首個元素是否和key一樣,如果相同直接覆蓋value,否則轉向步驟4,這裏的相同指的是hashCode以及equals。
判斷table[i] 是否爲treeNode,即table[i] 是否是紅黑樹,如果是紅黑樹,則直接在樹中插入鍵值對,否則轉向步驟5。
遍歷table[i],判斷鏈表長度是否大於8,大於8的話把鏈表轉換爲紅黑樹,在紅黑樹中執行插入操作,否則進行鏈表的插入操作;遍歷過程中若發現key已經存在直接覆蓋value即可。
插入成功後,判斷實際存在的鍵值對數量size是否超多了最大容量threshold,如果超過,進行擴容。

public V put(K key, V value) {
        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;
        // 步驟1:tab爲空則創建
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 步驟2:計算index,並對null做處理
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            // 步驟3:節點key存在,直接覆蓋value
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            // 步驟4:判斷該鏈爲紅黑樹
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            // 步驟5:該鏈爲鏈表
            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已經存在直接覆蓋value
                    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;
        // 步驟6:超過最大容量 就擴容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
View Code

HashMap的擴容機制---resize()

什麼時候擴容:當向容器添加元素的時候,會判斷當前容器的元素個數,如果大於等於閾值(知道這個閾字怎麼念嗎?不念fa值,念yu值四聲)---即當前數組的長度乘以加載因子的值的時候,就要自動擴容啦。

擴容(resize)就是重新計算容量,向HashMap對象裏不停的添加元素,而HashMap對象內部的數組無法裝載更多的元素時,對象就需要擴大數組的長度,以便能裝入更多的元素。當然Java裏的數組是無法自動擴容的,方法是使用一個新的數組代替已有的容量小的數組,就像我們用一個小桶裝水,如果想裝更多的水,就得換大水桶。

先看一下什麼時候,resize();

/** 
 * HashMap 添加節點 
 * 
 * @param hash        當前key生成的hashcode 
 * @param key         要添加到 HashMap 的key 
 * @param value       要添加到 HashMap 的value 
 * @param bucketIndex 桶,也就是這個要添加 HashMap 裏的這個數據對應到數組的位置下標 
 */  
void addEntry(int hash, K key, V value, int bucketIndex) {  
    //size:The number of key-value mappings contained in this map.  
    //threshold:The next size value at which to resize (capacity * load factor)  
    //數組擴容條件:1.已經存在的key-value mappings的個數大於等於閾值  
    //             2.底層數組的bucketIndex座標處不等於null  
    if ((size >= threshold) && (null != table[bucketIndex])) {  
        resize(2 * table.length);//擴容之後,數組長度變了  
        hash = (null != key) ? hash(key) : 0;//爲什麼要再次計算一下hash值呢?  
        bucketIndex = indexFor(hash, table.length);//擴容之後,數組長度變了,在數組的下標跟數組長度有關,得重算。  
    }  
    createEntry(hash, key, value, bucketIndex);  
}  
  
/** 
 * 這地方就是鏈表出現的地方,有2種情況 
 * 1,原來的桶bucketIndex處是沒值的,那麼就不會有鏈表出來啦 
 * 2,原來這地方有值,那麼根據Entry的構造函數,把新傳進來的key-value mapping放在數組上,原來的就掛在這個新來的next屬性上了 
 */  
void createEntry(int hash, K key, V value, int bucketIndex) {  
    HashMap.Entry<K, V> e = table[bucketIndex];  
    table[bucketIndex] = new HashMap.Entry<>(hash, key, value, e);  
    size++;  
}

我們分析下resize的源碼,鑑於JDK1.8融入了紅黑樹,較複雜,爲了便於理解我們仍然使用JDK1.7的代碼,好理解一些,本質上區別不大,具體區別後文再說。

    void resize(int newCapacity) {   //傳入新的容量
        Entry[] oldTable = table;    //引用擴容前的Entry數組
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {  //擴容前的數組大小如果已經達到最大(2^30)了
            threshold = Integer.MAX_VALUE; //修改閾值爲int的最大值(2^31-1),這樣以後就不會擴容了
            return;
        }
 
        Entry[] newTable = new Entry[newCapacity];  //初始化一個新的Entry數組
        transfer(newTable);                         //!!將數據轉移到新的Entry數組裏
        table = newTable;                           //HashMap的table屬性引用新的Entry數組
        threshold = (int) (newCapacity * loadFactor);//修改閾值
    }

這裏就是使用一個容量更大的數組來代替已有的容量小的數組,transfer()方法將原有Entry數組的元素拷貝到新的Entry數組裏。

    void transfer(Entry[] newTable) {
        Entry[] src = table;                   //src引用了舊的Entry數組
        int newCapacity = newTable.length;
        for (int j = 0; j < src.length; j++) { //遍歷舊的Entry數組
            Entry<K, V> e = src[j];             //取得舊Entry數組的每個元素
            if (e != null) {
                src[j] = null;//釋放舊Entry數組的對象引用(for循環後,舊的Entry數組不再引用任何對象)
                do {
                    Entry<K, V> next = e.next;
                    int i = indexFor(e.hash, newCapacity); //!!重新計算每個元素在數組中的位置
                    e.next = newTable[i]; //標記[1]
                    newTable[i] = e;      //將元素放在數組上
                    e = next;             //訪問下一個Entry鏈上的元素
                } while (e != null);
            }
        }
    }
    static int indexFor(int h, int length) {
        return h & (length - 1);
    }

newTable[i]的引用賦給了e.next,也就是使用了單鏈表的頭插入方式,同一位置上新元素總會被放在鏈表的頭部位置;這樣先放在一個索引上的元素終會被放到Entry鏈的尾部(如果發生了hash衝突的話),這一點和Jdk1.8有區別(不再使用頭插法,因爲會存在併發問題),下文詳解。在舊數組中同一條Entry鏈上的元素,通過重新計算索引位置後,有可能被放到了新數組的不同位置上。

從上面的for循環內部開始說起吧:詳細解釋下,這個轉存的過程。和怎麼個頭插入法(看代碼更容易,就是循環的換到另一個連表上,如果衝突).
Entry<K, V> e = src[j];
這句話,就把原來數組上的那個鏈表的引用就給接手了,所以下面src[j] = null;可以放心大膽的置空,釋放空間。告訴gc這個地方可以回收啦。
繼續到do while 循環裏面,
Entry<K, V> next = e.next;
int i = indexFor(e.hash, newCapacity);計算出元素在新數組中的位置
下面就是單鏈表的頭插入方式轉存元素啦

關於這個 單鏈表的頭插入方式 的理解,我多說兩句。
這地方我再看的時候,就有點蒙了,他到底怎麼在插到新的數組裏面的?
要是在插入新數組的時候,也出現了一個數組下標的位置處,出現了多個節點的話,那又是怎麼插入的呢?
1,假設現在剛剛插入到新數組上,因爲是對象數組,數組都是要默認有初始值的,那麼這個數組的初始值都是null。不信的可以新建個Javabean數組測試下。
那麼e.next = newTable[i],也就是e.next = null啦。然後再newTable[i] = e;也就是 說這個時候,這個數組的這個下標位置的值設置成這個e啦。
2,假設這個時候,繼續上面的循環,又取第二個數據e2的時候,恰好他的下標和剛剛上面的那個下標相同啦,那麼這個時候,是又要有鏈表產生啦、
e.next = newTable[i];,假設上面第一次存的叫e1吧,那麼現在e.next = newTable[i];也就是e.next = e1;
然後再,newTable[i] = e;,把這個後來的賦值在數組下標爲i的位置,當然他們兩個的位置是相同的啦。然後注意現在的e,我們叫e2吧。e2.next指向的是剛剛的e1,e1的next是null。
這就解釋啦:先放在一個索引上的元素終會被放到Entry鏈的尾部。這句話。

關於什麼時候resize()的說明:

看1.7的源碼上說的條件是:
if ((size >= threshold) && (null != table[bucketIndex])) {。。。}
其中
size表示當前hashmap裏面已經包含的元素的個數。
threshold:threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
一般是容量值X加載因子。
而1.8的是:
if (++size > threshold){}
其中
size:The number of key-value mappings contained in this map.和上面的是一樣的
threshold:newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
也是一樣的,
最後總結一下:就是這個map裏面包含的元素,也就是size的值,大於等於這個閾值的時候,纔會resize();
具體到實際情況就是:假設現在閾值是4;在添加下一個假設是第5個元素的時候,這個時候的size還是原來的,還沒加1,size=4,那麼閾值也是4的時候,
當執行put方法,添加第5個的時候,這個時候,4 >= 4。元素個數等於閾值。就要resize()啦。添加第4的時候,還是3 >= 4不成立,不需要resize()。
經過這番解釋,可以發現下面的這個例子,不應該是在添加第二個的時候resize(),而是在添加第三個的時候,才resize()的。
這個也是我後來再細看的時候,發現的。當然,這個咱可以先忽略,重點看如何resize(),以及如何將舊數據移動到新數組的

 

 

下面我們講解下JDK1.8做了哪些優化。經過觀測可以發現,我們使用的是2次冪的擴展(指長度擴爲原來2倍),所以,

經過rehash之後,元素的位置要麼是在原位置,要麼是在原位置再移動2次冪的位置。對應的就是下方的resize的註釋。

/** 
 * Initializes or doubles table size.  If null, allocates in 
 * accord with initial capacity target held in field threshold. 
 * Otherwise, because we are using power-of-two expansion, the 
 * elements from each bin must either stay at same index, or move 
 * with a power of two offset in the new table. 
 * 
 * @return the table 
 */  
final Node<K,V>[] resize() {  }

看下圖可以明白這句話的意思,n爲table的長度,圖(a)表示擴容前的key1和key2兩種key確定索引位置的示例,圖(b)表示擴容後key1和key2兩種key確定索引位置的示例,其中hash1是key1對應的哈希值(也就是根據key1算出來的hashcode值)與高位與運算的結果。


 

 


元素在重新計算hash之後,因爲n變爲2倍,那麼n-1的mask範圍在高位多1bit(紅色),因此新的index就會發生這樣的變化:

 

 

 

 

因此,我們在擴充HashMap的時候,不需要像JDK1.7的實現那樣重新計算hash,只需要看看原來的hash值新增的那個bit是1還是0就好了,是0的話索引沒變,是1的話索引變成“原索引+oldCap”(加原數組的長度),可以看看下圖爲16擴充爲32的resize示意圖:

 

 

這個設計確實非常的巧妙,既省去了重新計算hash值的時間,而且同時,由於新增的1bit是0還是1可以認爲是隨機的,因此resize的過程,均勻的把之前的衝突的節點分散到新的bucket了。這一塊就是JDK1.8新增的優化點。有一點注意區別,JDK1.7中rehash的時候,舊鏈表遷移新鏈表的時候,如果在新表的數組索引位置相同,則鏈表元素會倒置,但是從上圖可以看出,JDK1.8不會倒置。有興趣的同學可以研究下JDK1.8的resize源碼,寫的很贊,如下:

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;
            return oldTab;
        }
        // 沒超過最大值,就擴充爲原來的2倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                  oldCap >= DEFAULT_INITIAL_CAPACITY)
             newThr = oldThr << 1; // double threshold
     }
     else if (oldThr > 0) // initial capacity was placed in threshold
         newCap = oldThr;
     else {               // zero initial threshold signifies using defaults
         newCap = DEFAULT_INITIAL_CAPACITY;
         newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
     }
     // 計算新的resize上限
     if (newThr == 0) {
 
         float ft = (float)newCap * loadFactor;
         newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                   (int)ft : Integer.MAX_VALUE);
     }
     threshold = newThr;
     @SuppressWarnings({"rawtypes","unchecked"})
         Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        // 把每個bucket都移動到新的buckets中
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode) //如果是紅黑樹節點
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // 鏈表優化重hash的代碼塊
                    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;
                        }
                        // 原索引+oldCap
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 原索引放到bucket裏
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 原索引+oldCap放到bucket裏
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

先略過紅黑樹的情況,描述下簡單流程,在JDK1.8中發生hashmap擴容時,遍歷hashmap每個bucket裏的鏈表,每個鏈表可能會被拆分成兩個鏈表,不需要移動的元素置入loHead爲首的鏈表,需要移動的元素置入hiHead爲首的鏈表,然後分別分配給老的buket和新的buket。

補充一下jdk 1.8中,HashMap擴容時紅黑樹的表現

擴容時,如果節點是紅黑樹節點,就會調用TreeNode的split方法對當前節點作爲跟節點的紅黑樹進行修剪 

...
else if (e instanceof TreeNode) //如果是紅黑樹節點
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
...
    //參數介紹
    //tab 表示保存桶頭結點的哈希表
    //index 表示從哪個位置開始修剪
    //bit 要修剪的位數(哈希值)
    final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
        TreeNode&lt;K,V&gt; b = this;
        // Relink into lo and hi lists, preserving order
        TreeNode<K,V> loHead = null, loTail = null;
        TreeNode<K,V> hiHead = null, hiTail = null;
        int lc = 0, hc = 0;
        for (TreeNode<K,V> e = b, next; e != null; e = next) {
            next = (TreeNode<K,V>)e.next;
            e.next = null;
            //如果當前節點哈希值的最後一位等於要修剪的 bit 值
            if ((e.hash &amp; bit) == 0) {
                    //就把當前節點放到 lXXX 樹中
                if ((e.prev = loTail) == null)
                    loHead = e;
                else
                    loTail.next = e;
                //然後 loTail 記錄 e
                loTail = e;
                //記錄 lXXX 樹的節點數量
                ++lc;
            }
            else {  //如果當前節點哈希值最後一位不是要修剪的
                    //就把當前節點放到 hXXX 樹中
                if ((e.prev = hiTail) == null)
                    hiHead = e;
                else
                    hiTail.next = e;
                hiTail = e;
                //記錄 hXXX 樹的節點數量
                ++hc;
            }
        }
 
 
        if (loHead != null) {
            //如果 lXXX 樹的數量小於 6,就把 lXXX 樹的枝枝葉葉都置爲空,變成一個單節點
            //然後讓這個桶中,要還原索引位置開始往後的結點都變成還原成鏈表的 lXXX 節點
            //這一段元素以後就是一個鏈表結構
            if (lc <= UNTREEIFY_THRESHOLD)
                tab[index] = loHead.untreeify(map);
            else {
            //否則讓索引位置的結點指向 lXXX 樹,這個樹被修剪過,元素少了
                tab[index] = loHead;
                if (hiHead != null) // (else is already treeified)
                    loHead.treeify(tab);
            }
        }
        if (hiHead != null) {
            //同理,讓 指定位置 index + bit 之後的元素
            //指向 hXXX 還原成鏈表或者修剪過的樹
            if (hc <= UNTREEIFY_THRESHOLD)
                tab[index + bit] = hiHead.untreeify(map);
            else {
                tab[index + bit] = hiHead;
                if (loHead != null)
                    hiHead.treeify(tab);
            }
        }
    }

從上述代碼可以看到,HashMap 擴容時對紅黑樹節點的修剪主要分兩部分,先分類、再根據元素個數決定是還原成鏈表還是精簡一下元素仍保留紅黑樹結構。

1.分類

指定位置、指定範圍,讓指定位置中的元素 (hash & bit) == 0 的,放到 lXXX 樹中,不相等的放到 hXXX 樹中。

2.根據元素個數決定處理情況

符合要求的元素(即 lXXX 樹),在元素個數小於等於 6 時還原成鏈表,最後讓哈希表中修剪的痛 tab[index] 指向 lXXX 樹;在元素個數大於 6 時,還是用紅黑樹,只不過是修剪了下枝葉;

不符合要求的元素(即 hXXX 樹)也是一樣的操作,只不過最後它是放在了修剪範圍外 tab[index + bit]。

 

(1) 擴容是一個特別耗性能的操作,所以當程序員在使用HashMap的時候,估算map的大小,初始化的時候給一個大致的數值,避免map進行頻繁的擴容。

(2) 負載因子是可以修改的,也可以大於1,但是建議不要輕易修改,除非情況非常特殊。

(3) HashMap是線程不安全的,不要在併發的環境中同時操作HashMap,建議使用ConcurrentHashMap。

(4) JDK1.8引入紅黑樹大程度優化了HashMap的性能。

(5) 還沒升級JDK1.8的,現在開始升級吧。HashMap的性能提升僅僅是JDK1.8的冰山一角。

六:爲什麼1.8之後超過8會轉化爲紅黑樹

爲什麼不是平衡二叉樹?

AVL樹(平衡二叉樹)
AVL樹是帶有平衡條件的二叉查找樹,一般是用平衡因子差值判斷是否平衡並通過旋轉來實現平衡,左右子樹高度差不超過1,和紅黑樹相比,AVL樹是嚴格的平衡二叉樹,平衡條件必須滿足(所有結點的左右子樹高度差不超過1)。不管我們是執行插入還是刪除操作,只要不滿足上面的條件,就要通過旋轉來保存平衡,而因爲旋轉非常耗時,由此我們可以知道AVL樹適合用於插入與刪除次數比較少,但查找多的情況。

侷限性:
由於維護這種高度平衡所付出的代價比從中獲得的效率收益還大,故而實際的應用不多,更多的地方是用追求局部而不是非常嚴格整體平衡的紅黑樹。當然,如果應用場景中對插入刪除不頻繁,只是對查找要求較高,那麼AVL還是較優於紅黑樹。

紅黑樹
一種二叉查找樹,但在每個節點增加一個存儲位表示結點的顏色,可以是紅或黑(非紅即黑)。通過對任何一條從根到葉子的路徑上各個節點着色的方式的限制,紅黑樹確保沒有一條路徑會比其他路徑長出兩倍,因此,紅黑樹是一中弱平衡二叉樹(由於是弱平衡,可以看到,在相同的節點情況下,AVL樹的高度低於紅黑樹)相對於要求嚴格的AVL樹來說,它的旋轉次數少,插入最多兩次旋轉,刪除最多三次旋轉,所以對於搜索,插入,刪除操作較多的情況下,我們就用紅黑樹

特點:

結點非紅即黑
根結點是黑色的
每個葉子節點(NULL節點)是黑色的
每個紅色節點的兩個子節點都是黑色的。(不能有兩連續的紅色節點)
從任一節點到其每個葉子的所有路徑都包含相同數目的黑色節點。

JDK1.8中HashMap優化分析

1. hash算法優化

    // jdk1.8 HashMap中hash源碼
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

源碼解讀:
用 (key的hash值) 與 (key的hash值右移16位)進行異或運算

例如:

        HashMap<String,String> hashMap = new HashMap<>(); // 1
        hashMap.put("test1","測試數據1");    // 2
        hashMap.put("test2","測試數據2");    // 3

詳細過程:

// 對第二行中的key進行hash:
// hash後:
h = 110251487
// 二進制的表示形式爲:
0000 0110 1001 0010 0100 1101 1101 1111
// 對其右移16位:
0000 0000 0000 0000 0000 0110 1001 0010
// 進行異或運算:
0000 0110 1001 0010 0100 1011 0100 1101
// 轉回二進制爲:
110250829
右移16位:將高16位推到了低16位上,高16位用0來補齊
^表示異或:a、b兩個值不同,則異或結果爲1。如果相同,異或結果爲0
&表與運算:兩位同時爲“1”,結果才爲“1”,否則爲0

優化的實質是將高16位與低16位進行異或運算(結果中與之前高十六位是一致)。
這麼做的目的是:是希望低16位中同時保留低16位與高16位的特徵,儘量避免key的低16位近似,以免發生hash衝突

2. 尋址算法優化
核心:hash & (n-1)
第一步中異或後的值 與 數組長度-1進行 &(與) 運算
0000 0110 1001 0010 0100 1011 0100 1101
0000 0000 0000 0000 0000 0000 0000 1111(假設數組長度16 ,15的二進制)
因爲15的高16位都爲0,所以與運算結果都是0,一般數組的值都比較小,所以核心點在低16位的與運算;

問題:爲什麼不再像JDK1.7一樣進行取模運算?

答 :取模運算相對來說性能比較差一些,hash&(n-1) 與取模操作效果一樣,但是與運算的性能更高

問題:爲什麼hash&(n-1)和hash值對數組長度取模效果一樣?

答:數學上得到結論,當數組長度是2的冪次時,hash值對數組長度取模的效果和 hash&(n-1) 是一樣的。

尋址算法優化的目的:用與運算代替取模,提升性能。

3. 解決hash碰撞問題
多個key的hash值,與 n-1 (數組長度減一) ,與運算之後,依舊會出現定位的位置相同問題,即 hash碰撞

解決方式爲:
如果發現定位到的位置已有元素,則在該位置掛一個鏈表,在鏈表裏放多個元素,用來解決hash碰撞問題。

在使用get方法時,如果發現該位置是一個鏈表,則遍歷該鏈表,拿到該key對應的元素。

但是當鏈表的長度過長時,性能會下降,時間複雜度爲O(n)

所以當檢測到鏈表達到一定長度時(當鏈表長度大於8的時候轉換爲紅黑樹),將鏈表轉化爲紅黑樹,來提升性能,紅黑樹的時間複雜度爲:logn

4. 擴容
當put時檢測到數組滿了,則進行擴容,擴容的方式是2倍擴容
進行rehash時,使用key的hash與新的數組長度-1進行與運算,判斷結果的高位是否多出一個 bit 的 1,如果沒多,那麼就是原來的index,如果多了出來,那麼就是index+oldCap(原來的index+原來數組的長度),通過這個方式,可以避免rehash時,用每個hash對新的數組.length進行取模,取模性能不高,位運算的性能比較高

5. jdk1.7中擴容後死循環問題
HashMap擴容導致死循環的主要原因:
在於擴容過程中使用頭插法將oldTable中的單鏈表中的節點插入到newTable的單鏈表中,所以newTable中的單鏈表會倒置oldTable中的單鏈表。那麼在多個線程同時擴容的情況下就可能導致擴容後的HashMap中存在一個有環的單鏈表,從而導致後續執行get操作的時候,會觸發死循環,引起CPU的100%問題。所以一定要避免在併發環境下使用HashMap。

jdk1.7 HashMap擴容時死循環問題

jdk1.7 hashmap在resize時進行擴容時,會導致死循環,主要是因爲jdk1.7採用的是頭插法

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;
        }
    }
}

假如hashmap有一個鏈表的數據是A,B,C。

當進行擴容時,有兩個線程進行處理。

假如

1.線程1擴容時,執行到e=A,然後next是B,這時候線程1的時間片結束

2.線程2此時進行擴容,執行e=A,next 是B,再往下執行,此時鏈表的結構是 B->A,此時線程2時間片結束

3.線程1進入數據區再進行處理,A處理完之後處理B,但是由於B之前已經next指向了A,現在又拿到了A,根據頭插法A又要插在B前面,即A->B,但是又是之前已經又B->A這樣的結構,這樣就形成了循環鏈表。

在hashmap的get時候,如果給A或者B還可以取得到,如果get C 那就取不到,就會死循環,CPU佔據100%

 

 

HashCode計算方法

 前一次的結果*31+本次的ascall值,結果作爲下次循環的輸入。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

參考:

(7條消息) JDK1.8中HashMap優化分析_dwhhome的博客-CSDN博客

(7條消息) jdk1.7 HashMap擴容時死循環問題_xby7437的博客-CSDN博客_hashmap擴容時死循環問題

(7條消息) HashMap的擴容機制---resize()_潘建南的博客-CSDN博客_hashmap的擴容機制

JDK 源碼中 HashMap 的 hash 方法原理是什麼? - 知乎 (zhihu.com)

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章