JDK7中HashMap源碼分析

JDK7中的HashMap

個人學習所記錄,難免會有不足之處,有不足之處,還請指出,感激不盡。

一、JDK7中HashMap源碼中重要的參數

	/**
     * 默認初始容量-必須爲2的冪
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    /**
     * 最大容量,如果任意一個構造函數使用參數隱式指定了更高的值,則使用此容量。
     * 必須是2的冪 <= 1 << 30
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;

    /**
     * 在構造函數中未指定時使用的負載係數。
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    /**
     * 當表未膨脹時要共享的空表實例。
     */
    static final Entry<?,?>[] EMPTY_TABLE = {};

    /**
     * 該表,根據需要調整大小。 長度必須始終爲2的冪。
     */
    transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

    /**
     * 此映射中包含的鍵-值映射數。
     */
    transient int size;

    /**
     * 下一個要調整大小的大小值(容量*負載係數)
     */
    // 如果table == EMPTY_TABLE,那麼這是膨脹時將在其中創建表的初始容量。
    int threshold;

    /**
     *  哈希表的負載因子。
     */
    final float loadFactor;

    /**
     * 對該HashMap進行結構修改的次數
     * 結構修改是指更改HashMap中的映射次數或以其他方式修改其內部結構(例如,重新哈希)的修改。
     * 此字段用於使HashMap的Collection-view上的迭代器快速失敗。
     * (請參見ConcurrentModificationException)。
     *
     */
    transient int modCount;

    /**
     * The default threshold of map capacity above which alternative hashing is
     * used for String keys. Alternative hashing reduces the incidence of
     * collisions due to weak hash code calculation for String keys.
     *
     * 映射容量的默認閾值,高於該閾值時,字符串鍵將使用替代哈希。
     * 備用哈希可減少由於字符串鍵的哈希碼計算能力較弱而導致的衝突發生率。
     *
     */
    static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;

二、JDK7中HashMap的構造方法

HashMap中有四個構造方法:

在這裏插入圖片描述

先看一下這個無參構造方法:

無參構造方法就是應對:下面這種new出來一個對象的時候,寫過java的都懂

Map<String,String> map = new HashMap<>();

在無參構造方法中調用的是一個兩個參數的構造方法

//使用默認的初始容量(16)和默認的加載因子(0.75)構造一個空的HashMap。
public HashMap() {
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}

再來看一下兩個的有參構造方法:

/**
* 使用指定的初始容量和加載因子構造一個空的HashMap 。
*
* @param  initialCapacity the initial capacity 默認的數組的容量
* @param  loadFactor      the load factor 默認的加載因子
*/
public HashMap(int initialCapacity, float loadFactor) {
    //驗證指定的數組的容量符合不符合要求
    //如果不符合就會報錯
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);


    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);

    //如果傳進來了加載因子,就賦值給加載因子
    this.loadFactor = loadFactor;
    threshold = initialCapacity;//如果容量合法,就賦值給閾值
    init();//都合法後,纔會進行初始化,在HashMap中未使用
}

    /*
     * 在HashMap中init()方法是空的;那就是在HashMap中沒有使用。
     * 其實init方法是在LikedHashMap中使用的
     */
    void init() {
    }

1一個參數的構造方法:

    /**
     * 使用指定初始容量和默認負載因子(0.75)構造一個的空HashMap。
     *
     * @param  initialCapacity the initial capacity. 初始容量
     * @throws IllegalArgumentException if the initial capacity is negative. 如果初始容量爲負則會出現異常
     */
    public HashMap(int initialCapacity) {
        //會調用兩個參數的構造方法,並使用默認的負載因子和制定的容量
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

傳入map對象的構造方法:

   /**
     * 使用與指定Map相同的映射構造一個新的HashMap。
     * 使用默認的負載因子(0.75)和足以將映射保存在指定Map中的初始容量創建HashMap。
     *
     * @param   m the map whose mappings are to be placed in this map
     * @throws  NullPointerException if the specified map is null
     */
    public HashMap(Map<? extends K, ? extends V> m) {
        this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                      DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
        inflateTable(threshold);

        putAllForCreate(m);
    }

三、JDK7中創建一個HashMap的步驟

創建HashMap的步驟和創建普通的對象是一樣的。new出來就行了。

重點是源代碼中創建HashMap對象的邏輯和思路是值得學習的。

創建一個簡單的測試類:

/**
 * @Auther: truedei
 * @Date: 2020 /20-7-5 10:14
 * @Description:
 */
public class HashMapTest {

    public static void main(String[] args) {

        Map<String,String> map = new HashMap<>();

        map.put("1","a");
        map.put("2","b");

        System.out.println(map);
    }
}

打上斷點,然後Debug模式運行該Java文件。

在這裏插入圖片描述

F7進入,

此時進入的是HashMap()無參構造方法,實際上是兩個參數的有參構造方法:

調用的時候把默認的兩個參數傳過去了而已。

DEFAULT_INITIAL_CAPACITY=16;默認的初始容量,這個地方必須是2的冪,只有爲什麼是2的冪,一會說。

DEFAULT_LOAD_FACTOR=0.75f; 默認的加載因子,此值並不是隨便寫的,這個值是經過大量得勁計算之後得出來的0.75f,在時間好空間上得到的很好的平衡。

public HashMap() {
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}

此時會調用兩個參數的構造方法:

該方法的寓意就是使用指定的容量和負載因子構造一個空的HashMap。

那爲什麼是一個空的HashMap呢?爲什麼在創建對象的是沒有開闢空間呢?一會揭曉答案。

這個方法的內容很簡單,就是做了一些對比,如果參數不合法就拋出異常,如何合法就把傳入的負載因子和傳入的容量的值賦值給全局中的參數。

/**
* 使用指定的初始容量和加載因子構造一個空的HashMap 。
*
* @param  initialCapacity the initial capacity 默認的數組的容量
* @param  loadFactor      the load factor 默認的加載因子
*/
public HashMap(int initialCapacity, float loadFactor) {
    //驗證指定的數組的容量符合不符合要求
    //如果不符合就會報錯
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);


    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);

    //如果傳進來了加載因子,就賦值給加載因子
    this.loadFactor = loadFactor;
    threshold = initialCapacity;//如果容量合法,就賦值給閾值
    init();//都合法後,纔會進行初始化,在HashMap中未使用
}

總結:

Map<String,String> map = new HashMap<>();	

在初始化HashMap對象的時候,說白了就是傳參,new出來一個對象。並沒有開闢真正使用的空間。

四、JDK7中HashMap的put方法執行流程的分析

(一)圖解JDK7中HashMap的put方法流程

如果你多多少少的看過,或者瞭解過HashMap的話,你可能知道JDK7中的HashMap的存儲結構是數組+鏈表的形式存儲的。JDK8中存儲的結構是數組+鏈表+紅黑樹的形式存儲的。

下圖就是JDK7中HashMap的存儲結構:

在這裏插入圖片描述

在這裏可以思考一下,鏈表的插入操作,是頭插法快,還是尾插法快????

答:當然是頭插法快了!

例如:我們插入一個鄭3,我們假設和已經有的鄭1和鄭2的hash值是一樣的,這個時候就需要在鏈表的位置插入了。

執行流程如下:

在這裏插入圖片描述

建立一個臨時的Entry來存放之前的鏈表,這樣就可以保證數據不丟失。

在這裏插入圖片描述

構造一個新的Entry,並把值存放進來,並 把臨時存放的Entry加入到新構造的Entry的next節點。

在這裏插入圖片描述

JDK7中就是這麼簡單,存儲的結構,就是我們教科書上所用的知識。

(二)JDK7中HashMap源碼分析put方法執行流程

現在就到了put方法的時候了:

   /**
     * 將指定值與該映射中的指定鍵相關聯。
     * 如果該映射先前包含該key的映射,則將替換舊值。
     */
    public V put(K key, V value) {
        //先判斷數組是不是一個空的,代表數組還未初始化;
        //類似懶加載,或者說是初始化,只有在put存元素的時候,纔會真正的初始化裏面的數組
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);//初始化的方法
        }
		
        if (key == null)
            return putForNullKey(value);

        int hash = hash(key);//計算一個哈希值

        int i = indexFor(hash, table.length);//計算索引位置 索引始終保持在0-table.length之間

        //for():爲了查找是不是重複的,如果key是重複的,就去覆蓋掉舊的key的值,把原來的key的值給返回
        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))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }

        //如果沒有重複的就繼續執行
        modCount++;
        //      key的hash值,key,val,索引
        addEntry(hash, key, value, i);
        return null;
    }

程序都是順序執行的,先判斷是否爲空,爲空的話就調用初始化table的方法進行初始化:

//先判斷數組是不是一個空的,代表數組還未初始化;
//類似懶加載,或者說是初始化,只有在put存元素的時候,纔會真正的初始化裏面的數組
if (table == EMPTY_TABLE) {
    inflateTable(threshold);//初始化的方法
}

初始化的方法:

/**
 * Inflates the table.
* 初始化table數組
*/
private void inflateTable(int toSize) {
    //找一個數  >= toSize的2的次冪的數
    int capacity = roundUpToPowerOf2(toSize);

    //擴容要用到的值
    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
    //使用計算出來的值,初始化存儲數據的數組
    table = new Entry[capacity];
    initHashSeedAsNeeded(capacity);
}

在初始化的時候,首先使用roundUpToPowerOf2(int number)方法要找到一個數 >= toSize的2的次冪的數:

//找到一個數  >= toSize的2的次冪的數
private static int roundUpToPowerOf2(int number) {
    // assert number >= 0 : "number must be non-negative";
    return number >= MAXIMUM_CAPACITY
        ? MAXIMUM_CAPACITY
        : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}

在這個當中起關鍵作用的,最最最核心的代碼就是Integer.highestOneBit((number - 1) << 1)

我們先不管Integer.highestOneBit()方法中傳入的什麼,我們先來看一下此方法是幹什麼的:

在源代碼中是這樣的:

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);
}

先來測試一下:

System.out.println(Integer.highestOneBit(5)); //4
System.out.println(Integer.highestOneBit(8)); //8
System.out.println(Integer.highestOneBit(10)); //8
System.out.println(Integer.highestOneBit(15)); //8
System.out.println(Integer.highestOneBit(20)); //16
System.out.println(Integer.highestOneBit(21)); //16

功能很明顯,就是:拿到小於等於這個傳入的數的2次冪的一個數

算法就是:

i |= (i >>  1);
i |= (i >>  2);
i |= (i >>  4);
i |= (i >>  8);
i |= (i >> 16);
return i - (i >>> 1);

這個算法究竟是怎麼運算的呢?

我們假設傳入的i=15

i |= (i >>  1);

i的十進制是15,二進制爲:0000 1111 

第一步:i先右移1位後: 0000 0111
第二步:原i 或上 i右移後的結果:
 
 0000 1111
|0000 0111
=0000 1111

那麼第一個i |= (i >> 1);執行之後的i就等於15

繼續執行下一條:

i |= (i >>  2);

i=0000 1111
右移2位:0000 0011

或操作:

 0000 1111
|0000 0011
=0000 1111
    
發現這次也是15

剩下的就不演示了,i會一直等於0000 1111

…省略中間的操作

重點是返回的結果:

這裏普及一個知識點:

:帶符號右移。正數右移高位補0,負數右移高位補1。比如:

4 >> 1,結果是2;-4 >> 1,結果是-2。-2 >> 1,結果是-1。

:無符號右移。無論是正數還是負數,高位通通補0。

對於正數而言,>>和>>>沒區別。

對於負數而言,-2 >>> 1,結果是2147483647(Integer.MAX_VALUE),-1 >>> 1,結果是2147483647(Integer.MAX_VALUE)。

以下代碼可以判斷兩個數的符號是否相等

return ((a >> 31) ^ (b >> 31)) == 0;
————————————————
版權聲明:本文爲CSDN博主「Victor.Chang」的原創文章,遵循CC 4.0 BY-SA版權協議,轉載請附上原文出處鏈接及本聲明。
原文鏈接:https://blog.csdn.net/qq_35402412/article/details/81156020

return i - (i >>> 1);


由於i是正數所以本次運算>>>>>無區別。

i - (i >>> 1) 對於這個公式,肯定需要先計算括號中的:

 0000 1111右移一位:0000 0111

 0000 1111
-0000 0111
=0000 1000 
  
0000 1000 轉換成10進制就是8

0000 1000 轉換成10進制就是8

真的是很智慧,實在是**佩服**。

我們重新回到roundUpToPowerOf2()方法:

//找到一個數  >= toSize的2的次冪的數
private static int roundUpToPowerOf2(int number) {
    // assert number >= 0 : "number must be non-negative";
    return number >= MAXIMUM_CAPACITY
        ? MAXIMUM_CAPACITY
        : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}

可以看到Integer.highestOneBit()方法並不是直接直接傳入的number,而是傳入的(number -1) << 1

爲什麼有這麼騷的操作呢?

因爲Integer.highestOneBit()方法是計算小於等於一個數的2次冪的數的。

而我們需要計算出大於等於2次冪的數。

我們重新演示:



假設我們傳入的number=16。



number - 1 = 16 -1 = 15

15右移一位等於乘2

15 << 1 = 30



(十進制)30 =(二進制)0001 1110

    
第一步:  i |= (i >>  1);
i>>1 = 0000 1111

    0001 1110
   |0000 1111
  i=0001 1111
    
第二步: i |= (i >>  2);
i>>2 = 0000 0111
    
    0001 1111
   |0000 0111
  i=0001 1111
    
    
忽略:
 		i |= (i >>  4);
        i |= (i >>  8);
        i |= (i >> 16);

到此位置,可以看到已經開始重複了,所以我們忽略剩下的幾個,得到的i的結果肯定也是0001 1111



我們來看這個:
return i - (i >>> 1);

i=0001 1111
i>>>1=0000 1111
    
  0001 1111
 -0000 1111
i=0001 0000=(十進制)16

是不是很奇妙:i=0001 0000=(十進制)16

這個16就是我們最終想要的值。

爲什麼減1?

如果傳入的數,正好是2的冪,那麼就可以直接返回這個數了。

假設傳入的是8,減1之後是7,那麼就可以拿到8直接返回了。

爲什麼翻倍?

因爲我們要拿到的是大於等於2次冪的數。而Integer.highestOneBit()方法是拿到小於這個數的二次冪,解決的辦法就是先增大一點,這個是有範圍的,增大的範圍就是不超過這個數的2倍。

到此位置才只完成了put()方法中的初始化操作:

if (table == EMPTY_TABLE) {
    inflateTable(threshold);
}

接下來是繼續put方法往下走:

此方法只有在key爲null的時候纔會執行這個putForNullKey(value)方法,一般也不會走到這個裏面,除非你設置的key就是null

if (key == null)
    return putForNullKey(value);

緊接着會把計算出來key的hash值

int hash = hash(key);//計算一個哈希值

我們來看一下這個hash()方法:

在HashMap或者HashTable中的hash方法是不同的,在不同的jdk版本中的hash方法也是不同的。本文只介紹JDK7中的HashMap的hash方法。

(在JDK8中是做了優化的)

    /**
     * 檢索對象哈希值,並對結果哈希應用補充哈希函數,以防止質量差的哈希函數。
     * 這很關鍵,因爲HashMap使用2的冪的哈希表,否則哈希表在低位無差異時會遇到衝突。
     * 注意:空鍵始終映射到哈希0,因此索引爲0。
     * Object k :k可以是任何值
     */
    final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }

        //^ : 異或運算,相同爲0,不同爲1
        h ^= k.hashCode();

        //這個方法可以保證只有不同的哈希值
        //每個位位置的恆定倍數有界
        //碰撞次數(默認負載因子下約爲8)。
        //這段代碼有容錯的功能,防止hash值的的大小不均勻的情況
        //在JDK8中是做了優化的
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

這段代碼是爲了對key的hashCode進行分散;

防止不同hashCode的高位不同但低位相同導致的hash衝突。

簡單點說,就是爲了把高位的特徵和低位的特徵組合起來,降低哈希衝突的概率,也就是說,儘量做到任何一位的變化都能對最終得到的結果產生影響。

計算hash值之後就是查找一個合適的索引位置,把數據放入到這個位置了:

int i = indexFor(hash, table.length);//計算索引位置 索引始終保持在0-table.length之間

該方法如下:

/**
* 返回哈希碼h的索引。
*/
static int indexFor(int h, int length) {
    // 長度必須爲非0的2次冪;
    //& :都爲1才爲1  & 和取摸的操作是一抹一樣的。至於這裏爲什麼會使用&而不是%呢?因爲&的速度會比%快。
    return h & (length-1);
}

隨後就是查找是否重複:

i就是使用hash值與table的長度取摸運算之後,計算出來的索引位置。

這個for循環的作用就是遍歷這個table[i]的鏈表;

功能很簡單,就是替換舊的值並返回舊的值

//for():爲了查找是不是重複的,如果key是重複的,就去覆蓋掉舊的key的值,把原來的key的值給返回
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))) {
        V V oldValue = e.value; //臨時記錄舊的value,便於返回舊的value
        e.value = value;//把新的value替換成舊的value,到了此步就完成了替換
        e.recordAccess(this); //在HashMap中並沒有發揮作用,暫且忽略
        return oldValue;//返回舊的value
    }
}

如下圖所示,如果這個i等於1,那這個for循環就是遍歷這個i位置的鏈表。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-B92E0LyS-1593962044668)(/media/truedei/文檔/大學/CSDN/JVM虛擬機/HashMap/HashMap.assets/1593953316534.png)]

//如果沒有重複的就繼續執行
modCount++;
//addEntry(key的hash值,key,value,索引)
addEntry(hash, key, value, i);
//返回null,就是沒有重複的值
return null;

最終的核心代碼落到了addEntry()方法中:

    /**
     * 將具有指定鍵,值和哈希碼的新條目添加到指定存儲桶。
     * 如果合適,此方法負責調整表的大小。 子類重寫此方法以更改put方法的行爲。
     */
    void addEntry(int hash, K key, V value, int bucketIndex) {
        //if(){} 和擴容有關係;新增數據前,先進行檢查是否需要擴容
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);

            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }


        createEntry(hash, key, value, bucketIndex);
    }

添加數據:

添加數據,鏈表的插入方法就是頭插法,我們一會看一下圖就明白了。

void createEntry(int hash, K key, V value, int bucketIndex) {
    //記錄下來索引的位置的鏈表,也就是桶中的位置的鏈表
    Entry<K,V> e = table[bucketIndex];

    //構造一個新的節點放到這個索引位置(其實就是頭插法),然後把舊的鏈表,放到這個的next處,就是下移了。
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    //數量+1
    size++;
}

我們來看一下Entry的這個構造方法:

/**
 * Creates new entry.
 * h:key的hash值
 * k:key的值
 * v:value存儲的數據
 * n:next下一個節點
*/
Entry(int h, K k, V v, Entry<K,V> n) {
    value = v;
    next = n;
    key = k;
    hash = h;
}

五、JDK7中HashMap的get方法執行流程的分析

實例代碼:

Map<String,String> map = new HashMap<>();

map.put("1","a");
map.put("2","b");

String s = map.get("1");

System.out.println(s);//結果肯定就是a了

get(K)源代碼:

    /**
     * 返回指定鍵映射到的值,如果此映射不包含鍵的映射,則爲null。
     */
    public V get(Object key) {
        //如果key就是等於null,那就直接去找key爲null的這個鏈表
        if (key == null)
            return getForNullKey();

        //如果key非null,就去查找桶中的數據
        Entry<K,V> entry = getEntry(key);

        //如果查到的數據等於null,說明沒有數據則直接返回空。否則返回查到的數據。
        return null == entry ? null : entry.getValue();
    }

我們來着重看一下getEntry(key),源碼如下:


    /**
     * 返回與HashMap中的指定key關聯的條目。
     * 如果HashMap不包含該key的映射,則返回null。
     */
final Entry<K,V> getEntry(Object key) {
    //size==0:說明table中是沒有數據的,則直接返回null。
    if (size == 0) {
        return null;
    }
    //計算key的hash值
    int hash = (key == null) ? 0 : hash(key);

    //遍歷這個桶中的鏈表,indexFor就是用來計算索引位置的,不在多說,前面已經很詳細的說過了。
    for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {

        Object k;
        /*
                A = (e.hash == hash)
                (k = e.key) == key
                B = (( || (key != null && key.equals(k))))

             */
        //所遍歷的這個鏈表的節點等於所計算的hash值,並且,(該節點的key等於所遍歷節點的key,或者,(key不等於null 並且 key等於所遍歷節點的key))
        if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
            return e;
    }
    return null;
}

get的源碼比put的簡單多了,這裏就不在使用圖來說明了,和普通的鏈表是一樣的操作。

六、JDK7中HashMap存在的問題

待補充

七、HashMap經典面試題總結

待補充

八、大總結

我已加入CSDN合夥人計劃

親愛的各位粉絲:可以添加我的CSDN官方企業微信號,和我近距離互動聊天,爲您答疑解惑

直接使用微信掃碼即可,不用下載企業微信

在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章