HashMap
HashMap 和 HashSet 是 Java Collection Framework 的兩個重要成員,其中 HashMap 是 Map 接口的常用實現類,HashSet 是 Set 接口的常用實現類。雖然 HashMap 和 HashSet 實現的接口規範不同,但它們底層的 Hash 存儲機制完全一樣,甚至 HashSet 本身就採用 HashMap 來實現的。
通過 HashMap、HashSet 的源代碼分析其 Hash 存儲機制
實際上,HashSet 和 HashMap 之間有很多相似之處,對於 HashSet 而言,系統採用 Hash 算法決定集合元素的存儲位置,這樣可以保證能快速存、取集合元素;對於 HashMap 而言,系統 key-value 當成一個整體進行處理,系統總是根據 Hash 算法來計算 key-value 的存儲位置,這樣可以保證能快速存、取 Map 的 key-value 對。
在介紹集合存儲之前需要指出一點:雖然集合號稱存儲的是 Java 對象,但實際上並不會真正將 Java 對象放入 Set 集合中,只是在 Set 集合中保留這些對象的引用而言。也就是說:Java 集合實際上是多個引用變量所組成的集合,這些引用變量指向實際的 Java 對象。
1、HashMap 的存儲實現
- jdk7 的 HashMap 結構是:數組+鏈表;
- jdk8 的 HashMap 結構是:數組+鏈表+紅黑樹
當程序試圖將多個 key-value 放入 HashMap 中時,以如下代碼片段爲例:
HashMap<String , Double> map = new HashMap<String , Double>();
map.put("語文" , 80.0);
map.put("數學" , 89.0);
map.put("英語" , 78.2);
HashMap 採用一種所謂的“Hash 算法”來決定每個元素的存儲位置。
當程序執行 map.put(“語文” , 80.0); 時,系統將調用"語文"的 hashCode() 方法得到其 hashCode 值——每個 Java 對象都有 hashCode() 方法,都可通過該方法獲得它的 hashCode 值。得到這個對象的 hashCode 值之後,系統會根據該 hashCode 值來決定該元素的存儲位置。
我們可以看 HashMap 類的 put(K key , V value) 方法的源代碼:
/*
* jdk7
*/
public V put(K key, V value) {
//第一次存儲元素,初始化
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// 如果 key 爲 null,調用 putForNullKey 方法進行處理
if (key == null)
return putForNullKey(value);
// 根據 key 的 keyCode 計算 Hash 值
int hash = hash(key);
// 搜索指定 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;
}
/*
* jdk8
*/
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;
//計算數組的索引位置是否有元素,沒有元素的話直接在該索引處存儲新元素即可
//(tab.length - 1)& hash這個方法與jdk7的indexFor方法一樣
if ((p = tab[i = (n - 1) & hash]) == null)
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;
//如果這個位置有元素,並且與原位置上的元素不等的話,判斷是否是紅黑樹
else if (p instanceof HashMap.TreeNode)
//新元素放置到entry鏈的頭部,moveRootToFront這個方法中
e = ((HashMap.TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//如果這個位置有元素,並且與原位置上的元素不等的話,不是紅黑樹即是鏈表
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//插入新元素到鏈表最尾
p.next = newNode(hash, key, value, null);
//如果鏈表個數超過8個,會進行具體的判斷,選擇擴容數組或者鏈表轉爲紅黑樹
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;
//如果已有這個key,node的值指向新值value
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//插入元素後檢查是否需要進行擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
上面程序中用到了一個重要的內部接口:Map.Entry,每個 Map.Entry 其實就是一個 key-value 對。從上面程序中可以看出:當系統決定存儲 HashMap 中的 key-value 對時,完全沒有考慮 Entry 中的 value,僅僅只是根據 key 來計算並決定每個 Entry 的存儲位置。這也說明了前面的結論:我們完全可以把 Map 集合中的 value 當成 key 的附屬,當系統決定了 key 的存儲位置之後,value 隨之保存在那裏即可。
hash(),這個方法是一個純粹的數學計算,jdk7 與jdk8 的計算方法有些許不同:
/*
* jdk7
*/
static int hash(int h)
{
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
/*
* jdk8
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
對於任意給定的對象,只要它的 hashCode() 返回值相同,那麼程序調用 hash(int h) 方法所計算得到的 Hash 碼值總是相同的。jdk7 採用接下來程序會調用 indexFor(int h, int length) 方法來計算該對象應該保存在 table 數組的哪個索引處。jdk8 也是使用 tab[i = (n - 1) & hash] 來計算索引位置:
/*
* jdk7
*/
static int indexFor(int h, int length)
{
return h & (length-1);
}
這個方法非常巧妙,它總是通過 h & (table.length -1) 來得到該對象的保存位置——而 HashMap 底層數組的長度總是 2 的 n 次方,這一點可參看後面關於 HashMap 構造器的介紹。通過一下代碼可以看出計算索引位置的原理:
/*
* 以"語文"作爲key,計算索引位置,得到index爲10
*/
int keyHashCode = "語文".hashCode();//keyHashCode結果爲1136442
int tableLength = 15;
int index1 = keyHashCode & tableLength;//index1結果爲10
/*
* 實現原理
*/
String keyBinaryString = Integer.toBinaryString(keyHashCode);//keyBinaryString結果爲100010101011100111010
String tableBinaryString = Integer.toBinaryString(tableLength);//tableBinaryString結果爲1111
/*
* 100010101011100111010
* & 000000000000000001111
* -----------------------
* 000000000000000001010
*/
Integer index2 = Integer.valueOf("1010", 2);//index2結果爲10
通過這種與運算,可以看出index1和index2結果一致,這樣計算的好處就是最後得到的索引值註定不會超過原來的table的大小。
根據上面 put 方法的源代碼可以看出,當程序試圖將一個 key-value 對放入 HashMap 中時,程序首先根據該 key 的 hashCode() 返回值決定該 Entry 的存儲位置:如果兩個 Entry 的 key 的 hashCode() 返回值相同,那它們的存儲位置相同。如果這兩個 Entry 的 key 通過 equals 比較返回 true,新添加 Entry 的 value 將覆蓋集合中原有 Entry 的 value,但 key 不會覆蓋。如果這兩個 Entry 的 key 通過 equals 比較返回 false,新添加的 Entry 將與集合中原有 Entry 形成 Entry 鏈。
當向 HashMap 中添加 key-value 對,由其 key 的 hashCode() 返回值決定該 key-value 對(就是 Entry 對象)的存儲位置。當兩個 Entry 對象的 key 的 hashCode() 返回值相同時,將由 key 通過 eqauls() 比較值決定是採用覆蓋行爲(返回 true),還是產生 Entry 鏈(返回 false)。
上面程序中還調用了 addEntry(hash, key, value, i) 代碼,其中 addEntry 是 HashMap 提供的一個包訪問權限的方法,該方法僅用於添加一個 key-value 對。下面是該方法的代碼:
/*
* jdk7
*/
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) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
//新生成entry的next指針永遠指向原位置上的entry
static class Entry<K,V> implements Map.Entry<K,V> {
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
}
/*
* jdk8
*/
if ((e = p.next) == null) {
//插入新元素到鏈表最尾
p.next = newNode(hash, key, value, null);
//如果鏈表個數超過8個,會進行具體的判斷,選擇擴容數組或者鏈表轉爲紅黑樹
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
這兩端代碼體現了 jdk8 和 jdk7 的 entry 鏈表插入新元素時的不同方式,jdk7 是插入鏈表頭部,jdk8 是插入鏈表尾部;jdk8 會在鏈表長度達到 8 時,並且數組長度達到 64 以上時,自動把鏈表轉爲紅黑樹,從而獲得更好的性能。
2、Hash 算法的性能選項
根據上面代碼可以看出,在同一個 bucket 存儲 Entry 鏈的情況下,新放入的 Entry 總是位於 bucket 中,而最早放入該 bucket 中的 Entry 則位於這個 Entry 鏈的最末端或者最前端。
上面程序中還有這樣兩個變量:
* size:該變量保存了該 HashMap 中所包含的 key-value 對的數量。
* threshold:該變量包含了 HashMap 能容納的 key-value 對的極限,它的值等於 HashMap 的容量乘以負載因子(load factor)。
從上面程序中②號代碼可以看出,當 size++ >= threshold 時,HashMap 會自動調用 resize 方法擴充 HashMap 的容量。每擴充一次,HashMap 的容量就增大一倍。
上面程序中使用的 table 其實就是一個普通數組,每個數組都有一個固定的長度,這個數組的長度就是 HashMap 的容量。HashMap 包含如下幾個構造器:
* HashMap():構建一個初始容量爲 16,負載因子爲 0.75 的 HashMap。
* HashMap(int initialCapacity):構建一個初始容量爲 initialCapacity,負載因子爲 0.75 的 HashMap。
* HashMap(int initialCapacity, float loadFactor):以指定初始容量、指定的負載因子創建一個 HashMap。
當創建一個 HashMap 時,系統會自動創建一個 table 數組來保存 HashMap 中的 Entry,下面是 HashMap 中一個構造器的代碼:
/*
* jdk7的初始化和擴容
*/
public HashMap(int initialCapacity, float loadFactor) {
// 初始容量不能爲負數
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 如果初始容量大於最大容量,讓出示容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 負載因子必須大於 0 的數值
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
// 設置容量極限等於容量 * 負載因子
threshold = initialCapacity;
init();
}
public V put(K key, V value) {
//第一次存儲元素時初始化容量
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
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++;
addEntry(hash, key, value, i);
return null;
}
private void inflateTable(int toSize) {
// 計算所需容量
int capacity = roundUpToPowerOf2(toSize);
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
private static int roundUpToPowerOf2(int number) {
//① 容量初始化
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
//當前數組容量的2倍進行擴容
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
//複製數組
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
//②需要重新計算entry的索引位置
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
/*
* jdk8的初始化和擴容
*/
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);
}
//①容量初始化
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;
}
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
}
//如果是初始化,容量設置爲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 HashMap.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 HashMap.TreeNode)
((HashMap.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;
//②位運算計算出與原索引的最高位是否是0,是0索引不變
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//不是0,原索引加上擴容大小爲新索引
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;
}
分開看 jdk7 和 jdk8 的初始化代碼,table 的實質就是一個數組,一個長度爲 capacity 的數組。看上面代碼註釋①,兩者雖然初始化方法不同,但是都是通過相應的算法計算出capacity 的這個值,這個值才代表了實際的map容量大小,也就是說無論指定初始化的大小是多少,初始化後的容量始終是16的倍數,即給定 initialCapacity 爲 10,map 的實際容量是 16;給定initialCapacity 爲 33,map的實際容量是64。
爲什麼要嚴格的設定容量是16或者16的倍數?因爲16的hash值是10000,這個數值對於擴容的計算會特別快,繼續看計算索引的代碼示例:
/*
* 以"語文"作爲key,計算索引位置,得到index爲10
*/
int keyHashCode1 = "語文".hashCode();//keyHashCode1結果爲1136442
int keyHashCode2 = "生物".hashCode();//keyHashCode2結果爲958762
int tableLength = 15;
int index1 = keyHashCode1 & tableLength;//index1結果爲10
int index2 = keyHashCode2 & tableLength;//index2結果爲10
/*
* 實現原理
*/
String keyBinaryString1 = Integer.toBinaryString(keyHashCode1);//keyBinaryString1結果爲100010101011100111010
String keyBinaryString2 = Integer.toBinaryString(keyHashCode2);//keyBinaryString2結果爲11101010000100101010
String tableBinaryString = Integer.toBinaryString(tableLength);//tableBinaryString結果爲1111
/*
* 100010101011100111010
* & 000000000000000001111
* -----------------------
* 000000000000000001010
*
* 011101010000100101010
* & 000000000000000001111
* -----------------------
* 000000000000000001010
*
*/
Integer myIndex1 = Integer.valueOf("1010", 2);//myIndex1結果爲10
Integer myIndex2 = Integer.valueOf("1010", 2);//myIndex2結果爲10
/*
* 擴容後
*/
tableLength = 31;
index1 = keyHashCode1 & tableLength;//index1結果爲26
index2 = keyHashCode2 & tableLength;//index2結果爲10
/*
* 擴容後實現原理
*/
tableBinaryString = Integer.toBinaryString(tableLength);//tableBinaryString結果爲11111
/*
* 100010101011100111010
* & 000000000000000011111
* -----------------------
* 000000000000000011010
* ↓
* 1
*
* 011101010000100101010
* & 000000000000000011111
* -----------------------
* 000000000000000001010
* ↓
* 0
*/
myIndex1 = Integer.valueOf("11010", 2);//myIndex1結果爲26
myIndex2 = Integer.valueOf("1010", 2);//myIndex2結果爲10
看一下這個實現原理:兩個不同的 key 通過 keyHashCode & (tableLength - 1)這個簡單的位運算得到一個索引值,擴容後再次得到一個新索引值,這個新的索引值與原來的索引值有很大關係:
如果容量的最高位(1)與 key 對應此位置的數值(1)正好一致的話,則計算結果最高位較原計算結果最高位多一個 1,也就是新索引值比原索引值大10000 ;如果容量的最高位(1)與 key 對應此位置的數值(0)不一致的話,那麼計算結果與原計算結果一致。而 16 的二進制值是10000,32 的二進制是 100000 ,以此類推,如果計算結果多出的 10000 就是 16 ,也就是原索引值需要增加 16 ,16 恰好就是原容量值。換句話說,得到的新索引值,不是原索引值就一定是原索引值加上原容量後的值。
上面這段計算對於 hashmap 存儲數據來說有什麼幫助呢?也許對於 jdk7 來說很小,但是對於 jdk8 來說幫助卻是特別大。因爲上面的分析可以看到,其實我們只需要把新索引值的二進制值與原索引值的二進制值的最高位進行比較即可,如果最高位的位數多一位的話,說明需要加大索引值;如果位數一致 ,說明使用原索引值即可。爲什麼說這對於 jdk7 幫助很小呢?看上面代碼註釋②,jdk7 進行擴容時需要每個元素重新計算新的索引值,而jdk8 通過這個方法來判斷:
if ((e.hash & oldCap) == 0)
這個方法的巧妙在哪呢?其實我們看上面的代碼, tableLength - 1 的原容量 16 - 1的二進制值是 1111,擴容後的 32 - 1 的二進制值是 11111 ,我們最後判斷索引值是否改變的標準就是看 key 值的 “↓” 下箭頭這個位置的數字是否是 1 ,是 1 則改變索引值, 是 0 則索引值不變。那麼怎麼簡單的判斷 key 二進制值中指定位置的數字是否是 0 呢?很簡單,原容量 16 二進制值恰好是 10000, 也就是說 key 二進制值與 10000,做個與運算即可。如果 “↓” 下箭頭這個位置的數字正好是 0 ,那麼與 10000 的位運算計算結果也必定是0。下面看代碼:
以“語文”和“生物”這兩個key的hashcode二進制值爲例,看一下 if ((e.hash & oldCap) == 0) 的原理:
/*
* key:語文
* 100010101011100111010
* & 000000000000000010000
* -----------------------
* 000000000000000010000
*
* key:生物
* 011101010000100101010
* & 000000000000000010000
* -----------------------
* 000000000000000000000
*/
最終我們通過這樣的運算,得到 0 或者 非 0 的結果,我們也以此來判斷是否使用原索引值。這就是初始容量是 16 的好處,可以通過簡單的位運算得到索引值,而不需要像 jdk7 那樣每個重新計算一次,這也是爲什麼每次擴容後的值一定是 16 的偶數倍。
3、HashMap 的讀取實現
當 HashMap 的每個 bucket 裏存儲的 Entry 只是單個 Entry ——也就是沒有通過指針產生 Entry 鏈時,此時的 HashMap 具有最好的性能:當程序通過 key 取出對應 value 時,系統只要先計算出該 key 的 hashCode() 返回值,在根據該 hashCode 返回值找出該 key 在 table 數組中的索引,然後取出該索引處的 Entry,最後返回該 key 對應的 value 即可。看 HashMap 類的 get(K key) 方法代碼:
/*
* jdk7
*/
public V get(Object key) {
// 如果 key 是 null,調用 getForNullKey 取出對應的 value
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
int hash = (key == null) ? 0 : hash(key);
// 直接取出 table 數組中指定索引處的值,
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
// 如果該 Entry 的 key 與被搜索 key 相同
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/*
* jdk8
*/
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) {
//每次都先檢查第一個元素是否匹配
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
//如果node類型是紅黑樹查找到key即可
if (first instanceof HashMap.TreeNode)
return ((HashMap.TreeNode<K,V>)first).getTreeNode(hash, key);
//如果node類型是鏈表,一個個向下查找即可
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
從上面代碼中可以看出,如果 HashMap 的每個 bucket 裏只有一個 Entry 時,HashMap 可以根據索引、快速地取出該 bucket 裏的 Entry;在發生“Hash 衝突”的情況下,單個 bucket 裏存儲的不是一個 Entry,而是一個 Entry 鏈,系統只能必須按順序遍歷每個 Entry,直到找到想搜索的 Entry 爲止——如果恰好要搜索的 Entry 位於該 Entry 鏈的最末端(該 Entry 是最早放入該 bucket 中),那系統必須循環到最後才能找到該元素。這也是爲什麼jdk8引入紅黑樹的原因,當鏈表長度大於8時,把鏈表轉爲紅黑樹存儲,這樣在查詢時不需要每個遍歷一次來找到相應的key。
歸納起來簡單地說,HashMap 在底層將 key-value 當成一個整體進行處理,這個整體就是一個 Entry 對象。HashMap 底層採用一個 Entry[] 數組來保存所有的 key-value 對,當需要存儲一個 Entry 對象時,會根據 Hash 算法來決定其存儲位置;當需要取出一個 Entry 時,也會根據 Hash 算法找到其存儲位置,直接取出該 Entry。由此可見:HashMap 之所以能快速存、取它所包含的 Entry,完全類似於現實生活中母親從小教我們的:不同的東西要放在不同的位置,需要時才能快速找到它。
當創建 HashMap 時,有一個默認的負載因子(load factor),其默認值爲 0.75,這是時間和空間成本上一種折衷:增大負載因子可以減少 Hash 表(就是那個 Entry 數組)所佔用的內存空間,但會增加查詢數據的時間開銷,而查詢是最頻繁的的操作(HashMap 的 get() 與 put() 方法都要用到查詢);減小負載因子會提高數據查詢的性能,但會增加 Hash 表所佔用的內存空間。
掌握了上面知識之後,我們可以在創建 HashMap 時根據實際需要適當地調整 load factor 的值;如果程序比較關心空間開銷、內存比較緊張,可以適當地增加負載因子;如果程序比較關心時間開銷,內存比較寬裕則可以適當的減少負載因子。通常情況下,程序員無需改變負載因子的值。
如果開始就知道 HashMap 會保存多個 key-value 對,可以在創建時就使用較大的初始化容量,如果 HashMap 中 Entry 的數量一直不會超過極限容量(capacity * load factor),HashMap 就無需調用 resize() 方法重新分配 table 數組,從而保證較好的性能。當然,開始就將初始容量設置太高可能會浪費空間(系統需要創建一個長度爲 capacity 的 Entry 數組),因此創建 HashMap 時初始化容量設置也需要小心對待。
ConcurrentHashMap
這部分是後來補充學習了一下,沒有去看 jdk7 的 ConcurrentHashMap 源碼,簡單的看了下 jdk8 的 ConcurrentHashMap 源碼。jdk7 的 ConcurrentHashMap 結構是 Segment 數組,每個 Segment 數組裏面相當於有一個 HashMap,jdk8 的 ConcurrentHashMap 結構與 jdk7 差別很大,廢棄了這種方式,只是在 HashMap 的基礎上加入了一些併發操作,基本結構與 jdk8 的 HashMap 一樣,也是數組 + 鏈表 + 紅黑樹的形式。
1、構造函數:
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
}
與 HashMap 一樣,構造函數中什麼也不做,只是簡單的計算了 sizeCtrl 這個值,這個值的計算結果最終也一定得到 16 的倍數(除非 initialCapacity 指定小於 4 的值)。
2、put方法:
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
//計算鏈表的長度
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//初始化數組
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//如果此hash值位置沒有元素,直接添加node即可
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
//MOVED這個值是-1,說明數組正在擴容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
//如果此hash值位置的頭部node節點hash值大於0,說明這是一個鏈表
if (fh >= 0) {
binCount = 1;
//計算鏈表長度
for (Node<K,V> e = f;; ++binCount) {
K ek;
//覆蓋舊值
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
//插入新值
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
//如果此hash值位置的node類型是紅黑樹
else if (f instanceof ConcurrentHashMap.TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((ConcurrentHashMap.TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
//①當計算鏈表長度超過8時,會進行具體的判斷,選擇擴容數組或者鏈表轉爲紅黑樹
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
//用於檢查是否需要擴容數組
addCount(1L, binCount);
return null;
}
對於插入元素時的併發問題,使用加鎖和CAS操作來保證線程安全,CAS介紹:
https://blog.csdn.net/mmoren/article/details/79185862
3、數組初始化:
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
//說明其他線程正在初始化數組,等待即可
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
//搶到線程後將sizeCtl的值賦值爲-1,不讓其他線程進行操作
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
//默認初始容量爲16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
//重新賦值後的sc值變爲12,也就是16的0.75
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
4、鏈表轉紅黑樹:
上面 put 方法中的代碼註釋①中,有一處判斷,新插入元素如果是在鏈表位置,去要看具體情況來判斷到底是需要擴容數組,還是把鏈表轉爲紅黑樹,下面是源碼:
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
//判斷數組長度是否選擇擴容
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
tryPresize(n << 1);
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
synchronized (b) {
if (tabAt(tab, index) == b) {
//新建紅黑樹,進行轉換
ConcurrentHashMap.TreeNode<K,V> hd = null, tl = null;
for (Node<K,V> e = b; e != null; e = e.next) {
ConcurrentHashMap.TreeNode<K,V> p =
new ConcurrentHashMap.TreeNode<K,V>(e.hash, e.key, e.val,
null, null);
if ((p.prev = tl) == null)
hd = p;
else
tl.next = p;
tl = p;
}
//把新轉換出來的紅黑樹插入到數組中
setTabAt(tab, index, new ConcurrentHashMap.TreeBin<K,V>(hd));
}
}
}
}
}
所以此處的邏輯與 HashMap 一致,並不是一定會把鏈表轉爲紅黑樹,而是需要根據數組的長度而來,只有當數組長度超過 64 時,纔會進行鏈表與紅黑樹的轉換,否則擴容數組即可。
5、數組擴容:
private final void tryPresize(int size) {
//數組直接根據size的值進行擴容
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
tableSizeFor(size + (size >>> 1) + 1);
int sc;
while ((sc = sizeCtl) >= 0) {
Node<K,V>[] tab = table; int n;
//這個與初始化代碼一樣
if (tab == null || (n = tab.length) == 0) {
n = (sc > c) ? sc : c;
if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if (table == tab) {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
}
}
else if (c <= sc || n >= MAXIMUM_CAPACITY)
break;
//不是初始化的話直接進行擴容,然後進行數據遷移
else if (tab == table) {
int rs = resizeStamp(n);
//已經有線程在擴容,幫助擴容即可
if (sc < 0) {
Node<K,V>[] nt;
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
//調用數據遷移方法時傳入新的數組對象,並且CAS操作把sizeCtl加1
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
//此時此線程第一個進行擴容,因爲sizeCtl現在是大於0的狀態,把sizeCtl設置成很大的負數,調用數據遷移的方法,但是新數組傳入的是null
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
}
}
}
進行擴容時會先判斷是否需要初始化,判斷不需要初始化的話,就會直接進行擴容。在數組初始化時,就有一個很重要的參數:sizeCtl。判斷是否自己進行初始化還是已經有線程進行初始化,判斷是否自己進行擴容還是已有線程進行擴容,都是根據這個參數來的,如果這個參數爲負數的話,說明已有線程進行了操作。sizeCtl 這個參數一直有CAS操作來維護。
當第一次擴容時,會把 sizeCtl 設置爲一個很大的負數,後續有線程幫助擴容時會把這個值加 1,但是依舊是一個負數,由此來判斷是自己進行擴容,還是幫助擴容。
另外每次 put 元素後都會進行一個檢查,檢查當前容量是否達到閾值,如果達到也需要擴容:
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
s = sumCount();
}
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
if (sc < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
這段代碼前面沒看懂,不過後面與 tryPresize 的擴容方法一樣。
6、數據遷移:
這是最難看懂的一部分代碼了:
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
//n是原數組的長度
int n = tab.length, stride;
//stride可以理解爲任務數,主要跟cpu的參數有關,代表一條線程負責遷移的元素個數,可以當作16處理
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
//nextTab爲null說明是第一個遷移的線程
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
//創建一個新數組,容量翻倍
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
//原數組的數組大小
transferIndex = n;
}
//nextn記錄下新數組的長度,這個值用於判斷,不會改變
int nextn = nextTab.length;
//ForwardingNode代表正在遷移的node,注意它的hash值是MOVED
//這個很重要,在其他方法代碼中都有對於這個值的判斷,前面不明白MOVED這個值怎麼來的應該可以理解了
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
//advance代表做完一個任務包的遷移,準備進行下一個任務包的遷移
boolean advance = true;
//finishing代表全部遷移完成
boolean finishing = false; // to ensure sweep before committing nextTab
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
//nextIndex代表下一個開始遷移的數組元素,它的初始值是transferIndex,所以是從數組最後一個元素向前來一個個遷移的
//nextIndex是元素的個數,最終判斷需要遷移元素的索引是由i = nextIndex - 1確定的
//nextBound相當於遷移邊界的意思,注意這個--i的操作,當遷移元素的索引達到邊界值,則停止遷移
int nextIndex, nextBound;
if (--i >= bound || finishing) {
advance = false;
}
//nextIndex小於0,代表所有任務包都在執行,等待就可以了,都遷移完後finishing會爲true,上個方法會退出循環
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
//這裏在CAS操作下拿到下一個需要遷移的nextIndex
//CAS操作維護TRANSFERINDEX,它預期值應該是nextIndex
//如果與預期值一樣,那麼退出while循環,自己來遷移nextIndex位置的元素,並將遷移邊界定義好,達到遷移邊界時停止
//如果與預期值不一樣,繼續while循環,當其他線程修改過transferIndex,再次拿到的nextIndex與TRANSFERINDEX一致,代表indexIndex位置的元素由自己來修改
//TRANSFERINDEX這個變量雖然是final修飾,但是它是指向transferIndex,transferIndex由volatile關鍵字保證線程安全
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
//bound存儲了nextBound的值,用於在if (--i >= bound || finishing) 中進行判斷使用
bound = nextBound;
//i代表需要開始遷移的索引
i = nextIndex - 1;
advance = false;
}
}
//開始遷移
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
//這裏finishing爲true時,遷移直接停止
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
//因爲每個線程遷移前都會把sizeCtl加1,這裏再減1後代表自己遷移完成
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // recheck before commit
}
}
//如果i索引位置的元素爲空,插入此位置一個空node即可
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
//其他線程正在遷移,不需要自己處理
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
//否則自己來做遷移,每次只會遷移一個元素,也就是i索引處的元素
else {
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
if (fh >= 0) {
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
else if (f instanceof TreeBin) {
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode<K,V> p = new TreeNode<K,V>
(h, e.key, e.val, null, null);
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<K,V>(hi) : t;
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
其實對於這部分代碼的理解,最難的在於理解while循環裏面的內容,如何拿到下一個需要遷移的 nextIndex:
首先要理解 transferIndex 和 TRANSFERINDEX這兩個變量的關係,TRANSFERINDEX是由 final 修飾,內存中是指向 transferIndex 這個變量的,所以我們並不是改變 final 修飾的這個變量,而是通過 CAS 操作修改 TRANSFERINDEX 指向的 transferIndex 這個變量,這個可以從靜態代碼塊中看出;transferIndex 是由 volatile 修飾來保證線程安全的。在下面這個 else if 條件判斷中:
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0)))
怎麼保證每條線程做自己該做的,不該做的不參與呢?是通過 TRANSFERINDEX 這個參數來確定的。當我們試圖修改 transferIndex 時,先檢查它的預期值是否是 nextIndex,如果是,計算遷移邊界 nextBound,nextBound 一定是 0 或者正數,因爲步長也就是任務數 stride 最小是16,代表每條線程負責遷移 16 個元素:
- 所以當數組的容量很小時,比如 16,那就只需要一條線程來遷移即可,nextBound 直接賦值爲 0,將 nextBound 賦值給 TRANSFERINDEX 指向的 transferIndex ,這樣所有的 16 個元素都由此線程來遷移。這樣當下一條線程進來時,發現 transferIndex 這個值已經爲 0 ,將不會再去計算下次需要遷移的索引 nextIndex,因爲所有元素都已經分配上一條線程了;
- 當數組容量很大,比如是 64 ,第一條線程的 nextIndex 賦值爲 64,遷移邊界 nextBound 賦值爲 48,第二條線程的 nextIndex 賦值爲 48,遷移邊界 nextBound 賦值爲 32,以此類推,每條線程都負責這個 nextIndex 向前的 stride 個元素的遷移工作,直到遷移邊界 nextBound 這個值纔會停止,如果此時 finishing 爲 true,代表所有數組元素都已經遷移完成,退出方法即可;如果 finishing 不爲 true,接着拿到 nextIndex 幫助完成後續的遷移。
這裏說的多條線程不一定是多條線程一起執行,也能是一條線程多次執行來完成遷移。再看下面的遷移代碼,每次遷移都是遷移一個元素,就是 i = nextIndex - 1 索引處的元素,遷移完成後,賦值 advance 爲true,再次進入 while 循環,在第一個 if 中將 i 做一個減 1 的操作,上面代碼註釋中有,此時減 1 後的 i 一定沒有到遷移邊界 nextBound 處,所以將繼續遷移減 1 後的 i 索引處的元素,直到 i 等於遷移邊界 nextBound,也就是說完成了此次遷移 stride 個元素的任務。
前面的擴容代碼 tryPresize 中說到,此次線程自己先進行遷移還是幫助遷移的不同是 transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) 這個方法中的第二個參數是否是 null, 如果是 null, 就現由自己這條線程來初始化新的數組,給 transferIndex 賦值,做好準備工作,後續線程進來後直接進行遷移即可。
沒有給具體遷移過程的代碼進行註釋,因爲也就是從老數組拿到一個元素,移動到新數組中,並重新計算索引的過程,這個其實不難,關鍵是分配遷移任務的這個思路和如何保證線程安全。jdk8 的源碼其實還是特別不好理解的。