日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當(dāng)前位置: 首頁 > 编程语言 > java >内容正文

java

hashmap value占用空间大小_【Java集合框架002】原理层面:HashMap全解析

發(fā)布時間:2023/12/10 java 26 豆豆
生活随笔 收集整理的這篇文章主要介紹了 hashmap value占用空间大小_【Java集合框架002】原理层面:HashMap全解析 小編覺得挺不錯的,現(xiàn)在分享給大家,幫大家做個參考.

一、前言

二、HashMap

2.1 HashMap數(shù)據(jù)結(jié)構(gòu) + HashMap線程不安全 + 哈希沖突

2.1.1 HashMap數(shù)據(jù)結(jié)構(gòu)

學(xué)習(xí)的時候,先整體后細(xì)節(jié),HashMap整體結(jié)構(gòu)是 底層數(shù)組+鏈表 ,先記住,再開始看下面的

HashMap相關(guān)知識點(diǎn):

  • 底層數(shù)據(jù)結(jié)構(gòu):HashMap基于哈希散列表實(shí)現(xiàn) ,可以實(shí)現(xiàn)對數(shù)據(jù)的讀寫。

  • 插入邏輯put()方法:將鍵值對傳遞給put方法時,它調(diào)用鍵對象的hashCode()方法來計算hashCode,然后找到相應(yīng)的bucket位置(即數(shù)組)來儲存值對象。當(dāng)獲取對象時,通過鍵對象的equals()方法找到正確的鍵值對,然后返回值對象。

  • 哈希沖突:插入邏輯中,如果發(fā)生哈希沖突,使用鏈表來解決hash沖突問題,即當(dāng)發(fā)生沖突了,對象將會儲存在鏈表的頭節(jié)點(diǎn)中。HashMap在每個鏈表節(jié)點(diǎn)中儲存鍵值對對象,當(dāng)兩個不同的鍵對象的hashCode相同時(HashMap在兩個key-value,hashcode相同導(dǎo)致index相同,就認(rèn)為發(fā)生哈希沖突,equals相同任務(wù)同一個,不重復(fù)插入,HashSet在hashcode和equals相同,認(rèn)為同一個,不重復(fù)插入),它們會儲存在同一個bucket位置的鏈表中,如果鏈表大小超過閾值(TREEIFY_THRESHOLD,8),鏈表就會被改造為樹形結(jié)構(gòu)。

  • HashMap和HashSet:HashMap在兩個key-value,hashcode相同導(dǎo)致index相同,哈希沖突,equals相同任務(wù)同一個,不重復(fù)插入,HashSet在hashcode和equals相同,認(rèn)為同一個,不重復(fù)插入。

  • 2.1.2 HashMap線程不安全

    HashMap是應(yīng)用更廣泛的哈希表實(shí)現(xiàn),而且大部分情況下,都能在常數(shù)時間性能的情況下進(jìn)行put和get操作。但是,HashMap在多線程并發(fā)的情況下是不安全的,通過兩個問題的回答來解釋原因。

    問題1:為什么說HashMap是線程不安全的?
    回答1:在接近臨界點(diǎn)時,若此時兩個或者多個線程進(jìn)行put操作,都會進(jìn)行resize(擴(kuò)容)和reHash(為key重新計算所在位置),而reHash在并發(fā)的情況下可能會形成鏈表環(huán)。即在多線程環(huán)境下,使用HashMap進(jìn)行put操作會引起死循環(huán),導(dǎo)致CPU利用率接近100%,所以在并發(fā)情況下不能使用HashMap。

    問題2:為什么在并發(fā)執(zhí)行put操作會引起死循環(huán)?
    回答2:因?yàn)槎嗑€程會導(dǎo)致HashMap的Entry鏈表形成環(huán)形數(shù)據(jù)結(jié)構(gòu),一旦形成環(huán)形數(shù)據(jù)結(jié)構(gòu),Entry的next節(jié)點(diǎn)永遠(yuǎn)不為空,就會產(chǎn)生死循環(huán)獲取Entry。值得注意的是,JDK1.7的情況下,并發(fā)擴(kuò)容時容易形成鏈表環(huán),此情況在1.8時就好太多太多了,因?yàn)樵?.8中當(dāng)鏈表長度大于閾值(默認(rèn)長度為8)時,鏈表會被改成樹形(紅黑樹)結(jié)構(gòu)。

    2.1.3 哈希沖突

    哈希沖突定義:若干Key的哈希值按數(shù)組大小取模后,如果落在同一個數(shù)組下標(biāo)上,將組成一條Entry鏈,對Key的查找需要遍歷Entry鏈上的每個元素執(zhí)行equals()比較(tip:知道了HashMap的“數(shù)組+鏈表”結(jié)構(gòu),就很好懂哈希沖突了)。

    加載因子:為了降低哈希沖突的概率,默認(rèn)當(dāng)HashMap中的鍵值對達(dá)到數(shù)組大小的75%時,即會觸發(fā)擴(kuò)容。因此,如果預(yù)估容量是100,即需要設(shè)定100/0.75=134的數(shù)組大小,又因?yàn)镠ashMap大小必須是2的整數(shù)次冪,所以是大于134的最小的2的整數(shù)次冪,即256。

    減少哈希沖突兩方法:降低加載因子,加大初始大小。

    HashMap中數(shù)組擴(kuò)容相關(guān)概念(size capacity size/capacity):

  • size是當(dāng)前現(xiàn)實(shí)的記錄數(shù);capacity是理論上的容量,第一次由初始化決定,之后由擴(kuò)容決定;initial capacity是初始的capacity,第一次的capacity。

  • 負(fù)載因子等于“size/capacity”,全稱為當(dāng)前負(fù)載因子,其作用是當(dāng)負(fù)載因子達(dá)到負(fù)載極限的時候擴(kuò)容。當(dāng)負(fù)載因子為0,表示空的hash表;負(fù)載因子為0.5,表示半滿的散列表,依此類推。輕負(fù)載的散列表具有沖突少、適宜插入與查詢的特點(diǎn)(但是使用Iterator迭代元素時比較慢)。

  • 負(fù)載極限:“負(fù)載極限”是一個0~1的數(shù)值,“負(fù)載極限”決定了hash表的最大填滿程度。當(dāng)hash表中的負(fù)載因子達(dá)到指定的“負(fù)載極限”時,hash表會自動成倍地增加容量(桶的數(shù)量),并將原有的對象重新分配,放入新的桶內(nèi),這稱為rehashing。HashMap和HashTable的構(gòu)造器允許指定一個負(fù)載極限,HashMap和HashTable默認(rèn)的“負(fù)載極限”為0.75,這表明當(dāng)該hash表的3/4已經(jīng)被填滿時,hash表會發(fā)生rehashing。

  • “負(fù)載極限”的默認(rèn)值(0.75)是時間和空間成本上的一種折中:

    程序員可以根據(jù)實(shí)際情況來調(diào)整“負(fù)載極限”值。

    • 較高的“負(fù)載極限”可以降低hash表所占用的內(nèi)存空間,但會增加查詢數(shù)據(jù)的時間開銷,而查詢是最頻繁的操作(HashMap的get()與put()方法都要用到查詢),所以會影響時間性能;

    • 較低的“負(fù)載極限”會提高查詢數(shù)據(jù)的性能,但會增加hash表所占用的內(nèi)存開銷,影響空間性能;

    以上專有名詞連接起來是:size是記錄數(shù),capacity是桶數(shù)量,兩者size/capacity得到負(fù)載因子,當(dāng)負(fù)載因子達(dá)到理論設(shè)定的負(fù)載極限的時候擴(kuò)容。專有名詞(size、capacity、負(fù)載因子、負(fù)載極限、擴(kuò)容(具體擴(kuò)容邏輯)),所有的這些都可以在源碼中找到邏輯。

    2.2 JDK1.7中HashMap的實(shí)現(xiàn)

    2.2.1 基本元素Entry

    數(shù)組中的每一個元素其實(shí)就是Entry[] table,Map中的key和value就是以Entry的形式存儲的。Entry包含四個屬性:key、value、hash值和用于單向鏈表的next。關(guān)于Entry的具體定義參看如下源碼:

    static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry next; int hash; Entry(int h, K k, V v, Entry n) { value = v; next = n; key = k; hash = h; } public final K getKey() { return key; } public final V getValue() { return value; } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } public final boolean equals(Object o) { if (!(o instanceof Map.Entry)) return false; Map.Entry e = (Map.Entry)o; Object k1 = getKey(); Object k2 = e.getKey(); if (k1 == k2 || (k1 != null && k1.equals(k2))) { Object v1 = getValue(); Object v2 = e.getValue(); if (v1 == v2 || (v1 != null && v1.equals(v2))) return true; } return false; } public final int hashCode() { return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue()); } public final String toString() { return getKey() + "=" + getValue(); } /** * This method is invoked whenever the value in an entry is * overwritten by an invocation of put(k,v) for a key k that's already * in the HashMap. */ void recordAccess(HashMap m) { } /** * This method is invoked whenever the entry is * removed from the table. */ void recordRemoval(HashMap m) { }}

    JDK7中的HashMap,小結(jié)為以下幾點(diǎn):

  • 基本元素為Entry,Entry包含四個屬性:key、value、int類型的hash值和用于單向鏈表的Node類型的next;

  • hash不是用于新插入的Entry和原有的鏈表節(jié)點(diǎn)的hashcode比較的,只是用于計算一下數(shù)組index的;

  • key,value 是用于新插入的Entry和原有的鏈表的節(jié)點(diǎn)的 key,value 比較的,只有到equals和hashcode(index)比較都先相同,就認(rèn)為是相同元素,被認(rèn)為是相同就不會插入HashMap;

  • next用于下一個鏈表中下一個節(jié)點(diǎn)。

  • 2.2.2 插入邏輯

    2.2.2.1 put()方法 = hash()方法 + indexFor()方法 + equals()方法

    當(dāng)向 HashMap 中 put一對鍵值時,它會根據(jù) key的 hashCode 值計算出一個位置, 該位置就是此對象準(zhǔn)備往數(shù)組中存放的位置。該計算過程參看如下代碼:

    transient int hashSeed = 0;final int hash(Object k) { int h = hashSeed; if (0 != h && k instanceof String) { return sun.misc.Hashing.stringHash32((String) k); } h ^= k.hashCode(); h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); } static int indexFor(int h, int length) { return h & (length-1);?}

    hash(Object)方法是用來計算哈希值的,indexFor(hash,length)方法是用來計算數(shù)組下標(biāo)的。
    兩者關(guān)系:indexFor方法根據(jù)hash(Object)方法的返回值作為實(shí)參來計算數(shù)組下標(biāo)。

    金手指:(put操作中的)hashcode方法和equals方法

  • 第一步,確定數(shù)組下標(biāo),indexFor使用hash方法計算出來的值得到數(shù)組下標(biāo);

  • 第二步,插入,如果指定的數(shù)組下標(biāo)無對象存在,不發(fā)生哈希沖突,直接插入;

  • 第三步,如果指定的數(shù)組下標(biāo)有對象存在,發(fā)生哈希沖突,使用equals對鏈條上對象比較,全部為false插入,其中一個為true,表示已經(jīng)存在(hash和equals都相同就是存在)

  • 小結(jié):僅以put操作為例,插入操作中hash方法用來作為計算數(shù)組下標(biāo)的輸入,equals用于比較對象是否存在。

    問題1:指定數(shù)組下標(biāo)有值,哈希沖突后如何處理?
    回答1:put操作的時候,當(dāng)兩個key通過hashCode計算相同時,則發(fā)生了hash沖突(碰撞),HashMap解決hash沖突的方式是用鏈表(拉鏈法),當(dāng)發(fā)生hash沖突時,則將存放在數(shù)組中的Entry設(shè)置為新值的next(比如A和B都hash后都映射到下標(biāo)i中,之前已經(jīng)有A了,當(dāng)map.put(B)時,將B放到下標(biāo)i中,A則為B的next,所以新值存放在鏈表最頭部的數(shù)組中,舊值在新值的鏈表上)。

    問題2:哈希沖突發(fā)生后,為什么后插入的值要放在鏈表頭部?
    回答2:因?yàn)楹蟛迦氲腅ntry是“熱乎的”,被查找的可能性更大(因?yàn)間et查詢的時候會遍歷整個鏈表),既然后插入的Entry是“熱乎的”,那么這個后插入的Entry應(yīng)該放在哪里呢?當(dāng)然是放在鏈表頭部,因?yàn)殒湵聿檎覐?fù)雜度為O(n),插入和刪除復(fù)雜度為O(1),如果將新值插在末尾,就需要先經(jīng)過一輪遍歷,這個開銷大,如果是插在頭結(jié)點(diǎn),省去了遍歷的開銷,還發(fā)揮了鏈表插入性能高的優(yōu)勢。

    2.2.2.2 addEntry() + createEntry()

    添加節(jié)點(diǎn)到鏈表中:找到數(shù)組下標(biāo)后,會先進(jìn)行key判重,如果沒有重復(fù),就準(zhǔn)備將新值放入到鏈表的表頭。

    void addEntry(int hash, K key, V value, int bucketIndex) { // addEntry方法中,如果當(dāng)前 HashMap 大小已經(jīng)達(dá)到了閾值,并且新值要插入的數(shù)組位置已經(jīng)有元素了,那么要擴(kuò)容 if ((size >= threshold) && (null != table[bucketIndex])) { // 擴(kuò)容 resize(2 * table.length); // 擴(kuò)容以后,重新計算 hash 值 hash = (null != key) ? hash(key) : 0; // 重新計算擴(kuò)容后的新的下標(biāo) bucketIndex = indexFor(hash, table.length); } // createEntry就是插入新值 createEntry(hash, key, value, bucketIndex); // key value由方法參數(shù)提供,未擴(kuò)容,hash bucketIndex使用方法參數(shù)傳遞的,擴(kuò)容,hash bucketIndex使用新計算的}// 這個很簡單,其實(shí)就是將新值放到鏈表的表頭,然后 size++void createEntry(int hash, K key, V value, int bucketIndex) { Entry e = table[bucketIndex]; table[bucketIndex] = new Entry<>(hash, key, value, e); size++;}

    上述代碼解釋了:JDK7情況下的擴(kuò)容

  • addEntry方法中,如果當(dāng)前 HashMap 大小已經(jīng)達(dá)到了閾值,并且新值要插入的數(shù)組位置已經(jīng)有元素了,那么要擴(kuò)容

  • HashMap擴(kuò)容方式:兩倍擴(kuò)容

  • 擴(kuò)容后重新計算要已經(jīng)插入了的key的數(shù)組下標(biāo):先hash,然后indexFor

  • 新元素插入指定數(shù)組下標(biāo)的鏈頭,table[bucketIndex] = new Entry<>(hash, key, value, e); 新建一個Entry就是一個元素

  • 這個方法的主要邏輯就是先判斷是否需要擴(kuò)容,需要帶的話先擴(kuò)容,然后再將這個新的數(shù)據(jù)插入到擴(kuò)容后的數(shù)組的相應(yīng)位置處的鏈表的表頭。

    2.2.3 擴(kuò)容邏輯:resize()

    定義:擴(kuò)容就是用一個新的大數(shù)組替換原來的小數(shù)組,并將原來數(shù)組中的值遷移到新的數(shù)組中。

    由于是雙倍擴(kuò)容,遷移過程中,會將原來table[i]中的鏈表的所有節(jié)點(diǎn),分拆到新的數(shù)組的newTable[i]和newTable[i+oldLength]位置上。比如:原來數(shù)組長度是16,那么擴(kuò)容后,原來table[0]處的鏈表中的所有元素會被分配到新數(shù)組中newTable[0]和newTable[16]這兩個位置。擴(kuò)容期間,由于會新建一個新的空數(shù)組,并且用舊的項填充到這個新的數(shù)組中去。所以,在這個填充的過程中,如果有線程獲取值,很可能會取到 null 值,而不是我們所希望的、原來添加的值。

    我們對照HashMap的結(jié)構(gòu)來說,如下:

    上圖中,左邊部分即代表哈希表,也稱為哈希數(shù)組(默認(rèn)數(shù)組大小是16,每對key-value鍵值對其實(shí)是存在map的內(nèi)部類entry里的),數(shù)組的每個元素都是一個單鏈表的頭節(jié)點(diǎn),跟著的藍(lán)色鏈表是用來解決沖突的,如果不同的key映射到了數(shù)組的同一位置處,就將其放入單鏈表中。

    當(dāng)size>=threshold( threshold等于“容量*負(fù)載因子”)時,會發(fā)生擴(kuò)容。

    void addEntry(int hash, K key, V value, int bucketIndex) { if ((size >= threshold) && (null != table[bucketIndex])) { resize(2 * table.length); // 擴(kuò)容 hash = (null != key) ? hash(key) : 0; // 擴(kuò)容后要用新的hash,不能用參數(shù)的 bucketIndex = indexFor(hash, table.length); // 擴(kuò)容后要用新的bucketIndex,不用用參數(shù)的 } createEntry(hash, key, value, bucketIndex); // key value由方法參數(shù)提供,未擴(kuò)容,hash bucketIndex使用方法參數(shù)傳遞的,擴(kuò)容,hash bucketIndex使用新計算的}

    特別提示:JDK1.7中resize,只有當(dāng) size>=threshold 并且 table中的那個槽中已經(jīng)有Entry時,才會發(fā)生resize。即有可能雖然size>=threshold,但是必須等到相應(yīng)的槽至少有一個Entry時,才會觸發(fā)擴(kuò)容,可以通過上面的代碼看到每次resize都會擴(kuò)大一倍容量(2 * table.length)。

    2.2.4 null處理

    前面說過HashMap的key是允許為null的,當(dāng)出現(xiàn)這種情況時,會放到table[0]中。

    private V putForNullKey(V value) { for (Entry e = table[0]; e != null; e = e.next) { if (e.key == null) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; addEntry(0, null, value, 0); return null;}

    put()邏輯:hash()方法、indexFor()方法、equals()方法
    金手指:(put操作中的)hashcode方法和equals方法
    JDK7 put 子步驟:擴(kuò)容 + createEntry插入 + 哈希沖突 ( JDK8 put 子步驟:擴(kuò)容 + createEntry插入 + 哈希沖突鏈表/樹化 )

  • 第一步,使用hash和tab.lenght計算數(shù)組下標(biāo)index,準(zhǔn)備插入,index=hash&(tab.length-1);

  • 第二步,執(zhí)行插入,如果指定的數(shù)組下標(biāo)無對象存在,不發(fā)生哈希沖突,直接插入;

  • 第三步,如果指定的數(shù)組下標(biāo)有對象存在,表示發(fā)生哈希沖突,使用equals對鏈條上對象比較,全部為false插入,其中一個為true,表示已經(jīng)存在(hash和equals都相同就是存在)

  • 小結(jié):插入操作中hash方法用來作為計算數(shù)組下標(biāo)的輸入?yún)?shù),equals用于比較鏈表上對象是否存在

    小結(jié):JDK7情況插入情況下的擴(kuò)容 addEntry

  • 擴(kuò)容方式:HashMap擴(kuò)容方式:兩倍擴(kuò)容 newsize=oldsize *2

  • 數(shù)組擴(kuò)容的觸發(fā)-兩個條件:addEntry方法中,如果當(dāng)前 HashMap 大小已經(jīng)達(dá)到了閾值75%,并且新值要插入的數(shù)組位置已經(jīng)有元素了,才執(zhí)行擴(kuò)容(兩個條件,即有可能雖然size>=threshold,但是必須等到相應(yīng)的槽至少有一個Entry時,才會擴(kuò)容)

  • 擴(kuò)容后重新計算要已經(jīng)插入了的key的數(shù)組下標(biāo):使用hash和tab.lenght計算數(shù)組下標(biāo)index,準(zhǔn)備插入,index=hash&(tab.length-1);

  • 擴(kuò)容后的插入,對于原來數(shù)組的位置:擴(kuò)容就是用一個新的大數(shù)組替換原來的小數(shù)組,并將原來數(shù)組中的值遷移到新的數(shù)組中。由于是雙倍擴(kuò)容,遷移過程中,會將原來table[i]中的鏈表的所有節(jié)點(diǎn),分拆到新的數(shù)組的newTable[i]和newTable[i+oldLength]位置上。如原來數(shù)組長度是16,那么擴(kuò)容后,原來table[0]處的鏈表中的所有元素會被分配到新數(shù)組中newTable[0]和newTable[16]這兩個位置,從而減小鏈表長度。

  • 擴(kuò)容后的新插入:equals比較全部為false,然后將新元素插入指定數(shù)組下標(biāo)的鏈頭,table[bucketIndex] = new Entry<>(hash, key, value, e); 新建一個Entry就是一個元素

  • 擴(kuò)容過程中的隱患:擴(kuò)容期間,由于會新建一個新的空數(shù)組,并且用舊的項填充到這個新的數(shù)組中去。所以,在這個填充的過程中,如果有線程獲取值,很可能會取到 null 值,而不是我們所希望的、原來添加的值。

  • tip:ArrayList 1.5倍擴(kuò)容,Vector兩倍擴(kuò)容,HashTable newsize=2oldsize +1 ,HashMap newsize=2oldsize

    辨析:擴(kuò)容、樹化、哈希沖突

  • 擴(kuò)容:擴(kuò)容的對象是數(shù)組,擴(kuò)容兩個條件:addEntry方法中,如果當(dāng)前 HashMap 大小已經(jīng)達(dá)到了閾值75%,并且新值要插入的數(shù)組位置已經(jīng)有元素了,才觸發(fā)擴(kuò)容(擴(kuò)容的第二個條件一定要put操作才能滿足)

  • 樹化:樹化的對象是鏈表,鏈表節(jié)點(diǎn)數(shù)達(dá)到8,且要求數(shù)組長度大于64
    樹化的時機(jī):鏈表樹化一定要在put操作才會出現(xiàn),樹鏈表化一定要在remove操作才會出現(xiàn)。
    注意:樹化第二個要求:數(shù)組長度必須大于等于MIN_TREEIFY_CAPACITY(64),否則繼續(xù)采用擴(kuò)容策略;

  • 哈希沖突:哈希沖突的定義是要插入的數(shù)組index位置有元素了。
    JDK7:
    (1)如果未發(fā)生哈希沖突,直接放到數(shù)組中;
    (2)如果哈希沖突,沒有達(dá)到閾值的75%,插入到鏈表后面;
    (3)如果哈希沖突,達(dá)到閾值75%,數(shù)組擴(kuò)容操作,擴(kuò)容后原數(shù)組元素和新插入的數(shù)組元素都要變動的;
    JDK8:
    (1)如果未發(fā)生哈希沖突,直接放到數(shù)組中;插入完成后判斷閾值是否擴(kuò)容
    (2)如果哈希沖突,鏈表節(jié)點(diǎn)數(shù)未達(dá)到8,但是數(shù)組長度小于64,尾插法放在鏈表后面,插入完成后判斷閾值是否擴(kuò)容
    (3)如果哈希沖突,鏈表節(jié)點(diǎn)數(shù)未達(dá)到8,但是數(shù)組長度大于等于64,尾插法放在鏈表后面,插入完成后判斷閾值是否擴(kuò)容
    (4)如果哈希沖突,鏈表節(jié)點(diǎn)數(shù)達(dá)到8,但是數(shù)組長度小于64,尾插法放在鏈表后面,插入完成后判斷閾值是否擴(kuò)容
    (5)如果哈希沖突,鏈表節(jié)點(diǎn)數(shù)達(dá)到8,且要求數(shù)組長度大于等于64,尾插法插入到鏈表后面,鏈表樹化,插入完成后判斷閾值是否擴(kuò)容
    小結(jié):擴(kuò)容是數(shù)組擴(kuò)容,哈希沖突是數(shù)組哈希沖突,但是對于JDK8的HashMap,擴(kuò)容和是否產(chǎn)生哈希沖突無關(guān),擴(kuò)容是判斷 size/capacity

  • 小結(jié):擴(kuò)容、樹化、哈希沖突都是在put操作出現(xiàn),但是三種不同。擴(kuò)容和樹化都是put操作發(fā)生哈希沖突導(dǎo)致的,put操作如果不哈希沖突啥是沒有,所以只最重要的是設(shè)計分布均衡的哈希算法,頭插和尾插,擴(kuò)容和樹化都是緩解措施。JDK7是put涉及哈希沖突、數(shù)組擴(kuò)容,JDK8是put涉及哈希沖突、數(shù)組擴(kuò)容、鏈表樹化,JDK8中remove可以涉及樹鏈表化。

  • 2.3 JDK1.8中HashMap的實(shí)現(xiàn)(所有內(nèi)容圍繞JDK7與JDK8最大不同——鏈表/紅黑樹而展開)

    2.3.1 屬性支持

    HashMap底層維護(hù)的是數(shù)組+鏈表,我們可以通過一小段源碼來看看:

    /** * The default initial capacity - MUST be a power of two. * 即 默認(rèn)初始大小,值為16 */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 /** * The maximum capacity, used if a higher value is implicitly specified * by either of the constructors with arguments. * MUST be a power of two <= 1<<30. * 即 最大容量,必須為2^30 */ static final int MAXIMUM_CAPACITY = 1 << 30; /** * The load factor used when none specified in constructor. * 負(fù)載因子為0.75 */ static final float DEFAULT_LOAD_FACTOR = 0.75f; /** * The bin count threshold for using a tree rather than list for a * bin. Bins are converted to trees when adding an element to a * bin with at least this many nodes. The value must be greater * than 2 and should be at least 8 to mesh with assumptions in * tree removal about conversion back to plain bins upon * shrinkage. * 大致意思就是說hash沖突默認(rèn)采用單鏈表存儲,當(dāng)單鏈表節(jié)點(diǎn)個數(shù)大于8時,會轉(zhuǎn)化為紅黑樹存儲 */ static final int TREEIFY_THRESHOLD = 8; /** * The bin count threshold for untreeifying a (split) bin during a * resize operation. Should be less than TREEIFY_THRESHOLD, and at * most 6 to mesh with shrinkage detection under removal. * hash沖突默認(rèn)采用單鏈表存儲,當(dāng)單鏈表節(jié)點(diǎn)個數(shù)大于8時,會轉(zhuǎn)化 為紅黑樹存儲。* 當(dāng)紅黑樹中節(jié)點(diǎn)少于6時,則轉(zhuǎn)化為單鏈表存儲 */ static final int UNTREEIFY_THRESHOLD = 6; /** * The smallest table capacity for which bins may be treeified. * (Otherwise the table is resized if too many nodes in a bin.) * Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts * between resizing and treeification thresholds. * hash沖突默認(rèn)采用單鏈表存儲,當(dāng)單鏈表節(jié)點(diǎn)個數(shù)大于8時,會轉(zhuǎn)化為紅黑樹存儲。 * 但是有一個前提:要求數(shù)組長度大于64,否則不會進(jìn)行轉(zhuǎn)化 */?static?final?int?MIN_TREEIFY_CAPACITY?=?64;

    通過以上代碼可以看出初始容量(16)、負(fù)載因子以及對數(shù)組的說明。

    小結(jié):HashMap相關(guān)的變量:

  • 初始化默認(rèn)大小是16 initial capacity 16 JDK7+JDK8都一樣

  • 最大容量,必須為2^30 JDK7+JDK8都一樣

  • 默認(rèn)負(fù)載因子為0.75 達(dá)到0.75就擴(kuò)容,JDK7+JDK8都一樣

  • 樹化閾值為8,鏈表化閾值為6 JDK8新增

  • 樹化的兩個條件:鏈表節(jié)點(diǎn)數(shù)達(dá)到8,且要求數(shù)組長度大于64

    HashMap中最重要的兩個操作是擴(kuò)容和哈希沖突,但是要注意以下幾點(diǎn):

  • 擴(kuò)容是數(shù)組擴(kuò)容,哈希沖突是鏈表/紅黑樹的哈希沖突,兩者是的對象是不同的,關(guān)系是擴(kuò)容是為了減低負(fù)載因子,減少哈希沖突;

  • 減少哈希沖突兩個設(shè)計:設(shè)計一個好的哈希算法 + 數(shù)組擴(kuò)容;

  • 對于真正發(fā)生了哈希沖突:JDK7是使用頭插法插入鏈表,JDK8額外添加了樹化邏輯;

  • 無論是數(shù)組擴(kuò)容還是哈希沖突后的鏈表/紅黑樹,都是發(fā)生在put操作中的,無論JDK7還是JDK8。put操作中的數(shù)組擴(kuò)容和鏈表/紅黑樹哈希沖突:
    JDK7的put方法:擴(kuò)容 + hashcode生成index插入 + 哈希沖突;
    JDK8的put方法:擴(kuò)容 + hashcode生成index插入 + 哈希沖突。

  • 2.3.2 基本元素Node

  • 在JDK1.8中HashMap的內(nèi)部結(jié)構(gòu)可以看作是數(shù)組(Node[] table)和鏈表的復(fù)合結(jié)構(gòu)。

  • 數(shù)組被分為一個個桶(bucket),通過哈希值決定了鍵值對在這個數(shù)組中的尋址(哈希值相同的鍵值對,就是哈希沖突,則以鏈表形式存儲。

  • 如果鏈表大小超過閾值(TREEIFY_THRESHOLD,8),圖中的鏈表就會被改造為樹形(紅黑樹)結(jié)構(gòu)。

  • transient?Node<K,V>[]?table;
  • Entry的名字變成了Node,原因是和紅黑樹的實(shí)現(xiàn)TreeNode相關(guān)聯(lián)。1.8與1.7最大的不同就是利用了紅黑樹,即由數(shù)組+鏈表(或紅黑樹)組成。JDK1.8中,當(dāng)同一個hash值的節(jié)點(diǎn)數(shù)不小于8時,將不再以單鏈表的形式存儲了,會被調(diào)整成一顆紅黑樹(上圖中null節(jié)點(diǎn)沒畫)。這就是JDK1.7與JDK1.8中HashMap實(shí)現(xiàn)的最大區(qū)別。

    在分析JDK1.7中HashMap的哈希沖突時,不知大家是否有個疑問就是萬一發(fā)生碰撞的節(jié)點(diǎn)非常多怎么辦?如果說成百上千個節(jié)點(diǎn)在hash時發(fā)生碰撞,存儲一個鏈表中,那么如果要查找其中一個節(jié)點(diǎn),那就不可避免的花費(fèi)O(N)的查找時間,這將是多么大的性能損失。這個問題終于在JDK1.8中得到了解決,在最壞的情況下,鏈表查找的時間復(fù)雜度為O(n),而紅黑樹一直是O(logn),這樣會提高HashMap的效率。

    JDK1.7中HashMap采用的是位桶+鏈表的方式,即我們常說的散列鏈表的方式;
    JDK1.8中采用的是位桶+鏈表/紅黑樹的方式,雖然加快鏈表查找效率,但是也是非線程安全的,因?yàn)橹挥挟?dāng)某個位桶的鏈表的長度達(dá)到某個閥值的時候,這個鏈表才會轉(zhuǎn)換成紅黑樹。

    小結(jié):從JDK7的Entry變?yōu)镴DK8的Node

  • 基本元素使用Node,意為紅黑樹的節(jié)點(diǎn),Node包含四個屬性:key、value、hash值和用于單向鏈表的next,和JDK7的Entry節(jié)點(diǎn)一樣;

  • hash不是用于新插入的Entry和原有的鏈表節(jié)點(diǎn)的hashcode比較的,只是用于計算一下數(shù)組index的;

  • key,value 是用于新插入的Entry和原有的鏈表的節(jié)點(diǎn)的 key,value 比較的,只有到equals和hashcode(index)比較都先相同,就認(rèn)為是相同元素,被認(rèn)為是相同元素的不插入HashMap;

  • next用于下一個鏈表中下一個節(jié)點(diǎn)。

  • 2.3.3 插入邏輯:put()方法

    通過分析put方法的源碼,可以讓這種區(qū)別更直觀:

    static final int TREEIFY_THRESHOLD = 8; // 樹化 public V put(K key, V value) { return putVal(hash(key), key, value, false, true); // 調(diào)用putVal } final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node[] tab; Node p; int n, i; //如果當(dāng)前map中無數(shù)據(jù),執(zhí)行resize方法。并且返回n JDK7先擴(kuò)容再插入,可能無效擴(kuò)容,JDK8先插入再擴(kuò)容 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //如果要插入的鍵值對要存放的這個位置剛好沒有元素,那么把他封裝成Node對象,放在這個位置上即可,插入的時候沒有哈希沖突 if ((p = tab[i = (n - 1) & hash]) == null) // 這里對p賦值,就是新的要插入的節(jié)點(diǎn) tab[i] = newNode(hash, key, value, null); // 插入 //否則的話,說明這數(shù)組上面有元素,插入的時候發(fā)生哈希沖突 else { Node e; K k; //如果這個元素的key與要插入的一樣,那么就替換一下。 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; // 直接將p賦值給局部變量e // 1.如果這個元素的key與要插入的不一樣,如果當(dāng)前節(jié)點(diǎn)是TreeNode類型的數(shù)據(jù),執(zhí)行putTreeVal方法 else if (p instanceof TreeNode) e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value); else { // else表示如果這個元素的key與要插入的不一樣,如果還是遍歷這條鏈子上的數(shù)據(jù),跟JDK7沒什么區(qū)別 for (int binCount = 0; ; ++binCount) { // 循環(huán) if ((e = p.next) == null) { // 循環(huán)找到一個空位置的就插入鏈表 p.next = newNode(hash, key, value, null); //2.完成了操作后多做了一件事情,判斷,并且可能執(zhí)行treeifyBin方法 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; // 插入并判斷是否樹化,break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; // 如果鏈表上已經(jīng)存在了,直接break;這里不用樹化了,應(yīng)該根本沒插入 p = e; // 不斷將e賦值給p,更新p,就是p在鏈條上不斷往后移動 } } // e 不為null 要么第一個if替換,要么else if樹插入,要么鏈表插入,總之插入成功了,返回oldValue if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) //true || -- e.value = value; //3. afterNodeAccess(e); return oldValue; } } ++modCount; //判斷閾值,決定是否數(shù)組擴(kuò)容 插入后決定是否擴(kuò)容 if (++size > threshold) resize(); //4. 插入之后的操作 afterNodeInsertion(evict); return null;????}

    以上代碼中的特別之處如下:

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

    treeifyBin()就是將鏈表轉(zhuǎn)換成紅黑樹。

    源碼解析putVal()操作(語言組織),如下:

    擴(kuò)容:如果當(dāng)前map中無數(shù)據(jù),執(zhí)行resize方法。并且返回n JDK8先擴(kuò)容再插入,JDK7先插入再擴(kuò)容,可能無效擴(kuò)容沒有哈希沖突:如果要插入的鍵值對要存放的這個位置剛好沒有元素,那么把他封裝成Node對象,放在這個位置上即可,插入的時候沒有哈希沖突 這里對p賦值,就是新的要插入的節(jié)點(diǎn) else 表示 否則的話,說明這數(shù)組上面有元素,插入的時候發(fā)生哈希沖突 if表示 //如果這個元素的key與要插入的一樣,那么就替換一下。 else if 表示 1.如果這個元素的key與要插入的不一樣,如果當(dāng)前節(jié)點(diǎn)是TreeNode類型的數(shù)據(jù),執(zhí)行putTreeVal方法 else表示如果這個元素的key與要插入的不一樣,如果還是遍歷這條鏈子上的數(shù)據(jù),跟JDK7沒什么區(qū)別 // 循環(huán) // 循環(huán)找到一個空位置的就插入鏈表 //2.完成了操作后多做了一件事情,判斷,并且可能執(zhí)行treeifyBin方法 // 插入并判斷是否樹化,break; // 如果鏈表上已經(jīng)存在了,直接break;這里不用樹化了,應(yīng)該根本沒插入 // 不斷將e賦值給p,更新p,就是p在鏈條上不斷往后移動 返回oldValue:e 不為null 要么第一個if替換,要么else if樹插入,要么鏈表插入,總之插入成功了,返回oldValue 插入后判斷是否擴(kuò)容:判斷閾值,決定是否數(shù)組擴(kuò)容 插入后決定是否擴(kuò)容 最后 插入之后的操作?完成了。

    關(guān)于putVal(),注意以下三點(diǎn):

  • 樹化有個要求就是數(shù)組長度必須大于等于MIN_TREEIFY_CAPACITY(64),否則繼續(xù)采用擴(kuò)容策略;

  • resize方法兼顧兩個職責(zé),創(chuàng)建初始存儲表格,或者在容量不滿足需求的時候;

  • 在JDK1.8中取消了indefFor()方法,直接用(tab.length-1)&hash,所以看到這個,代表的就是數(shù)組的下角標(biāo)。

  • 2.3.4 treeifyBin()樹化為紅黑樹(難點(diǎn):要看懂這個,一定要懂?dāng)?shù)據(jù)結(jié)構(gòu)紅黑樹)

    樹化操作的過程有點(diǎn)復(fù)雜,可以結(jié)合源碼來看看。將原本的單鏈表轉(zhuǎn)化為雙向鏈表,再遍歷這個雙向鏈表轉(zhuǎn)化為紅黑樹。

    final void treeifyBin(Node[] tab, int hash) { int n, index; Node e; //樹形化還有一個要求就是數(shù)組長度必須大于等于64,否則繼續(xù)采用擴(kuò)容策略 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) resize(); else if ((e = tab[index = (n - 1) & hash]) != null) { TreeNode hd = null, tl = null;//hd指向首節(jié)點(diǎn),tl指向尾節(jié)點(diǎn) do { TreeNode p = replacementTreeNode(e, null);//將鏈表節(jié)點(diǎn)轉(zhuǎn)化為紅黑樹節(jié)點(diǎn) if (tl == null) // 如果尾節(jié)點(diǎn)為空,說明還沒有首節(jié)點(diǎn) hd = p; // 當(dāng)前節(jié)點(diǎn)作為首節(jié)點(diǎn) else { // 尾節(jié)點(diǎn)不為空,構(gòu)造一個雙向鏈表結(jié)構(gòu),將當(dāng)前節(jié)點(diǎn)追加到雙向鏈表的末尾 p.prev = tl; // 當(dāng)前樹節(jié)點(diǎn)的前一個節(jié)點(diǎn)指向尾節(jié)點(diǎn) tl.next = p; // 尾節(jié)點(diǎn)的后一個節(jié)點(diǎn)指向當(dāng)前節(jié)點(diǎn) } tl = p; // 把當(dāng)前節(jié)點(diǎn)設(shè)為尾節(jié)點(diǎn) } while ((e = e.next) != null); // 繼續(xù)遍歷單鏈表 //將原本的單鏈表轉(zhuǎn)化為一個節(jié)點(diǎn)類型為TreeNode的雙向鏈表 if ((tab[index] = hd) != null) // 把轉(zhuǎn)換后的雙向鏈表,替換數(shù)組原來位置上的單向鏈表 hd.treeify(tab); // 將當(dāng)前雙向鏈表樹形化 }}

    特別注意:樹化有個要求就是數(shù)組長度必須大于等于MIN_TREEIFY_CAPACITY(64),否則繼續(xù)采用擴(kuò)容策略。

    總的來說,HashMap默認(rèn)采用數(shù)組+單鏈表方式存儲元素,當(dāng)元素出現(xiàn)哈希沖突時,會存儲到該位置的單鏈表中。但是單鏈表不會一直增加元素,當(dāng)元素個數(shù)超過8個時,會嘗試將單鏈表轉(zhuǎn)化為紅黑樹存儲。但是在轉(zhuǎn)化前,會再判斷一次當(dāng)前數(shù)組的長度,只有數(shù)組長度大于64才處理。否則,進(jìn)行擴(kuò)容操作。

    將雙向鏈表轉(zhuǎn)化為紅黑樹的實(shí)現(xiàn):

    final void treeify(Node[] tab) { TreeNode root = null; // 定義紅黑樹的根節(jié)點(diǎn) for (TreeNode x = this, next; x != null; x = next) { // 從TreeNode雙向鏈表的頭節(jié)點(diǎn)開始逐個遍歷 next = (TreeNode)x.next; // 頭節(jié)點(diǎn)的后繼節(jié)點(diǎn) x.left = x.right = null; if (root == null) { x.parent = null; x.red = false; root = x; // 頭節(jié)點(diǎn)作為紅黑樹的根,設(shè)置為黑色 } else { // 紅黑樹存在根節(jié)點(diǎn) K k = x.key; int h = x.hash; Class> kc = null; for (TreeNode p = root;;) { // 從根開始遍歷整個紅黑樹 int dir, ph; K pk = p.key; if ((ph = p.hash) > h) // 當(dāng)前紅黑樹節(jié)點(diǎn)p的hash值大于雙向鏈表節(jié)點(diǎn)x的哈希值 dir = -1; else if (ph < h) // 當(dāng)前紅黑樹節(jié)點(diǎn)的hash值小于雙向鏈表節(jié)點(diǎn)x的哈希值 dir = 1; else if ((kc == null && (kc = comparableClassFor(k)) == null) || (dir = compareComparables(kc, k, pk)) == 0) // 當(dāng)前紅黑樹節(jié)點(diǎn)的hash值等于雙向鏈表節(jié)點(diǎn)x的哈希值,則如果key值采用比較器一致則比較key值 dir = tieBreakOrder(k, pk); //如果key值也一致則比較className和identityHashCode TreeNode xp = p; if ((p = (dir <= 0) ? p.left : p.right) == null) { // 如果當(dāng)前紅黑樹節(jié)點(diǎn)p是葉子節(jié)點(diǎn),那么雙向鏈表節(jié)點(diǎn)x就找到了插入的位置 x.parent = xp; if (dir <= 0) //根據(jù)dir的值,插入到p的左孩子或者右孩子 xp.left = x; else xp.right = x; root = balanceInsertion(root, x); //紅黑樹中插入元素,需要進(jìn)行平衡調(diào)整(過程和TreeMap調(diào)整邏輯一模一樣) break; } } } } //將TreeNode雙向鏈表轉(zhuǎn)化為紅黑樹結(jié)構(gòu)之后,由于紅黑樹是基于根節(jié)點(diǎn)進(jìn)行查找,所以必須將紅黑樹的根節(jié)點(diǎn)作為數(shù)組當(dāng)前位置的元素 moveRootToFront(tab, root);}

    然后將紅黑樹的根節(jié)點(diǎn)移動端數(shù)組的索引所在位置上:

    static void moveRootToFront(Node[] tab, TreeNode root) { int n; if (root != null && tab != null && (n = tab.length) > 0) { int index = (n - 1) & root.hash; //找到紅黑樹根節(jié)點(diǎn)在數(shù)組中的位置 TreeNode first = (TreeNode)tab[index]; //獲取當(dāng)前數(shù)組中該位置的元素 if (root != first) { //紅黑樹根節(jié)點(diǎn)不是數(shù)組當(dāng)前位置的元素 Node rn; tab[index] = root; TreeNode rp = root.prev; if ((rn = root.next) != null) //將紅黑樹根節(jié)點(diǎn)前后節(jié)點(diǎn)相連 ((TreeNode)rn).prev = rp; if (rp != null) rp.next = rn; if (first != null) //將數(shù)組當(dāng)前位置的元素,作為紅黑樹根節(jié)點(diǎn)的后繼節(jié)點(diǎn) first.prev = root; root.next = first; root.prev = null; } assert checkInvariants(root); }}

    putVal方法處理的邏輯比較多,包括初始化、擴(kuò)容、樹化,近乎在這個方法中都能體現(xiàn),針對源碼簡單講解下幾個關(guān)鍵點(diǎn):

    如果Node[] table是null,resize方法會負(fù)責(zé)初始化,即如下代碼:

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

    resize方法兼顧兩個職責(zé):創(chuàng)建初始存儲表格 + 在容量不滿足需求的時候進(jìn)行擴(kuò)容(resize)。

    在放置新的鍵值對的過程中,如果發(fā)生下面條件,就會發(fā)生擴(kuò)容。

    if (++size > threshold)????resize();

    具體鍵值對在哈希表中的位置(數(shù)組index)取決于下面的位運(yùn)算:

    i?=?(n?-?1)?&?hash

    仔細(xì)觀察哈希值的源頭,會發(fā)現(xiàn)它并不是key本身的hashCode,而是來自于HashMap內(nèi)部的另一個hash方法。為什么這里需要將高位數(shù)據(jù)移位到低位進(jìn)行異或運(yùn)算呢?這是因?yàn)橛行?shù)據(jù)計算出的哈希值差異主要在高位,而HashMap里的哈希尋址是忽略容量以上的高位的,那么這種處理就可以有效避免類似情況下的哈希碰撞。

    在JDK1.8中取消了indefFor()方法,直接用(tab.length-1)&hash,所以看到這個,代表的就是數(shù)組的下角標(biāo)。

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

    2.3.5 JDK8中的HashMap的王牌功能:問題1:HashMap為什么要樹化?

    問題:JDK8中的HashMap的王牌功能:為什么HashMap為什么要樹化?
    回答:一句話概括:制造哈希碰撞從而造成DOS攻擊,樹化后優(yōu)化哈希碰撞產(chǎn)生后的存取。
    解釋:其實(shí)這本質(zhì)上一個安全問題。因?yàn)樵谠胤胖眠^程中,如果一個對象哈希沖突,都被放置到同一個桶里,則會形成一個鏈表,我們知道鏈表查詢是線性的,會嚴(yán)重影響存取的性能。而在現(xiàn)實(shí)世界,構(gòu)造哈希沖突的數(shù)據(jù)并不是非常復(fù)雜的事情,惡意代碼就可以利用這些數(shù)據(jù)大量與服務(wù)器端交互,導(dǎo)致服務(wù)器端CPU大量占用,這就構(gòu)成了哈希碰撞拒絕服務(wù)攻擊,國內(nèi)一線互聯(lián)網(wǎng)公司就發(fā)生過類似攻擊事件。
    哈希碰撞攻擊:用哈希碰撞發(fā)起拒絕服務(wù)攻擊(DOS,Denial-Of-Service attack),常見的場景是攻擊者可以事先構(gòu)造大量相同哈希值的數(shù)據(jù)(制造哈希碰撞從而造成DOS攻擊,樹化后優(yōu)化哈希碰撞產(chǎn)生后的存取),然后以JSON數(shù)據(jù)的形式發(fā)送給服務(wù)器,服務(wù)器端在將其構(gòu)建成為Java對象過程中,通常以HashTable或HashMap等形式存儲,哈希碰撞將導(dǎo)致哈希表發(fā)生嚴(yán)重退化,算法復(fù)雜度可能上升一個數(shù)據(jù)級,進(jìn)而耗費(fèi)大量CPU資源。

    2.3.6 JDK8中的HashMap的王牌功能:問題2:鏈表樹化的兩個條件?/為什么要將鏈表中轉(zhuǎn)紅黑樹的閾值設(shè)為8?

    我們可以這么來看,當(dāng)鏈表長度大于或等于閾值(默認(rèn)為 8)的時候,如果同時還滿足容量大于或等于 MIN_TREEIFY_CAPACITY(默認(rèn)為 64)的要求,就會把鏈表轉(zhuǎn)換為紅黑樹。同樣,后續(xù)如果由于刪除或者其他原因調(diào)整了大小,當(dāng)紅黑樹的節(jié)點(diǎn)小于或等于 6 個以后,又會恢復(fù)為鏈表形態(tài)。

    每次遍歷一個鏈表,平均查找的時間復(fù)雜度是 O(n),n 是鏈表的長度。紅黑樹有和鏈表不一樣的查找性能,由于紅黑樹有自平衡的特點(diǎn),可以防止不平衡情況的發(fā)生,所以可以始終將查找的時間復(fù)雜度控制在 O(log(n))。最初鏈表還不是很長,所以可能 O(n) 和 O(log(n)) 的區(qū)別不大,但是如果鏈表越來越長,那么這種區(qū)別便會有所體現(xiàn)。所以為了提升查找性能,需要把鏈表轉(zhuǎn)化為紅黑樹的形式。

    還要注意很重要的一點(diǎn),單個 TreeNode 需要占用的空間大約是普通 Node 的兩倍,所以只有當(dāng)包含足夠多的 Nodes 時才會轉(zhuǎn)成 TreeNodes,而是否足夠多就是由 TREEIFY_THRESHOLD 的值決定的。而當(dāng)桶中節(jié)點(diǎn)數(shù)由于移除或者 resize 變少后,又會變回普通的鏈表的形式,以便節(jié)省空間。

    默認(rèn)是鏈表長度達(dá)到 8 就轉(zhuǎn)成紅黑樹,而當(dāng)長度降到 6 就轉(zhuǎn)換回去,這體現(xiàn)了時間和空間平衡的思想,最開始使用鏈表的時候,空間占用是比較少的,而且由于鏈表短,所以查詢時間也沒有太大的問題。可是當(dāng)鏈表越來越長,需要用紅黑樹的形式來保證查詢的效率。

    在理想情況下,鏈表長度符合泊松分布,各個長度的命中概率依次遞減,當(dāng)長度為 8 的時候,是最理想的值。

    事實(shí)上,鏈表長度超過 8 就轉(zhuǎn)為紅黑樹的設(shè)計,更多的是為了防止用戶自己實(shí)現(xiàn)了不好的哈希算法時導(dǎo)致鏈表過長,從而導(dǎo)致查詢效率低,而此時轉(zhuǎn)為紅黑樹更多的是一種保底策略,用來保證極端情況下查詢的效率。

    通常如果 hash 算法正常的話,那么鏈表的長度也不會很長,那么紅黑樹也不會帶來明顯的查詢時間上的優(yōu)勢,反而會增加空間負(fù)擔(dān)。所以通常情況下,并沒有必要轉(zhuǎn)為紅黑樹,所以就選擇了概率非常小,小于千萬分之一概率,也就是長度為 8 的概率,把長度 8 作為轉(zhuǎn)化的默認(rèn)閾值。

    鏈表樹化的兩個條件:當(dāng)鏈表長度大于或等于閾值(默認(rèn)為 8)的時候,并且同時還滿足容量大于或等于 MIN_TREEIFY_CAPACITY(默認(rèn)為 64)的要求

    處理哈希沖突兩個方法:一個好的哈希算法、鏈表樹化(前者才是根本,后者只是網(wǎng)絡(luò)安全制造哈希

    問題:JDK8中的HashMap的王牌功能:鏈表樹化的兩個條件?/為什么要將鏈表中轉(zhuǎn)紅黑樹的閾值設(shè)為8?
    回答:碰撞從而造成DOS攻擊和不合理哈希算法的處理),所以設(shè)計為8。通常如果 hash 算法正常的話,那么鏈表的長度也不會很長,那么紅黑樹也不會帶來明顯的查詢時間上的優(yōu)勢,反而會增加空間負(fù)擔(dān)。所以通常情況下,并沒有必要轉(zhuǎn)為紅黑樹,所以就選擇了概率非常小,小于千萬分之一概率,也就是長度為 8 的概率,把長度 8 作為轉(zhuǎn)化的默認(rèn)閾值。
    值得注意的是,實(shí)際開發(fā)中,發(fā)現(xiàn) HashMap 內(nèi)部出現(xiàn)了紅黑樹的結(jié)構(gòu),那可能是我們的哈希算法出了問題,所以需要選用合適的hashCode方法,以便減少沖突。

    問題:為什么在JDK1.8中進(jìn)行對HashMap優(yōu)化的時候,把鏈表轉(zhuǎn)化為紅黑樹的閾值是8,而不是7或者不是20呢?
    標(biāo)準(zhǔn)回答:

  • 第一,避免頻繁轉(zhuǎn)換,樹化鏈表化轉(zhuǎn)換成本與二叉樹優(yōu)化查詢性能之間的平衡:如果選擇6和8(如果鏈表小于等于6樹還原轉(zhuǎn)為鏈表,大于等于8轉(zhuǎn)為樹),中間有個差值7可以有效防止鏈表和樹頻繁轉(zhuǎn)換。假設(shè)一下,如果設(shè)計成鏈表個數(shù)超過8則鏈表轉(zhuǎn)換成樹結(jié)構(gòu),鏈表個數(shù)小于8則樹結(jié)構(gòu)轉(zhuǎn)換成鏈表,如果一個HashMap不停的插入、刪除元素,鏈表個數(shù)在8左右徘徊,就會頻繁的發(fā)生樹轉(zhuǎn)鏈表、鏈表轉(zhuǎn)樹,效率會很低。

  • 第二,數(shù)學(xué)證明,泊松分布:還有一點(diǎn)重要的就是由于treenodes的大小大約是常規(guī)節(jié)點(diǎn)的兩倍,因此我們僅在容器包含足夠的節(jié)點(diǎn)以保證使用時才使用它們,當(dāng)它們變得太小(由于移除或調(diào)整大小)時,它們會被轉(zhuǎn)換回普通的node節(jié)點(diǎn),容器中節(jié)點(diǎn)分布在hash桶中的頻率遵循泊松分布,桶的長度超過8的概率非常非常小。所以作者應(yīng)該是根據(jù)概率統(tǒng)計而選擇了8作為閥值

  • 第三,統(tǒng)計學(xué)問題:一個統(tǒng)計的問題,java設(shè)計一定使用數(shù)學(xué)方法和統(tǒng)計方法,知道得到這個8,就像豐巢快遞為什么是12小時而不是24小時一樣。

  • 2.4 HashMap在1.7和1.8之間四個不同(對比方式,重要)

    我們可以簡單列下HashMap在1.7和1.8之間的變化,四點(diǎn)變化(除了底層結(jié)構(gòu),都要從源碼層面解釋):

    第一,底層數(shù)據(jù)結(jié)構(gòu)不同

    1.7中采用數(shù)組+鏈表,1.8采用的是數(shù)組+鏈表/紅黑樹,即在1.7中鏈表長度超過一定長度后就改成紅黑樹存儲。

    第二,index:擴(kuò)容后index的計算

    1.7擴(kuò)容時需要重新計算哈希值hash,根據(jù)hash計算索引位置index,equals比較鏈表上的元素。

    1.8并不重新計算哈希值hash,巧妙地采用和擴(kuò)容后容量進(jìn)行&操作來計算新的索引位置index,即index = hash & (tab.length - 1) 。

    第三,插入:哈希沖突的時候插入的值*

    1.7是采用表頭插入法插入鏈表,1.8采用的是尾部插入法。

    在1.7中采用表頭插入法,
    缺點(diǎn):因?yàn)轭^插法,在擴(kuò)容時會改變鏈表中元素原本的順序,以至于在并發(fā)場景下導(dǎo)致鏈表成環(huán)的問題;
    優(yōu)點(diǎn):JDK7考慮剛剛插入的值是熱乎的,所以放在表頭;

    在1.8中采用尾部插入法,
    優(yōu)點(diǎn):因?yàn)槲膊宸?#xff0c;所以在擴(kuò)容時會保持鏈表元素原本的順序,就不會出現(xiàn)鏈表成環(huán)的問題了;
    缺點(diǎn):因?yàn)槲膊宸?#xff0c;所有剛剛插入的節(jié)點(diǎn)放在最后面了,要找很麻煩,所以引入鏈表樹化,超過8個節(jié)點(diǎn)就可以樹化,找新插入的節(jié)點(diǎn)只要O(lgN)。

    第四,擴(kuò)容的時機(jī):擴(kuò)容與插入的先后順序

    1.7中是先擴(kuò)容后插入新值的,1.8中是先插值再擴(kuò)容

    問題:為什么在JDK1.7的時候是先進(jìn)行擴(kuò)容后進(jìn)行插入,而在JDK1.8的時候則是先插入后進(jìn)行擴(kuò)容的呢?
    答案:代碼就是這樣寫的,JDK8插入的時候使用了樹化,所以將數(shù)組擴(kuò)容放到了后面。

  • 對于JDK1.8
    在JDK1.7中的話,是先進(jìn)行插入新值然后進(jìn)行擴(kuò)容操作的,主要是因?yàn)閷︽湵磙D(zhuǎn)為紅黑樹進(jìn)行的優(yōu)化,因?yàn)槟悴迦脒@個節(jié)點(diǎn)的時候有可能是普通鏈表節(jié)點(diǎn),也有可能是紅黑樹節(jié)點(diǎn),所以導(dǎo)致先插入后擴(kuò)容,擴(kuò)容判斷與resize()函數(shù)調(diào)用如下:

  • //其實(shí)就是當(dāng)這個Map中實(shí)際插入的鍵值對的值的大小如果大于這個默認(rèn)的閾值的時候(初始是16*0.75=12)的時候才會觸發(fā)擴(kuò)容,//這個是在JDK1.8中的先插入后擴(kuò)容if (++size > threshold)????????????resize();
  • 對于JDK1.7
    在JDK1.7中的話,是先進(jìn)行擴(kuò)容操作然后進(jìn)行插入新值的,就是當(dāng)你發(fā)現(xiàn)你插入的桶是不是為空:
    (1)如果不為空說明存在值,當(dāng)前插入會發(fā)生哈希沖突,那么就必須得擴(kuò)容;
    (2)如果為空說明不存在值,當(dāng)前插入不會發(fā)生哈希沖突,那么本次插入不需要擴(kuò)容,那就等到下一次發(fā)生Hash沖突的時候在進(jìn)行擴(kuò)容,但是當(dāng)如果以后都沒有發(fā)生hash沖突產(chǎn)生,那么就不會進(jìn)行擴(kuò)容了,減少了一次無用擴(kuò)容,也減少了內(nèi)存的使用。
    先擴(kuò)容后插入代碼邏輯如下:

  • void addEntry(int hash, K key, V value, int bucketIndex) { //這里當(dāng)錢數(shù)組如果大于等于12(假如)閾值的話,并且當(dāng)前的數(shù)組的Entry數(shù)組還不能為空的時候就擴(kuò)容   if ((size >= threshold) && (null != table[bucketIndex])) {       //擴(kuò)容數(shù)組,比較耗時    resize(2 * table.length);   hash = (null != key) ? hash(key) : 0;   bucketIndex = indexFor(hash, table.length);   }   createEntry(hash, key, value, bucketIndex);  }?void?createEntry(int?hash,?K?key,?V?value,?int?bucketIndex)?{   Entry e = table[bucketIndex];    //把新加的放在原先在的前面,原先的是e,現(xiàn)在的是new,next指向e    table[bucketIndex] = new Entry<>(hash, key, value, e);//假設(shè)現(xiàn)在是new   size++;  }
  • 三、其他:HashTable、TreeMap、ConcurrentHashMap

    3.1 HashTable

    3.2 TreeMap

    TreeMap需要注意的三點(diǎn):

  • key-value是否為null,和HashTable一樣:
    與HashMap不同的是,TreeMap鍵、值都不能為null;

  • 紅黑樹是排序二叉樹:
    TreeMap自定義排序器,底層如何實(shí)現(xiàn)排序:樹中的每個節(jié)點(diǎn)的值都會大于或等于它的左子樹中的所有節(jié)點(diǎn)的值,并且小于或等于它的右子樹中的所有節(jié)點(diǎn)的值;

  • 紅黑樹是平衡二叉樹-時間復(fù)雜度:
    與HashMap不同的是它的get、put、remove之類操作都是O(log(n))的時間復(fù)雜度。

  • 對TreeMap做成如下小結(jié):
    TreeMap是基于紅黑樹的一種提供順序訪問的Map,與HashMap不同的是它的get、put、remove之類操作都是o(log(n))的時間復(fù)雜度,具體順序可以由指定的Comparator來決定,或者根據(jù)鍵的自然順序來判斷。

    3.3 ConcurrentHashMap(核心:鎖機(jī)制)

    Java5提供了ConcurrentHashMap,它是HashTable的替代,比HashTable的擴(kuò)展性更好,ConcurrentHashMap底層采用分段的數(shù)組+鏈表實(shí)現(xiàn),是線程安全。

    先看一下ConcurrentHashMap的類圖:

    由上面類圖,左邊是HashMap,右邊是ConcurrentHashMap,它都是繼承自AbstractMap抽象類,但是,在存儲結(jié)構(gòu)中,ConcurrentHashMap比HashMap多出了一個類Segment,而Segment就是一個可重入鎖,這個Segment就是ConcurrentHashMap實(shí)現(xiàn)分段鎖的關(guān)鍵,ConcurrentHashMap也正是使用這種鎖分段技術(shù)來保證線程安全的。

    鎖分段技術(shù)定義:首先將數(shù)據(jù)分成一段一段的存儲,然后給每一段數(shù)據(jù)配一把鎖,當(dāng)一個線程占用鎖訪問其中一個段數(shù)據(jù)的時候,其他段的數(shù)據(jù)也能被其他線程訪問。

    ConcurrentHashMap與HashTable的不同:

  • HashTable中采用的鎖機(jī)制是一次鎖住整個hash表,從而在同一時刻只能由一個線程對其進(jìn)行操作;而ConcurrentHashMap中則是一次鎖住一個桶。

  • ConcurrentHashMap默認(rèn)將hash表分為16個桶,諸如get、put、remove等常用操作只鎖住當(dāng)前需要用到的桶。這樣,原來只能一個線程進(jìn)入,現(xiàn)在卻能同時有16個寫線程執(zhí)行,并發(fā)性能的提升是顯而易見的。

  • 問題:ConcurrentHashMap是如何實(shí)現(xiàn)鎖機(jī)制的?
    回答:

  • 實(shí)現(xiàn)上,Segment內(nèi)部類實(shí)現(xiàn)分段:
    ConcurrentHashMap具有一個內(nèi)部類Segment,正是因?yàn)閮?nèi)部類Segment,將數(shù)據(jù)分成一段一段的存儲,然后給每一段數(shù)據(jù)配一把鎖,當(dāng)一個線程占用鎖訪問其中一個段數(shù)據(jù)的時候,其他段的數(shù)據(jù)也能被其他線程訪問;

  • 實(shí)現(xiàn)上,ConcurrentHashMap分段鎖就是Lock鎖:
    Segement extends ReentrantLock implements Serializable,所以說ConcurrentHashMap中的分段鎖就是一種普通的Lock鎖;

  • 方法上,段內(nèi)方法,分段操作,性能提高16倍,get() put() remove():
    ConcurrentHashMap默認(rèn)將hash表分為16個桶,諸如get、put、remove等常用操作只鎖住當(dāng)前需要用到的桶。ConcurrentHashMap是HashTable的替代,HashTable中采用的鎖機(jī)制是一次鎖住整個hash表,從而在同一時刻只能由一個線程對其進(jìn)行操作,而ConcurrentHashMap中則是一次鎖住一個桶;

  • 方法上,擴(kuò)容方法,分段鎖實(shí)現(xiàn)段內(nèi)擴(kuò)容resize():
    段內(nèi)數(shù)組擴(kuò)容(段內(nèi)元素超過該段對應(yīng)Entry數(shù)組長度的75%觸發(fā)擴(kuò)容,不會對整個Map進(jìn)行擴(kuò)容),插入前檢測需不需要擴(kuò)容,有效避免無效擴(kuò)容;

  • 方法上,跨段方法,跨段方法size()和containsValue():
    有些方法需要跨段,比如size()和containsValue(),它們可能需要鎖定整個表而而不僅僅是某個段,這需要按順序鎖定所有段,操作完畢后,又按順序釋放所有段的鎖;

  • 讀不加鎖、寫加鎖:
    讀操作不加鎖,由于HashEntry的value變量是 volatile的,也能保證讀取到最新的值。

  • 3.4 HashTable/ConcurrentHashMap、HashMap、TreeMap

  • 存儲內(nèi)容為key-value鍵值對:
    存儲的內(nèi)容是基于key-value的鍵值對映射,不能有重復(fù)的key,而且一個key只能映射一個value。HashSet底層就是基于HashMap實(shí)現(xiàn)的。

  • key、value是否為null:
    HashTable的key、value都不能為null;TreeMap鍵、值都不能為null;HashMap的key、value可以為null,不過只能有一個key為null,但可以有多個null的value;

  • HashTable、HashMap具有無序特性,TreeMap默認(rèn)升序,可以自定義排序方式:
    HashTable、HashMap具有無序特性,TreeMap是利用紅黑樹實(shí)現(xiàn)的(金手指:TreeMap自定義排序器,底層如何實(shí)現(xiàn)排序:樹中的每個節(jié)點(diǎn)的值都會大于或等于它的左子樹中的所有節(jié)點(diǎn)的值,并且小于或等于它的右子樹中的所有節(jié)點(diǎn)的值),實(shí)現(xiàn)了SortMap接口,能夠?qū)Ρ4娴挠涗浉鶕?jù)鍵進(jìn)行排序。所以一般需求排序的情況下首選TreeMap,默認(rèn)按鍵的升序排序(深度優(yōu)先搜索),也可以自定義實(shí)現(xiàn)Comparator接口實(shí)現(xiàn)排序方式。

  • 選用原則:一般用HashMap,需要排序使用TreeMap,需要保證線程安全使用ConcurrentHashMap
    一般情況下選用HashMap,因?yàn)镠ashMap的鍵值對在取出時是隨機(jī)的,其依據(jù)鍵的hashCode和鍵的equals方法存取數(shù)據(jù),具有很快的訪問速度,所以在Map中插入、刪除及索引元素時其是效率最高的實(shí)現(xiàn)。其他的,TreeMap的鍵值對在取出時是排過序的,效率會低一點(diǎn),而HashTable/ConcurrentHashMap是線程安全的,效率也低一點(diǎn)。

  • 四、小結(jié)

    HashMap全解析,完成了。

    天天打碼,天天進(jìn)步!!!

    寫留言

    總結(jié)

    以上是生活随笔為你收集整理的hashmap value占用空间大小_【Java集合框架002】原理层面:HashMap全解析的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。

    如果覺得生活随笔網(wǎng)站內(nèi)容還不錯,歡迎將生活随笔推薦給好友。