圖文並茂理解hashMap
閱讀此文後你將掌握的知識點:
1,hashMap原理
2,為什麼長度必須是2的整數次冪
3,PUT的資料如何進行儲存
4,java1.7的hashMap在高併發下會有什麼問題
5,java1.8有哪些改進
注:文中如有理解描述不當的地方請多多指正。
使用的資料結構
陣列 連結串列 紅黑樹(jdk > 1.7)
以陣列為主,連結串列 和 紅黑樹 為輔; 即HashMap底層是一個陣列,然後陣列的每一個元素是一個連結串列 或者 紅黑樹(1.8,當連結串列長度大於8時,連結串列會自動轉換成紅黑樹)
已陣列為主的優缺點:
優點:
- 查詢插入的時間複雜度為O(1)
缺點:
- 陣列佔用的空間必須是一塊連續的物理空間; 因此當分配的陣列空間不夠時會引發gc
原理
HashMap基於hashing原理,我們通過put()和get()方法儲存和獲取物件。
當我們將鍵值對傳遞給put()方法時,它呼叫鍵物件的hashCode()方法來計算hashcode,讓後找到bucket位置來儲存值物件。
當獲取物件時,通過hash(key)找到儲存的位置,如果此位置儲存的是一個連結串列,則在使用equals返回對應的值;
HashMap使用連結串列來解決碰撞問題,當發生碰撞了,物件將會儲存在連結串列的下一個節點中。 HashMap在每個連結串列節點中儲存鍵值對物件。
當兩個不同的鍵物件的hashcode相同時會發生什麼? 它們會儲存在同一個bucket位置的連結串列中。鍵物件的**equals()**方法用來找到鍵值對。
Base1.7
put
如果table中的某個位置已經是一個連結串列,此時在重新插入一個元素,得到的位置也是此位置,此時直接在頭部插入;
儲存索引的計算
第一種方式: hash(key) % length (對map長度取模)
第二種方式: hash(key) 進行位運算 == (length - 1) & hash(key)
通過檢視檢視indexFor(hash,table.length)
,得到的結論是第二種;
理由:
- 當資料量大的時候 位運算效能 遠遠高於 取模運算 (取模預算是加減乘除中效率最低的)
public V put(K key,V value) {
// HashMap允許存放null鍵和null值。
// 當key為null時,呼叫putForNullKey方法,將value放置在陣列第一個位置。
if (key == null)
return putForNullKey(value);
// 根據key的keyCode重新計算hash值。
int hash = hash(key.hashCode());
// 搜尋指定hash值在對應table中的索引。
int i = indexFor(hash,table.length);
// 如果 i 索引處的 Entry 不為 null,通過迴圈不斷遍歷 e 元素的下一個元素。
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;
}
}
// 如果i索引處的Entry為null,表明此處還沒有Entry。
modCount++;
// 將key、value新增到i索引處。
addEntry(hash,key,value,i);
return null;
}
void addEntry(int hash,K key,V value,int bucketIndex) {
// 獲取指定 bucketIndex 索引處的 Entry
Entry<K,V> e = table[bucketIndex];
// 將新建立的 Entry 放入 bucketIndex 索引處,並讓新的 Entry 指向原來的 Entry
table[bucketIndex] = new Entry<K,V>(hash,e);
// 如果 Map 中的 key-value 對的數量超過了極限
if (size++ >= threshold)
// 把 table 物件的長度擴充到原來的2倍。
resize(2 * table.length);
}
static int indexFor(int h,int length) {
return h & (length-1);
}
複製程式碼
為什麼Map長度總是2的整數次冪
如果在初始化的時候傳入的不是2的整數次冪,在原始碼實現中會自動進行強轉
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
複製程式碼
原因:
因為在計算元素儲存的位置的時候用的hash(key) & (table.length-1),所以一定要保證第二個運算數的二進位制是後面的數字一定要是連續的數字,這樣才能降低hash碰撞的概率,資料分佈就會相對的均勻,查詢的時候效率相對較高,
十進位制:2 4 8 16 32 -1 = 1 3 7 15 31
二進位制:10 100 1000 10000 100000 -1 = 01 011 0111 01111 011111
假設,map長度不是2的整數次冪,發生的效果假設length=15,在計算儲存位置的時候
hash(key) & (length-1) = hash(key) &(1110)
這樣計算之後,最後一位永遠都是0,最後導致在儲存的時候會有一個位置浪費,造成hash碰撞概率提高
例子
假設陣列長度分別為15和16,優化後的hash碼分別為8和9,那麼&運算後的結果如下:
h & (table.length-1) hash table.length-1
8 & (15-1): 0100 & 1110 = 0100
9 & (15-1): 0101 & 1110 = 0100
8 & (16-1): 0100 & 1111 = 0100
9 & (16-1): 0101 & 1111 = 0101
複製程式碼
Table.length-1 = (15-1) = 1110 分別與 8 和 9 的二進位制進行 &
操作,得到的二進位制的數最有一位肯定是0;
而0001,0011,0101,1001,1011,0111,1101這幾個位置永遠都不能存放元素了,空間浪費相當大,更糟的是這種情況中,陣列可以使用的位置比陣列長度小了很多,這意味著進一步增加了碰撞的機率,減慢了查詢的效率;
Table.length-1 = (16-1) = 1111 分別與 8 和 9 的二進位制進行 &
操作,得到的資料是不同的;2n-1得到的二進位制數的每個位上的值都為1,這使得在低位上&時,得到的和原hash的低位相同,加之hash(int h)方法對key的hashCode的進一步優化,加入了高位計算,就使得只有相同的hash值的兩個值才會被放到陣列中的同一個位置上形成連結串列.
負載因子
作用: 負載因子loadFactor衡量的是一個散列表(hashMap)的空間的使用程度 (如果length=8,當儲存的資料達到 8 * 0.75 = 6 時,開始進行擴容操作),在addEntry()
方法中進行判斷; 負載因子越大表示散列表的裝填程度越高,反之愈小。
resize(2 * table.length);
複製程式碼
超過後自定進行擴容 長度是原來的2倍; 因為擴容後需要重新對歷史資料進行rehash 以及 資料遷移操作,因此頻繁的擴容會造成效能問題;
對於使用連結串列法的散列表來說,查詢一個元素的平均時間是O(1+a),因此如果負載因子越大,對空間的利用更充分,然而後果是查詢效率的降低;如果負載因子太小,那麼散列表的資料將過於稀疏,對空間造成嚴重浪費。
因此 就是以空間換時間 (即浪費一些儲存空間換取高速的查詢)
get
public V get(Object key) {
if (key == null)
return getForNullKey();
int hash = hash(key.hashCode());
for (Entry<K,V> e = table[indexFor(hash,table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
return e.value;
}
return null;
}
複製程式碼
首先計算key的hashCode,找到陣列中對應位置的某一元素,然後通過key的equals方法在對應位置的連結串列中找到需要的元素。
高併發下的問題
會造成死鎖
造成死鎖的原因:當重新調整HashMap大小的時候,確實存在條件競爭,因為如果兩個執行緒都發現HashMap需要重新調整大小了,它們會同時試著調整大小。在調整大小的過程中,儲存在連結串列中的元素的次序會反過來,因為移動到新的bucket位置的時候,HashMap並不會將元素放在連結串列的尾部,而是放在頭部,這是為了避免尾部遍歷(tail traversing)。如果條件競爭發生了,那麼就死迴圈了。
擴容
void resize(int newCapacity) { //傳入新的容量
Entry[] oldTable = table; //引用擴容前的Entry陣列
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) { //擴容前的陣列大小如果已經達到最大(2^30)了
threshold = Integer.MAX_VALUE; //修改閾值為int的最大值(2^31-1),這樣以後就不會擴容了
return;
}
Entry[] newTable = new Entry[newCapacity]; //初始化一個新的Entry陣列
transfer(newTable); //!!將資料轉移到新的Entry陣列裡
table = newTable; //HashMap的table屬性引用新的Entry陣列
threshold = (int) (newCapacity * loadFactor);//修改閾值
}
void transfer(Entry[] newTable) {
Entry[] src = table; //src引用了舊的Entry陣列
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) { //遍歷舊的Entry陣列
Entry<K,V> e = src[j]; //取得舊Entry陣列的每個元素
if (e != null) {
src[j] = null;//釋放舊Entry陣列的物件引用(for迴圈後,舊的Entry陣列不再引用任何物件)
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash,newCapacity); //!!重新計算每個元素在陣列中的位置
/**
在此處就體現出了頭部插入;
*/
e.next = newTable[i]; //標記[1]
newTable[i] = e; //將元素放在陣列上
e = next; //訪問下一個Entry鏈上的元素
} while (e != null);
}
}
}
static int indexFor(int h,int length) {
return h & (length - 1);
}
複製程式碼
Base1.8
優化點:
- 增加了 紅黑樹,當連結串列長度>=8時 會預設將連結串列改成紅黑樹
- 優化擴容機制 (源Map上鍊表的值 在擴容的時候不需要rehash,新的索引 只有兩種情況:1,原索引; 3,原索引+ oldMAP.length
- hash 值計算
重要程式碼段講解
如何優化了hash值
static final int hash(Object key) {
int h;
/**
h >>> 16 將h的二進位制數向右移動16位,意味著捨棄低16位,然後將高16位補0
例如 有兩個數字 h1,h2 ; 對應的二進位制分別如下
h1 = 1011 0101 0010 1010 0101 1101 1100 1111
h2 = 1010 1001 0101 0101 0101 1101 1100 1111
我們發現這兩個數字的hashcode後16位是相同的,如果直接用次數進行與length-1進行&運算,得到的索引位置必然相同,這樣就會發生hash碰撞;
^ : 相同為0 不同為1
因此在1.8進行了改進 使用下面的方式; 仍用上面的例子
h1 = 1011 0101 0010 1010 0101 1101 1100 1111
h>>>16 = 0000 0000 0000 0000 1011 0101 0010 1010 ^
--------------------------------------------------
h1 1011 0101 0010 1010 1110 1000 1110 0101
h2 = 1010 1001 0101 0101 0101 1101 1100 1111
h>>>16 = 0000 0000 0000 0000 1010 1001 0101 0101 ^
--------------------------------------------------
h2 1010 1001 0101 0101 1111 0100 1001 1010
經過異或運算後,h1 h2的低位不在相等,因此在與length-1進行&運算後,索引的位置將不在相同,因此降低了ha sh碰撞的概率
*/
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
複製程式碼
Put
final V putVal(int hash,boolean onlyIfAbsent,boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n,i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash,null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this,tab,hash,value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash,null);
// TREEIFY_THRESHOLD 預設值 8,當大於等於時,轉換成tree
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
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;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 與1.7相比 條件進行了簡化
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
複製程式碼
resize
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = 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;
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) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this,newTab,j,oldCap);
else { // preserve order
Node<K,V> loHead = null,loTail = null;
Node<K,V> hiHead = null,hiTail = null;
Node<K,V> next;
do {
next = e.next;
/**
& 運算: 同1為1,否則為0
oldCap == tabel.length 一定是2的整數次冪,因此二進位制數一定是1開頭,後面全部都是0,因此在使用e.hash & oldCap的時候肯定只有兩種結果,一種是0 一種非0
例如:
已上面hash()為例子h2 為例,length=16
h2 = 1010 1001 0101 0101 1111 0100 1001 1010
length = 0000 0000 0000 0000 0000 0000 0001 0000 &
---------------------------------------------------
0000 0000 0000 0000 0000 0000 0001 0000
因此h2此資料在map進行擴容會進入到hiTail; 因此索引的位置為j+oldCap
*/
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) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
複製程式碼
split() 擴容時對tree進行拆分
final void split(HashMap<K,V> map,Node<K,V>[] tab,int index,int bit) {
TreeNode<K,V> b = this;
// Relink into lo and hi lists,preserving order
TreeNode<K,loTail = null;
TreeNode<K,hiTail = null;
int lc = 0,hc = 0;
for (TreeNode<K,V> e = b,next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
// 重新獲取元素的索引位置,兩種情況參見resize中的解釋演演算法
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
}
else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}
if (loHead != null) {
// UNTREEIFY_THRESHOLD 預設是6,當lc <= 6時,使用list進行儲存,否則使用tree
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
}
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
複製程式碼
untreeify() 將tree轉換成list
/**
將tree轉換成list進行儲存,兩種情況下進行轉換:
1,在remove節點的時候,如果該節點在remove之前是儲存在紅黑樹中,removeTreeNode()方法中進行判斷如果該樹節點太少則轉換成列表
2,在進行resize的時候,如果在resize前是某個索引位置儲存的是tree,在進行資料移動的時候會發生轉變
*/
final Node<K,V> untreeify(HashMap<K,V> map) {
Node<K,V> hd = null,tl = null;
for (Node<K,V> q = this; q != null; q = q.next) {
Node<K,V> p = map.replacementNode(q,null);
if (tl == null)
hd = p;
else
tl.next = p;
tl = p;
}
return hd;
}
複製程式碼
HashMap不是執行緒安全的,HashTable是執行緒安全的,因為在put方法上使用了synchronized,因為鎖的粒度太大,導致效能很差,因此在高併發時推薦使用concurrentHashMap;
參考資料:
crossoverjie.top/2018/07/23/… (包含一些面試常見問題)
blog.csdn.net/qq_41097354… (包含一些面試常見問題)