1. 程式人生 > >Java 8 HashMap 實現機制簡析

Java 8 HashMap 實現機制簡析

最近看《java核心思想》看到了容器部分,本書著重描述了HashMap 的實現機制,對於Map,我們的固有印象便是存取很快,特別是HashMap,我們知道底層是雜湊表結構。但HashMap具體怎麼維護這個資料結構,這是我們今天要記錄的問題。

HashMap的基本組成

要知道HashMap為什麼存取效能優異,就要了解它內部的構造。hashmap實質是由 陣列+連結串列 構成,在java 8 中,連結串列被優化成 在資料量比較大的情況下轉變成紅黑樹。下圖是HashMap的基本結構。

這裡寫圖片描述

對著圖,我們分析它內部一些必要組成結構。

陣列table
    /**
     * 這個表陣列,會在初次使用的時候初始化,並且在必要的時候重新設定大小,當空間重新分配時,長度總是2的倍數
     * The table, initialized on first use, and resized as
     * necessary. When allocated, length is always a power of two.
     */
transient Node<K,V>[] table;

所以HashMap的第一層,是一個數組table ,通過構造器你會發現,陣列table 不會在構造器內被初始化,而是在真正業務操作時初始化,比如put。陣列table裡面存的是Node<K,V>,這也就是HashMap的第二層。

Node<K,V>

Node<K,V>是一個內部類,結構如下:

可以看出這是一個自定義的容器 – HashMap真正存每個key-value資料的地方。通過 最前面的示例圖和 table,node的簡單介紹,我們可以總結出,hashmap先是一個數組,然後每個陣列內部是一個node連結串列,我們的key-value資料被存放在每個Node節點中,

    /**
     * Basic hash bin node, used for most entries. 
     */
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;  //key的hash值
        final K key; //key
        V value;   //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; } public final K getKey() { return key; } public final V getValue() { return value; } public final String toString() { return key + "=" + value; } public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } public final boolean equals(Object o) { if (o == this) return true; if (o instanceof Map.Entry) { Map.Entry<?,?> e = (Map.Entry<?,?>)o; if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue())) return true; } return false; } }

HashMap的存取機制

這張圖以put方法為例,精要的總結了HashMap存的流程,但是我們是不是看的一臉懵逼?那我們分步驟講解.

這裡寫圖片描述

當我們呼叫put方法時,我們需要先計算key的hash值

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
hashcode的意義和設計

hash()方法其實是呼叫了key的hashCode方法,每個Object都有equals()和hashCode方法,正常來說,我們都需要複寫這個方法。普通的線性查詢演算法從頭查到尾, 效率很慢,如果我們給每個key帶上一個索引,程式根據這個索引直接找到這個key,便可以直接鎖定這個值,這個索引便是我們說的hash值。折射到hashmap裡的設計,陣列table的下標儲存的不是key,而是hash值, 陣列的隨機訪問很快,通過索引我們可以做到快速定位。
根據《java程式設計思想》的說法,如果我們不復寫hashcode方法,預設的返回值是物件地址,也就是說hashcode返回的都是唯一值,但是這樣設計會要求陣列table的空間很大,並不利於效率,最好的方法hash值的設計應該根據物件內容來設計,物件內容一致的hash值應該要一樣。

這是Integer的hashcode,返回的就是實際的內容

public static int hashCode(int value) {
    return value;
}

但是還有一個問題,根據內容生成的hash值不就存在hash值相同的情況了嗎,table陣列怎麼存啊?這種情況稱為hash衝突,但是還記得上面說的 hashmap是由陣列+連結串列組成的嗎?所以hash值一樣的資料被組成了一個連結串列,先通過hash值確定物件在table陣列中的具體位置,然後通過equals()方法對連結串列上的資料進行一一比較,最終確定這個物件應該存在的位置。這種資料結構比從頭到尾的線性查詢更有效率。

所以在HashMap中 元素的操作都是通過 hashCode() 和 equals()組合來確定元素位置的。

再回到HashMap中的hash方法中,裡面除了提取物件的hashcode值,自己也做了一些位運算處理.

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
HashMap擴容機制

計算完hash後開始呼叫putVal方法,因為在呼叫get put之前,陣列table是沒有初始化的,所以會先做一個數組初始化操作,初始化操作都在resize()方法中進行,但是resize方法不止是初始化陣列,更重要的是後期陣列table的擴容操作。

if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;

HashMap預設的初始化大小為16

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

下面這段程式碼

final Node<K,V>[] resize() {
        //現在的table陣列
        Node<K,V>[] oldTab = table;
        //現在的table陣列容量
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //threshold 為擴容的臨界點,公式為 capacity(現在的table陣列容量) * load factor(裝載因子,衡量hashmap滿的程度,預設0.75),超過臨界點,就會擴容
        int oldThr = threshold;
        int newCap, newThr = 0;
        //如果現在的table陣列容量大於0
        if (oldCap > 0) {
        //如果table長度大於規定的最大容量,就不擴容
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //否則newCap變成當前容量的2倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                     //newThr 變成當前擴容臨界點的2倍
                newThr = oldThr << 1; // double threshold
        }
        //這個是針對建立hashmap時指定了initialCapacity 和 loadFactor的情況,會將容器擴容為計算好的threshold大小
      //HashMap(int initialCapacity, float loadFactor)
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
     //table還未初始化,初始化為系統預設大小,並且計算好擴容的臨界值
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        //總之,之前的都在計算newThr和newCap,確保它們有有意義的值。這樣做是為了生成新的table,眾所周知,當容器擴容時,為了保證kv元素能均勻的分佈,我們需要重新計算遷移元素。並把舊的table釋放。
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
        //根據計算的newCap生成新的table
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    if (e.next == null)
// 資料的遷移注意看這個公式,這也是計算某個kv元素存在table陣列具體位置的公式 e.hash & (newCap - 1)
// 假設初始容量為16  ,A元素沒擴容前hash值為15,則A在原來的table上的位置為(15& (16-1)) = 15,那麼擴容後為32,則新的hash值為  15&(32-1) = 15,這個元素不用做變動;
// B元素沒擴容前hash值為20,則B在原來的table上的位置為(20& (16-1)) = 4,那麼擴容後新的hash值為20&(32-1) = 20,這個元素正好向右偏移了oldCap個單位。
//這個巧妙的設計保證了一半元素原地不動,一半元素向右偏移了oldCap個單位(此結論未作嚴謹驗證,但是意在表達hashmap擴容後,元素的遷移思路)
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

hashmap擴容機制都寫在了上面程式碼的註釋裡,光從容器擴容的程式碼我們就可以大概瞭解到hashmap 的運作原理,其中包括了:

  • capacity,loadFactor,threshold的基本概念和作用
  • kv元素如何確定在table中的位置以及擴容後的位置

走完resize()方法,我們繼續走完putVal方法

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //通過公式e.hash & (newCap - 1)確定kv元素的位置,如果該位置沒有其它元素,直接在table[i]中插入
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        //如果有其它元素
        else {
            Node<K,V> e; K k;
            //如果舊kv的key跟新kv的key內容完全一樣,則先用e記錄,後面會覆蓋其value
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //如果該連結串列是一個紅黑樹結構,且兩個key內容不相等,則會用putTreeVal將這新的記錄插入到紅黑樹中去
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
           //如果這個連結串列不是紅黑樹結構,且兩個key內容不相等,會進入這段程式碼
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
      //這裡要注意,在java8中,如果一個連結串列裡的kv元素大於8,則會用treeifyBin將這個連結串列轉化成紅黑樹結構,紅黑樹的查詢效率更高。
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //這裡是針對key內容一樣需要做覆蓋處理的具體程式碼
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
HashMap的取過程

通過漫長的程式碼分析,我們走完hashmap是如何存一個元素的,下面再看看它是如何取的。

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    //依舊從公式(n - 1) & hash 確定kv元素的具體位置
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        //總是檢查第一個元素,如果第一個直接key內容完全匹配上,就返回
        if (first.hash == hash && 
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;

        if ((e = first.next) != null) {
        //如果第一個元素取不到,且接下來是個紅黑樹結構,就用getTreeNode方法去取值
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
        //如果不是紅黑樹結構,就迴圈連結串列去取值
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

可以看出,存和取是一個互逆過程,兩者原理相似。

結語

這篇文章從程式碼層面解釋了HashMap的存取機制,它利用了陣列的高隨機訪問能力,但是對於資料大容量問題,又用 連結串列/紅黑樹 與陣列配合 作為解決方案,可見HashMap設計的精妙之處。

本文內容也參考了該文章,對該文章作者表示感謝

http://www.jianshu.com/p/aa017a3ddc40