本文主要討論jdk1.7下hashMap的原始碼實現,其中主要是在擴容時容易出現死迴圈的問題,以及put元素的整個過程。
1、陣列結構
陣列+連結串列
示例圖如下:
常量屬性
/**
* The default initial capacity - MUST be a power of two.
* 預設初始容量大小
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* MUST be a power of two <= 1<<30.
* hashMap最大容量,可裝元素個數
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* The load factor used when none specified in constructor.
* 載入因子,如容量為16,預設閾值即為16*0.75=12,元素個數超過(包含)12且,擴容
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 空陣列,預設陣列為空,初始化後才才有記憶體地址,第一次put元素時判斷,延遲初始化
*/
static final Entry<?,?>[] EMPTY_TABLE = {};
2、存在的死迴圈問題
擴容導致的死迴圈,jdk1.7中在多執行緒高併發環境容易出死迴圈,導致cpu使用率過高問題,問題出在擴容方法resize()中,更具體內部的transfer方法:將舊陣列元素轉移到新陣列過程中,原始碼如下:
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
//1.如果原來陣列容量等於最大值了,2^30,設定擴容閾值為Integer最大值,不需要再擴容
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//2.建立新陣列物件
Entry[] newTable = new Entry[newCapacity];
//3.將舊陣列元素轉移到新陣列中,分析一
transfer(newTable, initHashSeedAsNeeded(newCapacity));
//4.重新引用新陣列物件和計算新的閾值
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
transfer方法
/**
* Transfers all entries from current table to newTable.
* 從當前陣列中轉移所有的節點到新陣列中
*/
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
//遍歷舊陣列
for (Entry<K,V> e : table) {
//1,首先獲取陣列下標元素
while(null != e) {
//2.獲取陣列該桶位置連結串列中下一個元素
Entry<K,V> next = e.next;
//3.是否需要重新該元素key的hash值
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
//4,重新確定在新陣列中下標位置
int i = indexFor(e.hash, newCapacity);
//5.頭插法:插入新連結串列該桶位置,若有元素,就形成連結串列,每次新加入的節點都插在第一位,就陣列下標位置
e.next = newTable[i];
newTable[i] = e;
//6.繼續獲取連結串列下一個元素
e = next;
}
}
}
//傳入容量值返回是否需要對key重新Hash
final boolean initHashSeedAsNeeded(int capacity) {
//1.hashSeed預設為0,因此currentAltHashing為false
boolean currentAltHashing = hashSeed != 0;
//2,sun.misc.VM.isBooted()在類載入啟動成功後,狀態會修改為true
// 因此變數在於,capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD,debug發現正常情況ALTERNATIVE_HASHING_THRESHOLD是一個很大的值,使用的是Integer的最大值
boolean useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
//3,兩者異或,只有不相同時才為true,即useAltHashing =true時,dubug程式碼發現useAltHashing =false,
boolean switching = currentAltHashing ^ useAltHashing;
if (switching) {
hashSeed = useAltHashing
? sun.misc.Hashing.randomHashSeed(this)
: 0;
}
//正常情況下是返回false,即不需要重新對key雜湊
return switching;
}
上面原始碼展示轉移元素過程:
以下模擬2個執行緒併發操作hashMap 在put元素時造成的死迴圈過程:
連結串列死迴圈圖例:
3、put方法
1.7的put方法,因沒有紅黑樹結構,相比較1.8簡單, 容易理解,流程圖如下所示:
程式碼如下:
public V put(K key, V value) {
//1,若當前陣列為空,初始化
if (table == EMPTY_TABLE) {
//分析1
inflateTable(threshold);
}
//2,若put的key為null,在放置在陣列下標第一位,索引為0位置,從該原始碼可知
// hashMap允許 鍵值對 key=null,但是隻能有唯一一個
if (key == null)
// 分析2
return putForNullKey(value);
//3,計算key的hash,這裡與1.8有區別
//分析3
int hash = hash(key);
// 4,確定在陣列下標位置,與1.8相同
int i = indexFor(hash, table.length);
// 5,遍歷該陣列位置,即該桶處遍歷
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
// 找到相同的key,則覆蓋原value值,返回舊值
V oldValue = e.value;
e.value = value;
//該方法為空,不用看
e.recordAccess(this);
return oldValue;
}
}
//因為hashMap執行緒不安全,修改操作沒有同步鎖,
//該欄位值用於記錄修改次數,用於快速失敗機制 fail-fast,防止其他執行緒同時做了修改,丟擲併發修改異常
modCount++;
// 6,原陣列中沒有相同的key,以頭插法插入新的元素
//分析4
addEntry(hash, key, value, i);
return null;
}
分析1: HashMap如何初始化陣列的,延遲初始化有什麼好處?
結論: 1、1.7,1.8都是延遲初始化,在put第一個元素時建立陣列,目的是為了節省記憶體。
初始化程式碼:
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
//1.該方法非常重要,目的為了得到一個比toSize最接近的2的冪次方的數,
// 且該數要>=toSize,這個2的冪次方方便後面各種位運算
// 如:new HashMap(15),指定15大小集合,內部實際 建立陣列大小為2^4=16
// 分析見下
int capacity = roundUpToPowerOf2(toSize);
//2,確定擴容閾值
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
//3,初始化陣列物件
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
Q:如何確保獲取到比toSize 最接近且大於等於它的2的冪次方的數?
深入理解roundUpToPowerOf2方法:
private static int roundUpToPowerOf2(int number) {
// assert number >= 0 : "number must be non-negative";
//如果number大於等於最大值 2^30,賦值為最大,主要是防止傳參越界,number一定是否非負的
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
//核心在於Integer.highestOneBit((number - 1) << 1) 此處
}
先丟擲2個問題:
1:這個 (number - 1) << 1 的作用是什麼?
2:這個方法highestOneBit肯定是為了獲取到滿足條件的2的冪次方的數,背後的原理呢?
結論: Integer的方法highestOneBit(i) 這個方法是通過位運算,獲取到i的二進位制位最左邊(最高位)的1,其餘位都抹去,置為0,即獲取的是小於等於i的2的冪次方的數.
如果直接傳入number,那麼獲取到的是2的冪次方的數,但是該數一定小於等於number,但這不是我們的目的;
如highestOneBit(15)=8highestOneBit(21)=16而我們是想要獲取一個剛剛大於等於number的2次方的數,(number-1)<<1 因此需要先將number 擴大二倍number <<1 , 為什麼需要number-1,是考慮到臨界值問題,恰好number本身就是2的冪次方,如 number=16,擴大2倍後為32, highestOneBit方法計算後結果還是32,這不符合需求。
public static int highestOneBit(int i) {
// HD, Figure 3-1
i |= (i >> 1);
i |= (i >> 2);
i |= (i >> 4);
i |= (i >> 8);
i |= (i >> 16);
return i - (i >>> 1);
}
2的冪次方二進值特點:只有最高位為1,其他位全為0
目的:將傳入i的二進位制最左邊的1保留,其餘低位的1全變為0
原理:某數二進位制: 0001 ,不關心其低位是什麼,以*代替,進行運算
- 右移1位
i |= (i >> 1);
0001****
|
00001***
----------
00011*** #保證左邊2位是1
- 右移2位
i |= (i >> 2);
00011***
|
0000011*
----------
0001111* #保證左邊4位是1
- 右移4位
i |= (i >> 4);
0001111*
|
00000001
----------
00011111 #把高位以下所有位變為1了,該數還是隻有5位,該計算可將8位下所有的置為1
Q:為什麼要再執行右移8位,16位?
因int型別 4個位元組,32位,這樣可以一定可以保證將低位全置為1;
- 最後一步,大功告成!
i - (i >>> 1);
#此時 i= 00011111
00011111
-
00001111 #無符號右移1位
---------
00010000 #拿到值
分析2: HashMap如何處理key 為null情況,value呢?
結論:
- 允許key為null,但最多唯一存在一個,放在陣列下標為0位置
- value為null的鍵值對可以有多個
- 由1,2 推得,鍵值對都為null的Entry物件可以有,但最多一個
private V putForNullKey(V value) {
//1.直接table[0] 位置獲取,先遍歷連結串列(這裡對該陣列位置統稱為連結串列,可能沒有元素,或者只有一個元素,或者連結串列)查詢是否存在相同的key,存在覆蓋原值
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
//此時注意新增節點時,第一個0即代表陣列下標位置,後面會分析該方法
addEntry(0, null, value, 0);
return null;
}
分析3:如何實現hash演算法,保證key的hash值均勻分散,減少hash衝突?
jdk1.7中為了儘可能的對key的hash後均勻分散,擾動函式實現採用了 5次異或+4次位移
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
//k的hashCode值 與hashSeed 異或
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
分析4:插入新的節點到map中,如果原陣列總元素個數超過閾值,先擴容再插入節點
void addEntry(int hash, K key, V value, int bucketIndex) {
//總元素個數大於等於閾值 且 當前陣列下標已存在元素了: 擴容
if ((size >= threshold) && (null != table[bucketIndex])) {
//1,擴容,上面已分析過程式碼
resize(2 * table.length);
//2,計算新加key的hash值,key為null的hash值為0
hash = (null != key) ? hash(key) : 0;
//3,確保計算的陣列下標一定在陣列有效索引內,見分析5
bucketIndex = indexFor(hash, table.length);
}
// 4,擴容後再插入新陣列中
createEntry(hash, key, value, bucketIndex);
}
//分析5
static int indexFor(int h, int length) {
// 與陣列長度-1與運算,一定可以確保結果值在陣列有效索引內,且均勻分散
return h & (length-1);
}
// 進一步分析插入節點方法
void createEntry(int hash, K key, V value, int bucketIndex) {
//1,首先獲取新陣列索引位置元素
Entry<K,V> e = table[bucketIndex];
//2,頭插法插入新節點, Entry構造方法第4個引數e表示指定當前新增節點的next指標指向該節點,形成連結串列
table[bucketIndex] = new Entry<>(hash, key, value, e);
//3,map元素個數+1
size++;
}
參考:
一、1.7解析:https://blog.csdn.net/carson_ho/article/details/79373026
二、1.8解析:https://www.jianshu.com/p/8324a34577a0