寫在前面
HashMap 資料結構非常重要,經常被用來面試。因為它綜合了陣列以及連結串列的知識,還有非常重要的hash演算法,在以後的工作中也經常被用到,其中還有很多非常高效的演算法。但是hashMap對於很多人來說比較困難,可能會用,但是並不清楚怎麼實現,或者不清楚他的執行邏輯。
我就通過語句的執行以及函式的呼叫順序來一步步揭開 hashMap的面紗,跟著我的思路走,至少hashMap的基本邏輯就知道了,校招相關的面試基本也能答得上來
註釋應該非常非常細了,因為我基本判斷語句以及一些不清楚的變數邏輯都進行了中文註釋 檔案地址在我的 github 上(目前只更新了put和get):github.com/leosanqing/…
- 採用 JDK 8 的原始碼進行分析
- 本人技術有限,紅黑樹部分並沒有進行分析,不過對於理解 HashMap 的存取過程影響不太大
- 對於泛型K,V使用 Object代替,其他的關鍵字比如final,transient並沒有寫。因為這不是重點
- 為了你們方便,我在截圖的時候擷取了原始碼的行號,你們可以自行去檢視原始碼對應的位置
- 資料型別,1.8應該使用的是
Node
命名,但是我使用的是Entry
,不過邏輯還是1.8的邏輯
本文結構脈絡
個人理解語句以及中文註釋
存放在我的 github 上:
類似於這種格式
HashMap 的資料結構
陣列+連結串列
為啥採用這種方式
當然是為了快,為了效率
陣列在知道下標之後查詢速度尤其快,O(1)的時間複雜度
連結串列在增刪的時候速度非常快,找到位置後(前提),處理只需要O(1)的時間複雜度,因為不需要移動資料的位置,只需要更改指向的地址即可。但是連結串列在遍歷對比的時候非常慢,時間複雜度為O(n),所以用來做 雜湊衝突時的解決方法
所以查詢一個資料的時間複雜度為 O(1)+O(n)。不過因為雜湊演算法的非常巧妙,會讓衝突儘可能地均勻分佈,所以鏈一般極其短。所以後面遍歷連結串列的時間可以忽略不計,而且在 JDK8 之後,如果衝突的連結串列長度大於 8,那麼就會轉化為 紅黑樹,他的遍歷的時間複雜度為O(log n)
原始碼中的變數名
陣列
陣列的話,原始碼中使用的是 table
命名,你也可以稱之為 桶
Node[] table;
複製程式碼
連結串列
連結串列的話,JDK 1.7中使用的是 Entry
,JDK1.8採用的是 Node
命名。基本一樣,只是名字不同,結構定義如下.
(我是按照1.7的命名, 不過其他邏輯是1.8的)
/**
* Entry 類 為map中基本的單元
*
* key 為鍵,value 為值
* next 是在雜湊衝突時,指向的下一個 Entry
* h 為傳入的hash值,原始碼中為 hash
*/
static class Entry{
Object key;
Object value;
Entry next;
int h;
}
複製程式碼
其他
// 初始預設的陣列容量
static final int INIT_CAPACITY = 1<<4;
//陣列最大的容量,因為 陣列設定為 2的整次方倍,而 32 次方為負數,所以最大隻能為 1 << 30,即2的31次方
static final int MAX_CAPACITY = 1<<30;
// 預設的裝填因子
static final float DEFAULT_LOADFACTOR = 0.75f;
// table 桶中的個數--陣列的大小;
int size;
// 修改次數
int modCount;
// 擴容的閾值, capacity * load factor
int threshold;
// 裝填因子
float loadFactor;
複製程式碼
put元素
如果你看懂了這個過程,那麼基本上 HashMap 的主要邏輯就算是基本理解了
步驟
- 判斷 key 是否為空,如果為空直接放到
table[0]
的位置,如果不為空,經過運算確定其在table
中的下標 - 然後再判斷相應的索引上是否已經有元素了,沒有的話,直接修改;有的話再判斷
key
值是否相等,相等的話,直接覆蓋value
,不相等的話遍歷連結串列(紅黑樹),並插入到連結串列最後 - 在第二步的插入時,先判斷 ++size是否已經大於了閾值,大於需要擴容。
稍微詳細些的步驟看下方思維導圖,同樣縮排的為 if-else 關係
還有的細節沒有寫,待會兒跟著原始碼再細講,我就跟著原始碼的呼叫順序分析
那麼假如我現在執行下面的語句,他到底怎麼執行
import java.util.HashMap;
public class Test {
public static void main(String[] args) {
HashMap hashMap = new HashMap();
hashMap.put("name","zhangSan");
}
}
複製程式碼
第一條語句-建構函式
public MyHashMap(int initCapacity,float loadFactor) {
if(initCapacity<0)
throw new IllegalArgumentException("初始化容量失敗: "+
initCapacity);
if(initCapacity>= MAX_CAPACITY)
initCapacity= MAX_CAPACITY;
if(loadFactor<=0||Float.isNaN(loadFactor))
throw new IllegalArgumentException("裝填因子不合法"+
loadFactor);
this.loadFactor=loadFactor;
this.threshold=tableSizeFor(initCapacity);
}
public MyHashMap(int initCapacity) {
this(initCapacity,DEFAULT_LOADFACTOR);
}
/**
* 無參的,全部預設
*/
public MyHashMap() {
this.loadFactor=DEFAULT_LOADFACTOR;
}
public MyHashMap(Map m){
this.loadFactor=DEFAULT_LOADFACTOR;
}
複製程式碼
如果沒有傳入引數,他就會呼叫無參的構造器,那麼預設的長度為 16,DEFAULT_INITIAL_CAPACITY
,預設的裝填因子為 0.75,DEFAULT_LOAD_FACTOR
,傳入範圍(0,1];
注意:這個時候,陣列還沒有初始化,僅僅是定義了一個Entry型別的陣列
第二條語句
執行hashMap.put("name","zhangSan")
put函式
首先他在原始碼中是這樣的,他又呼叫了putVal
函式,專門存入元素的函式(ps:原始碼 611行)
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
複製程式碼
他傳入了5個值,但是我們先重點關注前三個值,第一個是要存入的key的hash
值,第二個是key,第三個是value,至於K,V泛型如果不瞭解,你可以理解為 Object型別,如果按照測試的語句,你就可以把它當成 String
型別。
這個put函式,他有返回值,返回值是null,或者oldValue,看了下面的putValue
函式你就知道了
hash 函式,計算雜湊值
傳入這個引數是為了建立節點node以及計算索引時用
原始碼(第337 行)
static final int hash(Object key) {
int h;
// 將key 的高16位和低16位進行異或
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
複製程式碼
這個也是 JDK 1.8的改進,1.7不是這樣的。
改進的目的
主要是從速度、功效、質量來考慮的,這麼做可以在陣列table的n比較小的時候,也能保證考慮到高低bit都參與到hash的計算中(為了是分佈更均勻),同時不會有太大的開銷。
putValue函式
private Object putVal(int hash, Object key, Object value, boolean onlyIfAbsent, boolean evict) {
Entry[] tab;
Entry p;
int n,i;
// 如果第一次 進行存放資料,進行初始化,table 被延遲到進行資料存放時才初始化
if((tab = table) == null || (n = table.length)==0){
n = (tab = resize()).length;
}
if((p = table[i = ((n - 1) & hash)]) == null){
tab[i] = newEntry(hash,key,value,null);
}
else {
Entry e;
Object k;
// 如果 key 相同,那麼就直接將 value 覆蓋
// 為什麼要比較這麼多次
// 1.首先判斷 雜湊值是否相同
if(p.h == hash &&
// 2.判斷兩個key是否相等,使用 '==' 是非字串情況,之比較兩個的內容,使用'equals' 是針對字串
(((k = p.key) == key) || (key != null && key.equals(k))))
// 覆蓋value值
e = p;
// 這個是樹的情況
//else if(p instance of TreeNode)
// 鏈
else{
for(int binCount=0;;++binCount){
// 遍歷到最後,插入
if((e = p.next) == null){
p.next = newEntry(hash,key,value,null);
/*
如果 binCount >=轉化樹的閾值-1 ,則將連結串列轉化為樹
if(binCount >= TREEIFY_THRESHOLD-1)
treeifyBin(tab,hash);
*/
break;
}
if(p.h == hash &&
(((k = p.key) == key) || (key != null && key.equals(k))))
break;
// 移動到下一個
p = e;
}
// 如果有相應的對映,即key相同
if(e != null){
Object oldValue = e.value;
if(!onlyIfAbsent || oldValue == null)
e.value = value;
return oldValue;
}
}
}
// 修改次數 ++
++ modCount;
// 大於閾值就擴容
if(++size >threshold)
resize();
//afterNodeInsertion(evict);
return null;
}
複製程式碼
返回值
看了上面的原始碼分析你就能解決上面的疑問,put函式有返回值,返回值為null
或者oldValue
。
先記住答案:當他不產生覆蓋的時候,返回null;當他產生覆蓋的時候返回 oldVal,即原來被覆蓋的值
我們先進行測試,你就大概知道意思了
import java.util.HashMap;
public class Test {
public static void main(String[] args) {
HashMap hashMap = new HashMap();
hashMap.put("name","張三");
Object oldValue1 = hashMap.put("name","李四");
Object oldValue2 = hashMap.put("age",18);
System.out.println("oldValue = " + oldValue1);
System.out.println("oldValue2 = " + oldValue2);
}
}
複製程式碼
我想現在你應該清楚了,當輸入的key的內容相同,hash值也相同的時候,他就會覆蓋之前的Value值,並且返回被覆蓋前的value值。(假設輸入的只是String型別,如果是自定義的物件,需要重寫 hashCode 和 equals 方法)
這個的關鍵程式碼在上面函式的++modCount
一行上面,我有註釋
//如果有相應的對映,即key相同
if(e != null){
Object oldValue = e.value;
if(!onlyIfAbsent || oldValue == null)
e.value = value;
return oldValue;
}
複製程式碼
分析putValue函式
條件
首先要判斷table陣列是否初始化了,即這條語句if ((tab = table) == null || (n = tab.length) == 0)
,
- 如果沒有初始化則要呼叫
resize
方法(後面分析).可以直接看索引為resize函式
的內容 - 如果已經初始化了,就需要計算元素的索引了(這個是非常重要的一步,也是他為啥能在O(1)的時間複雜度內找到在陣列中的相應位置)
計算索引
將 key 的 hash 值和table.length-1
相與,相與的結果就是要存入的元素的table中的 位置tab[(n - 1) & hash]
。
這個時候看原始碼,它分為兩種情況:
第一種:相應的索引上沒有元素(只有這個時候 size才++,相應索引上有元素,size是不會 ++ 的)
// 如果table 陣列的相應的索引上沒有元素,那麼直接建立一個新的節點
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 修改次數++
++modCount;
// 判斷是否需要擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
複製程式碼
現在知道啥時候返回 null了吧
第二種:相應的索引上有元素
這個時候就要判斷元素的key是否相等
if(p.h == hash &&(((k = p.key) == key) || (key != null && key.equals(k))))
else {
Entry e;
Object k;
// 如果 key 相同,那麼就直接將 value 覆蓋
// 為什麼要比較這麼多次
// 1.首先判斷 雜湊值是否相同
if(p.h == hash &&
// 2.判斷兩個key是否相等,使用 '==' 是非字串情況,之比較兩個的內容,使用'equals' 是針對字串
(((k = p.key) == key) || (key != null && key.equals(k))))
// 覆蓋value值
e = p;
// 這個是樹的情況
//else if(p instance of TreeNode)
// 鏈
else{
for(int binCount=0;;++binCount){
// 遍歷到最後,插入
if((e = p.next) == null){
p.next = newEntry(hash,key,value,null);
/*
如果 binCount > 轉化樹的閾值 ,則將連結串列轉化為樹
if(binCount >= TREEIFY_THRESHOLD-1)
treeifyBin(tab,hash);
*/
break;
}
if(p.h == hash &&
(((k = p.key) == key) || (key != null && key.equals(k))))
break;
// 移動到下一個
p = e;
}
// 如果有相應的對映,即
if(e != null){
Object oldValue = e.value;
if(!onlyIfAbsent || oldValue == null)
e.value = value;
return oldValue;
}
}
}
複製程式碼
這就是返回 oldValue的情況,當然上面的也有情況並不會返回oldValue
resize函式
這個是進行擴容的函式,也是非常重要的,要確保每次擴容前後容量大小都是2的n次方
。並且在JDK 1.8中,對這個函式進行了優化,使得演算法非常的高效
呼叫的情景
-
初始化 陣列table。在putVal函式中,(原始碼第628行)
if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; 複製程式碼
-
進行擴容陣列table的size達到閾值時,即++size > load factor * capacity 時,也是在
putVal
函式中if (++size > threshold) resize(); 複製程式碼
執行邏輯
原始碼註釋
忽略了樹的邏輯,只有相應的條件
final Entry[] resize() {
// 定義舊的陣列為 Entry 型別的陣列,oldTab
Entry[] oldTab = table;
// 如果oldTab==null 則返回 0,否則返回陣列大小
int oldCap = (oldTab==null) ? 0 : oldTab.length;
int oldThreshold = threshold;
int newCap=0,newThreshold=0;
// 說明已經不是第一次 擴容,那麼已經初始化過,容量一定是 2的n次方,所以可以直接位運算
if(oldCap>0){
// 如果 原來的陣列大小已經大於等於了最大值,那麼閾值設定為 Integer的最大值,即不會再進行擴容
if(oldCap >= MAX_CAPACITY){
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 因此已經不是第一次擴容,一定是2的n次方
else if ((newCap = oldCap << 1) < MAX_CAPACITY &&
oldCap >= INIT_CAPACITY)
newThreshold = oldThreshold << 1;
}
// 如果oldThreshold > 0,並且oldCap == 0,說明是還沒有進行呼叫resize方法。
// 說明輸入了初始值,且oldThreshold為 比輸入值大的最小的2的n次方
// 那麼就把 oldThreshold 的值賦給 newCap ,因為這個值現在為 比輸入值大的最小的2的n次方
else if(oldThreshold>0)
newCap = oldThreshold;
// 滿足這個條件只有呼叫無參建構函式,注意只有;
else{
newCap = INIT_CAPACITY;
newThreshold = (int) (INIT_CAPACITY * DEFAULT_LOADFACTOR);
}
if(newThreshold == 0){
float ft = (float) (newCap * loadFactor);
newThreshold =(newCap < MAX_CAPACITY && ft < (float) MAX_CAPACITY ?
(int )ft : Integer.MAX_VALUE);
}
threshold = newThreshold;
Entry newTable[] = new Entry[newCap];
table=newTable;
// 將原來陣列中的所有元素都 copy進新的陣列
if(oldTab != null){
for (int j = 0; j < oldCap; j++) {
Entry e;
if((e = oldTab[j]) != null){
oldTab[j] = null;
// 說明還沒有成鏈,陣列上只有一個
if(e.next == null){
// 重新計算 陣列索引 值
newTable[e.h & (newCap-1)] = e;
}
// 判斷是否為樹結構
//else if (e instanceof TreeNode)
// 如果不是樹,只是連結串列,即長度還沒有大於 8 進化成樹
else{
// 擴容後,如果元素的 index 還是原來的。就使用這個lo字首的
Entry loHead=null, loTail =null;
// 擴容後 元素index改變,那麼就使用 hi字首開頭的
Entry hiHead = null, hiTail = null;
Entry next;
do {
next = e.next;
if((e.h & oldCap) == 0){
// 如果 loTail == null ,說明這個 位置上是第一次新增,沒有雜湊衝突
if(loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else{
if(hiTail == null)
loHead = e;
else
hiTail.next = e;
hiTail = e ;
}
}while ((e = next) != null);
if(loTail != null){
loTail.next = null;
newTable[j] = loHead;
}
// 新的index 等於原來的 index+oldCap
else {
hiTail.next = null;
newTable[j+oldCap] = hiHead;
}
}
}
}
}
return newTable;
}
複製程式碼
重要:擴容後元素的位置
// 將原來陣列中的所有元素都 copy進新的陣列
if(oldTab != null){
for (int j = 0; j < oldCap; j++) {
Entry e;
if((e = oldTab[j]) != null){
oldTab[j] = null;
// 說明還沒有成鏈,陣列上只有一個
if(e.next == null){
// 重新計算 陣列索引 值
newTable[e.h & (newCap-1)] = e;
}
// 判斷是否為樹結構
//else if (e instanceof TreeNode)
// 如果不是樹,只是連結串列,即長度還沒有大於 8 進化成樹
else{
// 擴容後,如果元素的 index 還是原來的。就使用這個lo字首的
Entry loHead=null, loTail =null;
// 擴容後 元素index改變,那麼就使用 hi字首開頭的
Entry hiHead = null, hiTail = null;
Entry next;
do {
next = e.next;
//這個非常重要,也比較難懂,將它和原來的長度進行相與,就是判斷他的原來的hash的上一個 bit 位是否為 1.下面我再詳細說
if((e.h & oldCap) == 0){
// 如果 loTail == null ,說明這個 位置上是第一次新增,沒有雜湊衝突
if(loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else{
if(hiTail == null)
loHead = e;
else
hiTail.next = e;
hiTail = e ;
}
}while ((e = next) != null);
if(loTail != null){
loTail.next = null;
newTable[j] = loHead;
}
// 新的index 等於原來的 index+oldCap
else {
hiTail.next = null;
newTable[j+oldCap] = hiHead;
}
}
}
}
}
複製程式碼
從上面的程式碼可以看出來,他遍歷陣列。將每個元素和原來的陣列長度進行與運算,判斷是否為 0
如果為0,那麼索引位置不變,
如果不為 0,那麼索引位置等於 原來的索引+原來的陣列長度,
你可能有點納悶,為啥要這樣,請參考下這篇文章。
不過閱讀前,我覺得得了解這些前提,
- **陣列table的長度絕對是2的n次方(一定是)。**至於為啥你可以參考另一篇文章"table長度到底是多少"
知道這個前提,那麼你就知道在陣列的長度中,只有最高位是1,其他全為0;
- 元素在陣列table的索引位置是 (key.hash&(table.length-1))
文章連結:www.jianshu.com/p/4177dc15d…
上面的這個演算法非常重要,也是JDK1.8之後的優化,效率非常高
最後
至此,put一個元素的過程基本就完了,可能還有一些小細節沒講到(應該不太重要,可以自行檢視我的註釋)
如果你put
方法搞懂了,那麼後面的get,contains,remove,iterator 這些基本沒有啥大的障礙,這些搞懂,hashMap的 70% 至少都懂了
後面應該還有上述方法的原始碼分析以及回答一些疑問。
比如"為啥hashMap的陣列長度一定是2的n次方",
"當我new HashMap()的時候,輸入的初始容量 0,1,2,3,4,5,6。table初始化的值到底為多少"
等等