1. 程式人生 > 其它 >HashMap擴容

HashMap擴容

HashMap簡介
HashMap在底層資料結構上採用了陣列+連結串列+紅黑樹,通過雜湊對映來儲存鍵值對資料因為在查詢上使用雜湊碼(通過鍵生成一個數字作為陣列下標,這個數字就是hash code)所以在查詢上的訪問速度比較快,HashMap最多允許一對鍵值對的Key為Null,允許多對鍵值對的value為Null。它是非執行緒安全的。在排序上面是無序的。

 

HashMap的主要成員變數
transient Node<K,V>[] table:這是一個Node型別的陣列(也有稱作Hash桶),可以從下面原始碼中看到靜態內部類Node在這邊可以看做就是一個節點,多個Node節點構成連結串列,當連結串列長度大於8的時候轉換為紅黑樹。

 

 

 

(圖片來自www.importnew.com/20386.html)

transient int size:表示當前HashMap包含的鍵值對數量

transient int modCount:表示當前HashMap修改次數

int threshold:表示當前HashMap能夠承受的最多的鍵值對數量,一旦超過這個數量HashMap就會進行擴容

final float loadFactor:負載因子,用於擴容

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4:預設的table初始容量

static final float DEFAULT_LOAD_FACTOR = 0.75f:預設的負載因子

static final int TREEIFY_THRESHOLD = 8: 連結串列長度大於該引數轉紅黑樹

static final int UNTREEIFY_THRESHOLD = 6: 當樹的節點數小於等於該引數轉成連結串列

介紹完了重要的幾個引數後我們來看看HashMap的構造引數。

 

HashMap的構造方法有四種




public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}


public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_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;
this.threshold = tableSizeFor(initialCapacity);
}


public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
前面三個構造器的區別都是在於指定初始容量以及負載因子,如果你選擇預設的構造器那麼在建立的時候不會指定threshold的值,而第二個以及第三個構造器在一開始的時候就會根據下面的這個方法來確認threshold值,可以看到下面用到了移位演算法(有關內容可以檢視博文:Java移位操作符以及按位操作符),最後一個構造器很顯然就是把另一個Map的值對映到當前新的Map中這邊不再贅述。

 

/**
* Returns a power of two size for the given target capacity.
* 返回距離指定引數最近的2的整數次冪,例如7->8, 8->8, 9->16, 17->32
* 保證為2的冪次方
*/
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

這邊先提下負載因子(loadFactor),原始碼中有個公式為threshold = loadFactor * 容量。HashMap和HashSet都允許你指定負載因子的構造器,表示當負載情況達到負載因子水平的時候,容器會自動擴容,HashMap預設使用的負載因子值為0.75f(當容量達到四分之三進行再雜湊(擴容))。當負載因子越大的時候能夠容納的鍵值對就越多但是查詢的代價也會越高。所以如果你知道將要在HashMap中儲存多少資料,那麼你可以建立一個具有恰當大小的初始容量這可以減少擴容時候的開銷。但是大多數情況下0.75在時間跟空間代價上達到了平衡所以不建議修改。

下面將根據預設的構造為出發點,從初始化一個HashMap到使用Get,Put方法進行一些原始碼解析。


put(K key, V value)
在使用預設構造器初始化一個HashMap物件的時候,首次Put鍵值對的時候會先計算對應Key的hash值通過hash值來確定存放的地址。

 

緊接著呼叫了putVal方法,在剛剛初始化之後的table值為null因此程式會進入到resize()方法中。而resize方法就是用來進行擴容的(稍後提到)。擴容後得到了一個table的節點(Node)陣列,接著根據傳入的hash值去獲得一個對應節點p並去判斷是否為空,是的話就存入一個新的節點(Node)。反之如果當前存放的位置已經有值了就會進入到else中去。接著根據前面得到的節點p的hash值以及key跟傳入的hash值以及引數進行比較,如果一樣則替覆蓋。如果存在Hash碰撞就會以連結串列的形式儲存,把當前傳進來的引數生成一個新的節點儲存在連結串列的尾部(JDK1.7儲存在首部)。而如果連結串列的長度大於8那麼就會以紅黑樹的形式進行儲存。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0) //首次初始化的時候table為null
n = (tab = resize()).length; //對HashMap進行擴容
if ((p = tab[i = (n - 1) & hash]) == null) //根據hash值來確認存放的位置。如果當前位置是空直接新增到table中
tab[i] = newNode(hash, key, value, null);
else {
//如果存放的位置已經有值
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p; //確認當前table中存放鍵值對的Key是否跟要傳入的鍵值對key一致
else if (p instanceof TreeNode) //確認是否為紅黑樹
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {//如果hashCode一樣的兩個不同Key就會以連結串列的形式儲存
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st 判斷連結串列長度是否大於8
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}

if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value; //替換新的value並返回舊的value
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();//如果當前HashMap的容量超過threshold則進行擴容
afterNodeInsertion(evict);
return null;
}

擴容機制核心方法Node<K,V>[] resize()
HashMap擴容可以分為三種情況:

第一種:使用預設構造方法初始化HashMap。從前文可以知道HashMap在一開始初始化的時候會返回一個空的table,並且thershold為0。因此第一次擴容的容量為預設值DEFAULT_INITIAL_CAPACITY也就是16。同時threshold = DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR = 12。

第二種:指定初始容量的構造方法初始化HashMap。那麼從下面原始碼可以看到初始容量會等於threshold,接著threshold = 當前的容量(threshold) * DEFAULT_LOAD_FACTOR。

第三種:HashMap不是第一次擴容。如果HashMap已經擴容過的話,那麼每次table的容量以及threshold量為原有的兩倍。

這邊也可以引申到一個問題HashMap是先插入還是先擴容:HashMap初始化後首次插入資料時,先發生resize擴容再插入資料,之後每當插入的資料個數達到threshold時就會發生resize,此時是先插入資料再resize。

final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;//首次初始化後table為Null
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;//預設構造器的情況下為0
int newCap, newThr = 0;
if (oldCap > 0) {//table擴容過
//當前table容量大於最大值得時候返回當前table
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//table的容量乘以2,threshold的值也乘以2
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
//使用帶有初始容量的構造器時,table容量為初始化得到的threshold
newCap = oldThr;
else { //預設構造器下進行擴容
// zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
//使用帶有初始容量的構造器在此處進行擴容
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
HashMap.Node<K,V> e;
if ((e = oldTab[j]) != null) {
// help gc
oldTab[j] = null;
if (e.next == null)
// 當前index沒有發生hash衝突,直接對2取模,即移位運算hash &(2^n -1)
// 擴容都是按照2的冪次方擴容,因此newCap = 2^n
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof HashMap.TreeNode)
// 當前index對應的節點為紅黑樹,這裡篇幅比較長且需要了解其資料結構跟演算法,因此不進行詳解,當樹的個數小於等於UNTREEIFY_THRESHOLD則轉成連結串列
((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 把當前index對應的連結串列分成兩個連結串列,減少擴容的遷移量
HashMap.Node<K,V> loHead = null, loTail = null;
HashMap.Node<K,V> hiHead = null, hiTail = null;
HashMap.Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
// 擴容後不需要移動的連結串列
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
// 擴容後需要移動的連結串列
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
// help gc
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
// help gc
hiTail.next = null;
// 擴容長度為當前index位置+舊的容量
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
/**
* 測試目的:理解HashMap發生resize擴容的時候對於連結串列的優化處理:
* 初始化一個長度為8的HashMap,因此threshold為6,所以當新增第7個數據的時候會發生擴容;
* Map的Key為Integer,因為整數型的hash等於自身;
* 由於hashMap是根據hash &(n - 1)來確定key所在的陣列下標位置的,因此根據公式 m(m >= 1)* capacity + hash碰撞的陣列索引下標index,可以拿到一組發生hash碰撞的資料;
* 例如本例子capacity = 8, index = 7,資料為:15,23,31,39,47,55,63;
* 有興趣的讀者,可以自己動手過後選擇一組不同的資料樣本進行測試。
* 根據hash &(n - 1), n = 8 二進位制1000 擴容後 n = 16 二進位制10000, 當8的時候由後3位決定位置,16由後4位。
*
* n - 1 : 0111 & index resize--> 1111 & index
* 15 : 1111 = 0111 resize--> 1111 = 1111
* 23 : 10111 = 0111 resize--> 10111 = 0111
* 31 : 11111 = 0111 resize--> 11111 = 1111
* 39 : 100111 = 0111 resize--> 100111 = 0111
* 47 : 101111 = 0111 resize--> 101111 = 1111
* 55 : 110111 = 0111 resize--> 110111 = 0111
* 63 : 111111 = 0111 resize--> 111111 = 1111
*
* 按照傳統的方式擴容的話那麼需要去遍歷連結串列,然後跟put的時候一樣對比key,==,equals,最後再放入新的索引位置;
* 但是從上面資料可以發現原先所有的資料都落在了7的位置上,當發生擴容時候只有15,31,47,63需要移動(index發生了變化),其他的不需要移動;
* 那麼如何區分哪些需要移動,哪些不需要移動呢?
* 通過key的hash值直接對old capacity進行按位與&操作如果結果等於0,那麼不需要移動反之需要進行移動並且移動的位置等於old capacity + 當前index。
*
* hash & old capacity(8)
* n : 1000 & index
* 15 : 1111 = 1000
* 23 : 10111 = 0000
* 31 : 11111 = 1000
* 39 : 100111 = 0000
* 47 : 101111 = 1000
* 55 : 110111 = 0000
* 63 : 111111 = 1000
*
* 從下面截圖可以看到通過原始碼中的處理方式可以拿到兩個連結串列,需要移動的連結串列15->31->47->63,不需要移動的連結串列23->39->55;
* 因此擴容的時候只需要把loHead放到原來的下標索引j(本例j=7),hiHead放到oldCap + j(本例為8 + 7 = 15)
*
* @param args
*/
public static void main(String[] args) {
HashMap<Integer, Integer> map = new HashMap<>(8);
for (int i = 1; i <= 7; i++) {
int sevenSlot = i * 8 + 7;
map.put(sevenSlot, sevenSlot);
}
}

 



負載因子loadFactor測試
前文提到負載因子loadFactor保持在0.75f是在時間跟空間上達到一個平衡,實際上也就是說0.75f是效率相對比較高的,下面將貼出測試案例。可以看到同樣的初始容量但是負載因子不同的map,map2,map4中map4是最快的,而相同的負載因子初始容量map3,map4中map3的速度更快。為此在使用HashMap的時候如果可以預知資料量大小的話最好指定初始容量,可以減少執行過程中擴容的次數,以達到一定的效能提升。

public static void main(String[] args) {
HashMap<Integer, Integer> map = new HashMap<>(16, 100);
HashMap<Integer, Integer> map2 = new HashMap<>(16, 0.5f);
HashMap<Integer, Integer> map3 = new HashMap<>(100);
HashMap<Integer, Integer> map4 = new HashMap<>();
DateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss:SS");

System.out.println(sdf.format(new Date()));
for (int i = 0; i < 5000000; i++) {
map.put(i, i);
}
System.out.println(sdf.format(new Date()));

System.out.println(sdf.format(new Date()));
for (int i = 0; i < 5000000; i++) {
map2.put(i, i);
}
System.out.println(sdf.format(new Date()));

System.out.println(sdf.format(new Date()));
for (int i = 0; i < 5000000; i++) {
map3.put(i, i);
}
System.out.println(sdf.format(new Date()));

System.out.println(sdf.format(new Date()));
for (int i = 0; i < 5000000; i++) {
map4.put(i, i);
}
System.out.println(sdf.format(new Date()));
}
20180705152534:83
20180705152546:87
20180705152546:87
20180705152550:643
20180705152550:643
20180705152552:980
20180705152552:980
20180705152556:132

get(Object key)
先前HashMap通過hash code來存放資料,那麼get方法一樣要通過hash code來獲取資料。可以看到如果當前table沒有資料的話直接返回null反之通過傳進來的hash值找到對應節點(Node)first,如果first的hash值以及Key跟傳入的引數匹配就返回對應的value反之判斷是否是紅黑樹,如果是紅黑樹則從根節點開始進行匹配如果有對應的資料則結果否則返回Null,如果是連結串列的話就會迴圈查詢連結串列,如果當前的節點不匹配的話就會從當前節點獲取下一個節點來進行迴圈匹配,如果有對應的資料則返回結果否則返回Null。

 

final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//如果當前table沒有資料的話返回Null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//根據當前傳入的hash值以及引數key獲取一個節點即為first,如果匹配的話返回對應的value值
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//如果引數與first的值不匹配的話
if ((e = first.next) != null) {
//判斷是否是紅黑樹,如果是紅黑樹的話先判斷first是否還有父節點,然後從根節點迴圈查詢是否有對應的值
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
//如果是連結串列的話迴圈拿出資料
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
————————————————
版權宣告:本文為CSDN博主「青元子」的原創文章,遵循CC 4.0 BY-SA版權協議,轉載請附上原文出處連結及本宣告。
原文連結:https://blog.csdn.net/u010890358/article/details/80496144