圖解HashMap原理

1. 前言

本文的源碼是基于JDK1.7,JDK1.8中HashMap的實現,引入了紅黑樹,在后面的文章會寫到。
后面還有一篇LinkedHashMap的解析:圖解LinkedHashMap原理

2. 使用與實現

2.1 基本使用

HashMap很方便地為我們提供了key-value的形式存取數據,使用put方法存數據,get方法取數據。

    Map<String, String> hashMap = new HashMap<String, String>();
    hashMap.put("name", "josan");
    String name = hashMap.get("name");

2.2 定義

HashMap繼承了Map接口,實現了Serializable等接口。

public class HashMap<K,V>
    extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable
{
    /**
     * The table, resized as necessary. Length MUST Always be a power of two.
     */
    transient Entry<K,V>[] table;

其實HashMap的數據是存在table數組中的,它是一個Entry數組,Entry是HashMap的一個靜態內部類,看看它的定義。

    static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;
        int hash;

可見,Entry其實就是封裝了key和value,也就是我們put方法參數的key和value會被封裝成Entry,然后放到table這個Entry數組中。但值得注意的是,它有一個類型為Entry的next,它是用于指向下一個Entry的引用,所以table中存儲的是Entry的單向鏈表。默認參數的HashMap結構如下圖所示:


HashMap結構.png

2.3 構造方法

HashMap一共有四個構造方法,我們只看默認的構造方法。

    /**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }


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

        // 找到第一個大于等于initialCapacity的2的平方的數
        int capacity = 1; 
        while (capacity < initialCapacity)
            capacity <<= 1;

        this.loadFactor = loadFactor;
        // HashMap擴容的閥值,值為HashMap的當前容量 * 負載因子,默認為12 = 16 * 0.75
        threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        // 初始化table數組,這是HashMap真實的存儲容器
        table = new Entry[capacity];
        useAltHashing = sun.misc.VM.isBooted() &&
                (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
        // 該方法為空實現,主要是給子類去實現
        init();
    }

initialCapacity是HashMap的初始化容量(即初始化table時用到),默認為16。
loadFactor為負載因子,默認為0.75。
threshold是HashMap進行擴容的閥值,當HashMap的存放的元素個數超過該值時,會進行擴容,它的值為HashMap的容量乘以負載因子。比如,HashMap的默認閥值為16*0.75,即12。

HashMap提供了指定HashMap初始容量和負載因子的構造函數,這時候會首先找到第一個大于等于initialCapacity的2的平方數,用于作為初始化table。至于為什么HashMap的容量總是2的平方數,后面會說到。

繼續看HashMap構造方法,init是個空方法,主要給子類實現,比如LinkedHashMap在init初始化頭部節點,這里暫時先不介紹。

2.4 put方法

 public V put(K key, V value) {
        // 對key為null的處理
        if (key == null)
            return putForNullKey(value);
        // 根據key算出hash值
        int hash = hash(key);
        // 根據hash值和HashMap容量算出在table中應該存儲的下標i
        int i = indexFor(hash, table.length);
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            // 先判斷hash值是否一樣,如果一樣,再判斷key是否一樣
            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;
    }

首先,如果key為null調用putForNullKey來處理,我們暫時先不關注,后面會講到。然后調用hash方法,根據key來算得hash值,得到hash值以后,調用indexFor方法,去算出當前值在table數組的下標,我們可以來看看indexFor方法:

    static int indexFor(int h, int length) {
        return h & (length-1);
    }

這其實就是mod取余的一種替換方式,相當于h%(lenght-1),其中h為hash值,length為HashMap的當前長度。而&是位運算,效率要高于%。至于為什么是跟length-1進行&的位運算,是因為length為2的冪次方,即一定是偶數,偶數減1,即是奇數,這樣保證了(length-1)在二進制中最低位是1,而&運算結果的最低位是1還是0完全取決于hash值二進制的最低位。如果length為奇數,則length-1則為偶數,則length-1二進制的最低位橫為0,則&位運算的結果最低位橫為0,即橫為偶數。這樣table數組就只可能在偶數下標的位置存儲了數據,浪費了所有奇數下標的位置,這樣也更容易產生hash沖突。這也是HashMap的容量為什么總是2的平方數的原因。我們來用表格對比length=15和length=16的情況


image.png

我們再回到put方法中,我們已經根據key得到hash值,然后根據hash值算出在table的存儲下標了,接著就是這段for代碼了:

        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            // 先判斷hash值是否一樣,如果一樣,再判斷key是否一樣
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }

首先取出table中下標為i的Entry,然后判斷該Entry的hash值和key是否和要存儲的hash值和key相同,如果相同,則表示要存儲的key已經存在于HashMap,這時候只需要替換已存的Entry的value值即可。如果不相同,則取e.next繼續判斷,其實就是遍歷table中下標為i的Entry單向鏈表,找是否有相同的key已經在HashMap中,如果有,就替換value為最新的值,所以HashMap中只能存儲唯一的key。

關于需要同時比較hash值和key有以下兩點需要注意

  1. 為什么比較了hash值還需要比較key:因為不同對象的hash值可能一樣
  2. 為什么不只比較equal:因為equal可能被重寫了,重寫后的equal的效率要低于hash的直接比較

假設我們是第一次put,則整個for循環體都不會執行,我們繼續往下看put方法。

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

這里主要看addEntry方法,它應該就是把key和value封裝成Entry,然后加入到table中的實現。來看看它的方法體:

    void addEntry(int hash, K key, V value, int bucketIndex) {
        // 當前HashMap存儲元素的個數大于HashMap擴容的閥值,則進行擴容
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }
        // 使用key、value創建Entry并加入到table中
        createEntry(hash, key, value, bucketIndex);
    }

這里牽涉到了HashMap的擴容,我們先不討論擴容,后面會講到。然后調用了createEntry方法,它的實現如下:

    void createEntry(int hash, K key, V value, int bucketIndex) {
        // 取出table中下標為bucketIndex的Entry
        Entry<K,V> e = table[bucketIndex];
        // 利用key、value來構建新的Entry
        // 并且之前存放在table[bucketIndex]處的Entry作為新Entry的next
        // 把新創建的Entry放到table[bucketIndex]位置
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        // HashMap當前存儲的元素個數size自增
        size++;
    }

這里其實就是根據hash、key、value以及table中下標為bucketIndex的Entry去構建一個新的Entry,其中table中下標為bucketIndex的Entry作為新Entry的next,這也說明了,當hash沖突時,采用的拉鏈法來解決hash沖突的,并且是把新元素是插入到單邊表的表頭。如下所示:


put.png

2.5 擴容

如果當前HashMap中存儲的元素個數達到擴容的閥值,且當前要存在的值在table中要存放的位置已經有存值時,怎么處理的?我們再來看看addEntry方法中的擴容相關代碼:

        if ((size >= threshold) && (null != table[bucketIndex])) {
            // 將table表的長度增加到之前的兩倍
            resize(2 * table.length);
            // 重新計算哈希值
            hash = (null != key) ? hash(key) : 0;
            // 從新計算新增元素在擴容后的table中應該存放的index
            bucketIndex = indexFor(hash, table.length);
        }

接下來我們看看resize是如何將table增加長度的:

    void resize(int newCapacity) {
        // 保存老的table和老table的長度
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }
        // 創建一個新的table,長度為之前的兩倍
        Entry[] newTable = new Entry[newCapacity];
        // hash有關
        boolean oldAltHashing = useAltHashing;
        useAltHashing |= sun.misc.VM.isBooted() &&
                (newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
        // 這里進行異或運算,一般為true
        boolean rehash = oldAltHashing ^ useAltHashing;
        // 將老table的原有數據,從新存儲到新table中
        transfer(newTable, rehash);
        // 使用新table
        table = newTable;
        // 擴容后的HashMap的擴容閥門值
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

再來看看transfer方法是如何將把老table的數據,轉到擴容后的table中的:

    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        // 遍歷老的table數組
        for (Entry<K,V> e : table) {
            // 遍歷老table數組中存儲每條單項鏈表
            while(null != e) {
                // 取出老table中每個Entry
                Entry<K,V> next = e.next;
                if (rehash) {
                    //重新計算hash
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                // 根據hash值,算出老table中的Entry應該在新table中存儲的index
                int i = indexFor(e.hash, newCapacity);
                // 讓老table轉移的Entry的next指向新table中它應該存儲的位置
                // 即插入到了新table中index處單鏈表的表頭
                e.next = newTable[i];
                // 將老table取出的entry,放入到新table中
                newTable[i] = e;
                // 繼續取老talbe的下一個Entry
                e = next;
            }
        }
    }

從上面易知,擴容就是先創建一個長度為原來2倍的新table,然后通過遍歷的方式,將老table的數據,重新計算hash并存儲到新table的適當位置,最后使用新的table,并重新計算HashMap的擴容閥值。

2.6 get方法

    public V get(Object key) {
        // 當key為null, 這里不討論,后面統一講
        if (key == null)
            return getForNullKey();
        // 根據key得到key對應的Entry
        Entry<K,V> entry = getEntry(key);
        // 
        return null == entry ? null : entry.getValue();
    }

然后我們看看getEntry是如果通過key取到Entry的:

    final Entry<K,V> getEntry(Object key) {
        // 根據key算出hash
        int hash = (key == null) ? 0 : hash(key);
        // 先算出hash在table中存儲的index,然后遍歷table中下標為index的單向鏈表
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            // 如果hash和key都相同,則把Entry返回
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }

取值,最簡單粗暴的方式肯定是遍歷table,并且遍歷table中存放的單向鏈表,這樣的話,get的時間復雜度就是O(n的平方),但是HashMap的put本身就是有規律的存儲,所以,取值時,可以按照規律去降低時間復雜度。上面的代碼比較簡單,其實節約的就是遍歷table的過程,因為我們可以用key的hash值算出key對應的Entry所在鏈表在在table的下標。這樣,我們只要遍歷單向鏈表就可以了,時間復雜度降低到O(n)。
get方法的取值過程如下圖所示:


get.png

2.7 使用entrySet取數據

HashMap除了提供get方法,通過key來取數據的方式,還提供了entrySet方法來遍歷HashMap的方式取數據。如下:

        Map<String, String> hashMap = new HashMap<String, String>();
        hashMap.put("name1", "josan1");
        hashMap.put("name2", "josan2");
        hashMap.put("name3", "josan3");
        Set<Entry<String, String>> set = hashMap.entrySet();
        Iterator<Entry<String, String>> iterator = set.iterator();
        while(iterator.hasNext()) {
            Entry entry = iterator.next();
            String key = (String) entry.getKey();
            String value = (String) entry.getValue();
            System.out.println("key:" + key + ",value:" + value);
        }
image.png

結果可知,HashMap存儲數據是無序的。

我們這里主要是討論,它是如何來完成遍歷的。HashMap重寫了entrySet。

    public Set<Map.Entry<K,V>> entrySet() {
        return entrySet0();
    }

    private Set<Map.Entry<K,V>> entrySet0() {
        Set<Map.Entry<K,V>> es = entrySet;
        // 相當于返回了new EntrySet
        return es != null ? es : (entrySet = new EntrySet());
    }

代碼比較簡單,直接new EntrySet對象并返回,EntrySet是HashMap的內部類,注意,不是靜態內部類,所以它的對象會默認持有外部類HashMap的對象,定義如下:

    private final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
        // 重寫了iterator方法
        public Iterator<Map.Entry<K,V>> iterator() {
            return newEntryIterator();
        }
       // 不相關代碼
       ...
    }

我們主要是關心iterator方法,EntrySet 重寫了該方法,所以調用Set的iterator方法,會調用到這個重寫的方法,方法內部很簡單單,直接調用了newEntryIterator方法,返回了一個自定義的迭代器。我們看看newEntryIterator:

    Iterator<Map.Entry<K,V>> newEntryIterator()   {
        return new EntryIterator();
    }

可看到,直接new了一個EntryIterator對象返回,看看EntryIterator的定義:

    private final class EntryIterator extends HashIterator<Map.Entry<K,V>> {
        // 重寫了next方法
        public Map.Entry<K,V> next() {
            return nextEntry();
        }
    }

EntryIterator 是繼承了HashIterator,我們再來看看HashIterator的定義:

    private abstract class HashIterator<E> implements Iterator<E> {
        Entry<K,V> next;        // 下一個要返回的Entry
        int expectedModCount;   // For fast-fail
        int index;              // 當前table上下標
        Entry<K,V> current;     // 當前的Entry

        HashIterator() {
            expectedModCount = modCount;
            if (size > 0) { // advance to first entry
                Entry[] t = table;
                while (index < t.length && (next = t[index++]) == null)
                    ;
            }
        }

        public final boolean hasNext() {
            return next != null;
        }

        final Entry<K,V> nextEntry() {
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            Entry<K,V> e = next;
            if (e == null)
                throw new NoSuchElementException();

            if ((next = e.next) == null) {
                Entry[] t = table;
                while (index < t.length && (next = t[index++]) == null)
                    ;
            }
            current = e;
            return e;
        }
        // 不相關
        ......
    }

我們先看構造方法:

        HashIterator() {
            expectedModCount = modCount;
            if (size > 0) { // advance to first entry
                Entry[] t = table;
                // 這里其實就是遍歷table,找到第一個返回的Entry next
                // 該值是table數組的第一個有值的Entry,所以也肯定是單向鏈表的表頭
                while (index < t.length && (next = t[index++]) == null)
                    ;
            }
        }

以上,就是我們調用了Iterator<Entry<String, String>> iterator = set.iterator();代碼所執行的過程。

接下來就是使用while(iterator.hasNext())去循環判斷是否有下一個Entry,EntryIterator沒有實現hasNext方法,所以也是調用的HashIterator中的hasNext,我們來看看該方法:

        public final boolean hasNext() {
            // 如果下一個返回的Entry不為null,則返回true
            return next != null;
        }

該方法很簡單,就是判斷下一個要返回的Entry next是否為null,如果HashMap中有元素,那么第一次調用hasNext時next肯定不為null,且是table數組的第一個有值的Entry,也就是第一條單向鏈表的表頭Entry。

接下來,就到了調用EntryIterator.next去取下一個Entry了,EntryIterator對next方法進行了重寫,看看該方法:

        public Map.Entry<K,V> next() {
            return nextEntry();
        }

直接調用了nextEntry方法,返回下一個Entry,但是EntryIterator并沒有重寫nextEntry,所以還是調用的HashIterator的nextEntry方法,方法如下:

        final Entry<K,V> nextEntry() {
            // 保存下一個需要返回的Entry,作為返回結果
            Entry<K,V> e = next;
            // 如果遍歷到table上單向鏈表的最后一個元素時
            if ((next = e.next) == null) {
                Entry[] t = table;
                // 繼續往下尋找table上有元素的下標
                // 并且把下一個talbe上有單向鏈表的表頭,作為下一個返回的Entry next
                while (index < t.length && (next = t[index++]) == null)
                    ;
            }
            current = e;
            return e;
        }

其實nextEntry的主要作用有兩點

  1. 把當前遍歷到的Entry返回
  2. 準備好下一個需要返回的Entry

如果當前返回的Entry不是單向鏈表的最后一個元素,那只要讓下一個返回的Entrynext為當前Entry的next屬性(下圖紅色過程);如果當前返回的Entry是單向鏈表的最后一個元素,那么它就沒有next屬性了,所以要尋找下一個table上有單向鏈表的表頭(下圖綠色過程)


HashMap的next.png

可知,HashMap的遍歷,是先遍歷table,然后再遍歷table上每一條單向鏈表,如上述的HashMap遍歷出來的順序就是Entry1、Entry2....Entry6,但顯然,這不是插入的順序,所以說:HashMap是無序的。

2.8 對key為null的處理

先看put方法時,key為null

    public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value);
       //其他不相關代碼
       .......
    }

看看putForNullKey的處理

    private V putForNullKey(V value) {
        // 遍歷table[0]上的單向鏈表
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            // 如果有key為null的Entry,則替換該Entry中的value
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        // 如果沒有key為null的Entry,則構造一個hash為0、key為null、value為真實值的Entry
        // 插入到table[0]上單向鏈表的頭部
        addEntry(0, null, value, 0);
        return null;
    }

其實key為null的put過程,跟普通key值的put過程很類似,區別在于key為null的hash為0,存放在table[0]的單向鏈表上而已。

我們再來看看對于key為null的取值:

    public V get(Object key) {
        if (key == null)
            return getForNullKey();
            //不相關的代碼
            ......
    }

取值就是通過getForNullKey方法來完成的,代碼如下:

    private V getForNullKey() {
        //  遍歷table[0]上的單向鏈表
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            // 如果key為null,則返回該Entry的value值
            if (e.key == null)
                return e.value;
        }
        return null;
    }

key為null的取值,跟普通key的取值也很類似,只是不需要去算hash和確定存儲在table上的index而已,而是直接遍歷talbe[0]。

所以,在HashMap中,不允許key重復,而key為null的情況,只允許一個key為null的Entry,并且存儲在table[0]的單向鏈表上。

2.9 remove方法

HashMap提供了remove方法,用于根據key移除HashMap中對應的Entry

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

首先調用removeEntryForKey方法把key對應的Entry從HashMap中移除。然后把移除的值返回。我們繼續看removeEntryForKey方法:

    final Entry<K,V> removeEntryForKey(Object key) {
        // 算出hash
        int hash = (key == null) ? 0 : hash(key);
        // 得到在table中的index
        int i = indexFor(hash, table.length);
        // 當前結點的上一個結點,初始為table[index]上單向鏈表的頭結點
        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--;
                // 如果是table上的單向鏈表的頭結點,則直接讓把該結點的next結點放到頭結點
                if (prev == e)
                    table[i] = next;
                else
                    // 如果不是單向鏈表的頭結點,則把上一個結點的next指向本結點的next
                    prev.next = next;  
                // 空實現
                e.recordRemoval(this);
                return e;
            }
            // 沒有找到刪除的結點,繼續往下找
            prev = e;
            e = next;
        }

        return e;
    }

其實邏輯也很簡單,先根據key算出hash,然后根據hash得到在table上的index,再遍歷talbe[index]的單向鏈表,這時候需要看要刪除的元素是否就是單向鏈表的表頭,如果是,則直接讓table[index]=next,即刪除了需要刪除的元素;如果不是單向鏈表的頭,那表示有前面的結點,則讓pre.next = next,也刪除了需要刪除的元素。

2.10 線程安全問題

由前面HashMap的put和get方法分析可得,put和get方法真實操作的都是Entry[] table這個數組,而所有操作都沒有進行同步處理,所以HashMap是線程不安全的。如果想要實現線程安全,推薦使用ConcurrentHashMap。

3 總結

  1. HashMap是基于哈希表實現的,用Entry[]來存儲數據,而Entry中封裝了key、value、hash以及Entry類型的next
  2. HashMap存儲數據是無序的
  3. hash沖突是通過拉鏈法解決的
  4. HashMap的容量永遠為2的冪次方,有利于哈希表的散列
  5. HashMap不支持存儲多個相同的key,且只保存一個key為null的值,多個會覆蓋
  6. put過程,是先通過key算出hash,然后用hash算出應該存儲在table中的index,然后遍歷table[index],看是否有相同的key存在,存在,則更新value;不存在則插入到table[index]單向鏈表的表頭,時間復雜度為O(n)
  7. get過程,通過key算出hash,然后用hash算出應該存儲在table中的index,然后遍歷table[index],然后比對key,找到相同的key,則取出其value,時間復雜度為O(n)
  8. HashMap是線程不安全的,如果有線程安全需求,推薦使用ConcurrentHashMap。
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 5.1、對于HashMap需要掌握以下幾點 Map的創建:HashMap() 往Map中添加鍵值對:即put(Ob...
    rochuan閱讀 694評論 0 0
  • Java集合:HashMap源碼剖析 一、HashMap概述 二、HashMap的數據結構 三、HashMap源碼...
    記住時光閱讀 743評論 2 1
  • HashMap 是 Java 面試必考的知識點,面試官從這個小知識點就可以了解我們對 Java 基礎的掌握程度。網...
    野狗子嗷嗷嗷閱讀 6,686評論 9 107
  • 27/02/2016 讀完《媽媽是最好的英語老師》,樸炫英,新世界出版社,2012. 作者是韓國著名的主持人、電臺...
    派克懶閱讀 2,612評論 0 0
  • 你可以打垮我稚嫩的身軀 卻阻礙不住待放的花蕾 暴雨只能洗滌去曾經不堪的郁悶 不久的將來 呈現的是火一樣的花
    吳永昌閱讀 165評論 0 0