前言
HashMap使我們經常使用的數據結構,大家常說map的數據結構就是數組+鏈表,其中數組指的就是HashMap 中的table,是一個Node類型的數組如下:
/**
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node<K,V>[] table;
所說的鏈表其實就是多個Node相連,具體可以看其實現,Node中含有next成員變量,標記了它的nextNode,如此一個個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;
}
put 方法
先給出put 代碼實現:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); //2
}
通過上面代碼我們可以看到put的實際實現是調用了putVal,對key 調用hash對其hash,hash實現2處之所以要對h既key.hashcode右移16位是為了讓高位bit參與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
*/
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)
n = (tab = resize()).length; //1 table為null,數組初始化,大小為16
if ((p = tab[i = (n - 1) & hash]) == null) //2 數組當前位置為null,即沒有數據
tab[i] = newNode(hash, key, value, null);//3 調用node 構造方法生成新的node
else { //hash到的位置已有node
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) //1.8后當鏈表長度大于8后變為treenode 的處理
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) { //當前鏈表長度還小于8,遍歷鏈表
if ((e = p.next) == null) { //如果遍歷完未找到已存在的key相同的node,則新增一個node,放在鏈表尾部,不再使用頭插法
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st 如果binconut大于7 ,則將鏈表轉化為紅黑樹,-1是因為從0開始計數
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); //hashMap是空實現
return oldValue;
}
}
++modCount; //增加modCount,遍歷時拋出的modCount 不可改變異常與之相關
if (++size > threshold) //如果當前size 大于下次擴容節點 則擴容,threshold在下面resize 分析中可見
resize();
afterNodeInsertion(evict);//hashMap是空實現
return null;
}
上面是putVal的實現,其中1處會先判斷當前table 是否為null或為空,如果為空就調用resize方法初始化map,resize 實現如下,只需關注關鍵注釋節點:
/**
* Initializes or doubles table size. If null, allocates in
* accord with initial capacity target held in field threshold.
* Otherwise, because we are using power-of-two expansion, the
* elements from each bin must either stay at same index, or move
* with a power of two offset in the new table.
*
* @return the table
*/
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length; //第一次擴容時為null
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) { //如果原有數組長度大于2的30次方,則直接把下一次擴容結點設為2的31次方即最大整數值,返回原有數組不再擴容
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY) //如果原長度乘以2后仍小于2的30次方且原有長度大于默認長度16(非初始化),則新長度為原來2倍
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults map初始化,設置初始化容量16,初始化下次擴容結點即16*0.75
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]; //按照新的擴容長度生成新的node數組
table = newTab; //將新生成的空數組放回table中去 多線程問題 發生點1
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) //原有數組j位置 只有一個node 非鏈表 則直接對該node hash 放入新位置
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode) //1.8后變化,treenode 轉化方法
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null; //新數組低位node
Node<K,V> hiHead = null, hiTail = null; //新數組高位node,高低位以原有數組長度劃分
Node<K,V> next;
do { //多線程問題2
next = e.next;
//如果原來數組hash與原有長度相與為0 ,則證明在新數組中位置不用變,直接將其放入低位node,
//只有與2的n次方-1與運算才等于取模,1.8中非常巧妙的實現,后面分析
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {//不為0.則證明需要放入高位head
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
//低位鏈表直接放入新數組j位置,高位鏈表則放入j+原數組長度的新位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
多線程下可能發生的問題
resize是我們常說的hashmap 線程不安全問題發生的主要地方,很多文章都分析到當多個線程同時執行數組擴容舊數組數組放到新數組操作時有可能會發生頭尾指針向同一node,造成resize 源碼標2處發生死循環導致cpu飆高,所以需要避免多個線程同時進行put操作,此處不再具體分析;
今天閱讀源碼發現另一個多線程問題代碼中標1處,在舊數組遷移到新數組之前就已經把新數組賦給了table,導致當一個線程擴容操作執行到此處時,其他線程讀取map 獲得數據為null,具體實驗如下:
public static void main(String[] args) {
HashMap<Integer, Integer> map = Maps.newHashMap();
map.put(1,1);
System.out.println("獲取到的1的值為"+map.get(1));
new Thread(() -> {
System.out.println("find線程啟動");
while (true){
if (map.get(1)==null){
System.out.println("獲取到的1為空,發現多線程問題");
}
}
}).start();
new Thread(() -> {
for (int i=2;i<600000;i++){
map.put(i,1);
}
System.out.println("數放完了");
}).start();
}
結果如下:
獲取到的1的值為1
find線程啟動
獲取到的1為空,發現多線程問題
獲取到的1為空,發現多線程問題
獲取到的1為空,發現多線程問題
獲取到的1為空,發現多線程問題
獲取到的1為空,發現多線程問題
獲取到的1為空,發現多線程問題
獲取到的1為空,發現多線程問題
獲取到的1為空,發現多線程問題
獲取到的1為空,發現多線程問題
獲取到的1為空,發現多線程問題
獲取到的1為空,發現多線程問題
獲取到的1為空,發現多線程問題
數放完了
通過上述實驗,我們可以得出一個線程put,多個線程讀取這種操作也是不安全的,多線程還是盡量用concurrentHashMap。
巧妙的&
計算hash值和index時的與運算
- 調用hashCode計算出一個二進制數值記為h
- 將h無符號右移16位
- 將h與右移后的h做異或運算,保證高低位都參與hash計算,得到hash值
- 將hash值和n-1 做與運算得到index,等價于用hash對n 取模
與運算和 取模操作等價原因,n保證了是2的k次方,所以n-1 的二進制永遠是為1,高位為0;所以與hash值進行與運算,參與運算的只有低位為1的值,即相與就會的到hash對n的余數。
擴容時與n相與
與1.7不同,1.8擴容時不再重新通過hash計算每個node的index,而是通過計算node hash和原數組長度的與操作結果是否為0,來判斷node在新數組中的位置是否需要在原位置加上原數組長度。
a代表擴容前,b代表擴容后,由圖可見擴容后n-1是在高位多了一個1,于是決定新的hash值是否與原來相同要看hash對應的位置是0還是1,如果是0則與運算結果和原來相同,否則高位要加1,即原來數組的長度n;
我們需要做的就是要判斷對應位置是0還是1,于是源碼中的操作是和n 做與運算,因為n永為2的k次方,所以二進制中只有1個bit位是1,所以當與n做與運算就可以判斷出hash值的高位是否為0,若結果為0 則代表擴容后位置不用變,若為1 則代表擴容后二進制在高位加了1,即原數組長度n,新的index需要在原index上加n。
&的優勢
- 計算index時改取模操作為與運算,使計算機由耗能巨大的除法運算變為了簡單的移位運算;
- 擴容時& 與操作,避免重復進行通過hash值進行index計算提高了性能,但這一條看起來很奇怪因為計算index 操作也是& 運算,并且得到結果可以直接獲取對應index,擴容時和n進行& 與運算還要根據結果判斷是否進行加法運算,是否真的提高了性能值得商榷~
get方法
get方法的實現相對簡單,show you the code~
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) { //根據hash計算出index 位置
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k)))) //檢查index位置的第一個元素是否是目標元素
return first;
if ((e = first.next) != null) {//檢查后續元素
if (first instanceof TreeNode)//1.8紅黑樹相關實現
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {//遍歷鏈表匹配key值
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
Map中的entrySet,keySet,values
在沒看源碼以前,認為這兩個方法的實現就是把map中的table轉換一下返回一個set;看了源碼發現這兩個方法實現比較有意思。
以entrySet為例,它返回的是繼承了AbstractSet,然后重寫了size,clear,iterator,contains等方法的EntrySet。
final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
public final int size() { return size; }
public final void clear() { HashMap.this.clear(); }
public final Iterator<Map.Entry<K,V>> iterator() {
return new EntryIterator();//1.繼承HashIterator,重寫next方法,調用HashIterator的nextNode
}
public final boolean contains(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry<?,?> e = (Map.Entry<?,?>) o;
Object key = e.getKey();
Node<K,V> candidate = getNode(hash(key), key);
return candidate != null && candidate.equals(e);
}
public final boolean remove(Object o) {
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>) o;
Object key = e.getKey();
Object value = e.getValue();
return removeNode(hash(key), key, value, true, true) != null;
}
return false;
}
public final Spliterator<Map.Entry<K,V>> spliterator() {
return new EntrySpliterator<>(HashMap.this, 0, -1, 0, 0);
}
public final void forEach(Consumer<? super Map.Entry<K,V>> action) {
Node<K,V>[] tab;
if (action == null)
throw new NullPointerException();
if (size > 0 && (tab = table) != null) {
int mc = modCount;
for (int i = 0; i < tab.length; ++i) {
for (Node<K,V> e = tab[i]; e != null; e = e.next)
action.accept(e);
}
if (modCount != mc)
throw new ConcurrentModificationException();
}
}
}
這里著重看下iterator的實現,可見代碼中返回了new EntrySet(),實現如下:
/**
*調用了HashIterator中的nextNode方法
*/
final class EntryIterator extends HashIterator
implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() { return nextNode(); }
}
final Node<K,V> nextNode() {
Node<K,V>[] t;
Node<K,V> e = next; //next 是HashIterator的成員變量
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
if ((next = (current = e).next) == null && (t = table) != null) {//遍歷每個index下的鏈表,把下一結點賦值給next變量方便下次調用next
do {} while (index < t.length && (next = t[index++]) == null);//取table數組中下一個index繼續遍歷其中鏈表
}
return e;//返回結點
}
keySet和values的Iterator實現和entrySet基本相同,只是它返回分別是KeyIterator()和ValueIterator(),實現如下
final class KeyIterator extends HashIterator
implements Iterator<K> {
public final K next() { return nextNode().key; }
}
final class ValueIterator extends HashIterator
implements Iterator<V> {
public final V next() { return nextNode().value; }
}
可以看到其實現同樣調用了nextNode方法,只不過一個取了key,一個取了value。
通過上面代碼可以看出,遍歷map的時候最好使用entrySet,可以一次性拿到key,value;如果使用了keySet本質代價和entrySet一樣,但還要多出一步get的過程,性能降低~
總結
- 擴容非常消耗性能,使用hashmap時最好能夠提前預估大小,盡量避免頻繁擴容
- 就算是單線程寫,多線程讀,hashmap一樣存在多線程問題,多線程條件下建議使用ConcurrentHashMap
- 1.8后引入的紅黑樹優化,在hash沖突嚴重的情況下性能提升非常明顯
- 遍歷時盡可能使用entrySet或直接使用1.8的新方法forEach
- 這篇文章未涉及紅黑樹分析,留待后續文章