Java集合源碼閱讀之HashMap

@author StormMa
@date 2017-05-31


生命不息,奮鬥不止!


基於jdk1.8的HashMap源碼分析。


前期準備

什麼是HashMap

官方解釋

Hash table based implementation of the Map interface. This implementation provides all of the optional map operations, and permits null values and the null key. (The HashMap class is roughly equivalent to Hashtable, except that it is unsynchronized and permits nulls.) This class makes no guarantees as to the order of the map; in particular, it does not guarantee that the order will remain constant over time.

基於哈希表的Map接口實現,此實現提供了可選的映射操作,並且允許null的key和null的value。(HashMap類大致等價於HashTable,除過它不是同步的並且允許nulls)。HashMap類也無法保證元素的順序, 特別是,它更不能保證在一段時間之內元素的順序不變。

總之,官方的解釋說明了HashMap基於什麼樣的數據結構,與HashTable的區別,以及它是無序的。

什麼是哈希表

既然官方已經說了HashMap是基於哈希表的Map接口實現,我想我們需要看一下哈希表是個什麼玩意兒!

散列表(Hash table,也叫哈希表),是根據鍵(Key)而直接訪問在內存存儲位置的數據結構。也就是說,它通過計算一個關於鍵值的函數,將所需查詢的數據映射到表中一個位置來訪問記錄,這加快了查找速度。這個映射函數稱做散列函數,存放記錄的數組稱做散列表。
一個通俗的例子是,爲了查找電話簿中某人的號碼,可以創建一個按照人名首字母順序排列的表(即建立人名x到首字母F(x)的一個函數關係),在首字母爲W的表中查找“王”姓的電話號碼,顯然比直接查找就要快得多。這裏使用人名作爲關鍵字,“取首字母”是這個例子中散列函數的函數法則F(),存放首字母的表對應散列表。關鍵字和函數法則理論上可以任意確定。

上面的文字節選自維基百科。我想我們得用一張圖看一下哈希表究竟是個多麼神奇的數據結構。

我想我得對這個圖做一點解釋。上圖就是哈希表,當然也可以叫做鏈表的數組。很顯然,上圖中的數據結構其實就是一個數組,而數組的元素又是一個鏈表,這就很巧妙的結合了數組和鏈表的全部優點。在開始學習HashMap之前,我想我有必要說兩個概念,一個是Hash衝突, 爲什麼會發生Hash衝突呢?在說Hash衝突之前,我想要說一下HashMap的table數組下標是怎麼確定的,他有確定的計算公式,先得到key的hashcode值,然後進行二次hash之後和table數組的(length-1)做與運算得到數組的下標。但是這個數組的下標有可能會發生衝突,這個衝突就叫做Hash衝突。有了Hash衝突,纔會出現上圖中鏈狀的形狀的結構。鏈式的東西我們暫且稱爲bucket(桶)。我想我有必要說一下jdk1.8中和jdk1.7做了一些變化。jdk1.8中,如果一個bucket中的元素大於8個,那麼會把鏈表轉換成紅黑樹來提高效率。

HashMap實現內探

HashMap聲明

HashMap繼承了AbstractMap類並且實現了Map、Cloneable、和Serializable接口。

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
    ...

    //哈希表中的數組(HashMap中稱爲table數組), 元素是Node<K, V>,在jdk1.8之前應該是HashMapEntry<K,V>[]//Entry[K, V]初始化大小爲1<<4=16
    transient Node<K,V>[] table;

    /**
     * Holds cached entrySet(). Note that AbstractMap fields are used
     * for keySet() and values().
     */
    transient Set<Map.Entry<K,V>> entrySet;

    //也就是HashMap所有的Key=>Value的個數
    transient int size; 

    //記錄HashMap內部結構發生變化的次數,例如put新鍵值對,但是某個key對應的value值被覆蓋不屬於結構變化。
    transient int modCount;

    //所能容納的key-value對極限 
    int threshold;

    //加載因子
    final float loadFactor;
    ...

}

Node[] table的初始化長度length(默認值是16),Load factor爲負載因子(默認值是0.75),threshold是HashMap所能容納的最大數據量的Node(鍵值對)個數。threshold = length * Load factor。也就是說,在數組定義好長度之後,負載因子越大,所能容納的鍵值對個數越多。這裏存在一個問題,即使負載因子和Hash算法設計的再合理,也免不了會出現拉鍊過長的情況,一旦出現拉鍊過長,則會嚴重影響HashMap的性能。於是,在JDK1.8版本中,對數據結構做了進一步的優化,引入了紅黑樹。而當鏈表長度太長(默認超過8)時,鏈表就轉換爲紅黑樹,利用紅黑樹快速增刪改查的特點提高HashMap的性能,其中會用到紅黑樹的插入、刪除、查找等算法。接下來我們先看一下Node[K, V]的聲明

    /**
     * Basic hash bin node, used for most entries.  (See below for
     * TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
     */
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        //下一個Node[K, V]的指針,鏈表的實現
        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存儲之前,我想應該先看一下怎麼確定確定哈希桶數組索引位置,即table數組的索引位置

//方法一:
static final int hash(Object key) {   //jdk1.8 & jdk1.7
     int h;
     // h = key.hashCode() 爲第一步 取hashCode值
     // h ^ (h >>> 16)  爲第二步 高位參與運算
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//方法二:
static int indexFor(int h, int length) {  //jdk1.7的源碼,jdk1.8沒有這個方法,但是代碼裏面的實現是一樣的。
     return h & (length-1);  //第三步 取模運算
}

對於任意給定的對象,只要它的hashCode()返回值相同,那麼程序調用方法一所計算得到的Hash碼值總是相同的。我們首先想到的就是把hash值對數組長度取模運算,這樣一來,元素的分佈相對來說是比較均勻的。但是,模運算的消耗還是比較大的,在HashMap中是這樣做的:調用方法二來計算該對象應該保存在table數組的哪個索引處。
這個方法非常巧妙,它通過h & (table.length -1)來得到該對象的保存位,而HashMap底層數組的長度總是2的n次方,這是HashMap在速度上的優化。當length總是2的n次方時,h& (length-1)運算等價於對length取模,也就是h%length,但是&比%具有更高的效率。
在JDK1.8的實現中,優化了高位運算的算法,通過hashCode()的高16位異或低16位實現的:(h = k.hashCode()) ^ (h >>> 16),主要是從速度、功效、質量來考慮的,這麼做可以在數組table的length比較小的時候,也能保證考慮到高低Bit都參與到Hash的計算中,同時不會有太大的開銷。

put方法實現

put方法實現的大致思路:

  1. 對key的hashCode()做hash,然後再計算index;
  2. 如果沒碰撞直接放到bucket裏;
  3. 如果碰撞了,以鏈表的形式存在buckets後;
  4. 如果碰撞導致鏈表過長(大於等於TREEIFY_THRESHOLD),就把鏈表轉換成紅黑樹;
  5. 如果節點已經存在就替換old value(保證key的唯一性)
  6. 如果bucket滿了(超過load factor*current capacity),就要resize。
    /**
     * Associates the specified value with the specified key in this map.
     * If the map previously contained a mapping for the key, the old
     * value is replaced.
     *
     * @param key key with which the specified value is to be associated
     * @param value value to be associated with the specified key
     * @return the previous value associated with <tt>key</tt>, or
     *         <tt>null</tt> if there was no mapping for <tt>key</tt>.
     *         (A <tt>null</tt> return can also indicate that the map
     *         previously associated <tt>null</tt> with <tt>key</tt>.)
     */
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

putVal()

    /**
     * Implements Map.put and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent if true, don't change existing value
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //判斷table數組是否爲空
        if ((tab = table) == null || (n = tab.length) == 0)
            //創建一個新的table數組,並且獲取該數組的長度 
            n = (tab = resize()).length;
        //根據鍵值key計算hash值得到插入的數組索引i,如果table[i]==null,直接新建節點添加 
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {//如果對應的節點存在, 那麼就是bucket衝突
            Node<K,V> e; K k;
            //判斷table[i]的首個元素是否和key一樣,如果相同直接覆蓋value
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;

            //判斷table[i] 是否爲treeNode,即table[i] 是否是紅黑樹,如果是紅黑樹,則直接在樹中插入鍵值對
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {//鏈表
                    //遍歷table[i],判斷鏈表長度是否大於TREEIFY_THRESHOLD(默認值爲8),大於8的話把鏈表轉換爲紅黑樹,在紅黑樹中執行插入操作,否則進行鏈表的插入操作;遍歷過程中若發現key已經存在直接覆蓋value即可;
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        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;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        // 插入成功後,判斷實際存在的鍵值對數量size是否超多了最大容量threshold,如果超過,進行擴容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

簡單地說put時候先判斷table數組是不是一個空數組,如果不是,根據hash(key)&(table.length-1)來得到table的index,然後判斷table[index]是否爲空節點,如果不是那麼判斷table[index]的首元素的key值和要put的key是否相等,如果是就覆蓋value,保證key的唯一性
,如果不相等,先判斷節點是否是treeNode,如果是調用紅黑樹的putVal方法,如果不是那麼就是鏈表,判斷鏈表的長度是否大於8,如果是轉換成紅黑樹進行插入。

get的實現

大致思路:

  1. bucket裏的第一個節點,直接命中;
  2. 如果有衝突,則通過key.equals(k)去查找對應的entry
    若爲樹,則在樹中通過key.equals(k)查找,O(logn);
    若爲鏈表,則在鏈表中通過key.equals(k)查找,O(n)。
    /**
     * Returns the value to which the specified key is mapped,
     * or {@code null} if this map contains no mapping for the key.
     *
     * <p>More formally, if this map contains a mapping from a key
     * {@code k} to a value {@code v} such that {@code (key==null ? k==null :
     * key.equals(k))}, then this method returns {@code v}; otherwise
     * it returns {@code null}.  (There can be at most one such mapping.)
     *
     * <p>A return value of {@code null} does not <i>necessarily</i>
     * indicate that the map contains no mapping for the key; it's also
     * possible that the map explicitly maps the key to {@code null}.
     * The {@link #containsKey containsKey} operation may be used to
     * distinguish these two cases.
     *
     * @see #put(Object, Object)
     */
    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

getNode()

/**
     * Implements Map.get and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @return the node, or null if none
     */
    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            //直接找到
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            //第一次未命中
            if ((e = first.next) != null) {
                    //如果是樹的實現
                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;
    }
擴容機制

擴容(resize)就是重新計算容量,向HashMap對象裏不停的添加元素,而HashMap對象內部的數組無法裝載更多的元素時,對象就需要擴大數組的長度,以便能裝入更多的元素。當然Java裏的數組是無法自動擴容的,方法是使用一個新的數組代替已有的容量小的數組。我要補充一點就是,jdk1.8的resize方法比較jdk1.8之前的resize方法有了很多的變化,這些變化不僅僅是紅黑樹,還有rehash的實現。我不想直接拿着jdk1.8的resize方法來啃,我們還是先來看看jdk1.7的resize方法吧。在閱讀resize方法源碼的時候,剛開始我是看不懂的,然後找了幾篇博客學習了一下,較之前來說好多了。


jdk1.7 resize()

/**
 * @param newCapacity 新的容量值
 */
void resize(int newCapacity) {
    //引用擴容前的Entry數組(因爲jdk1.7HashMap的table元素都是Entry而不是Node)
    Entry[] oldTable = table;      
    int oldCapacity = oldTable.length;  
    //擴容前的數組大小如果已經達到最大(2^30)了
    if (oldCapacity == MAXIMUM_CAPACITY) {  
        //修改閾值爲int的最大值(2^31-1),這樣以後就不會擴容了  
        threshold = Integer.MAX_VALUE;   
        return;  
    }  
    //初始化一個新的Entry數組 
    Entry[] newTable = new Entry[newCapacity];
    //將數據轉移到新的Entry數組裏    
    transfer(newTable);            
    //HashMap的table屬性引用新的Entry數組               
    table = newTable;                            
    threshold = (int) (newCapacity * loadFactor);//修改閾值
}  

transfer()方法用於把元素移動到新的table數組中去

void transfer(Entry[] newTable) {  
    //舊數組
    Entry[] src = table;
    //新容量
    int newCapacity = newTable.length; 
    //遍歷舊的table數組 
    for (int j = 0; j < src.length; j++) {  
        //Entry的第一個元素
        Entry<K, V> e = src[j]; 
        if (e != null) {  
            //釋放舊table數組的引用
            src[j] = null; 
            do {  
                Entry<K, V> next = e.next;  
                //重新計算新table數組中應該插在哪個位置,這一步就是rehash操作
                int i = indexFor(e.hash, newCapacity);
                //e.next = null
                e.next = newTable[i];  
                //table[i]引用e(其實就是鏈表頭插法,和當年c語言寫鏈表操作沒啥區別)
                newTable[i] = e;   
                //訪問這個Entry鏈上的下一個元素
                e = next;   
            } while (e != null);  
        }  
    }  
}  

我上面所說的table數組和Entry數組其實就是一個東西,在jdk1.8中的生命是Node[K, V] [] table形式,在jdk1.7有點不一樣,但是實際差不多,就更改了一下命名而已。基於上面的那個transfer方法,用一句話來概括…先放在一個索引上的元素終會被放到Entry鏈的尾部,,爲什麼呢,因爲上面是採用鏈表的頭插法,第一個引用的元素肯定就是尾部的元素了。[嘿哈]..


jdk1.8 resize()

    /**
     * Initializes or doubles table size.  If null, allocates in
     * accord with initial capacity target held in field threshold.
     * Otherwise, because we are using power-of-two expansion, the
     * elements from each bin must either stay at same index, or move
     * with a power of two offset in the new table.
     *
     * @return the table
     */

     //上面的javadoc,翻譯之後得到
初始化或加倍table數組大小。如果爲null,則根據在場閾值中保持的初始容量目標進行分配。否則,因爲我們使用的是二次冪擴展(就是擴容的新容量值是舊的2倍),所以每個bin中的元素必須保持相同的索引,或者在新表中以兩個偏移量的方式移動。

    //其實上句話的意思就是經過rehash之後,元素的位置要麼是在原位置,要麼是在原位置再移動2次冪的位置。
    final Node<K,V>[] resize()

看下圖可以明白這句話的意思,n爲table的長度,圖(a)表示擴容前的key1和key2兩種key確定索引位置的示例,圖(b)表示擴容後key1和key2兩種key確定索引位置的示例,其中hash1是key1對應的哈希值(也就是根據key1算出來的hashcode值)與高位與運算的結果。(注:這部門來源於別人的博客講解,感覺講得挺好的,所以引用來以助於自己理解!)

元素在重新計算hash之後,因爲n變爲2倍,那麼n-1的mask範圍在高位多1bit(紅色),因此新的index就會發生這樣的變化:

    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        //舊table的容量
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
                //擴容前的數組大小如果已經達到最大(2^30)了
            if (oldCap >= MAXIMUM_CAPACITY) {
            //修改閾值爲int的最大值(2^31-1),這樣以後就不會擴容了
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            // 沒超過最大值,就擴充爲原來的2倍, 印證javadoc所述
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        // 計算新的resize上限
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        // 把每個bucket都移動到新的buckets中
        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)
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                    // 鏈表優化重hash的代碼塊
                        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;
                            }
                            // 原索引+oldCap,對應上圖看
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        // 原索引放到bucket裏
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        // 原索引+oldCap放到bucket裏
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

jdk1.7的resize方法還算簡單,但是jdk1.8的擴容必須好好研究一下。HashMap源碼閱讀就寫到這裏了,寫博客真的能學習到很多,之前對HashMap的認識只停留到不同步允許null的key和value無序並且會用的層次上,但是這遠遠不夠,還是應該多看看源碼,瞭解一下設計者的初心。原文章來自我的個人站點: http://blog.stormma.me,轉載請註明出處!

發佈了108 篇原創文章 · 獲贊 250 · 訪問量 9萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章