HashMap原始碼分析筆記
HashMap底層原理分析筆記
目錄先貼出HashMap的一些屬性
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable { // 預設初始化容量 - 必須是2的冪次方. static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16 // 最大容量 static final int MAXIMUM_CAPACITY = 1 << 30; // 載入因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; // 閾值:連結串列轉化為紅黑樹的條件之一(連結串列長度大於等於8) static final int TREEIFY_THRESHOLD = 8; // 閾值:當長度小於等於6的時候紅黑樹轉化為連結串列 static final int UNTREEIFY_THRESHOLD = 6; // 連結串列轉化為紅黑樹的條件之一(陣列容量大於等於64) static final int MIN_TREEIFY_CAPACITY = 64;
HashMap裡陣列和連結串列是怎麼樣儲存鍵值對的?
首先我們知道Hashmap集合儲存的是一對鍵值對<key,value>
,然後HashMap底層資料結構是陣列+連結串列。(在jdk1.8的時候加入了紅黑樹),那麼陣列和連結串列是怎麼樣儲存這個鍵值對的呢?其實HashMap底層是把這個鍵值對封裝成了一個物件。然後再把這個物件儲存在了數組裡。原始碼分析:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
可以看到HashMap的put方法返回了一個叫putVal的方法,點進去再看
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//這裡聲明瞭一個Node<K,V>陣列,也就是說數組裡放的是Node物件
Node<K,V>[] tab; Node<K,V> p; int n, i;
//put方法的邏輯程式碼
...
}
通過putVal這個方法知道了這是一個Node陣列,Node物件還可以再點,點進去看看
/** * Basic hash bin node, used for most entries. (See below for * TreeNode subclass, and in LinkedHashMap for its Entry subclass.) */ static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next;
這裡可以看到Node類實現了一個叫Map.Entry<K,V>的介面,其實這個Entry介面就是Map介面的一個內部介面。然後Node還有一個屬性叫next
,這裡是代表指向下一個節點,也就是連結串列的下一個節點。在這裡可以得出結論:HashMap儲存的鍵值對是以Node物件(也可以說是Entry物件)的形式儲存在Node陣列和連結串列裡面的。
HashMap的put方法是如何確定put的鍵值對元素應該存放到陣列哪個位置的呢(定位到陣列下標的)?
貼出原始碼分析:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
/**
*首先判斷這個tab陣列是不是為空或者長度是不是為0,如果是空或者長度為0的話,呼叫resize方法進行擴容。
*所以jdk1.8是先put元素再擴容。1.8之前是先擴容
*/
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
/**
*這裡如果p這個Node物件=tab[某個下標] == null時,把一個新的Node物件賦值給tab[i]。
*/
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
}
由以上原始碼可以知道,在獲取tab陣列下標時,是這樣一段程式碼tab[i = (n - 1) & hash]
這裡n = tab.length,那這個hash是什麼東西?其實如果看下原始碼的話,原始碼的註釋已經告訴我們了
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
hash就是key的hashCode,所以HashMap判斷元素存放到陣列哪個位置其實就是先獲取key,然後利用hash函式得出key的hashCode,再用key的hashCode與陣列的長度-1做&運算。(tab.length - 1) & hash
。那麼這樣怎麼保證獲取的這個下標值不會越界呢?就比如說tab.length是16,利用(tab.length - 1) & hash
它是怎麼保證獲取的值的範圍是0-15的呢?這個就跟&運算子和為什麼hashMap的容量必須是2的冪次方有關係了,這裡引用B站“@dilidili王老桔”的評論
Q6:容量大小為什麼要取2的指數倍?
A:兩個原因:1,提升計算效率:因為2的指數倍的二進位制都是隻有一個1,而2的指數倍-1的二進位制就都是左全0右全1。那麼跟(2^n - 1)做按位與運算的話,得到的值就一定在【0,(2^n - 1)】區間內,這樣的數就剛合適可以用來作為雜湊表的容量大小,因為往雜湊表裡插入資料,就是要對其容量大小取餘,從而得到下標。所以用2^n做為容量大小的話,就可以用按位與操作替代取餘操作,提升計算效率。2.便於動態擴容後的重新計算雜湊位置時能均勻分佈元素:因為動態擴容仍然是按照2的指數倍,所以按位與操作的值的變化就是二進位制高位+1,比如16擴容到32,二進位制變化就是從0000 1111(即15)到0001 1111(即31),那麼這種變化就會使得需要擴容的元素的雜湊值重新按位與操作之後所得的下標值要麼不變,要麼+16(即挪動擴容後容量的一半的位置),這樣就能使得原本在同一個連結串列上的元素均勻(相隔擴容後的容量的一半)分佈到新的雜湊表中。(注意:原因2(也可以理解成優點2),在jdk1.8之後才被發現並使用)
分析到這裡我們解答了兩個問題,
-
HashMap裡陣列和連結串列是怎麼樣儲存鍵值對的?
HashMap儲存的鍵值對是以Node物件的形式儲存在Node陣列和連結串列裡面的 -
HashMap的put方法是如何確定put的鍵值對元素應該存放到陣列哪個位置的呢(定位到陣列下標的)?
HashMap判斷元素存放到陣列哪個位置其實就是先獲取key,然後利用hash函式得出key的hashCode,再用key的hashCode與陣列的長度-1做&運算得到陣列下標。陣列長度-1 & hash
haspmap擴容機制(簡述)
那麼接下來,當我們put元素越來越多的時候,HashMap就需要擴容了,那麼HashMap的擴容機制又是怎麼樣的呢?,前面給出了一個預設的載入因子 static final float DEFAULT_LOAD_FACTOR = 0.75f;
而hashmap的擴容就跟這個載入因子有關,當第一次往hashmap裡面put元素的時候,使用resize()方法對陣列進行擴容(其實就是new了一個新陣列)
/**
* The number of key-value mappings contained in this map.
*/
transient int size;
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果tab ==null的時候,tab = resize(),呼叫resize方法進行擴容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
...
}
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;//threshold:有參構造裡的引數
int newCap, newThr = 0;
//數組裡已經有元素
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//如果呼叫的是有參構造
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//呼叫預設建構函式
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;//16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//12
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
return newTab;
}
可以看到,如果在new一個hashmap物件的時候不指定容量大小
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;//16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//12
}
那麼新的陣列大小會等於DEFAULT_INITIAL_CAPACITY=16,newThr就是一個擴容條件,即當hashmap裡面的元素數量達到這個值時會進行下一次擴容。可以看到這個newThr是DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY,也就是12,
if (oldCap > 0) {
//陣列大小大於等於最大容量時不能進行擴容了
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//沒有達到最大容量
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
繼續看這一段原始碼,當陣列第一次擴容之後數組裡存放了元素,那麼這個oldCap就是當前陣列的容量大小(也就是16),那麼新陣列(newCap)的大小是當前陣列(oldCap)左移一位,就是乘2,newThr也相應的左移一位。繼而得出結論,當hashMap第一次put元素的時候,hashMap裡陣列大小是預設初始化大小16,當元素個數大於 陣列長度*預設載入因子(也就是12)的時候,hashMap發生擴容,擴容後的陣列大小是原來的兩倍
總結一下:
- 當呼叫預設構造方法新建一個hashMap物件的時候,hashMap裡陣列大小為初始容量16,預設載入因子0.75,當hashmap裡陣列大小為
陣列長度 * 預設載入因子
時,發生擴容,擴容後大小為原來的2倍