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)
  • 所以好處是查找和修改的時間複雜度更低

參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章