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)
下面,我們逐步分析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都是啥?
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。
這裡的tab就是我們hash的陣列桶。
然後呢,table是成員變數。
原始碼就這樣,給你搞各種簡寫,煩死了,那其實就是這樣嘛。
// 直接把成員變數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方法看一下,它有兩個用處。
- 真的是進行擴容
- 第一次的時候,初始化我們的陣列。
所以在resize的時候,前面的邏輯大概就是。
把咱們的陣列桶(16)、擴容閾值(12)都擴容成2倍。
後面的是移動元素的,咱們先不看。
2.3.2 首次放置
所以呢,剛進來的時候,這裡就是搞了個16長度的Node陣列給我們,然後賦值了幾下,順便記錄了下擴容的閾值是12。
回到這個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?
那現在剛進來,肯定就是沒元素唄,所以tab[9]這個元素是空的,咱們就要建立個Node節點放上去,執行newNode()。
那tab[i]就是咱們這個陣列桶上的玩意唄,它為null,說明裡面沒東西還,直接扔節點進去就完事,你看next固定傳null。
所以,tab[i]上沒元素的時候很簡單,放一個Node節點進來就完事。
所以,當最開始的時候,咱們的邏輯很簡單,初始化了下,往tab[9]扔了個Node元素。
這個很簡單對吧?關鍵是有元素的時候咋整。
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下,看第二次的。
然後就來到了一個for迴圈。
這段程式碼中,e是咱們當前的元素,p是陣列上的元素,最上面那個節點嘛。
所以呢,這個大的if,就是在放置元素,有的話跳出。
Node既然放進去了,最後就是值的填充,跟我們前面的ifAbsent
標記有關,來看最後的if。
所以呢,經過上面的操作,咱們的hashMap就變成了這個樣子。
然後就是追加值進去嘛。
2.連結串列轉紅黑樹
在上面的例子中,正常情況下都不會觸發樹化,直到有一次。
這個時候呢,咱們的連結串列上是有9個元素的哦,所以說,咱們的樹化,是先把連結串列構建出來,再觸發樹化。
接著,就進入到咱們的treeifyBin(tab, hash);
方法中。
你看,這裡把咱們的tab傳進來了,tab是啥,是整個hash表哈,頭上這個,整個tab都傳進來了(現在tab的9號位上有個連結串列)。
哎關鍵點來了,沒有觸發樹化啊?
在HashMap
中,treeifyBin
方法用於將連結串列轉換為樹結構(即紅黑樹)以提高查詢和插入效能。
但在進行這個轉換之前,有一個檢查條件是雜湊表的容量(陣列長度)是否小於一個特定的最小值MIN_TREEIFY_CAPACITY
。
-
MIN_TREEIFY_CAPACITY
是64
。這是一個經驗值,用於避免在雜湊表還比較小的時候進行樹化操作。 -
如果雜湊表的容量小於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());
}
}
然後再在此處斷點。
好咯,紅黑樹太複雜了,先不研究了。