Java集合容器系列04-HashMap

1、HashMap介紹

    HashMap是基於哈希表實現的Map容器,存儲的元素是鍵值對映射。繼承自AbstractMap,實現了Map、Cloneable、java.io.Serializable接口。是非線程安全的集合而且容器中存儲的鍵值對映射是無序的,HashMap容許鍵和值都爲null這點與HashTable相反,HashTable是線程安全的且鍵和值均不能爲null。java

2、HashMap的數據結構

1 - 繼承結構

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

    HashMap繼承了AbstractMap,AbstractMap提供了Map操做的一些基本實現,實現了Map接口由於父類AbstractMap已經實現了Map接口此處只是起到相似文檔標識的做用,這種應用在jdk中還有不少,此外HashMap還實現了Cloneable和Serializable接口支持對象拷貝和序列化。node

2 - 成員變量

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

    private static final long serialVersionUID = 362498820763181265L;
    //容器默認初始容量16
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 

    //容器最大容量2的30次方
    static final int MAXIMUM_CAPACITY = 1 << 30;
    
    //默認負載因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    //閾值,容器保存hash衝突節點的桶上鍊表節點數超過這個值就會轉成紅黑樹結構存儲
    static final int TREEIFY_THRESHOLD = 8;

    //閾值,當桶的鏈表數小於這個值時,存儲hash衝突節點的紅黑樹會轉回鏈表存儲結構
    static final int UNTREEIFY_THRESHOLD = 6;

    //樹的最小容量
    static final int MIN_TREEIFY_CAPACITY = 64;

    //存儲鍵值對節點的數組,採用了拉鍊法解決Hash衝突,Node對象其實是單鏈表或者紅黑樹,老是2的倍數,爲何要這樣設置 
    //分析後續的方法源碼就能夠知道
    transient Node<K,V>[] table;

    //鍵值對映射集合
    transient Set<Map.Entry<K,V>> entrySet;

    //鍵值對個數
    transient int size;

    //容器結構修改計數器
    transient int modCount;

    //臨界值,會進行擴容
    int threshold;

    //填充因子
    final float loadFactor;

}

 

3、HashMap源碼分析

1 - 構造函數

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;
        this.threshold = tableSizeFor(initialCapacity);
    }

    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }


    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

    HashMap類提供了三個構造函數,無參構造函數HashMap()很簡單就是初始化對象的負載因子爲默認的負載因子。HashMap(int initialCapacity)在方法內部調用了HashMap(int initialCapacity, float loadFactor)方法,所以咱們能夠直接分析該構造方法,該構造方法首先對初始容量initialCapcity和加載因子loadFactor作了合法性校驗,若是初始容量大於Hashmap容量最大限制2的30次方,設置爲最大容量,初始化根據方法指定參數初始化加載因子,調用tableSizeFor計算臨界值,跟進該方法源碼,tableSizeFor方法源碼以下:數組

static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

    該方法經過一系列移位和邏輯運算保證計算出的臨界值是最小的大於方法指定初始化容量cap的2的指數次方,至於爲何臨界值要設置爲2的指數次方,咱們後續會講到。安全

2 - int hash(Object k)方法

    由於HashMap底層是基於hashtable實現的,容器的各類操做包括元素插入、刪除、修改和查詢都須要調用hash函數計算key對應的hash值進而定位元素所屬槽(bucket)在哈希表(table數組)中索引,hash函數做爲重點,咱們首先進行分析,方法源碼以下:數據結構

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

    查看源碼可知,在HashMap中key的hash值計算邏輯爲:key爲null結束計算返回0,不然調用key.hashCode方法計算key的哈希值,把key的哈希值做爲底數,key哈希值右移16位做爲指數作冪運算,返回運算結果。app

 

3 - V get(Object key)根據key獲取value方法

方法源碼:函數

public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

    方法內部核心邏輯在getNode(hash(key), key)),繼續跟進該方法源碼源碼分析

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計算得出key所在槽的在哈希表中的索引位置,獲取到的多是鏈表表頭也多是紅黑樹樹 
        //根
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            //找到key對應的鍵值對映射,返回對應的鍵值對節點
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            //若是當前節點的後續節點不爲空
            if ((e = first.next) != null) {
                //如果樹節點即當前槽保存的hash衝突節點個數超過8個,在紅黑樹中查找key對應的鍵值對節點
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                //不然繼續遍歷鏈表,若是鏈表中存在匹配指定key的鍵值對節點(equals且hashCode相等),結束返回節點
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

    查看源碼咱們大致瞭解了get方法的邏輯。它首先根據key計算hash而後與哈希表長度table.length-1作與運算獲取該key所在槽的頭節點根據該槽hash衝突狀況的不一樣可能返回的是紅黑樹的樹根也多是鏈表的頭節點,這裏咱們知道了爲何node數組table的長度總要設置爲2的指數次方,由於2的指數次方-1,的二進制位是一連串1,HashMap中(n-1) & hash的計算結果更加分散,能下降Hash衝突的機率提高查詢效率。性能

 

4 - V put(K key, V value)插入鍵值對方法

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

    方法內部調用putVal進行鍵值對插入,繼續跟進該方法源碼優化

/**
     * Implements Map.put and related methods
     *
     * @param hash key的hash計算值
     * @param key 鍵值對中的鍵
     * @param value 鍵值對中的值
     * @param onlyIfAbsent 若是爲true,當容器中已存在該key對應的鍵值對,不進行插入
     * @param evict 若爲false,table處於建立模式
     * @return 返回key所在鍵值對被覆蓋的前一個value,若是沒有返回null
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //1.若是table爲空即HashMap中不存在任何鍵值對映射,則爲table分配內存
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //2.(n-1) & hash獲取鍵值對在table中的索引位置,若索引所在的節點爲空則說明當前日期不存在與指定鍵值對key的 
        //hash值相等的鍵值對節點,直接插入
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        //不然指定插入鍵值對存在hash衝突
        else {
            Node<K,V> e; K k;
            //若hash衝突槽第一個節點hash值相等且key值相等
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //若hash衝突槽以紅黑樹數據結構存儲,在紅黑樹中插入鍵值對節點
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            //不然衝突槽的數據結構爲單鏈表,若當前容器不存在key對應的鍵值對直接基於指定的鍵值對建立一個新節點在鏈表 
            //尾插入,若插入鍵值對後超過鏈表樹化閾值則將存儲hash衝突節點的鏈表轉化爲紅黑樹結構,
            //不然獲取當前容器中hash相等且key相等的節點用於後續操做
            else {
                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;
                }
            }
            //若當前HashMap中存在key對應的鍵值對節點e
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                //若onlyIfAbsent設置爲false即容許覆蓋容器中鍵值對節點的value值或舊value值爲null,則設置爲
                //新值value
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                //在HashMap中是空實現留給子類作擴展
                afterNodeAccess(e);
                return oldValue;
            }
        }
        //3.HashMap插入鍵值對映射沒有Hash衝突時執行後續代碼
        //容器結構修改計數器遞增
        ++modCount;
        //table中即哈希表中有存儲鍵值對映射的槽的個數大於閥值threadshold則調用resize方法擴容
        if (++size > threshold)
            resize();
        //空實現
        afterNodeInsertion(evict);
        return null;
    }

    該方法是HashMap中put和相關方法如putIfAbsent的底層實現方法,方法流程邏輯整理以下:

1)判斷存儲鍵值對節點數組table是否爲空,若爲空調用resize方法進行擴容,咱們看下該方法源碼:

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            //若數組table容量大於等於最大容量限制MAXIMUM_CAPACITY
            if (oldCap >= MAXIMUM_CAPACITY) {
                //將擴容臨界值設置爲Integer的最大值,再也不進行擴容
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //若擴容2倍以後容量小於HashMap最大容量限制且原來的容量大於初始容量則進行2倍擴容且臨界值*2
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        //若容器爲空,且臨界值大於0,則容器擴容後的容量設置爲臨界值
        else if (oldThr > 0) 
            newCap = oldThr;
        //臨界值和容器初始容量均爲0,則爲臨界值和容器初始容量分別分配一個默認值,臨界值爲初始容量乘以默認負載因子
        else {
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        //若擴容後的臨界值等於0根據負載因子和擴容後從新計算擴容後的臨界值,若計算後臨界值大於等於最大容量限制則須要重 
        //置爲Integer.MAX_VALUE
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        //爲table從新一個新的節點數組,數組長度爲擴容後的容器容量newCap
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        //若是老table不爲空,原來容器中存在鍵值對節點,須要在新的節點數組table中填入原來的數據
        if (oldTab != null) {
            //循環容器原來的節點數組table,將鍵值對數據填充到新數組table
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                //table當前下標位置保存hash相同(也可說衝突)節點的槽不爲空
                if ((e = oldTab[j]) != null) {
                    //釋放槽中的節點對象
                    oldTab[j] = null;
                    //槽中只包含單個節點,基於hash & (newCap-1)獲取該節點在新數組table的下標位置,在數組中對應 
                    //位置保存該節點
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    //若槽的數據存儲結構是紅黑樹,則也爲新table當前槽建立紅黑樹存儲槽中的鍵值對數據
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    //若槽的數據結構是鏈表,
                    else {
                        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;
    }

    這一步的話由於判斷的是原容器爲空的狀況,主要作的實際上是爲table分配一個默認初始容量的數組。

2)(n-1) & hash基於key的hash與容器長度n-1的二進制位作&運算,獲取插入鍵值對在哈希表table中的下標位置,由於n爲2的指數故n-1的二進制是一系列1,key的hash與n-1作&運算能使鍵值對分散更隨機均勻,有利於提升查詢效率,若是哈希表table中存儲鍵值對節點的槽爲null那麼直接爲指定鍵值對建立新節點,並在table對應下標保存新節點引用;

3)若是插入key存在Hash衝突,須要根據保存Hash衝突節點的槽的數據存儲結構是紅黑樹仍是鏈表作處理,如果鏈表且插入指定節點後超出樹化閥值則須要將鏈表轉化爲紅黑樹保存hash衝突節點;

4)若容器發生結構修改(指的是插入的鍵值對節點未發生hash衝突新佔用了數組table的空間),modCount(結構修改計數器)++,判斷是否超過臨界值超過須要調用resize進行擴容,這個方法以前已經分析過了,能夠回過頭看下方法實現邏輯,通常狀況下是擴容2倍。

 

5 - 高效使用HashMap

       HashMap性能消耗比較嚴重的主要有兩個過程,第一個是當發生Hash衝突時,table中存儲單個節點的槽會退化爲鏈表查詢時須要額外遍歷這個鏈表,Hash衝突越劇烈查詢性能越低,儘管JDK1.8對此做了優化當鏈表節點數超過8會轉化爲紅黑樹存儲,查詢花費的時間複雜度下降到O(logn),下降Hash衝突咱們須要作的包括爲Key對象類型選擇一個合理的hashCode函數,合理規劃HashMap的初始容量(table數組長度)讓插入的鍵值對基於Hash和初始容量計算出的數組table下標儘可能分散;第二個是當容器中節點佔用的槽的個數(也就是數組table中被佔用的數據項個數)超過臨界值時會進行擴容,擴容須要將原HashMap中存儲的鍵值對數據填充到新的數組table中,過程當中須要從新遍歷HashMap中的鍵值對數據,並從新定位他們在新節點數組table中的位置,涉及Hash衝突須要重構鏈表、紅黑樹,效率極低。針對第二點咱們能夠經過調整負載因子(loadFactor)和容器初始容量去減小擴容次數。通常狀況下不建議去修改loadFactor的默認值,咱們能夠在使用HashMap前預估插入鍵值對的個數,經過調整初始容量initialCapcity大小,使threadshold=initialCapcity*loadFactor大於預估節點個數,或者調整到一個較爲合理的值,防止擴容或下降插入過程當中的擴容次數。

相關文章
相關標籤/搜索