Java集合——深入理解HashMap

SachinLea發表於2019-03-07

1. HashMap概述

  HashMap 是一個儲存鍵值對的集合類,其中的元素是無序的,且沒有重複的 key 值;有點類似數學中的函式,x 對應一個 y 值。Java API 中對 HashMap 描述如下:

HashMap是基於雜湊表的Map介面實現。此實現提供所有可選的對映操作,並允許空值和空鍵。 (HashMap類大致相當於Hashtable,除了它是不同步的並且允許空值。)這個類不保證Map的順序;特別是,它不保證順序會隨著時間的推移保持不變。

  HashMap 底層是雜湊表,元素是無序的,允許 key 和 value 為 null 的情況。

假設雜湊函式在桶之間正確地分散元素,該實現為基本操作(get和put)提供了恆定時間效能。對集合檢視的迭代需要與HashMap例項的“容量”(桶的數量)加上其大小(鍵 - 值對映的數量)成比例的時間。因此,如果迭代效能很重要,則不要將初始容量設定得太高(或負載因子太低)非常重要。
HashMap的一個例項有兩個影響其效能的引數:初始容量和負載因子。容量是雜湊表中的桶數,初始容量只是建立雜湊表時的容量。載入因子是在自動增加容量之前允許雜湊表獲取的完整程度的度量。當雜湊表中的條目數超過載入因子和當前容量的乘積時,雜湊表將被重新雜湊(即,重建內部資料結構),以便雜湊表具有大約兩倍的桶數。

  HashMap 的初始容量和負載因子會影響其效能,設定的太大迭代會花費跟多的時間,設定的太小,又可能會頻繁擴容;當元素個數超過當前容量*負載因子時,需要擴容,大約為原來的兩倍。

作為一般規則,預設載入因子(0.75)在時間和空間成本之間提供了良好的權衡。較高的值會減少空間開銷,但會增加查詢成本(反映在HashMap類的大多數操作中,包括get和put)。在設定其初始容量時,應考慮對映中的預期條目數及其載入因子,以便最小化重新雜湊操作的數量。如果初始容量大於最大條目數除以載入因子,則不會發生重新載入操作。

  負載因子預設情況下是0.75,太大能減少空間開銷,但是會增加查詢成本。

如果要將多個對映儲存在HashMap例項中,則使用足夠大的容量建立對映將允許對映更有效地儲存,而不是根據需要執行自動重新雜湊來擴充套件表。請注意,使用具有相同hashCode()的許多鍵是減慢任何雜湊表效能的可靠方法。為了改善影響,當鍵是Comparable時,此類可以使用鍵之間的比較順序來幫助打破關係。

  在知道容量的情況下,儘量初始化時設定足夠的容量,避免擴容影響效率。
  另外,HashMap 不是執行緒安全的,多執行緒使用時需要注意;同時,迭代器也是fail-fast的,也就是使用迭代器迭代過程中,不能對其進行修改,否則直接丟擲異常。

2. 成員變數

靜態成員變數:

// 預設容量 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

/**
 * 最大容量 2^30
 */
static final int MAXIMUM_CAPACITY = 1 << 30;

/**
 * 負載因子
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

/**
 * 桶 使用樹的閾值,節點超過8個時,有連結串列改為樹結構
 */
static final int TREEIFY_THRESHOLD = 8;

/**
 * 桶 恢復為連結串列的閾值
 */
static final int UNTREEIFY_THRESHOLD = 6;

/**
 * 容器轉化為樹的閾值,超過該容量,將桶轉化為樹,否則繼續擴容
 * 至少為 4 * TREEIFY_THRESHOLD,避免擴容和樹形結構化之間的衝突
 */
static final int MIN_TREEIFY_CAPACITY = 64;
複製程式碼

成員變數:

// 連結串列陣列
transient Node<K,V>[] table;

// 快取的鍵值對集合
transient Set<Map.Entry<K,V>> entrySet;

// 鍵值對元素個數
transient int size;

// 集合修改次數
transient int modCount;

// 擴容容量,capacity * load factor
int threshold;

// 載入因子
final float loadFactor;
複製程式碼

3. 構造方法

// 傳入初始化容量,載入因子
public HashMap(int initialCapacity, float loadFactor) {
    // 如果初始化容量 < 0 ,丟擲異常
    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);
}
複製程式碼

  上邊的構造方法中,傳入的初始化容量,會使用tableSizeFor方法處理,將初始化容量設定為2的冪,為了後邊hash時,均勻分佈。

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;
}
複製程式碼

  上邊的方法,通過移位運算,通過不斷的無符號右移和原值進行或運算,將二進位制從最高位開始,每一位都置為1,也就是2^N^-1,所以,最後需要再加一,變為2^N^。上邊的過程也很好理解,例如:傳入的是9,n=8,轉為二進位制,000 000 0000 .... 1000,只看後4位:

  • 第一次操作之後變為 1100,
  • 然後,右移兩位0011,與1100進行或運算,變為1111;

  後邊兩步此時計算了也不會改變,因此最後結果是16;為什麼最後只移動到16位呢?因為我們知道int是32位,即時n=2^31^,經過這幾步也會變為 2^32^-1;

其他構造方法:

// 只傳入初始化容量
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

// 無參構造器,均使用預設值
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

// 初始化傳入一個Map
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}
複製程式碼

4. 主要方法

4.1 新增元素 put(K key, V value)

  HashMap中最核心的就是新增元素方法,涉及到了擴容,Java8 中還有資料介面轉換。

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
複製程式碼

  新增元素,首先對 key 進行 hash 操作,key 在陣列中的索引為(n - 1) & hash(key)

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

  上邊的方法中,首先獲取 key 的 hashCode 值,然後,高16位和低16位進行異或操作,得到hash值,最後計算索引時,又使用(n-1) & hash;而我們知道 n (HashMap容量)是2的N次方,相當於和長度取模操作,防止索引超過了容量。
  hash計算中,為什麼要使用高16位和低16位異或呢?因為元素的 hashCode 低位很多都是相同的,這樣在和容量進行取模運算時,可能造成同一個索引元素過多,發生碰撞。因此,使用高位進行異或運算之後,再取模,儘可能使元素均勻分佈。
  回到 put 方法,其中主要呼叫了 putVal 方法:

/*
* onlyIfAbsent 如果為true, 新增的key如果存在,不改變原來的值
*/
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 為null,使用 resize()建立一個 雜湊表
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    
    // 如果新增到陣列中的索引處,節點為null, 直接在該索引位置建立一個新的節點
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
        
    else {
        Node<K,V> e; K k;
        // 如果索引處,桶的第一個節點 key 和插入節點key相同,獲取該節點;後邊判斷是否需要替換
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        
        // 如果,第一個節點不匹配,並且已經是一個樹形結構,新增樹節點
        else if (p instanceof TreeNode)
        	// <2> 新增樹節點
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
        	// 如果是連結串列結構,遍歷連結串列
            for (int binCount = 0; ; ++binCount) {
            	// 如果遍歷到最後,都沒有key相同的,在連結串列末尾新增節點
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    
                    // <3> 如果超過了樹形結構閾值,轉換為紅黑樹
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 如果找到key匹配節點,獲取節點,用於後邊判斷是否替換
                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;

			// onlyIfAbsent 為false,即允許替換;或者舊值為null,替換節點處的值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
   	// 如果超過了容量,擴容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}
複製程式碼

  上邊程式碼展示了HashMap新增元素的基本過程:

  1. 首先,如果雜湊表為空,需要建立儲存元素的雜湊表;
  2. 然後,計算key對應的索引,如果雜湊表中該索引位置還未新增元素,直接在雜湊表中新增一個節點;
  3. 如果,該索引位置已經有值,需要判斷該節點的key是否和插入的key相同,如果相同獲取節點;
  4. 如果不同,且桶仍是連結串列結構,遍歷連結串列,找到key相同的節點,如果沒有找到,就在連結串列末尾新增元素;
  5. 如果該處桶已經是紅黑樹結構,想紅黑樹中新增元素。

流程圖大致如下:

在這裡插入圖片描述
(圖片來源:Java 8系列之重新認識HashMap)

4.2 擴容操作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;
    // 如果擴容前容量大於0
    if (oldCap > 0) {
    	// 超過了最大容量,直接返回
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 如果擴容前容量,超過預設容量16;新容量為原來的2倍;
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // 如果擴容前容量大於0,
    // 並且擴容容量threshold > 0(初始化時,為初始化傳入的容量計算和的值)
    else if (oldThr > 0) // initial capacity was placed in threshold
        // 新容量等於 擴容容量,即初始化容量
        newCap = oldThr;
    
    // 如果初始化時,沒有傳入容量,設定為預設容量16
    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的值
    threshold = newThr;

	// 新建一個長度為newCap的Node陣列
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    
    // table 指向新陣列
    table = newTab;

	// 如果原雜湊表不為空,將原來的資料複製過來
    if (oldTab != null) {
    	// 遍歷原雜湊表
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            // 如果當前索引位置有節點,需要將節點新增到新雜湊表中
            // 首先,獲取索引出節點
            if ((e = oldTab[j]) != null) {
            	// 將原雜湊表中節點設定為null,方便GC
                oldTab[j] = null;
                // 如果原雜湊表中該位置,只有一個節點,直接將該節點重新rehash之後,插入新雜湊表
                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
                    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;
}
複製程式碼

  上邊擴容的過程,相比 JDK1.7 變化很大, 首先引入了紅黑樹,擴容時,需要處理紅黑樹;對於連結串列的處理也不一樣了,但是,只是處理方式不同,最終結果還是相同的。JDK 1.7 中resize() 方法中,計算節點新的索引位置是通過 has & (newCapacity-1) 的方式來計算的,這和我們最開始新增元素時一樣,很好理解。
  在上邊說過,每次擴容時擴容原來容量的兩倍,因此上邊操作過程,可以展示如下圖:

在這裡插入圖片描述
  上圖展示了容量從16擴容為32的 rehash 過程,索引位置從5變為5或者21(5+16);從圖中也可以看出來,之和最高一位有關,如果和最高一位與運算結果為0,那麼還是原來位置,如果為1,就是原來的索引加上擴容的長度,即原長度;因此,上邊的Java8 中的程式碼直接使用(e.hash & oldCap) 運算,判斷索引是在原來位置,還是需要移動原來的長度。另外上邊的程式碼中沒有打亂連結串列的順序,避免了原來多執行緒下出現死迴圈的問題;但是HashMap 仍是執行緒不安全的,多執行緒下建議使用ConcurrentHashMap。

4.3 獲取元素

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

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;

	// 如果,雜湊表table為空,直接返回null;如果key對應的索引處為空,也直接返回null
    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;
}
複製程式碼

  上邊的get(Key key) 方法,主要思路,在雜湊表不為空的前提下,首先對 key 進行 hash 操作,然後根據 hash 值獲取對應雜湊表的索引,該過程和 put 方法中相同;找到雜湊表中的索引後,先看第一個節點是否匹配,不匹配的話就開始遍歷連結串列,需要注意連結串列已經轉換為樹的情況。

4.4 判斷元素是否存在

判斷key是否存在:

public boolean containsKey(Object key) {
    return getNode(hash(key), key) != null;
}
複製程式碼

  判斷key是否存在,比較方便,直接通過key查詢,如果查到元素就說明key是存在的。
判斷value 是否存在:

public boolean containsValue(Object value) {
    Node<K,V>[] tab; V v;
    if ((tab = table) != null && size > 0) {
        for (int i = 0; i < tab.length; ++i) {
            for (Node<K,V> e = tab[i]; e != null; e = e.next) {
                if ((v = e.value) == value ||
                    (value != null && value.equals(v)))
                    return true;
            }
        }
    }
    return false;
}
複製程式碼

  判斷是否包含 value 需要依次遍歷雜湊表的各個節點比對,相對而言比較麻煩,不過一般也很少使用。

4.5 遍歷HashMap

  之前遍歷HashMap我們都是使用EntrySet,Java8 中新增了一個foreach方法,可以直接使用該方法遍歷HashMap。

public void forEach(BiConsumer<? super K, ? super V> action) {
    Node<K,V>[] tab;
    if (action == null)
        throw new NullPointerException();
    if (size > 0 && (tab = table) != null) {
        int mc = modCount;
        for (int i = 0; i < tab.length; ++i) {
            for (Node<K,V> e = tab[i]; e != null; e = e.next)
                action.accept(e.key, e.value);
        }
        // 遍歷過程中修改,會丟擲異常
        if (modCount != mc)
            throw new ConcurrentModificationException();
    }
}
複製程式碼

使用示例:

public static void main(String[] args) {
    HashMap<String, Object> hashMap = createHashMap();
    hashMap.forEach((key, value) -> {
        System.out.println(key+":"+value);
    });
}
複製程式碼

總結

  HashMap 是一個存放鍵值對的集合,其中的元素是無序的,允許key 和 value 為null的情況,但是不存在重複的 key。HashMap 的容量都是2的次冪,為了是元素更加均勻的分佈,另外,每次擴容時都是擴容為原來的2倍。Java8 中引入了紅黑樹資料結構,優化了 HashMap 的效率,解決了多執行緒擴容可能出現環的問題,但是 HashMap 仍然是執行緒不安全的,需要保證執行緒安全的情況下建議使用ConcurrentHashMap。

  HashMap 細節還有很多,暫時整理到這裡,文中如有錯誤請大家指正。

參考:

美團技術網:Java 8系列之重新認識HashMap

相關文章