Jdk1.7下的HashMap原始碼分析

夕陽下飛奔的豬發表於2020-08-12

本文主要討論jdk1.7下hashMap的原始碼實現,其中主要是在擴容時容易出現死迴圈的問題,以及put元素的整個過程。

1、陣列結構

陣列+連結串列

示例圖如下:

Jdk1.7下的HashMap原始碼分析

常量屬性

/**
 * 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元素時造成的死迴圈過程:

連結串列死迴圈圖例:

Jdk1.7下的HashMap原始碼分析

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呢?

結論:

  1. 允許key為null,但最多唯一存在一個,放在陣列下標為0位置
  2. value為null的鍵值對可以有多個
  3. 由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

相關文章