1. 程式人生 > >Jdk1.7下的HashMap原始碼分析

Jdk1.7下的HashMap原始碼分析

本文主要討論jdk1.7下hashMap的原始碼實現,其中主要是在擴容時容易出現死迴圈的問題,以及put元素的整個過程。 #### 1、陣列結構 ```java 陣列+連結串列 ``` 示例圖如下: **常量屬性** ```java /** * 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方法:將舊陣列元素轉移到新陣列過程中,原始碼如下: ```java 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方法** ```java /** * Transfers all entries from current table to newTable. * 從當前陣列中轉移所有的節點到新陣列中 */ void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; //遍歷舊陣列 for (Entry e : table) { //1,首先獲取陣列下標元素 while(null != e) { //2.獲取陣列該桶位置連結串列中下一個元素 Entry 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元素時造成的死迴圈過程: ![](https://img2020.cnblogs.com/blog/1458219/202008/1458219-20200812180827181-841679404.jpg) 連結串列死迴圈圖例:
#### 3、put方法 1.7的put方法,因沒有紅黑樹結構,相比較1.8簡單, 容易理解,流程圖如下所示: ![](https://img2020.cnblogs.com/blog/1458219/202008/1458219-20200812180901950-1450393026.png) 程式碼如下: ```java 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 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第一個元素時建立陣列,目的是為了節省記憶體。 **初始化程式碼:** ```java 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方法:** ```java 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,這不符合需求。 ```java 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位 ```java i |= (i >> 1); 0001**** | 00001*** ---------- 00011*** #保證左邊2位是1 ``` - 右移2位 ```java i |= (i >
> 2); 00011*** | 0000011* ---------- 0001111* #保證左邊4位是1 ``` - 右移4位 ```java i |= (i >> 4); 0001111* | 00000001 ---------- 00011111 #把高位以下所有位變為1了,該數還是隻有5位,該計算可將8位下所有的置為1 ``` Q:為什麼要再執行右移8位,16位? 因int型別 4個位元組,32位,這樣可以一定可以保證將低位全置為1; - 最後一步,大功告成! ```java i - (i >>> 1); #此時 i= 00011111  00011111 - 00001111 #無符號右移1位 --------- 00010000 #拿到值 ``` 分析2: HashMap如何處理key 為null情況,value呢? 結論: 1. 允許key為null,但最多唯一存在一個,放在陣列下標為0位置 2. value為null的鍵值對可以有多個 3. 由1,2 推得,鍵值對都為null的Entry物件可以有,但最多一個 ```java private V putForNullKey(V value) { //1.直接table[0] 位置獲取,先遍歷連結串列(這裡對該陣列位置統稱為連結串列,可能沒有元素,或者只有一個元素,或者連結串列)查詢是否存在相同的key,存在覆蓋原值 for (Entry 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次位移 ```java 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中,如果原陣列總元素個數超過閾值,先擴容再插入節點 ```java 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 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/8324a