Java拾遺:002 - HashMap源碼解讀

哈希表

散列表(Hash Table,也叫哈希表),是根據關鍵碼值(Key value)而直接進行訪問的數據結構。這種數據結構在不考慮哈希碰撞的條件下,有前着O(1)的時間複雜度,因此效率很是高。Java中HashMap底層就使用了哈希表,因此一般咱們認爲HashMap的時間複雜度也是O(1)。html

HashMap實現原理淺析

在JDK中,HashMap底層是由數組實現,該數組即爲哈希表(由HashCode決定索引位置)。在不存在哈希碰撞的條件下,哈希表的性能最優,但在實際代碼實現中不能不考慮這個問題。因此在HashMap中,每一個Bucket(哈希表中的節點)都是一個鏈表(JDK1.8中當鏈表元素超過8個時,會將鏈表轉換爲紅黑樹),當發生哈希碰撞時,該元素將被添加到鏈表的末端。因爲鏈表中的時間複雜度是O(n),因此當Bucket所在鏈表過長時,會影響HashMap性能。java

JDK1.7 HashMap源碼解讀

JDK自1.6之後(之前的代碼沒讀過)的版本HashMap的實現本質上沒有太大差異(核心結構都是哈希表),這裏以JDK1.7版本爲例講解,下面是HashMap的核心方法源碼解讀。數組

構造方法

下面是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();
    }

方法只是初始化了一些屬性,this.loadFactor是擴容因子,即當實際使用容量比總容量爲該因子時,將發生擴容。threshold表示擴容閾值,由總容量乘以擴容因子計算得出,但在構造方法中,直接使用初始容量表示。在無參的構造方法中,初始容量爲16,擴容因子爲0.75。多線程

isEmpty方法

判斷集合是否爲空的方法,實際只是內部維護了一個計數器,若是計數器爲0即爲空,不然非空。併發

public boolean isEmpty() {
        return size == 0;
    }
size方法

同理集合實際大小也是由該計數器表示,該計數器將在添加、移除元素時被維護。app

public int size() {
        return size;
    }
put方法

put方法是HashMap最核心方法之一,其代碼實現複雜之處也在於此。函數

public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);
        int i = indexFor(hash, table.length);
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }

        modCount++;
        addEntry(hash, key, value, i);
        return null;
    }

代碼中先判斷哈希表是否爲空,空則執行擴容(初始化哈希表的過程實際使用的擴容的邏輯,因此構造方法中用擴容閾值來表示初始容量,減小一個全局變量)。 初始化哈希表後,會判斷鍵是否爲空,空鍵不會使用哈希函數來計算哈希和索引,而是直接遍歷哈希表找到空鍵所在Bucket,將元素加入該Bucket(該Bucket也是一個鏈表,但最多隻能有一個元素,這就是HashMap中最多隻能一個空鍵的緣由)。 若是鍵不爲空,則計算它的哈希碼,並根據哈希碼找到其對應哈希表的索引。找到的Bucket是一個鏈表,遍歷該鏈表,若是鍵已存在,則更新找到的節點值,將舊值返回,方法結束,若是沒有找到該鍵,則做爲一個新的節點加入鏈表末端。 上面解讀中遺留了幾個細節沒講,下面一一解讀:性能

  • 擴容inflateTable(threshold);邏輯
private void inflateTable(int toSize) {
        // Find a power of 2 >= toSize
        int capacity = roundUpToPowerOf2(toSize);

        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }

實際邏輯很簡單,經過roundUpToPowerOf2方法保證擴容容量值必定是恰好大於等於傳入容量值的2的整數次冪(關於這點的緣由,後面會解釋),而後根據擴容後的容量計算擴容後的擴容閾值threshold,最後從新構造哈希表table(最後一行根據容量生成哈希種子的邏輯不影響主邏輯,這裏略過)。實際上這個方法只在哈希表空時執行,只是一個初始化的方法,後續在添加新元素的過程當中觸發擴容是由其它方法實現。優化

  • 空鍵的插入邏輯
private V putForNullKey(V value) {
        for (Entry<K,V> 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;
    }

由源碼能夠看出只是簡單的遍歷哈希表,找到空鍵對應的Bucket(沒有就新增一個),更新找到節點值,返回舊值。

  • 計算哈希碼和根據哈希碼查找索引
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);
    }

生成哈希碼的過程略過(我會告訴你是由於實現得太過複雜,我也不是很懂?),重點講一下根據哈希碼計算索引的邏輯。代碼只有一行h & (length-1),這行代碼實際上保證了返回值在[0 ~ 哈希表長度 - 1]這個區間內,若是不用位運算,它的等效(注意:這裏是說等效,非等價,二者的返回值未必是相同的,但效果是一致的)實現爲:h % length,但位運算的性能更好,因此使用了這種寫法,另外使用位運算還有一個緣由,後面解釋爲何HashMap的容量必定是2的整數次冪裏會講到,這裏先略過。 計算出索引後,直接從哈希表中獲得Bucket(由於是經過下標查找,因此時間複雜度是O(1)),Bucket是一個鏈表,遍歷這個鏈表找到對應的節點(鍵相同),更新節點值,返回舊值,若是未找到說明是一個新的節點,經過addEntry(hash, key, value, i);添加一個節點到鏈表末端。

  • 添加一個新節點
void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            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<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
    }

實際上添加一個新節點很容易,只須要構造一個Entry節點,添加到鏈表末端便可,但這裏須要考慮的時,若是實際容量到達擴容閾值時,須要觸發擴容邏輯。 首先經過resize(2 * table.length);將哈希表擴容爲原來的兩倍,而後從新計算哈希碼和索引,最後經過createEntry(hash, key, value, bucketIndex);建立一個新節點,將其加入鏈表末端。 這裏重點須要介紹一下resize方法(區別於inflateTable方法,這個方法會被屢次調用)

void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        Entry[] newTable = new Entry[newCapacity];
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

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

使用擴容後的容量構造一個新的哈希表,將原哈希表中的數據複製到新表中,再將新表賦值給類的table屬性,從而完成擴容。

get方法

查找一個元素,在put方法中已經體現了查找過程,先計算哈希碼,再計算索引,找到Bucket後遍歷鏈表,根據鍵找到目標元素便可

public V get(Object key) {
        if (key == null)
            return getForNullKey();
        Entry<K,V> entry = getEntry(key);

        return null == entry ? null : entry.getValue();
    }

    final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }

        int hash = (key == null) ? 0 : hash(key);
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }
remove方法

刪除元素,先找到元素,將其從對應鏈表中刪除便可,此時size計數器會遞減

public V remove(Object key) {
        Entry<K,V> e = removeEntryForKey(key);
        return (e == null ? null : e.value);
    }

    final Entry<K,V> removeEntryForKey(Object key) {
        if (size == 0) {
            return null;
        }
        int hash = (key == null) ? 0 : hash(key);
        int i = indexFor(hash, table.length);
        Entry<K,V> prev = table[i];
        Entry<K,V> e = prev;

        while (e != null) {
            Entry<K,V> next = e.next;
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k)))) {
                modCount++;
                size--;
                if (prev == e)
                    table[i] = next;
                else
                    prev.next = next;
                e.recordRemoval(this);
                return e;
            }
            prev = e;
            e = next;
        }

        return e;
    }
clear方法

清空集合,只須要將哈希表(數組)全部元素置空,並將計數器歸零便可

public void clear() {
        modCount++;
        Arrays.fill(table, null);
        size = 0;
    }

類重寫hashCode方法時返回一個常數會引發什麼問題?

前面講過,哈希表的時間複雜度爲O(1),但前提是沒有發生哈希碰撞的狀況下。若是一個類在重寫hashCode方法時直接返回了一個常數(下次不能偷懶了~),就會形成存入集合的全部元素的哈希碼相同,也就是哈希碰撞,那麼HashMap的存儲結構就會退化爲一個鏈表結構(只有一個Bucket),而鏈表的時間複雜度爲O(n),所以一個好的hashCode實現,能夠提高其在HashMap或者HashSet等數據結構中的性能的,而只返回一個常數是萬不可取的。

爲何HashMap實際容量必定是2的整數次冪

以前提到過個問題,這時就要解釋一下indexFor這個方法了。h & (length-1)使用了按位與運算,而該運算的特色是,參與計算的兩個相同位都爲1時輸出1,不然輸出0。參考一下下面的示例(假設當前容量爲8):

# 8 - 1 == 7 轉換二進制爲 00000111
7 & 1 == 00000111 & 00000001 == 00000001 == 1
7 & 2 == 00000111 & 00000010 == 00000010 == 2
7 & 11 == 00000111 & 00001011 == 00000011 == 3

首先第一點,解釋一下該方法是怎樣保證返回索引必定在[0, length - 1]這個區間內。根據按位與運算的特色,(length - 1)中爲1的項,在與任何數作按位與計算時纔有可能爲1,而(length - 1)中爲0的項與任何數作按位與計算必定返回0,因此整個運算返回的最大值只能是(length - 1),而最小值是0,因此保證了[0, length - 1]這個區間範圍。 HashMap實際容量必定是2的整數次冪也和這裏的按位與運算有關。若是length是2的整數次冪(那麼length的二進制必定是...1000...0的形式),那麼(length - 1)的二進制必定是...0001111...1形式(這點讀者自行去驗證一下)。而在作按位與計算時,1纔是會引發值變化的項,假設length爲8,那麼length-1的二進制就是00000111,任意數與其作按位與計算能夠獲得[0000, 0111]即[0, 7]全部項,而若是length不是2的整數次冥,那麼length-1必須中間會有0(空項)存在,這意味着這些位置沒法表示,從而形成哈希表中存在空洞(空間浪費),實際可用空間減小,那麼哈希碰撞的概率就更大,即浪費空間,也影響效率。

# 下面是一組length不爲2的整數次冪時,length - 1的二進制值
length = 10, length - 1 == 9 ==   00001001
length = 13, length - 1 == 12 == 00001100
length = 15, length - 1 == 14 == 00001110
... ...

因此只有length值爲2的整數次冪時,length - 1纔會是...1111...1的結構,作按位與計算才能表示全部值。

JDK1.8 HashMap源碼有什麼變化?

JDK1.8中對HashMap作了大量優化,代碼細節調整很是多,但代碼結構基本一致,也仍然使用哈希表,因此這裏再也不展開, 有興趣的自行閱讀。JDK1.8中對HashMap作的最大調整是哈希表中單項(Bucket)再也不徹底使用鏈表結構了,當鏈表長度超過8時,將被轉換爲紅黑樹,而紅黑樹相比與鏈表有着更好的查詢性能。

爲何HashMap不適用於多線程場景

具體分析過程略,這裏只說結論: 由於在HashMap中添加元素時,可能發生擴容,擴容過程當中會將遍歷原來的鏈表數據,複製到新哈希表過,在多線程的狀況下能夠多個線程同時觸發擴容,那麼在這個過程當中頗有可能形成鏈表變成循環鏈表或者空表,致使數據get的時候死循環或者數據丟失的狀況。 因此多線程(併發)場景下推薦使用ConcurrentHashMap,後面的文章中會介紹該集合類。

參考資料:

結語

HashMap在實際開發中用得很是多,其代碼實現所涉及到的知識點也比較多,並且應用普遍,因此它的源碼仍是頗有必要讀一讀的。本文主要介紹的是JDK1.7的源碼,JDK1.8作了至關多的調整,後續有時間會深刻閱讀一下JDK1.8的源碼,尤爲是關於紅黑樹這一塊。

編寫本文除了閱讀源碼之外,了參考了其它博主的文章(其實都比我寫得好,不會畫圖是硬傷):

相關文章
相關標籤/搜索