HashMap詳解



HashMap底層數據結構?

  • 底層:數組+鏈表 大概結構如圖:

  • 能說得再詳細一點嗎?

    1.在jdk1.7中,HashMap的主干由一個一個的Entry數組組成,源碼:

    /**
         * An empty table instance to share when the table is not inflated.
         */
        static final Entry<?,?>[] EMPTY_TABLE = {};
        /**
         * The table, resized as necessary. Length MUST Always be a power of two.
         */
        transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
    
    static class Entry<K,V> implements Map.Entry<K,V> {
            final K key;
            V value;
            Entry<K,V> next;
            int hash;
    
            /**
             * Creates new entry.
             */
            Entry(int h, K k, V v, Entry<K,V> n) {
                value = v;
                next = n;
                key = k;
                hash = h;
            }
        
        	...... //后面的省略
    }
    

    2.jdk1.8中,HashMap主干由名叫Node的數組組成,源碼:

    transient Node<K,V>[] table;
    
    static class Node<K,V> implements Map.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;
            }
        	.... //略
    }
    

    以及一些其他的默認屬性:

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默認數組長度2^4=16
    static final int MAXIMUM_CAPACITY = 1 << 30; // 最大數組容量2^30
    static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默認負載因子
    static final int TREEIFY_THRESHOLD = 8; // 鏈表轉紅黑樹的閾值
    static final int UNTREEIFY_THRESHOLD = 6; // 擴容時紅黑樹轉鏈表的閾值
    

說說put方法的具體?

  • put的實現原理

在每次put數據的時候,會調用HashMap的hashCode計算key的hash值,然后執行:

n = tab.length //n就是數組的長度
index = (n - 1) & hash

計算出的index,就是HashMap數組的下標索引,然后添加進去,但是在添加的的時候還會做檢查:

​ 1.檢查到這個位置沒有數據,則直接添加。

​ 2.檢查到這個位置有數據,則還需要再做一次判斷:

​ (1)調用equals方法判斷這兩個key是不是一樣:

​ 如果是同一個key,那么就對之前的value進行覆蓋,如果不同,則添加在后面,這樣就會形成鏈表。(這是jdk1.8以后的尾插法,之前都是在頭部插入,有什么區別呢?后續~~),

注:可能會有小伙伴會有疑問,為什么兩個不同的key值會計算出相同的索引??

這就是所謂的hash沖突,即便內容不同,但是很有可能計算出來的hashCode值一樣。

jdk1.7和jdk1.8put的區別是什么?

  • jdk1.7中使用的是 “頭部插入法”,即當有新的元素加入到鏈表中的時候,是加在鏈表頭部。然而,從jdk1.8以后,都修改了,執行“尾部插入法”,即插在鏈表的末尾。

  • 為什么要把“頭部插入法”修改為“尾部插入法”呢?

    也許有點小伙伴會覺得,這也沒啥講究的,可能人家心情不好就隨手給改了~~~~然鵝,這里面大有文章!!!!

    我們知道,HashMap是會動態擴容的,擴容的影響因素有兩個:

    • Capacity:HashMap當前長度。
    • LoadFactor:負載因子,默認值0.75f。
    • 官方源碼說明:
    The next size value at which to resize (capacity * load factor).
    

    如果不指定長度,一開始的capacity默認值是16(為什么是16呢?后續),當添加了:

     newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    //即閾值 = 16*0.75=12
    

    當添加了12個數據,在添加第13個的時候,就會進行擴容,而且擴容為原來的兩倍!!(擴容細節后續~~~)

  • 這個擴容和尾插法有什么關系呢??

    舉個例子,現在往一個容量大小為2的HashMap中put兩個值,負載因子是0.75,則在put第二個的時候就會進行resize!

    現在我們用多個線程插入三個鍵值對:A = 1 ,B = 2,C = 3 ;如果我們在 resize 執行之前打一個斷點,這樣就會出現數據已經添加了但是還沒來得及擴容,此時 A 是指向 B 的:

    擴容前

    ​ 而resize的機制是,使用單鏈表的頭插入方法,同一位置上的新元素總會被放到鏈表的頭部,而在擴容之前數組中同一條Entry鏈上的元素,會被重新計算,然后放到不同的位置上,如果這時候剛好把 B 放到了A原來的位置,如圖:

    ​ 此時的情況是,B 居然指向了 A ???還記得沒有擴容之前嗎?是 A 指向 B !!

    ​ 由於是多線程同時操作,當所有線程都執行完畢以后,就可能會出現這樣的情況:

    ​ 此時居然出現了環狀的鏈表結構,如果這個時候去取值,就會出錯——InfiniteLoop(死循環)。

  • 結論:

    使用頭插會改變鏈表的上的順序,但是如果使用尾插,在擴容時會保持鏈表元素原本的順序,就不會出現鏈表成環的問題了。

    Java7在多線程操作HashMap時可能引起死循環,原因是擴容轉移后前后鏈表順序倒置,在轉移過程中修改了原來鏈表中節點的引用關系。

    Java8在同樣的前提下並不會引起死循環,原因是擴容轉移后前后鏈表順序不變,保持之前節點的引用關系。

說說resize時,擴容是怎么實現的?

(1)擴容:新建一個Entry空數組,長度是原來的兩倍。

(2)ReHash :遍歷原來的Entry數組,把所有的數據重新Hash到新的數組。

  • 為什么要重新Hash呢,直接復制過去不是更快捷方便嗎?

    因為擴容以后的數組長度變了,index = HashCode(Key) & (Length - 1),擴容后的length和之前不一樣了,之前是16,現在是32,重新Hash算出來的index值肯定也不一樣,而且重新計算后,會使元素更加均勻的分布在HashMap表中,如果直接復制的話,那么數據肯定都堆在一起了。

為什么HashMap初始化時默認容量是16?為什么要是2的n次冪?

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 ,

有幾方面的原因:

  • 根據上述官方的定義可知,16 = 2^4,也就是提醒我們這個數最好設置為 2 的N次冪。

  • 在計算index的時候:index = (n - 1) & hash,n就是數組的長度,這里就是16,而

    16-1 = 15 ,15的二進制數為:1111,假設hash值為:01011101100011,則:

    	 01011101100011
    	 &		  1111
    ---------------------	
       index:      0011
    

    可以看出,15的二進制全都是1,假設默認值是8,則8-1=7 ,7的二進制是 111,再假設默認值是32,

    32-1 = 31 ,31的二進制 11111。可以發現這樣的規律,所有2的n次冪的數減一的二進制所有位都是1。

    這樣一來,在計算index的時候,就只和hashCode的最后幾位有關,這樣可以極大的提高效率。

    而至於為什么非要用 16 而不用8或者32,官方也沒有具體說明,所以這個應該只是一個經驗數字,除了他滿足是2的冪以外,不大也不小,剛好合適,能滿足日常大部分需求。

  • index計算的時候,為什么要用位運算&呢?

    主要是效率問題,位運算(&)效率要比代替取模運算(%)高很多,主要原因是位運算直接對內存數據進行操作,不需要轉成十進制,因此處理速度非常快。在jdk1.8之前的index計算就是用的取模運算:%.

為什么加載因子是0.75f?

​ 加載因子太大的話,也就是說需要盡可能的把HashMap表填滿了才進行擴容,那這樣會使得hash沖突的 概率增大,但是如果加載因子太小,那只用了很小一部分空間就要開始擴容,使得空間利用率很低。那怎 么辦平衡hash碰撞和空間利用率這個問題,這就是問題的關鍵!!

​ 根據官方源碼的注釋可以看到:

threshold of 0.75, although with a large variance because of
     * resizing granularity. Ignoring variance, the expected
     * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
     * factorial(k)). The first values are:
     *
     * 0:    0.60653066
     * 1:    0.30326533
     * 2:    0.07581633
     * 3:    0.01263606
     * 4:    0.00157952
     * 5:    0.00015795
     * 6:    0.00001316
     * 7:    0.00000094
     * 8:    0.00000006
     * more: less than 1 in ten million

大概意思就是說,在理想情況下,使用隨機哈希碼,節點出現的頻率在hash桶中遵循泊松分布,同時給出了桶中元素個數和概率的對照表。從上面的表中可以看到當桶中元素到達8個的時候,概率已經變得非常小,也就是說用0.75作為加載因子,每個碰撞位置的鏈表長度超過8個的概率達到了一百萬分之一。

即加載因子為0.75,同一個桶中出現8個元素然后轉化為紅黑樹的概率為100萬分之一。

HashMap的主要構造器都有哪些?

  • HashMap():構建一個初始容量為 16,負載因子為 0.75 的 HashMap
  • HashMap(int initialCapacity):構建一個初始容量為 initialCapacity,負載因子為 0.75 的 HashMap
  • HashMap(int initialCapacity, float loadFactor):以指定初始容量、指定的負載因子創建一個 HashMap。

HashMap的主要屬性參數:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默認數組長度2^4=16
static final int MAXIMUM_CAPACITY = 1 << 30; // 最大數組容量2^30
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默認負載因子
static final int TREEIFY_THRESHOLD = 8; // 鏈表轉紅黑樹的閾值
static final int UNTREEIFY_THRESHOLD = 6; // 擴容時紅黑樹轉鏈表的閾值

最后:

​ 限於筆者水平有限,難免有些不足或錯漏之處,歡迎各位大佬批評指出,不勝感激~~
個人網站:https://www.coding-makes-me-happy.top/


免責聲明!

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



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