Java8 HashMap詳解(轉)


 

Java8 對 HashMap 進行了一些修改,最大的不同就是利用了紅黑樹,所以其由 數組+鏈表+紅黑樹 組成。

 

  根據 Java7 HashMap 的介紹,我們知道,查找的時候,根據 hash 值我們能夠快速定位到數組的具體下標,但是之后的話,需要順着鏈表一個個比較下去才能找到我們需要的,時間復雜度取決於鏈表的長度,為 O(n)

 

  為了降低這部分的開銷,在 Java8 中,當鏈表中的元素超過了 8 個以后,會將鏈表轉換為紅黑樹,在這些位置進行查找的時候可以降低時間復雜度為 O(logN)

 

來一張圖簡單示意一下吧:

 

 

 

 


先回答幾個問題:

1.HashMap的什么時候擴容,哪些操作會觸發

        當向容器添加元素的時候,會判斷當前容器的元素個數,如果大於等於閾值,即當前數組的長度乘以加載因子的值的時候,就要自動擴容。默認容量為16,擴容因子是0.75,閾值為12。

        有參構造方法和put、merge操作都會導致擴容。

 

2.HashMap push方法的執行過程? 

        最先判斷桶的長度是否為0,為0的話則需要先對桶進行初始化操作,接着,求出hashcode並通過擾動函數確定要put到哪個桶中,若桶中沒有元素直接插入,若有元素則判斷key是否相等,如果相等的話,那么就將value改為我們put的value值,若不等的話,那么此時判斷該點是否為樹節點,如果是的話,調用putreeval方法,以樹節點的方式插入,如果不是樹節點,那么就遍歷鏈表,如果找到了key那么修改value,沒找到新建節點插到鏈表尾部,最后判斷鏈表長度是否大於8 是否要進行樹化。

 

3.HashMap檢測到hash沖突后,將元素插入在鏈表的末尾還是開頭? 

        因為JDK1.7是用單鏈表進行的縱向延伸,采用頭插法就是能夠提高插入的效率,但是也會容易出現逆序且環形鏈表死循環問題。但是在JDK1.8之后是因為加入了紅黑樹使用尾插法,能夠避免出現逆序且鏈表死循環的問題。

 

4.JDK1.8還采用了紅黑樹,講講紅黑樹的特性,為什么人家一定要用紅黑樹而不是AVL、B樹之類的?

        在CurrentHashMap中是加鎖了的,實際上是讀寫鎖,如果寫沖突就會等待,如果插入時間過長必然等待時間更長,而紅黑樹相對AVL樹B樹的插入更快,AVL樹查詢確實更快一些,但是對於操作密集型,紅黑樹的旋轉更少,效率更高。

 

5.HashMap get方法的執行過程? 

        首先和put一樣,確定對應的key在哪一個桶中,如果桶容量為0或者該桶內沒有元素直接返回空,反之會判斷該桶會檢查桶中第一個元素是否和要查的key相等,相等的話直接返回,不相等的話判斷該節點是否為樹節點,是的話以樹節點方式遍歷整棵樹來查找,不是的話那就說明存儲結構是鏈表,以遍歷鏈表的方式查找。

 


源碼與其中的算法技巧

1.構造方法

public HashMap(int initialCapacity, float loadFactor) {

    //當指定的 initialCapacity (初始容量) < 0 的時候會拋出 IllegalArgumentException 異常
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);

    //當指定的 initialCapacity (初始容量)= MAXIMUM_CAPACITY(最大容量) 的時候 
    if (initialCapacity > MAXIMUM_CAPACITY)
        //初始容量就等於 MAXIMUM_CAPACITY (最大容量)
        initialCapacity = MAXIMUM_CAPACITY;

    //當 loadFactory(負載因子)< 0 ,或者不是數字的時候會拋出 IllegalArgumentException 異常
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;

    //tableSizeFor()的主要功能是返回一個比給定整數大且最接近2的冪次方整數
    //比如我們給定的數是12,那么tableSizeFor()會返回2的4次方,也就是16,因為16是最接近12並且大於12的數
    this.threshold = tableSizeFor(
        initialCapacity);
}

執行順序注釋寫的很清楚了,但是有些同學對最后對 tableSizeFor 方法很有疑問,這是用來求傳入容量的最小2的冪次方整數的。

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

這是一系列的或操作,舉個例子

n-=1;// n=1000000(二進制)
...//16、8無變化
n|=n>>>4;//n=n|(n>>>4)=1000000|0000100=1000100
n|=n>>>2;//n=n|(n>>>2)=1000100|0010001=1010101
...

看出規律來了吧,右移多少位,就把最高位右邊的第x位設置為1;第二次,就把兩個為1的右邊xx位再設置為1;第n次,就把上一步出現的1右邊xxxx位置為1;

這樣執行完,原來是1000000,變成了1111111,最后加1,就變成2的整數次方數了。之所以先減一是因為有可能本身就是最小2的冪次方整數。

 

2.Put方法

        put方法的核心就是 putVal ,源碼和執行過程如下。

//實現 put 和相關方法
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為空或者長度為0,則進行resize()(擴容)
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;

    //確定插入table的位置,算法是上面提到的 (n - 1) & hash,在 n 為 2 的時候,相當於取模操作
    if ((p = tab[i = (n - 1) & hash]) == null)
        //找到key值對應的位置並且是第一個,直接插入
        tab[i] = newNode(hash, key, value, null);

    //在table的 i 的位置發生碰撞,分兩種情況
    //1、key值是一樣的,替換value值
    //2、key值不一樣的
    //而key值不一樣的有兩種處理方式:1、存儲在 i 的位置的鏈表 2、存儲在紅黑樹中
    else {
        Node<K,V> e; K k;

        //第一個Node的hash值即為要加入元素的hash
        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 {
            //如果不是TreeNode的話,即為鏈表,然后遍歷鏈表
            for (int binCount = 0; ; ++binCount) {

                //鏈表的尾端也沒有找到key值相同的節點,則生成一個新的Node
                //並且判斷鏈表的節點個數是不是到達轉換成紅黑樹的上界達到,則轉換成紅黑樹
                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;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }

        //如果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;
}

為了方便理解,配圖

putVal中有一段代碼提到了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;

    //判斷Node的長度,如果不為零
    if (oldCap > 0) {
        //判斷當前Node的長度,如果當前長度超過 MAXIMUM_CAPACITY(最大容量值)
        if (oldCap >= MAXIMUM_CAPACITY) {
            //新增閥值為 Integer.MAX_VALUE
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }

        //如果小於這個 MAXIMUM_CAPACITY(最大容量值),並且大於 DEFAULT_INITIAL_CAPACITY (默認16)
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
            //進行2倍擴容
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        //指定新增閥值
        newCap = oldThr;

    //如果數組為空
    else {               // zero initial threshold signifies using defaults
        //使用默認的加載因子(0.75)
        newCap = DEFAULT_INITIAL_CAPACITY;
        //新增的閥值也就為 16 * 0.75 = 12
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    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數組
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;

    //如果數組不為空,將原數組中的元素放入擴容后的數組中
    if (oldTab != null) {
        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 { // preserve order
                    //如果節點不為空,且為單鏈表,則將原數組中單鏈表元素進行拆分
                    Node<K,V> loHead = null, loTail = null;//保存在原有索引的鏈表
                    Node<K,V> hiHead = null, hiTail = null;//保存在新索引的鏈表
                    Node<K,V> next;
                    do {
                        next = e.next;

                        //哈希值和原數組長度進行&操作,為0則在原數組的索引位置
                        //非0則在原數組索引位置+原數組長度的新位置
                        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;
                    }
                }
            }
        }
    }
    return newTab;

為什么會hash沖突?

        就是根據key即經過一個函數f(key)得到的結果的作為地址去存放當前的key,value鍵值對(這個是hashmap的存值方式),但是卻發現算出來的地址上已經有數據。這就是所謂的hash沖突。

hash沖突的幾種情況:

        1兩個節點的key值相同(hash值一定相同),導致沖突 

        2 兩個節點的key值不同,由於hash函數的局限性導致hash值相同,導致沖突 

        3 兩個節點的key值不同,hash值不同,但hash值對數組長度取模后相同,導致沖突 

        

        如何解決hash沖突?解決hash沖突的方法主要有兩種,一種是開放尋址法,另一種是鏈表法 。

 

 

開放尋址法--線性探測

        開放尋址法的原理很簡單,就是當一個Key通過hash函數獲得對應的數組下標已被占用的時候,我們可以尋找下一個空檔位置

  比如有個Entry6通過hash函數得到的下標為2,但是該下標在數組中已經有了其它的元素,那么就向后移動1位,看看數組下標為3的位置是否有空位 

        比如有個Entry6通過hash函數得到的下標為2,但是該下標在數組中已經有了其它的元素,那么就向后移動1位,看看數組下標為3的位置是否有空位

 

 

 但是下標為3的數組也已經被占用了,那么久再向后移動1位,看看數組下標為4的位置是否為空

數組下標為4的位置還沒有被占用,所以可以把Entry6存入到數組下標為4的位置。這就是開放尋址的基本思路,尋址的方式有很多種,這里只是簡單的一個示例

 

鏈表法

        鏈表法也正是被應用在了HashMap中,HashMap中數組的每一個元素不僅是一個Entry對象,還是一個鏈表的頭節點。每一個Entry對象通過next指針指向它的下一個Entry節點。當新來的Entry映射到與之沖突的數組位置時,只需要插入到對應的鏈表中即可

 

 

JDK7和JDK8中的HashMap都是線程不安全的,主要體現在哪些方面?

  只要是對於集合有一定了解的一定都知道HashMap是線程不安全的,我們應該使用ConcurrentHashMap。但是為什么HashMap是線程不安全的呢,之前面試的時候也遇到到這樣的問題,但是當時只停留在***知道是***的層面上,並沒有深入理解***為什么是***。於是今天重溫一個HashMap線程不安全的這個問題。

  首先需要強調一點,HashMap的線程不安全體現在會造成死循環數據丟失數據覆蓋這些問題。其中死循環和數據丟失是在JDK1.7中出現的問題,在JDK1.8中已經得到解決,然而1.8中仍會有數據覆蓋這樣的問題。

擴容引發的線程不安全

HashMap的線程不安全主要是發生在擴容函數中,即根源是在transfer函數中,JDK1.7中HashMaptransfer函數如下:

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同時對下面這個HashMap進行擴容操作:

 

正常擴容后的結果是下面這樣的: 

 

但是當線程A執行到上面transfer函數的第11行代碼時,CPU時間片耗盡,線程A被掛起。即如下圖中位置所示: 

 

 此時線程A中:e=3、next=7、e.next=null

 

 當線程A的時間片耗盡后,CPU開始執行線程B,並在線程B中成功的完成了數據遷移

重點來了,根據Java內存模式可知,線程B執行完數據遷移后,此時主內存中newTabletable都是最新的,也就是說:7.next=3、3.next=null。

隨后線程A獲得CPU時間片繼續執行newTable[i] = e,將3放入新數組對應的位置,執行完此輪循環后線程A的情況如下:

 

接着繼續執行下一輪循環,此時e=7,從主內存中讀取e.next時發現主內存中7.next=3,於是乎next=3,並將7采用頭插法的方式放入新數組中,並繼續執行完此輪循環,結果如下: 

 

執行下一次循環可以發現,next=e.next=null,所以此輪循環將會是最后一輪循環。接下來當執行完e.next=newTable[i]即3.next=7后,3和7之間就相互連接了,當執行完newTable[i]=e后,3被頭插法重新插入到鏈表中,執行結果如下圖所示: 

上面說了此時e.next=null即next=null,當執行完e=null后,將不會進行下一輪循環。到此線程A、B的擴容操作完成,很明顯當線程A執行完后,HashMap中出現了環形結構,當在以后對該HashMap進行操作時會出現死循環。

並且從上圖可以發現,元素5在擴容期間被莫名的丟失了,這就發生了數據丟失的問題。

 

JDK1.8中的線程不安全
  根據上面JDK1.7出現的問題,在JDK1.8中已經得到了很好的解決,如果你去閱讀1.8的源碼會發現找不到transfer函數,因為JDK1.8直接在resize函數中完成了數據遷移。另外說一句,JDK1.8在進行元素插入時使用的是尾插法。

為什么說JDK1.8會出現數據覆蓋的情況喃,我們來看一下下面這段JDK1.8中的put操作代碼:

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) // 如果沒有hash碰撞則直接插入元素
            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);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    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;
    }

  其中第六行代碼是判斷是否出現hash碰撞,假設兩個線程A、B都在進行put操作,並且hash函數計算出的插入下標是相同的,當線程A執行完第六行代碼后由於時間片耗盡導致被掛起,而線程B得到時間片后在該下標處插入了元素,完成了正常的插入,然后線程A獲得時間片,由於之前已經進行了hash碰撞的判斷,所有此時不會再進行判斷,而是直接進行插入,這就導致了線程B插入的數據被線程A覆蓋了,從而線程不安全。

  除此之前,還有就是代碼的第38行處有個++size,我們這樣想,還是線程A、B,這兩個線程同時進行put操作時,假設當前HashMap的zise大小為10,當線程A執行到第38行代碼時,從主內存中獲得size的值為10后准備進行+1操作,但是由於時間片耗盡只好讓出CPU,線程B快樂的拿到CPU還是從主內存中拿到size的值10進行+1操作,完成了put操作並將size=11寫回主內存,然后線程A再次拿到CPU並繼續執行(此時size的值仍為10),當執行完put操作后,還是將size=11寫回內存,此時,線程A、B都執行了一次put操作,但是size的值只增加了1,所有說還是由於數據覆蓋又導致了線程不安全。

 

結論
HashMap的線程不安全主要體現在下面兩個方面:
  1.在JDK1.7中,當並發執行擴容操作時會造成環形鏈和數據丟失的情況。
  2.在JDK1.8中,在並發執行put操作時會發生數據覆蓋的情況。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM