HashMap 底層實現原理 JDK1.7

一. 底層存儲結構

HashMap底層是由數組+鏈表構成的,HashMap會通過hashcode()爲待插入的元素計算存儲到的數組下標,在插入到數組中時,會把元素拼裝成Entry對象,並構建一個鏈表。如果同一個數組下標已經存放了Entry,則將後來者插入到鏈表的頭結點上。

橫向被稱作table[] 數組

縱向被稱作bucket哈希桶,實際上就是由Entry組成的鏈表。

二. 源碼分析

1. 成員變量

    /**
      * HashMap底層容器(數組)的默認初始容量 必須是2的冪次方 默認16
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

    /**
     * HashMap底層容器(數組)的最大初始容量 默認2的30次方
     * 用戶可以在構造函數中顯示的傳入數組的最大初始容量,只不過不能超過2的30次方
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;

    /**
     * 負載因子 默認0.75  計算公式: 負載因子 = 當前元素個數/容器容量
     * 用戶可以在構造函數中顯示的指明
     * 當插入數據時導致負載因子大於設定值時,HashMap會對自身容器進行擴容
     * 數值越小看,hash碰撞的可能性就越小,但擴容的頻率就越高
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    /**
     * 初始化一個Entry空數組
     */
    static final java.util.HashMap.Entry<?,?>[] EMPTY_TABLE = {};

    /**
     * 將初始化好的空數組賦值給table table纔是真正存儲數據的地方
     * 序列化時忽略本屬性
     */
    transient java.util.HashMap.Entry<K,V>[] table = (java.util.HashMap.Entry<K,V>[]) EMPTY_TABLE;

    /**
     * HashMap容器中實際存儲元素的個數
     */
    transient int size;

    /**
     * 下一個需要調整容器大小的閾值
     * 計算公式: 閾值 = 當前容量 * 負載因子
     */
    int threshold;

    /**
     * 負載因子 //TODO 這個幹什麼用的?爲什麼有兩個負載因子?
     */
    final float loadFactor;

    /**
     * HashMap的內部結構被修改的次數 modCount用於迭代器
     */
    transient int modCount;

    /**
     * hash計算時閾值的默認值
     */
    static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;

     /**
     * hash計算時使用到的一個因子
     */
    transient int hashSeed = 0;

2. 構造函數

 /**
     * 構造一個空的HashMap,指定容器初始化大小和負載因子
     */
    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                    initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                    loadFactor);

        this.loadFactor = loadFactor;
        threshold = initialCapacity;
        init();
    }

    /**
     * 構造一個空的HashMap 指定容器初始化大小 使用默認負載因子0.75
     */
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    /**
     * 構造一個空的HashMap,默認容器初始化大小爲16,默認負載因子0.75
     */
    public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }

    /**
     * 創建一個與傳入Map具有相同數據結構的Map,初始容量大小爲原Map的容量大小+1,如果比16小,則取16。
     */
    public HashMap(Map<? extends K, ? extends V> m) {
        this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
        inflateTable(threshold);

        putAllForCreate(m);
    }

3. 成員方法 

1. roundUpToPowerOf2( )  輸入期望的數組長度,返回經過計算得出的合理的數組長度,確保數組長度是2的N次冪。

    1. 數組長度最大不得超過MAXIMUM_CAPACITY,也就是2的30次方。

    2. Integer.highestOneBit(int n):將n轉換成二進制數,只取最高位(非符號位),其餘位數補0,計算出新的值。

                                                        比如9的二進制爲1001,取最高位並補0後爲1000,也就是8。

    3. 如果這個數的二進制數爲0,則返回1。

    4. Integer.bitCount(int n): 將n轉換成二進制數,返回1的個數。比如7的二進制爲0111,,因此返回3。

    5. 不難注意到,2的N次冪比如2,4,8,16,它們轉換成二進制數後都只會出現一個1,比如8->1000,16->10000。如果期望數組長度不是2的N次冪,那麼會將其取最高位,其餘位數補0後,再乘以2,否則直接返回期望數組長度。

    本方法實際上就是在確保數組長度爲2的冪,如果期望的長度不是2的冪,就去找到比它大且最鄰近的2的冪。比如期望的數組長度爲9,對應二進制數1001,由於9不是2的冪,且在2的3次方和2的4次方之間,因此會返回2^3 * 2 = 16。再比如8,由於滿足了上述所有約束要求,因此直接返回8。

    private static int roundUpToPowerOf2(int number) {

        int rounded = number >= MAXIMUM_CAPACITY
                ? MAXIMUM_CAPACITY
                : (rounded = Integer.highestOneBit(number)) != 0
                ? (Integer.bitCount(number) > 1) ? rounded << 1 : rounded
                : 1;

        return rounded;
    }

2.  初始化容器

    通過觀察initHashSeedAsNeeded( )方法不難發現,絕大部分情況下hashSeed的值都爲0,有可能促使hashSeed重新初始化的因素只有useAltHashing。也就是說,除非人爲的在jvm中手動設置可選閾值jdk.map.althashing.threshold,並且要比預期初始化容器的長度小,纔可能會重新計算hashSeed的值。(因爲在默認情況下,ALTERNATIVE_HASHING_THRESHOLD的值爲Integer.MAX_VALUE,capacity一般都比它小,因此useAltHashing=false)

   計算hashSeed的方法是: sun.misc.Hashing.randomHashSeed(Object var0)  

    private void inflateTable(int toSize) {
        // 找到一個>=toSize且是2的N次冪的數
        int capacity = roundUpToPowerOf2(toSize);
        // 重新計算閾值
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        // 重新初始化table
        table = new java.util.HashMap.Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }
    final boolean initHashSeedAsNeeded(int capacity) {
        boolean currentAltHashing = hashSeed != 0;
        boolean useAltHashing = sun.misc.VM.isBooted() &&
                (capacity >= java.util.HashMap.Holder.ALTERNATIVE_HASHING_THRESHOLD);
        boolean switching = currentAltHashing ^ useAltHashing;
        if (switching) {
            hashSeed = useAltHashing
                    ? sun.misc.Hashing.randomHashSeed(this)
                    : 0;
        }
        return switching;
    }

 3. hash算法

     HashMap使用hash算法來計算某一個元素被存放的位置(數組的下標)。前文提到,HashMap是由數組和鏈表構成的,爲了使元素均勻分佈,在理想情況下,希望數組中一個下標中只存放一個元素,換句話說,每一條鏈表中至多隻會有一個元素,這樣一來,如果想得知某一個元素被存放的位置,只需要計算它的hash值,就可以直接定位到元素,而不需要再去遍歷該下標上對應的鏈表。

     1. 只有在hashSeed不爲0,且k的類型爲string時,纔會使用stringHash32算法,這個算法是JDK7新引入的。

     2. indexFor(int h, int length): 傳入hascode值和數組長度,返回hashcode對應的數組下標。除非length=1,否則此方法一定不會返回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();

        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
}
    static int indexFor(int h, int length) {
        // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
        return h & (length-1);
    }

4. put( )

     1. 當Hash值相同,key的內存地址和內容都相同,但value不相同時,後者覆蓋前者,這個操作不會改變HashMap的數據結構,不會使modeCount++
     2. key爲null的元素只能存儲在table[0]中,但並不意味着table[0]只能存null。當table的長度爲1時,唯一的元素只能存儲在table[0]當中。

    public V put(K key, V value) {
        // 如果底層容器爲空,則重新初始化容器
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        // 如果key爲null,則取出數組中的第一個bucket,也即table[0],這個bucket對應的鏈表專門用於存放key爲null的元素
        // 只有一種情況下,table[0]裝載着非null元素,那就是容器本身的初始化長度爲1。
        if (key == null)
            return putForNullKey(value);
        // 計算key對應的hash值
        int hash = hash(key);
        // 計算元素應當存儲的數組下標
        int i = indexFor(hash, table.length);
        // 遍歷table[i]上的Entry對象,判斷該鏈表中是否有相同key值的元素存在
        for (java.util.HashMap.Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            // Hash值相同、equals相同或key的內存地址相同
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                // 鳩佔鵲巢 後來者的value覆蓋前者的value
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        // HashMap數據結構修改的次數加一
        modCount++;
        // 目前的table中不存在與待新增的key相同的元素,因此將當前元素添加到table[i]中
        addEntry(hash, key, value, i);
        return null;
    }

       put()操作中,HashMap會使用hash()將key計算成hash值,再通過indexFor()找到待新增元素將被存儲到數組中的下標,也即找到目標bucket。

       如果不同的key對應的hash值放入indexFor()計算出的bucket下標相同,則說明產生衝突。jdk1.7使用單鏈表來解決衝突。衝突時,HashMap分成兩種情況來解決問題:

       1. bucket下標相同,key值相同(內存地址或者equals相同)。處理方式: 後者的value將覆蓋前者的value。
       2. bucket下標相同,key值不同。處理方式: 將本次put()的key和value組裝成Entry對象,從當前bucket內已有的Entry鏈表的頭結點處添加Entry對象。

5. Entry

      Entry是HashMap數據結構中的最小單元,用於存儲數據信息。Entry有四個屬性: 

      1. final K key;     鍵

      2. V value;          值

      3. Entry<K, V> next    指向下一個Entry的指針

      4. int hash;        當前元素的hash值

      通過以上屬性,我們不難得知兩條信息: 1. Entry是一個單向鏈表 2. 同一個bucket內,同一條Entry鏈表中的hash值一定相同。

      值得注意的方法: 

      1. addEntry( )  

    void addEntry(int hash, K key, V value, int bucketIndex) {
        // 如果新增後容器內存放元素的個數大於等於閾值,並且要待新增的bucket從未添加過元素
        if ((size >= threshold) && (null != table[bucketIndex])) {
            // 對數組進行擴容 期望擴大爲原先兩倍的容量大小
            resize(2 * table.length);
            // 由於數組的長度發生了變化,因此需要重新計算待新增元素的hash值
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }
        createEntry(hash, key, value, bucketIndex);
    }

      2. createEntry( )

          Step1: 找到table[i]位置對應的bucket上原先存在的Entry對象A。

          Step2: 通過hash值、key、value,構造一個新的Entry對象,將它的next指針指向對象A,這樣一來,一個新的頭結點就構造完畢了,並且舊數據得以保留。

          Step3: 讓table[i]指向將新的Entry。

          Step4: HashMap存儲的元素個數加1。

    void createEntry(int hash, K key, V value, int bucketIndex) {
        java.util.HashMap.Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new java.util.HashMap.Entry<>(hash, key, value, e);
        size++;
    }

      3. Entry(int h, K k, V v, Entry<K,V> n) 

Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            // next指針指向了舊鏈表的表頭節點
            next = n;
            key = k;
            hash = h;
}

6.  get(Object key)

    首先,通過hash算法計算出待查詢key的hash值,接着通過indexFor找到目標bucket在數組容器中的位置(下標)。如果目標bucket中的Entry鏈表爲null,則返回null,否則循環遍歷鏈表中的每一個Entry對象,直到某個Entry對應的key值與待查詢的key相等(equals或者內存地址相同)時,返回當前Entry對應的value值。

    這種 bucket單向鏈表的數據結構的缺陷在查詢時被暴露的玲離盡致: 如果待查詢的key恰好在單向鏈表的尾端,並且這個key對應的hash碰撞問題非常嚴重,那麼在極端情況下,數組+單向鏈表的數據結構將退化成普通的單向鏈表,時間複雜度由對數階變成了線性階,極大地影響了查詢性能。

public V get(Object key) {
        // 若key爲null,遍歷table[0]處的鏈表(實際上要麼沒有元素,要麼只有一個Entry對象),取出key爲null的value
        if (key == null)
            return getForNullKey();
        // 若key不爲null,用key獲取Entry對象
        Entry<K,V> entry = getEntry(key);
        // 若鏈表中找到的Entry不爲null,返回該Entry中的value
        return null == entry ? null : entry.getValue();
    }

    final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }
        // 計算key的hash值
        int hash = (key == null) ? 0 : hash(key);
        // 計算key在數組中對應位置,遍歷該位置的鏈表
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            // 若key完全相同,返回鏈表中對應的Entry對象
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        // 鏈表中沒找到對應的key,返回null
        return null;
    }

7. 擴容 

    當存放元素的個數大於或等於容器容量與負載因子的乘積時,HashMap就會觸發擴容(默認情況下是 16 * 0.75 = 12)。通過調用resize()方法,將數組的容量擴展爲原來的兩倍,並對原先table中存放的元素重新計算hash值,最終遷移到新的table中。以上過程又被稱作rehash,因爲它爲元素重新調用hash方法,計算hash值以及存儲在新數組中的位置。

    1. resize()

        1. 每一次擴容,都會給GC帶來壓力。因爲舊數組失去引用後,會被GC在合適的時機回收。

        2. threshold = Integer.MAX_VALUE; 這局代碼看似寫的有問題, 數組的最大容量是MAXIUM_CAPACITY=2的30次方,而閾值threshold居然能夠達到Integer.MAX_VALUE=2的31次方-1,HashMap難道不怕數組越界嗎?其實這種擔心是多餘的,因爲這是Entry[]數組,每一個元素實際上是一條鏈表,又可以掛載多個元素。threadshold閾值希望限制的不是數組內元素的個數,而是HashMap容器中,key(鍵)的個數。

           這樣想來,回過頭來看看公式: 負載因子 = 元素個數 / 數組容量,不難發現,負載因子的值實際上是可以大於1的。

    void resize(int newCapacity) {
        java.util.HashMap.Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        // 若舊數組的容量已經達到上限,則把閾值提高到Integer.MAX_VALUE
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }
        // 根據新傳入的容量創建新數組
        java.util.HashMap.Entry[] newTable = new java.util.HashMap.Entry[newCapacity];
        // 爲每一箇舊元素重新計算hash值,分配存儲位置,並將舊元素遷移至新數組中
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        // table指針指向新數組,舊數組由於失去引用,因此會在合適的時機被GC回收
        table = newTable;
        // 重新計算閾值 數組容量 * 負載因子 最大不能超過MAXIMUM_CAPACITY + 1
        // 這裏設置成MAXIMUM_CAPACITY + 1也是有原因的,按照最理想的情況下,元素在數組中均勻分佈,每一個元素至佔用一個數組空位
        // 此時元素的個數(size)爲MAXIMUM_CAPACITY,接下來再新增元素時,會調用addEntry()方法,其中的條件表達式size >= threshold
        // 會使得數組觸發擴容機制,儘量避免出現hash值衝突,形成鏈表的情況。
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

    2. transfer()

        首先遍歷舊數組中的每一個bucket,接着遍歷bucket對應鏈表中的每一個Entry節點。擴容並非意味着一定要爲每一箇舊的                key-value重新計算hash值,當且僅當initHashSeedAsNeed()==true,新數組的預期容量比可選閾值還要大時,才需要重新            計算hash值(前文提到,可選閾值默認爲Integer.MAX_VALUE)。但擴容一定會爲每一個key-value重新計算存儲在新數組中的位置(使用indexFor())

        注意: 如果不重新計算key的hash值,那麼在使用indexFor()重新計算準備存儲的bucket下標後,產生的結果只有兩種可能: 原下標或原下標+oldCapacity

        原因在於indexFor()的內部實現爲hash & (capacity-1),由於capacity本身保證是2的N次冪,因此在二進制下,capacity減1後除最高位外,其餘全爲1。大家都知道,操作數無論是0還是1,在和1做與運算時,結果仍等於操作數本身。經過擴容後,數組的容量會變成原來的兩倍,因此newCapacity-1會比oldCapacity-1除符號位以外多出一個1,這樣一來,重新計算indexFor()的關鍵就在於在二進制下,hash對應oldCapacity最高位N的數字到底是0還是1,如果是0,則計算出的結果不發生變化,如果是1,則轉換到十進制後,新值比舊值多出一個oldCapacity。

        比如hash值爲44,與31做與運算:   (0)101100 & (0)011111 = (0)001100    -> 12

        擴容後,44 & (64-1)  =>      (0)101100 & (0)111111 = (0)101100     -> 32+12 = 44

        hash對應oldCapacity除符號位以外的最高位(第6位)恰好爲1,因此最終計算出的值要比舊值多出2的5次方。

 

    void transfer(java.util.HashMap.Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (java.util.HashMap.Entry<K,V> e : table) {
            while(null != e) {
                java.util.HashMap.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;
            }
        }
    }

三. 爲什麼是0.75

           爲什麼HashMap默認的負載因子的值爲0.75,而不是0.7,0.8?0.75究竟有什麼神奇之處呢?

           關鍵詞: 泊松分佈。在理想情況下,使用默認的負載因子和隨機的hash碼能使得每一個bucket中的節點數量遵循泊松分佈,從泊松分佈表中可以得知,當某個bucket中存放達到8個元素時,再向容器添加新的元素,幾乎不會存放到這個bucket中。也就是說,hash衝突中的單向鏈表內的節點個數幾乎不可能超過8個,不會讓鏈表變得過於長,儘可能的避免HashMap的查詢時間複雜度從O(1)變成O(n)。

四.性能問題

          1. 從擴容的問題中,我們可以看到HashMap要不斷的調用indexFor(),爲元素計算存儲在新數組中的下標,這個過程非常消耗性能。因此,我們在使用HashMap之前需要評估可能存儲元素的規模,爲HashMap底層容器指定初始化大小,儘可能的避免擴容。

          2. 避免尾部遍歷。當遇到hash衝突時,HashMap需要把元素添加到鏈表中,鏈表可能已經存在多個元素,如果從鏈表的尾端插入元素,則需要從鏈表的頭結點開始不停地遍歷,直到尾節點,最後修改next指針指向新的元素。這麼做效率太低了,所以HashMap從頭節點開始插入。

五. 線程安全問題

          併發下,擴容時可能產生死循環,根本原因是插入元素時爲了避免尾部遍歷,採用了頭部插入的方式。此處參考鏈接,這篇文章最後死循環的邏輯似乎有問題。

          截取transfer部分代碼:

java.util.HashMap.Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;

          假設有兩個線程,同時執行put()操作,此時都需要對HashMap進行擴容。indexFor的過程很簡單,使用key對數組長度取模即可。下圖所示,key爲3,7,5的元素經過hash與indexFor後,都被存儲在數組下標爲1的鏈表中。

          Step1: 線程1率先執行,代碼執行到Entry next = e.next; 被調度器掛起了。此時,線程一中,e指向key=3,next指向key=7

                     線程2開始執行,它把擴容工作全部做完了。由於單鏈表LIFO的特性,key7插在了key3的前面。

                     注意: Entry[] table不是一個線程私有的對象,它被所有線程共享。但e和next都是方法內部定義的,因此是線程私有                                  的。不難注意到,雖然線程1中的e和next雖然指向的元素沒有發生變化,但元素本身在鏈表中存放的順序卻被反                                轉了。

          

           Step2: 線程1恢復執行,首先執行int i = indexFor(e.hash, newCapacity); 顯然,計算的結果與線程二一定相同,都爲3。

                      接着,執行e.next = newTable[i]; 要知道,此時的newTable[3]中已經寄存了key=7,因此next=>key(7),也即

                      元素: table[3] -> key(7) -> key(3)                  指針: next指向key(7),e指向key(3)

                      然後,執行newTable[i] = e; 也即 

                      元素: table[3] -> key(3) 且 key(7)->key(3)     指針: next指向key(7),e指向key(3)

                      最後,執行e = next; 也即

                      元素: table[3] -> key(7) -> key(3)                  指針: next指向key(7),e指向key(7)

           Step3: 進入下一輪循環

                      執行next = e.next;  由於e.next指向key(7),而key(7).next指向key(3),因此:

                      table[3] -> key(7) -> key(3)                          指針: next指向key(3),e指向key(7)

                      然後,執行e.next = new Table[i];  由於new Table[i] == key(7) ,而e指向key(7),因此這句話執行後會導致                                  key(7).next指向key(7) ,造成了死循環。比如用戶希望查詢key(3)的值,首先需要先找到數組下標爲3的單鏈條的頭                          結點key(7),接着循環遍歷單鏈表,由於key(7)的next是key(7)本身,永遠也找不到key(3),因此就會報出Infinite                              Loop 無限循環的問題。

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