1. 程式人生 > >Java集合高階(一)HashMap

Java集合高階(一)HashMap

目錄

  • HashMap容器簡介
  • HashMap原始碼及資料結構深入分析
  • 注意問題及效能優化

HashMap容器簡介         HashMap以K/V形式來儲存資料,基於雜湊表結構,本質上是一個數組+連結串列的結構,提供了高效率的新增和檢索。影響HashMap效能的主要有兩個因素,一個是桶的數量,另外一個就是載入因子,桶數*載入因子就是HashMap擴容的臨界值。如果擴容臨界值設定過小,實際儲存資料又過多,擴容次數就會很頻繁,從時間成本上就會影響效能。相反如果臨界值設定過大,get和迭代操作效能就會降低,這是由空間成本引起的。 與Hashtable相比,HashMap允許使用 null 值和 null 鍵,除了非同步和允許使用 null 之外,它與 Hashtable 大致相同,此外HashMap的迭代器也是快速失敗的,因為迭代過程中不會檢測對集體結構上的修改(比如put一個新k/v,或remove已有值,但不包括替換),從而會出併發修改異常HashMap原始碼及資料結構分析(版本JDK1.8.0_131)

初始化:基本狀態及建構函式部分原始碼

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16,預設容量
   
    static final int MAXIMUM_CAPACITY = 1 << 30;        // 最大容量

    static final float DEFAULT_LOAD_FACTOR = 0.75f;     //預設載入因子

    transient Node<K,V>[] table;                        //Map.Entry陣列(桶陣列) 

    transient int modCount;                             //結構被修改的次數

    int threshold;                                      //下次擴容臨界值
  
    final float loadFactor;                             //載入因子(不允許修改)

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);    //首次擴容臨界值=大於給定cap的第一個滿足2的N次冪的值(如15則首次擴容為臨界值為2的4次方=16)
    }
   
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

從原始碼可以看出,在JDK1.8中HashMap預設的初始容量仍然是16,載入因子是0.75,擴容臨界值threshold仍然等於【桶數*載入因子 =(int)8*0.75 = 6】雖然在1.8的構造器中把threshold設定成了取大於capacity的第一個等於2的N次冪【8】,但第一次put後又會把threshold變成桶數*載入因子,所以目前來看構造器中的設定並沒有意義)。此外在JDK1.8中,new HashMap<>()時不會建立Node<K,V>[] table桶陣列,而是在第一次put()的時候去建立,也就是說首次擴容操作(resize())發生在put()時候

put()/get()核心原始碼

  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;    //構造時沒有建立table,所以首次擴容發生在第一次put。
        if ((p = tab[i = (n - 1) & hash]) == null)    //校驗table[i]位置是否被佔用,對於key==null的鍵,其hash==0,永遠處於第一個元素)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k)))) 
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                for (int binCount = 0; ; ++binCount) { //迭代,如果在迭代過程中發現相同Node,則記錄該Node。否則把新的Node新增到連結末尾
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);    //連結串列長度達到8個即轉為紅黑樹結構
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key  (如果存在相同Node,則進行值的替換)
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();                //重調Map
        afterNodeInsertion(evict);
        return null;
    }
    
    /**
     * 重調map
     */
    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) {            
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&    //newCap調整為oldCap的兩倍
                     oldCap >= DEFAULT_INITIAL_CAPACITY)     
                newThr = oldThr << 1; // double threshold    //大於=16時,調整為oldThr兩倍,其實等價於newCap * 載入因子,比如newThr(8*0.75) = oldThr(4*0.75)*2 = 3 * 2 
        }
        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);
        }
        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]; //建立新Node陣列
        table = newTab;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {        //複製元素,並刪除舊Node陣列元素
                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  保證原連結串列順序,由於擴容後table長度會發生變化,所以需要重新計算每個Entry<K,V>的儲存位置, 這裡拆分Entry鏈。
                        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內部維護了一個儲存資料的Entry陣列(並保證該陣列不管是初始化還是擴容後其大小永遠是2^{n}),Entry本質上是一個單向連結串列,HashMap採用該連結串列來解決key衝突的情況key為null的鍵值對永遠都放在以table[0]為頭結點的連結串列中。當put一個key-value對時,首先通過hash(key)計算得到key的hash值,然後結合陣列長度通過計算(table.length - 1) & hash得到儲存位置table[n](2^{n}-1使得所有二進位制位都為1),如果table[n]位置未被佔用則建立一個Entry並插入到位置n;否則產生碰撞,迭代Entry鏈依次比較新Key與每個Entry.K,如果key.equals(Entry.K)==true則替換舊的value,否則建立新Entry並插入到連結串列的末尾(1.7中在頭部),預設情況下當HashMap大小達到容量的75%時,會執行擴容操作建立一個新的Entry陣列,長度是原來的2倍,在1.8中當某個Entry鏈長度>= 8 時,還會把該Entry鏈轉換為紅黑樹結構。在擴容過程中由於Node<K,V> table陣列容量發生的變化,所以需要重新計算所有元素的儲存位置,如有必要拆分Entry鏈。下面程式碼雖然簡單但卻非常全面的測試了map初始化、碰撞及擴容過程

public static void main(String[] args) throws Exception { 
		HashMap<Integer, Object> map = new HashMap<>(1);      
		map.put(1, 1);			
		map.put(3, 3);			
	}

從原始碼角度分析程式碼:(1) new HashMap<>(1):最終結果capacity == 1、threshold == 2的0次 == 1、Node<K,V>[] table == null、size == 0 (2) map.put(1,1);這裡執行了兩次resize()操作。

  •         Entry[] table==null時執行第一次resize(),結果capacity == 1、threshold == (int)1*0.75 == 0、table = new Node<K,V>[1])
  •         put(1,1)之後,由於++size(++0) > threshold(0),需要擴容,所以此時執行第二次resize()操作,擴容結果:capacity = 1*2、 threshold = (int)2*0.75 = 1、table = new Node<K,V>[2]所以Entry<1,1>位於(tab.length - 1 & 1) == 1 & 1 == tab[1] 

(3) map.put(3,3) 同樣由於(tab.length - 1 & 3) == 1 & 3 == 1, 所以Entry<3,3>同樣應處於tab[1]位於並且作為Entry<1,1>的next元素而存在。但此時由於++size(++1)又大於了threshold(1),將再次執行resize(),最終capacity == 4、threshold==-3,由於Entry[] table的容量發生了變化,所以此時需要迭代每個Entry鏈,對Entry鏈中的每個元素進行重新計算,得出新的儲存位置。所以這裡tab[1]處的Entry鏈(Entry<1,1>-->Entry<3,3>)將會被拆分,因為(tab.length  - 1) & 3 = 3 & 3 = 3,不再是1,所以最終會把Entry<3,3>儲存在tab[3]。而Entry<1,1>仍然處於tab[1]處。最終結果及對應變化可以用下圖表示資料結構 上方的原始碼其實已經說明了HashMap底層的資料結構,圖解的話大致如下注意問題及效能優化      HashMap並不是執行緒安全的,如果需要執行緒安全的話可以考慮通過Collections.synchronizeMap(hmap)來包裝,但這種方式本質上和HashTable沒什麼區別,都是以map本身為鎖物件,效率並不高。併發情況下應該使用ConcurrentHashMap<K,V>。      HashMap擴容時,由於Node<K,V>[] table長度發生變化,所以會重新計算每個元素的位置,在這個過程中可能會拆分Entry 鏈。另外隨著table陣列的增長,每次擴容的時間和空間成本都會增加。所以初始化時應儘可能設定合理的大小,如果計劃儲存20個元素,在載入因子不變的情況下,容量設為 2^{5}=32最為合理(20 / 0.75 = 26)。      不考慮衝突的情況下,HashMap的複雜度為O(1),不管資料量多大,一次計算就可以找到目標;當然實際應用中隨著Key的增加,衝突的可能性越大, 如果請求大量key不同,但是hashCode相同的資料甚至可以造成Hash攻擊,讓HashMap不斷髮生碰撞,硬生生的變成一個單鏈表,這樣put/get效能就從O(1)變成了O(N)。從這點出發應儘量減少碰撞的機率,使用比較高率的hashCode,比如可以採用Integer、String等final變數作為Key,這種型別的hash值產生衝突的可能性很少,比如1的hashCode就是1,2的就是2。   另外一種常見的問題就是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) {
                src[j] = null;
                do {
                    Entry<K,V> next = e.next;    //若執行緒A執行此行被掛起,執行緒B整個更新連結串列。執行緒A繼續執行,則很可能產生死迴圈或者put丟失。
                    int i = indexFor(e.hash, newCapacity);
                    e.next = newTable[i];
                    newTable[i] = e;
                    e = next;
                } while (e != null);
            }
        }
    }

死迴圈問題在1.8中已經被解決,1.8中在擴容時聲明瞭兩對指標,維護兩個連結串列,依次在末端新增新的元素,在多執行緒操作的情況下,不會出現交叉的情況,頂多也就是每個執行緒重複同樣操作。

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

    HashMap迭代過程中不會檢測對map結構的修改,所以併發訪問情況下(或單執行緒下在迭代中修改)很容易丟擲