Java HashMap 裡面的 0.75 factor

偶然間, 在跟工程師朋友閒聊間, 他說面試時, 面試官問了一個 HashMap 的 0.75 factor 的用意是?

於是稍微研究一下 SDK 裡面的 HashMap.

HashMap 的一些特性

Key 的雜湊值

讀完註解的時候, 我覺得很神奇, 因為只用了 XOR 邏輯運算處理雜湊.

static final int hash(Object key) {
  int h;
  return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • Key 是否為 null: 如果 key 是 null,則直接返回 0 作為 hash 值。這是因為 HashMap 允許 null 作為 key,但只能有一個 null key,並且它的 hash 值設為 0

  • key.hashCode(): 如果 key 不是 null,則先計算 key 的標準 hashCode。這是 key 的對象所定義的 hash 值,用來作為初始的 hash 值。

  • 高位與低位 XOR 運算 (h = key.hashCode()) ^ (h >>> 16):

    • h >>> 16 是將 hash 值的高 16 位向右移,與低 16 位進行 XOR 運算。

    • XOR(異或運算)能夠將原始的 hash 值進行進一步的隨機化處理,使得 hash 值中的高位和低位信息混合,從而更加均勻地分佈 hash 值。

        # XOR 的運算
        0 xor 0 : result 0
        0 xor 1 : result 1
        1 xor 0 : result 1
        1 xor 1 : result 0
      

這樣操作的原因是為了要讓雜湊更平均的分散, 這邊的平均分散我的理解是: 在一個常態的分布空間中 (想像程式會在這個隨機空間中, 抓取某個點作為亂數種子), 如果 hash Code 的品質不好, 會讓點集中在某個區塊, 因此會不夠隨機.

假設 hashCode() 返回的 h 值如下(二進位表示):

h = 11001010 01010011 10110000 00101111

經過 h >>> 16 後

h >>> 16 = 00000000 00000000 11001010 01010011

再進行 XOR 運算:

h ^ (h >>> 16) = 11001010 01010011 01111010 01111100

為什麼這樣會比較分散, AI 是這樣解釋的

經過 h ^ (h >>> 16) 處理的 hash 值: 這個操作就像是把正方形區域對角線翻轉並疊加的過程,原本集中在一起的點會因為翻轉和疊加而被分散開來,變得更均勻。

影響 performance 的常數

An instance of HashMap has two parameters that affect its performance: initial capacity and load factor. The capacity is the number of buckets in the hash table, and the initial capacity is simply the capacity at the time the hash table is created. The load factor is a measure of how full the hash table is allowed to get before its capacity is automatically increased. When the number of entries in the hash table exceeds the product of the load factor and the current capacity, the hash table is rehashed (that is, internal data structures are rebuilt) so that the hash table has approximately twice the number of buckets.

  • 初始容量 DEFAULT_INITIAL_CAPACIT = 1 << 4, 也就是 2 的 4 次方, 16.

  • 初始負載因子 DEFAULT_LOAD_FACTOR = 0.75f, 用來衡量何時要擴充 CAPACIT.

      // jdk21, 可以用這個建構, 控制 initCap 與 loadFact
      public HashMap(int initialCapacity, float loadFactor)
    
  • 這兩個參數的設計, 是基於 Poisson distribution 離散機率分布計算, 簡單來說就是採用這個離散設計, 控制何時要擴充 CAPACIT , 讓 get()put() 的操作可以接近穩定的時間內處理完畢, 當空間越大雜湊碰撞機率就低, 但如果常常擴充, 又容易消耗計算 rehash.

    • 這邊的細節可以看 source code 的 resize()putVal()

    • 比較重要的 threadhold = loadFactor * cap , 當超過 threadhold 會做 resize()

  • 樹化臨界值 TREEIFY_THRESHOLD = 8, 當 HashMap 的容量超過 8 的時候, 會改用 treeNode 的方式操作, 在樹化之前是用 Node 的方式.

為何 LOAD_FACTOR 是 0.75 ?

假設我們有一個 HashMap,容量為 16,負載因子為 0.75。

  1. 初始情況:

    容量 = 16,負載因子 = 0.75 當元素數量達到 16 * 0.75 = 12 時 (使用了 3/4),HashMap 將擴容(rehash)。 這意味著我們可以存入最多 12 個元素,當插入第 13 個元素時,會觸發擴容操作。

  2. 添加元素過程:

    插入第 1 個到第 12 個元素時,HashMap 不需要擴容,操作時間複雜度保持在 O(1) 左右。 當插入第 13 個元素時,因為達到負載因子的限制,HashMap 會進行擴容,將容量翻倍至 32,並重新分配所有元素的位置(rehash)。

  3. 擴容後的效果:

    新的容量為 32,HashMap 可以存入的最大元素數量變為 32 * 0.75 = 24。 這樣避免了過於頻繁的擴容,也保證了較好的查找性能。

    1. 如果 loadFactor 設為 1.0 或更高?

      如果負載因子設定為 1.0,那麼在每個 bucket 中放置更多的元素,會導致更多的哈希衝突,降低查找性能(從 O(1) 變成 O(n))。 雖然節省了內存,但查找操作會變慢,特別是在大量元素的情況下。

    2. 如果 loadFactor 設為 0.5 或更低?

      設置更低的負載因子會導致更早的擴容操作,從而佔用更多的內存。這樣會提高查找性能,但代價是更大的空間浪費。

總結

0.75 是一個平衡點,是基於 Poisson distribution 離散機率分布計算,既可以保持較高的查找效率 (讓時間複雜度保持在 O(1)),又不會過度浪費內存空間。這使得 HashMap 能在大多數應用場景下表現良好。