終於要開篇寫HashMap了,作爲集合屆的頭把交椅,HashMap不可不謂爲響噹噹的,其代碼也讓很多人望而生畏,但其實仔細琢磨一下,其複雜度並沒有特別的讓人害怕(起碼對比ConcurrentHashMap而言),因此,讓我們走近來近距離瞧一瞧這個大名鼎鼎的HashMap吧。
鑑於我本地安裝的版本是1.8的,因此,分析1.8版本的是HashMap。最後會分析1.7和1.8的有什麼區別。
首先,HashMap使用鏈地址法來解決hash衝突的問題,HashMap1.8使用的是數組+鏈表/紅黑樹。
注:雖然是源碼解析,但是並不是所有的源碼都會涉及到,只涉及到經常使用的那些。
首先看看HashMap的類關係圖,瞭解一下它的繼承關係和實現的接口。
再來看看一些一些常量和變量,以及構造方法和hash方法。
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
// 默認的初始化容量,即默認的數組大小,爲16.該值必須是2的次方。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 最大的容量,爲2的30次方。
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默認的負載因子。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 樹形化的臨界值。
static final int TREEIFY_THRESHOLD = 8;
// 樹形轉回鏈表的臨界值。
static final int UNTREEIFY_THRESHOLD = 6;
// 如果沒達到這個容量,會先擴容,而不是樹形化。這樣避免調整大小和樹形化衝突。
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* 變量
**/
// HashMap的數組定義
transient Node<K,V>[] table;
// 做遍歷時使用。
transient Set<Map.Entry<K,V>> entrySet;
// HashMap大小。
transient int size;
// HashMap結構改變的次數。
transient int modCount;
// 表示size大於它的時候會進行擴容操作。
int threshold;
// 負載因子。
final float loadFactor;
// 參數爲初始容量和負載因子的構造函數。
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;
// 將容量調整爲大於參數的最小2次方
this.threshold = tableSizeFor(initialCapacity);
}
// 參數爲初始容量的構造方法。
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 無參構造函數,會將負載因子設爲默認的。
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
// 參數爲Map類型的構造函數
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
// hashMap自帶的hash函數。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
首先講解一些常量,可以看出來,HashMap容量大小默認是16,且必須是2的次方,這個原因後續會說。負載因子爲0.75,也就是說HashMap中的元素達到容量的0.75就擴容,如16*0.75=12,那麼容量使用達到12就會擴容。因此這個值太小了容易導致擴容頻繁,非常消耗性能,太大了容易導致哈希衝突概率變大,鏈表變長,這樣的話查找效率就低了。總之值的大小的優缺點是對立的,0.75是官方認爲一個較爲平衡的值。
至於變量,註釋已經給出了相應的解釋。
最後看下構造函數和hash方法,在所有的構造函數中,都會設置負載因子和初始化容量,如果用戶沒有給,那麼就使用默認的,其中,初始化容量,即使用戶給了非2的次方數,也會使用tableSize方法調整過來,比如傳11,容量並不會就是11,而是16,傳17,就會是32。始終保持2的次方。至於hash方法,又叫擾動函數,它能使hash的值分佈的更隨機,避免hash衝突太頻繁。
常用API
這次就不列增刪改查了,直接從方法出發。
put方法
// 調用了下面的方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* 真正執行put的方法
*/
// onlyIfAbsent爲true時,不會改變已經存在的值,也就是說,只有key不存在時纔會put。
// evict不用關心
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 如果數組爲null,或是長度爲0,就進行擴容。
// 這意味着第一次put時就會進行擴容。
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 如果key對應的那個位置爲空,那麼直接創建一個node放置便可。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else { // 否則,說明有hash衝突了。
Node<K,V> e; K k;
// p是這個數組的第一個節點,如果p和要插入的數據是一個值,那麼將p賦值給e
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, key, value);
else { // 否則,是鏈表節點,那麼順着鏈表查找與插入的數相等的值。
for (int binCount = 0; ; ++binCount) {
// 如果沒找到,就新建一個node節點,放在p後面。可以看出,這是尾插。
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 如果插入值之後是第八個節點,那麼樹形化。
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;
}
}
// 如果e不爲null,也就是說要插入的鍵值對中的key是存在。
if (e != null) { // existing mapping for key
// 將舊的值取出
V oldValue = e.value;
// 如果onlyIfAbsent爲false,或者舊值爲null,將新的值覆蓋
// (有關onlyIfAbsent的地方,方法開頭已經寫了)
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 這個不重要。
afterNodeAccess(e);
return oldValue;
}
}
// 如果新增了node節點,就會modCount自增
++modCount;
// 同時由於新增node,size也會自增,自增後超過閾值,也需要擴容。
if (++size > threshold)
resize();
// 這個也不重要。
afterNodeInsertion(evict);
return null;
}
put方法還是比較好理解的。籠統概括一下。首先,如果是第一次put元素,那麼就會先擴容,達到默認的16或者用戶自定義的大小。然後使用(n - 1) & hash進行取模運算,這個與運算和hash % n的效果是一樣的,同時由於是位操作,因此會比%快。取模運算後看key的hash值是在數組的哪個位置,如果該位置上沒有元素,那麼直接新建一個node元素放在該位置上。否則說明數組上已經有元素了,那麼首先判斷該key是不是頭結點,如果是,將頭節點賦給e。如果不是,且頭節點是紅黑樹的節點,那麼走紅黑樹的查找。否則是鏈表,遍歷鏈表,如果找到了,將節點賦給e。如果遍歷了還是沒有,就新建node節點放在鏈表尾部,此時,如果新增的元素剛好是第8個節點,那麼樹形化。最後,如果e的值不爲null,說明key已經在map中存在了,覆蓋然後返回舊值就行了,當然,**onlyIfAbsent爲true,且有舊值時是不能覆蓋的。**否則,要插入的鍵值對是新增的,那麼增加
modCount,同時增加size,如果大於閾值,就擴容。
注意點
我們現在看看樹形化的代碼,這裏有一個需要引起注意的地方。
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
裏面具體如何樹形化的我就不解釋了,但是可以看到該方法的開頭, 如果數組爲空,或者數組長度小於MIN_TREEIFY_CAPACITY(即64),那麼都會先擴容。
也就是說!!! 擴容的時機其實並不僅僅是數組大小大於閾值纔會擴容,在樹形化時如果數組大小沒有達到64,也是會先擴容的!!!
總結一下put方法:
- 第一次put時,會導致擴容。
- 鏈表長爲8時會樹形化。
- 新增節點後,如果大於閾值,會導致擴容。
- 樹形化時,如果數組大小沒有達到默認的64,會先擴容,而不是樹形化。
resize方法
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
// 舊的容量,如果是第一次擴容,舊容量就是0
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;
}
// 將數組擴容至2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 閾值也翻倍。
newThr = oldThr << 1; // double threshold
}
// 說明是第一次擴容,且使用了帶參的構造方法,將閾值賦給新容量。
else if (oldThr > 0)
newCap = oldThr;
else { // 說明是第一次擴容,使用的是無參的構造方法,那麼使用系統默認的值
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;
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;
// 如果該位置是紅黑樹,提一句,遷移數據之後,長度小於UNTREEIFY_THRESHOLD(即6),那麼就會轉回爲鏈表
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-while是將數組分爲兩個鏈表,一個是與舊容量相與爲1,一個是爲0
do {
next = e.next;
// 如果e的hash值與舊容量進行與運算後還是爲0
if ((e.hash & oldCap) == 0) {
// 如果loTail 爲null,那麼loHead指向e.
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else { // 如果進行與運算爲1
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 如果相與爲0,那麼待在原位置。
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 如果爲1,則遷去新位置,這個新位置是原位置+原容量。
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
擴容也是比較好理解的,記錄舊的容量和舊的閾值。如果是第一次擴容,那麼將容量設爲默認的或者用戶傳來的。如果不是,將容量擴容至原來的兩倍,同時,在這裏可以看到閾值 = 負載因子 * 容量。
如果不是第一次擴容,需要進行元素遷移。如果是鏈表的遷移,在擴容中只用判斷原來的 hash 值與原容量按位與操作是 0 或 1 就行,0 的話索引就不變,1 的話索引變成原索引加上擴容前數組,這裏爲什麼是這樣解釋一下,新容量的大小是原大小的兩倍,之前當key判斷自己應該在數組中的哪個位置時,使用的是(n - 1) & hash,這裏的n是指數組大小。那麼元素遷移要判斷自己位置時,也就是(新容量 - 1) & hash,新容量-1和舊容量-1在二進制中只是多了最高位上的1,而這個1就是舊容量上的1,因此只要與舊容量進行&運算就行。
可能文字解釋的比較繞口。使用實例解釋一下吧。
// 假設hash值是10101。
// oldSize是 10000.
// newSize就是 100000.
// (oldSize - 1) & hash = 01111 & 10101 = 00101.
// (newSize - 1) & hash = 011111 & 10101 = 10101.
// 可以看出newSize - 1比oldSize - 1只是多了1,而這個1的位置就是oldSize的1的位置,其他並沒有變
// 因此在newSize是oldSize兩倍的情況下,(newSize - 1) & hash與oldSize & hash的結果是一樣的
get方法
// 調用下面的方法
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;
// 如果數組不爲null,且長度不爲空,且key的hash對應的數組位置不爲null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 如果第一個元素就是,直接返回第一個元素
if (first.hash == hash &&
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
// 如果是紅黑樹,用紅黑樹的方法。
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);
}
}
// 沒找到就返回null。
return null;
}
get方法對比put和resize方法還是很簡單的。這裏不解釋了。
在這裏插入代碼片
與1.7版本的對比
由於不會寫1.7版本的hashMap源碼,因此這裏說一下兩者之間的區別。
- 首先,是結構上的區別。1.7的是數組+鏈表,1.8是數組+鏈表/紅黑樹。
- 其次,是數據上的區別。初始化時1.8是直接用resize方法的,1.7使用了額外的inflateTable方法。插入數據時,1.8使用的是尾插法,1.7是頭插法。
- 最後,是擴容時的區別。
- 1.8遷移元素使用的是hash值和原容量進行&操作,新的位置一般在原位置或者原位置+原容量的位置上,而1.7還是使用的原來的方法,即先進行擾動處理,再進行(n - 1) & hash,判斷新位置在哪;
- 同時,1.8擴容還是使用的尾插法,而1.7擴容還是使用頭插法,這樣很容易導致環形鏈表死循環的情況;
- 1.8是先插入後判斷是否需要擴容,1.7是先擴容再插入。
與HashTable的對比
HashTable其實現在很少用了,但還是提一下主要的區別吧。
- HashTable繼承自Dictionary類,而HashMap繼承自Map。
- HashMap允許key和value爲null的,HashTable則不行。
- HashTable的所有方法都是加上了sychronized的,性能因此會比較低,而HashMap不是。
總結一下(1.8版本)
- HashMap是基於數組+鏈表/紅黑樹的,HashMap有閾值,該閾值大小是由負載因子相乘容量大小決定的,一旦容量大於該閾值,就會導致擴容,同樣的第一次put操作也會擴容。一旦鏈表長度達到8,就樹形化爲紅黑樹,如果此時數組長度大小小於默認的64,就會先擴容,而不是樹形化。
- HashMap在添加元素和擴容時都是使用尾插法,而且是先插值後判斷是否需要擴容。
- HashMap是線程不安全的,在多線程併發訪問時需要同步,可以使用替代的ConcurrentHashMap或者使用 Collections.synchronizedMap()修飾。
以上,是關於HashMap 1.8的全部內容。
謝謝各位的觀看。本人才疏學淺,如有錯誤之處,歡迎指正,共同進步。