1. 程式人生 > 實用技巧 >HashMap 常問的 9 個問題

HashMap 常問的 9 個問題

1、HashMap 的資料結構是什麼?

HashMap 我們知道 HashMap 的資料結構是陣列+連結串列,所以這個問題可以理解為陣列+連結串列有什麼優點?

  • 如果只是陣列,就存在陣列的缺點,如:需要更長的連續記憶體空間;擴容更加頻繁;並且刪除操作需要移動其他元素位置,等等
  • 如果只是連結串列,就存在連結串列的缺點,如:查詢複雜度 O(n) 太高,等等
  • 而陣列+連結串列是一個折中的方案

2、為什麼陣列的預設長度是 16?

/**
* The default ∞initial capacity - MUST be a power of two. 預設初始容量必須是 2 的冪次方。
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

這跟計算陣列下標有關,計算陣列下標的程式碼為 index = hash & (n-1),當 n 為 2 的冪,如 16,n-1 = 15,轉換為二進位制為 1111,通過按位與 & ,陣列下標就由 hash 二進位制的低 4 位決定。比起傳統的取模(餘)操作,效率更高。

,且註釋指明預設初始容量必須是 2 的冪次方,這是為什麼呢

3、為什麼HashMap 沒有直接用 Key 的 hashCode,而是生成一個新的 hash?

hash = (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

同樣跟陣列下標有關,HashMap 沒有用取模(餘)運算,而是直接使用低 4 位作為陣列下標,如果使用 hashCode 低 4 位,碰撞機率很大,將 hashCode 的低位和高位異或生成 hash,取 hash 的低 4 位作為陣列下標,可以增加低位的隨機性,減少碰撞。>>>

無符號右移h >>> 16:捨棄右邊 16 位,將高位向右移動 16 位,左邊用 0 補齊。

當 key == null 時,hash 為 0,所以 key 可以為 null。

4、擴容因子為 0.75,有什麼好處?

// As a general rule, the default load factor (.75) offers a good tradeoff between time and space costs. Higher values decrease the space overhead but increase the lookup cost (reflected in most of the operations of the HashMap class, including get and put).
// 作為一般的規則,預設擴容因子(.75)在時間和空間成本之間提供了一個很好的折中。較高的值減少了空間開銷,但增加了查詢成本(反映在 HashMap 類的大多數操作中,包括 get 和 put)。 
static final float DEFAULT_LOAD_FACTOR = 0.75f;
  • 如果是 0.5 的話,空間利用率不高,擴容太頻繁。
  • 如果是 1 的話,因為不是均勻分佈,碰撞產生連結串列,擴容後,連結串列將更長,查詢和修改的時間複雜度將更高。
  • 0.75 是一個折中的方案。
  • 注意:擴容觸發條件 0.75,不是指陣列 75% 的區域被佔用時,而是指當前 Map 的容量,包括連結串列上的元素。

5、HashMap 如何擴容?

// 虛擬碼
static final float DEFAULT_LOAD_FACTOR = 0.75f;
int threshold = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
if (++size > threshold)
    resize();

final Node<K,V>[] resize() {
    // 建立一個容量為原來的 2 倍的新陣列
    newCap = oldCap << 1;
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    for (int j = 0; j < oldCap; ++j) {
        Node<K,V> e;
        if ((e = oldTab[j]) != null) {
            // 當只有一個元素時,根據節點原來的 hash 和新的陣列長度得到新的陣列下標
            if (e.next == null)
            	newTab[e.hash & (newCap - 1)] = e
            else {
                // oldCap 初始為 16,即 10000,最高位始終為 1,通過 1 來隨機判斷使用原陣列下標還是加上原陣列長度的新陣列下標
                if ((e.hash & oldCap) == 0)
                    loHead = e
            	else
                    hiHead = e
            	if (loTail != null) {
                    newTab[j] = loHead;
                }
                if (hiTail != null) {
                    newTab[j + oldCap] = hiHead;
                }    
            }
        }
    }
}
  • 首先,HashMap 需要擴容,否則,連結串列長度過長,查詢和修改的複雜度都將變高。
  • 擴容時,建立一個容量為原來的 2 倍的新陣列,遍歷原陣列,如果當前位置只有一個元素,則根據節點原來的 hash 和新的陣列長度得到新的陣列下標,如果是一個連結串列,則通過 oldCap 來隨機判斷使用原陣列下標還是加上原陣列長度的新陣列下標
  • 因為需要擴容,需要額外的效能,在能估算容量的情況下,可以直接設定初始容量。
public HashMap(int initialCapacity) {}

6、HashMap 為什麼執行緒不安全?

 /* <p><strong>Note that this implementation is not synchronized.</strong>
  • HashMap 不是執行緒安全的,它的執行緒安全版本是 HashTable 和 ConcurrentHashMap
  • 它的執行緒不安全是因為 HashMap 存在變數,如 DEFAULT_INITIAL_CAPACITY 等,物件在方法中使用這些共享變數時,沒有加鎖。共享變數在併發操作,值容易被覆蓋,存在丟失資料的問題。
  • 在 jdk 1.7 中,併發操作下,擴容時,還可能造成死迴圈,Java HashMap.get(Object) infinite loop

9、為什麼如果物件作為 HashMap 的 Key,物件需要重寫 hashCode 和 equals 方法?

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

因為 HashMap 通過 Key 物件的 hashCode 計算 hash,還通過 equals 方法比較物件是否相同,如果不重寫,相同內容的兩個物件,其 hashCode 將不同,也不會相等。

String 如何重寫:

  • hashCode() - 如果字串已經存在,那麼 hash 不變;如果不存在,通過每個字元的 ASSCII 碼計算
public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;

        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}
  • equals() - 依次比較每個字元是否相同
public boolean equals(Object anObject) {
    if (this == anObject) {
        return true;
    }
    if (anObject instanceof String) {
        String anotherString = (String)anObject;
        int n = value.length;
        if (n == anotherString.value.length) {
            char v1[] = value;
            char v2[] = anotherString.value;
            int i = 0;
            while (n-- != 0) {
                if (v1[i] != v2[i])
                    return false;
                i++;
            }
            return true;
        }
    }
    return false;
}

8、get() 和 containsKey() 的時間複雜度是多少?

// This implementation provides constant-time performance for the basic operations (get and put)
// 這個實現為基本操作(get 和 put)提供了常量時間效能,假設雜湊函式將元素正確地分散在儲存桶中。

時間複雜度為 O(1),因為連結串列的長度不會過長,基本不會達到 8 個

9、當連結串列長度大於 8 個時,將連結串列轉換為紅黑樹,有什麼好處?

  • 連結串列查詢的時間複雜度時 O(n)
  • 紅黑樹查詢的時間複雜度時 O(logN)
  • 所以好處是查詢和修改的時間複雜度更低

參考