Java-HashMap中put原始碼解讀

羊37發表於2024-06-21

1.背景

Map型別 優點 缺點 執行緒安全性
HashMap 1. 查詢、插入、刪除操作的時間複雜度為O(1)。
2. 允許鍵和值為null。
1. 無序,不保證迭代順序。
2. 不是執行緒安全的。
×
LinkedHashMap 1. 保留插入順序或訪問順序。
2. 與HashMap效能相似。
1. 記憶體開銷較高,因為維護了一個雙向連結串列。
2. 不是執行緒安全的。
×
TreeMap 1. 保持鍵的自然順序或指定的排序順序。
2. 支援範圍查詢。
1. 查詢、插入、刪除操作的時間複雜度為O(log n)。
2. 記憶體開銷較高。
×
Hashtable 1. 執行緒安全,方法是同步的。
2. 不允許鍵和值為null。
1. 效能較低,因為所有方法都是同步的。
2. 無序。
ConcurrentHashMap 1. 執行緒安全,使用分段鎖提高併發性。
2. 允許併發讀取。
1. 記憶體開銷較高,因為使用了分段鎖。
2. 複雜度較高。
WeakHashMap 1. 使用弱引用鍵,當鍵不再被使用時,自動垃圾回收。 1. 效能較低,尤其在垃圾回收時。
2. 無序。
×
IdentityHashMap 1. 使用==而不是equals來比較鍵。
2. 記憶體開銷較小。
1. 使用場景有限。
2. 無序。
×

2.HashMap

2.1 屬性值

欄位名稱 預設值 描述
DEFAULT_INITIAL_CAPACITY 16 預設初始容量,必須是2的冪。
MAXIMUM_CAPACITY 1 << 30 最大容量,如果透過建構函式指定了更高的值,將使用此值,必須是2的冪且不大於2^30。
DEFAULT_LOAD_FACTOR 0.75f 當建構函式中未指定時使用的負載因子。
TREEIFY_THRESHOLD 8 使用樹而不是列表作為bin的閾值,當bin中的節點數量達到此數量時轉換為樹。
UNTREEIFY_THRESHOLD 6 在調整大小操作期間解樹化(從樹轉換為列表)的閾值,應該小於TREEIFY_THRESHOLD。
MIN_TREEIFY_CAPACITY 64 可以進行樹化的最小表容量,如果一個bin中的節點數量太多而表容量較小時,表會被調整大小。

2.2 hash值

2.2.1 雜湊值計算

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

這段程式碼計算出鍵的雜湊值,它透過對鍵的hashCode值和它右移16位之後的值進行按位異或運算來生成最終的雜湊值。

2.2.2 陣列位置計算

在雜湊表中,陣列位置透過以下公式計算:

int index = (n - 1) & hash;

其中n是雜湊表的容量(陣列長度)。

為啥取模是(n-1)&hash看我這篇文章:Java-取模操作中的&和(length-1)

2.2.3 逆推衝突鍵

比如我的key是yang,hash結果是3701497,最後(n - 1) & hash放到9號位置。

int index = (n - 1) & hash;

對於容量為16的雜湊表,n - 1等於15(即二進位制的0000 1111)。

所以,我們需要找到一個hash2,使得:

15 & hash2 == 9

給出一個demo程式碼。

package cn.yang37.map;

/**
 * @description:
 * @class: HashMapDebug
 * @author: yang37z@qq.com
 * @date: 2024/6/20 22:56
 * @version: 1.0
 */
public class HashMapDebug {

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

    public static void main(String[] args) {
        int n = 16;
        // 目標位置
        int targetIndex = 9;
        // 要找到的鍵的數量
        int numberOfKeysToFind = 10;

        // 驗證已知key "yang"
        String knownKey = "yang";
        int knownHash = hash(knownKey);
        System.out.println(String.format("[0] Hash-'%s': %d", knownKey, knownHash));
        System.out.println(String.format("[0] Index-'%s': %d", knownKey, (n - 1) & knownHash));
        System.out.println();

        // 找到n個可能的key
        int count = 0;
        for (int i = 0; count < numberOfKeysToFind; i++) {
            String newKey = "test" + i;
            int newHash = hash(newKey);
            int nowIndex = (n - 1) & newHash;
            if (nowIndex == targetIndex) {
                count++;
                System.out.println(String.format("[%d] Found key: %s", count, newKey));
                System.out.println(String.format("[%d] Hash-'%s': %d", count, newKey, newHash));
                System.out.println(String.format("[%d] Index-'%s': %d", count, newKey, nowIndex));
                System.out.println();
            }
        }
    }

}

輸出結果:

[0] Hash-'yang': 3701497
[0] Index-'yang': 9

[1] Found key: test40
[1] Hash-'test40': -877157063
[1] Index-'test40': 9

[2] Found key: test51
[2] Hash-'test51': -877157095
[2] Index-'test51': 9

[3] Found key: test62
[3] Hash-'test62': -877156999
[3] Index-'test62': 9

[4] Found key: test73
[4] Hash-'test73': -877157031
[4] Index-'test73': 9

[5] Found key: test84
[5] Hash-'test84': -877157191
[5] Index-'test84': 9

[6] Found key: test95
[6] Hash-'test95': -877157223
[6] Index-'test95': 9

[7] Found key: test100
[7] Hash-'test100': -1422462167
[7] Index-'test100': 9

[8] Found key: test111
[8] Hash-'test111': -1422462199
[8] Index-'test111': 9

[9] Found key: test122
[9] Hash-'test122': -1422462103
[9] Index-'test122': 9

[10] Found key: test133
[10] Hash-'test133': -1422462135
[10] Index-'test133': 9

2.3 內部類

2.4 put方法

首先,put方法呼叫的是過載的put方法。

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

現在,我們來分析下具體的put方法。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) 
引數名稱 型別 描述
hash int 鍵的雜湊值。通常由鍵的hashCode方法生成,然後再經過某種擾動函式處理,以減少雜湊衝突。
key K 要插入的鍵。
value V 要插入的值。
onlyIfAbsent boolean 如果為true,則只有在當前對映中沒有該鍵的對映時才插入該值。如果為false,則總是插入(覆蓋現有的值)。
evict boolean 該引數用於標識在建立模式下是否可以刪除(逐出)條目。該引數在某些內部操作(如調整大小或轉換為樹節點時)可能會有不同的處理方式。通常,使用者不需要關心此引數。

這裡我們需要關注的是,onlyIfAbsent預設是false,為true的時候,則是存在才會插入。

像我們的putIfAbsent就是傳遞的true。

hashMap.putIfAbsent( k, v)

image-20240620212259623

下面,我們逐步分析put方法,以下方程式碼為例。

public class Hash {

    public static void main(String[] args) {

        HashMap<String, Object> hashMap = new HashMap<>();
        hashMap.put("yang", "123");

        System.out.println(hashMap.size());
    }
}

首先,剛進來,我們就會有疑問,tab、p、n、i都是啥?

image-20240620212627808

Node<K,V>[] tab; Node<K,V> p; int n, i;

tab:型別為Node<K,V>[],表示雜湊表(陣列),儲存的是HashMap的桶陣列。

p:型別為Node<K,V>,表示一個節點。這個就是具體的元素唄,咱們的key、value組成一個Node節點。

n:型別為int,表示雜湊表的長度,tab.length就是咱們桶的長度。

i:型別為int,表示計算得到的鍵值對應該插入的雜湊表索引位置。

那我們先來看第一個if。

2.3.1 初始化

我們先來看最開始的這個if。

image-20240620222906958

這裡的tab就是我們hash的陣列桶。

image-20240620213822212

然後呢,table是成員變數。

image-20240620213909972

原始碼就這樣,給你搞各種簡寫,煩死了,那其實就是這樣嘛。

// 直接把成員變數table扔給tab,最開始為null。
tab = table;
// tab的長度
n = tab.length;
if ( tab == null || n == 0)
    tab = resize()
    n = tab.length;

第一次進來,tab用的是table這個成員變數的值,為null。

所以,肯定會走到if裡面去,執行resize方法擴容,然後更新n為長度。

resize方法看一下,它有兩個用處。

  • 真的是進行擴容
  • 第一次的時候,初始化我們的陣列。

image-20240620221150438

所以在resize的時候,前面的邏輯大概就是。

把咱們的陣列桶(16)、擴容閾值(12)都擴容成2倍。

後面的是移動元素的,咱們先不看。

2.3.2 首次放置

所以呢,剛進來的時候,這裡就是搞了個16長度的Node陣列給我們,然後賦值了幾下,順便記錄了下擴容的閾值是12。

image-20240620223302809

回到這個p的判斷,p是什麼,p就是咱們一個Node元素。

 if ((p = tab[i = (n - 1) & hash]) == null)

翻譯一下,為啥取模是(n-1)&hash看我這篇文章:Java-取模操作中的&和(length-1)

// 這是在取模
i = (n - 1) & hash
// 就是看這個節點上有元素沒
p = tab[i]
p==null?

image-20240620223352333

那現在剛進來,肯定就是沒元素唄,所以tab[9]這個元素是空的,咱們就要建立個Node節點放上去,執行newNode()。

那tab[i]就是咱們這個陣列桶上的玩意唄,它為null,說明裡面沒東西還,直接扔節點進去就完事,你看next固定傳null。

image-20240620223522135

所以,tab[i]上沒元素的時候很簡單,放一個Node節點進來就完事。

image-20240620223807002

所以,當最開始的時候,咱們的邏輯很簡單,初始化了下,往tab[9]扔了個Node元素。

image-20240620224505397

image-20240620224330863

這個很簡單對吧?關鍵是有元素的時候咋整。

2.3.2 雜湊衝突

1.陣列轉連結串列

在2.2.3節中,我們已經知道,下面這些都是咱們的衝突鍵,它們都會落到9號位。

test40
test51
test62
test73
test84
test95
test100
test111
test122
test133

不妨增加下put。

        HashMap<String, Object> hashMap = new HashMap<>();
        hashMap.put("yang", "123");
        hashMap.put("test40", "123");

現在我們重新debug下,看第二次的。

image-20240620231054089

image-20240620231347319

image-20240620231438243

然後就來到了一個for迴圈。

這段程式碼中,e是咱們當前的元素,p是陣列上的元素,最上面那個節點嘛。

image-20240620231913818

image-20240620232835925

所以呢,這個大的if,就是在放置元素,有的話跳出。

image-20240620233157735

Node既然放進去了,最後就是值的填充,跟我們前面的ifAbsent標記有關,來看最後的if。

image-20240620233452667

所以呢,經過上面的操作,咱們的hashMap就變成了這個樣子。

image-20240620233727208

image-20240620233603703

然後就是追加值進去嘛。

image-20240620233917810

image-20240620233859219

2.連結串列轉紅黑樹

在上面的例子中,正常情況下都不會觸發樹化,直到有一次。

image-20240620234406521

這個時候呢,咱們的連結串列上是有9個元素的哦,所以說,咱們的樹化,是先把連結串列構建出來,再觸發樹化。

image-20240620234747734

image-20240620235122096

接著,就進入到咱們的treeifyBin(tab, hash);方法中。

你看,這裡把咱們的tab傳進來了,tab是啥,是整個hash表哈,頭上這個,整個tab都傳進來了(現在tab的9號位上有個連結串列)。

image-20240620235625404

哎關鍵點來了,沒有觸發樹化啊?

HashMap中,treeifyBin方法用於將連結串列轉換為樹結構(即紅黑樹)以提高查詢和插入效能。

但在進行這個轉換之前,有一個檢查條件是雜湊表的容量(陣列長度)是否小於一個特定的最小值MIN_TREEIFY_CAPACITY

  • MIN_TREEIFY_CAPACITY64。這是一個經驗值,用於避免在雜湊表還比較小的時候進行樹化操作。

  • 如果雜湊表的容量小於64,即使連結串列的節點數量超過了 TREEIFY_THRESHOLD(8),HashMap 也不會立即將連結串列轉換為紅黑樹,而是優先進行擴容操作。

  • 這樣做的目的是在較小的雜湊表中透過擴容來減少雜湊衝突,從而避免過早地進行復雜的樹化操作。

有辦法嗎?當然,根據我們的擴容規律。

12的時候會擴容到16*2=32。

24的時候會擴容到32*2=64。

所以,我們把key值拉到24個,此時陣列長度是64,不滿足小於條件。

package cn.yang37.map;

import java.util.HashMap;

/**
 * @description:
 * @class: HashCode
 * @author: yang37z@qq.com
 * @date: 2023/6/17 16:05
 * @version: 1.0
 */
public class Hash {

    public static void main(String[] args) {
        HashMap<String, Object> hashMap = new HashMap<>();
        hashMap.put("yang", "123");
        hashMap.put("test40", "123");
        hashMap.put("test51", "123");
        hashMap.put("test62", "123");
        hashMap.put("test73", "123");
        hashMap.put("test84", "123");
        hashMap.put("test95", "123");
        hashMap.put("test100", "123");
        hashMap.put("test111", "123");
        hashMap.put("test122", "123");
        hashMap.put("test133", "123");
        hashMap.put("test144", "123");
        hashMap.put("test155", "123");
        hashMap.put("test166", "123");
        hashMap.put("test177", "123");
        hashMap.put("test188", "123");
        hashMap.put("test199", "123");
        hashMap.put("test210", "123");
        hashMap.put("test221", "123");
        hashMap.put("test232", "123");
        hashMap.put("test243", "123");
        hashMap.put("test254", "123");
        hashMap.put("test265", "123");
        hashMap.put("test276", "123");
        System.out.println(hashMap.size());
    }
}

然後再在此處斷點。

image-20240621000959515

image-20240621002231247

好咯,紅黑樹太複雜了,先不研究了。

2.3.4 擴容

相關文章