Java面試必問之Hashmap底層實現原理(JDK1.7)


1. 前言

Hashmap可以說是Java面試必問的,一般的面試題會問:

  • Hashmap有哪些特性?
  • Hashmap底層實現原理(get\put\resize)
  • Hashmap怎么解決hash沖突?
  • Hashmap是線程安全的嗎?
  • ...

今天就從源碼角度一探究竟。筆者的源碼是OpenJDK1.7

2. 構造方法

首先看構造方法的源碼

    // 默認初始容量
    static final int DEFAULT_INITIAL_CAPACITY = 16;
    // 默認負載因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    // 數組, 該數據不參與序列化
    transient Entry[] table;  
    
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        // 初始容量16,擴容因子0.75,擴容臨界值12
        threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
        // 基礎結構為Entry數組
        table = new Entry[DEFAULT_INITIAL_CAPACITY];
        init();
    }

由以上源碼可知,Hashmap的初始容量默認是16, 底層存儲結構是數組(到這里只能看出是數組, 其實還有鏈表,下邊看源碼解釋)。基本存儲單元是Entry,那Entry是什么呢?我們接着看Entry相關源碼,

    static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;    // 鏈表后置節點
        final int hash;

        /**
         * Creates new entry.
         */
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;   // 頭插法: newEntry.next=e
            key = k;
            hash = h;
        }
        ...
    }

由Entry源碼可知,Entry是鏈表結構。綜上所述,可以得出:
Hashmap底層是基於數組和鏈表實現的

3. Hashmap中put()過程

我已經將put過程繪制了流程圖幫助大家理解

先上put源碼

    public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value);
        // 根據key計算hash
        int hash = hash(key.hashCode());
        // 計算元素在數組中的位置
        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;
    }

上圖中多次提到頭插法,啥是 頭插法 呢?接下來看 addEntry 方法

    void addEntry(int hash, K key, V value, int bucketIndex) {
        // 取出原bucket鏈表
        Entry<K,V> e = table[bucketIndex];
        // 頭插法
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        // 判斷是否需要擴容
        if (size++ >= threshold)
            // 擴容好容量為原來的2倍
            resize(2 * table.length);
    }

結合Entry類的構造方法,每次插入新元素的時候,將bucket原鏈表取出,新元素的next指向原鏈表,這就是 頭插法 。為了更加清晰的表示Hashmap存儲結構,再繪制一張存儲結構圖。

4. Hashmap中get()過程

get()邏輯相對比較簡單,如圖所示

我們來對應下get()源碼

    public V get(Object key) {
        // 獲取key為null的值
        if (key == null)
            return getForNullKey();
        // 根據key獲取hash
        int hash = hash(key.hashCode());
        // 遍歷鏈表,直到找到元素
        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.equals(k)))
                return e.value;
        }
        return null;
    }

5. Hashmap中resize()過程

只要是新插入元素,即執行addEntry()方法,在插入完成后,都會判斷是否需要擴容。從addEntry()方法可知,擴容后的容量為原來的2倍。

    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);
        // table指向新的數組
        table = newTable;
        // 新的擴容臨界值
        threshold = (int)(newCapacity * loadFactor);
    }

這里有個transfer()方法沒講,別着急,擴容時線程安全的問題出現在這個方法中,接下來講解數組復制過程。

6. Hashmap擴容安全問題

大家都知道結果: 多線程擴容有可能會形成環形鏈表,這里用圖給大家模擬下擴容過程。

首先看下單線程擴容的頭插法

然后看下多線程可能會出現的問題

以下是源碼,你仔細品一品

    void transfer(Entry[] newTable) {
        Entry[] src = table;
        int newCapacity = newTable.length;
        for (int j = 0; j < src.length; j++) {
            Entry<K,V> e = src[j];
            if (e != null) {
                // 釋放舊Entry數組的對象引用
                src[j] = null;
                do {
                    Entry<K,V> next = e.next;
                    // 重新根據新的數組長度計算位置(同一個bucket上元素hash相等,所以擴容后必然還在一個鏈表上)
                    int i = indexFor(e.hash, newCapacity);
                    // 頭插法(同一位置上新元素總會被放在鏈表的頭部位置),將newTable[i]的引用賦給了e.next
                    e.next = newTable[i];
                    // 將元素放在數組上
                    newTable[i] = e;
                    // 訪問下一個元素
                    e = next;
                } while (e != null);
            }
        }
    }

7. Hashmap尋找bucket位置

    static int indexFor(int h, int length) {
        // 根據hash與數組長度mod運算
        return h & (length-1);
    }

由源碼可知, jdk根據key的hash值和數組長度做mod運算,這里用位運算代替mod。

hash運算值是一個int整形值,在java中int占4個字節,32位,下邊通過圖示來說明位運算。

8. AD

如果您覺得還行,請關注公眾號【當我遇上你】, 您的支持是我輸出的最大動力。
同時,歡迎大家一起交流學習。


免責聲明!

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



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