HashMap底層存儲結構
HashMap是一個用於存儲Key-Value鍵值對的集合,每一個鍵值對其實就是HashMap內部的Entry類對象。
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
/**
* Creates new entry.
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
Entry是HashMap的內部類,用來保存我們的鍵值,next指向下一個節點,hash用來保存key值的哈希碼
HashMap它底層是基於數組和鏈表實現的數據存儲結構。
HashMap在初始化時會創建一個默認長度爲16的數組,當我們添加元素時它會根據key值的哈希碼和數組長度取餘得到元素在數組的存儲位置。但是存在的問題就是不同的key值在經過計算之後可能會映射到相同的位置上,當插入一個元素時,發現該位置已經被佔用,這時候就會產生衝突,也就是所謂的哈希衝突,所以HashMap結合鏈表正是解決了位置衝突問題。
HashMap 設置數組的每一個元素對應一個鏈表的頭結點。當位置發生衝突時就往該鏈表的頭部插入新的節點,新的節點指向舊的頭結點。
HashMap存儲數據的流程:
如上圖: 當添加一個新的元素時先計算出元素在數組的存儲下標,如果位置是空的直接插入到數組,如果位置不爲空判斷key值是否相等,相等則覆蓋value值,不相等則歷遍鏈表。歷遍鏈表結束後key還是沒找到則往鏈表的頭部插入新的節點。
像上圖數組第一個位置存放着一個Entry對象,當插入新Entry對象計算出的位置也是數組的第一個位置,這時候發生哈希衝突了。系統會把新的Entry插入到數組的第一個位置,並且新的Entry.next屬性指向舊的Entry對象。
HashMap數據查找流程:
因爲HashMap在內部維護這一個數組table,數組的每個位置保存着每個鏈表的表頭結點,查找元素時,先通過hash函數得到key值對應的hash值,再根據hash值和數組長度計算得到在數組中的索引位置,拿到對應的鏈表的表頭,最後去遍歷這個鏈表,得到對應的value值。
put()方法源碼解析:
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
//計算key的哈希值
int hash = hash(key);
//根據key哈希值和數組長度計算出存儲下標
int i = indexFor(hash, table.length);
//歷遍table[i]整個鏈表,如果出現key重複的則覆蓋value值,然後return結束程序
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++;
//上面的for循環結束後沒有發現key沒有重複則會執行這個方法
addEntry(hash, key, value, i);
return null;
}
private V putForNullKey(V value) {
//獲取數組的第一個位置元素,歷遍鏈表找到key爲null的鍵值對然後覆蓋value值
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++;
//上面的for循環結束後沒有發現key爲null的元素則會執行這個方法
addEntry(0, null, value, 0);
return null;
}
void addEntry(int hash, K key, V value, int bucketIndex) {
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) {
//根據bucketIndex獲取數組指定位置的元素,e 是鏈表的頭結點
Entry<K,V> e = table[bucketIndex];
//創建節點放到數組中,這時候該節點成爲頭結點,同時它的next指向上一個頭結點e
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
先是判斷key是否爲null,是則執行putForNullKey()
,putForNullKey歷遍table[0]整個鏈表,如果有key等於null的元素時則覆蓋value值,然後結束程序(因此haspMap只能有一個key爲null的元素)。如果table[0]整個鏈表沒有key等於null的元素則執行 addEntry(0, null, value, 0)
,addEntry調用createEntry()
,createEntry就是將table[0]的元素取出來,然後把新的元素放到table[0]中,同時新的元素指向舊的元素,鏈表size++
當key不爲null時,先根據key的哈希值和數組長度計算出存儲的下標位置,歷遍table[i]的整個鏈表看有沒有key重複,如果出現key重複的則覆蓋value值,然後return結束程序。否則執行addEntry()
,addEntry調用createEntry()
,createEntry就是將table[i]的元素取出來,然後把新的元素放到table[i]中,同時新的元素指向舊的元素,鏈表size++
get()方法源碼解析:
public V get(Object key) {
//先判斷key是否爲null,是則執行getForNullKey
if (key == null)
return getForNullKey();
//key不等於null,執行getEntry
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
private V getForNullKey() {
//因爲key=null的元素hashMap都是存放在table[0]指向的鏈表中
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
//歷遍找到key=null的元素
if (e.key == null)
return e.value;
}
return null;
}
final Entry<K,V> getEntry(Object key) {
//計算key的哈希值
int hash = (key == null) ? 0 : hash(key);
//indexFor(hash, table.length)是根據key哈希值和table長度計算元素在數組的索引,然後for循環歷遍整個table[i]
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
//比較hash值和key值,找到元素後返回
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
先是判斷key是否爲null,是則調用getForNullKey()
,getForNullKey內部則會歷遍table[0]指向的鏈表。
若key不等於null,則調用getEntry()
,getEntry根據計算算出數組下標i,然後歷遍table[i],找不到元素返回null。
最後聲明一點,這是基於jdk1.7的源碼分析。1.8後對hashMap進行了優化。
1.7採用數組+單鏈表,1.8在單鏈表超過一定長度後改成紅黑樹存儲
1.7擴容時需要重新計算哈希值和索引位置,1.8並不重新計算哈希值,巧妙地採用和擴容後容量進行&操作來計算新的索引位置。
1.7插入元素到單鏈表中採用頭插入法,1.8採用的是尾插入法。
因此引申倆個問題:
1.爲啥1.7之前元素添加是採用頭插入法
因爲hashMap設計者們認爲新加的數據被訪問的機率大於舊的數據,所以放在前面訪問更快。
2.爲啥1.8之後元素添加改爲了尾插入法
HashMap在jdk1.7之前採用頭插入法,在擴容時會導致鏈表的順序倒置,在線程併發的情況下擴容容易導致鏈表死循環(即倆個節點的next節點相互指向對方),並且新加的數據被訪問的機率大於舊的數據這個說法並不成立,而尾插法在擴容的時候節點順序不會打亂。
jdk1.8之後HashMap爲何從頭插入改爲尾插入
3.1.8之後對計算元素索引進行了優化。未擴容前HashMap通過哈希值的二進制和數組長度-1的二進制進行按位與運算得到的結果就是下數組的索引,(圖是網上覆制的)
&是二進制“與”運算,參加運算的兩個數的二進制按位進行運算,運算的規律是:
0 & 0=0
0 & 1=0
1 & 0=0
1 & 1=1
例如:一個key的哈希值二進制是 0001 1010 ,數組長度是n=16,二進制:10000,n-1二進制是1111
哈希值和n-1進行與運算得到二進制:1010 轉成十進制就是10,即索引就是在table[10]
當數組擴容後n=32 ,二進制是:100000,n-1的二進制是:11111(n-1的最高位和舊數組的最高位相同
),當然我們依舊通過上訴的計算也是可以得到每個元素的索引,但是沒必要。你會發現當n-1的最高位對應的哈希值二進制數是0的話計算出來的索引不變,對應的是1則計算出來的結果是原位置+舊數組長度。
例如下圖:擴容後n-1的最高位是1(往左數第五個數),最高位對應hash1的二進制數是1,因此計算出來的結果是26,最高位對應hash2的二進制數是0,因此索引保持不變。又因爲舊數組的最高位和n-1的最高位是一樣的
,因此擴容的時候HashMap通過(e.hash & oldCap) == 0判斷節點是否爲新位置節點,等於1則移動到原位置+舊數組長度的索引(數組長度永遠是2的次冪,二進制只有最高位是1其他是0,因此不管誰和它進行&算要麼得1要麼得0)
現在推薦使用 ConcurrentHashMap,它是Java中的一個線程安全且高效的HashMap實現。