1. HashMap概述
HashMap 是一個儲存鍵值對的集合類,其中的元素是無序的,且沒有重複的 key 值;有點類似數學中的函式,x 對應一個 y 值。Java API 中對 HashMap 描述如下:
HashMap是基於雜湊表的Map介面實現。此實現提供所有可選的對映操作,並允許空值和空鍵。 (HashMap類大致相當於Hashtable,除了它是不同步的並且允許空值。)這個類不保證Map的順序;特別是,它不保證順序會隨著時間的推移保持不變。
HashMap 底層是雜湊表,元素是無序的,允許 key 和 value 為 null 的情況。
假設雜湊函式在桶之間正確地分散元素,該實現為基本操作(get和put)提供了恆定時間效能。對集合檢視的迭代需要與HashMap例項的“容量”(桶的數量)加上其大小(鍵 - 值對映的數量)成比例的時間。因此,如果迭代效能很重要,則不要將初始容量設定得太高(或負載因子太低)非常重要。
HashMap的一個例項有兩個影響其效能的引數:初始容量和負載因子。容量是雜湊表中的桶數,初始容量只是建立雜湊表時的容量。載入因子是在自動增加容量之前允許雜湊表獲取的完整程度的度量。當雜湊表中的條目數超過載入因子和當前容量的乘積時,雜湊表將被重新雜湊(即,重建內部資料結構),以便雜湊表具有大約兩倍的桶數。
HashMap 的初始容量和負載因子會影響其效能,設定的太大迭代會花費跟多的時間,設定的太小,又可能會頻繁擴容;當元素個數超過當前容量*負載因子時,需要擴容,大約為原來的兩倍。
作為一般規則,預設載入因子(0.75)在時間和空間成本之間提供了良好的權衡。較高的值會減少空間開銷,但會增加查詢成本(反映在HashMap類的大多數操作中,包括get和put)。在設定其初始容量時,應考慮對映中的預期條目數及其載入因子,以便最小化重新雜湊操作的數量。如果初始容量大於最大條目數除以載入因子,則不會發生重新載入操作。
負載因子預設情況下是0.75,太大能減少空間開銷,但是會增加查詢成本。
如果要將多個對映儲存在HashMap例項中,則使用足夠大的容量建立對映將允許對映更有效地儲存,而不是根據需要執行自動重新雜湊來擴充套件表。請注意,使用具有相同hashCode()的許多鍵是減慢任何雜湊表效能的可靠方法。為了改善影響,當鍵是Comparable時,此類可以使用鍵之間的比較順序來幫助打破關係。
在知道容量的情況下,儘量初始化時設定足夠的容量,避免擴容影響效率。
另外,HashMap 不是執行緒安全的,多執行緒使用時需要注意;同時,迭代器也是fail-fast的,也就是使用迭代器迭代過程中,不能對其進行修改,否則直接丟擲異常。
2. 成員變數
靜態成員變數:
// 預設容量 16
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;
/**
* 桶 使用樹的閾值,節點超過8個時,有連結串列改為樹結構
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 桶 恢復為連結串列的閾值
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 容器轉化為樹的閾值,超過該容量,將桶轉化為樹,否則繼續擴容
* 至少為 4 * TREEIFY_THRESHOLD,避免擴容和樹形結構化之間的衝突
*/
static final int MIN_TREEIFY_CAPACITY = 64;
複製程式碼
成員變數:
// 連結串列陣列
transient Node<K,V>[] table;
// 快取的鍵值對集合
transient Set<Map.Entry<K,V>> entrySet;
// 鍵值對元素個數
transient int size;
// 集合修改次數
transient int modCount;
// 擴容容量,capacity * load factor
int threshold;
// 載入因子
final float loadFactor;
複製程式碼
3. 構造方法
// 傳入初始化容量,載入因子
public HashMap(int initialCapacity, float loadFactor) {
// 如果初始化容量 < 0 ,丟擲異常
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);
}
複製程式碼
上邊的構造方法中,傳入的初始化容量,會使用tableSizeFor
方法處理,將初始化容量設定為2的冪,為了後邊hash時,均勻分佈。
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;
}
複製程式碼
上邊的方法,通過移位運算,通過不斷的無符號右移和原值進行或運算,將二進位制從最高位開始,每一位都置為1,也就是2^N^-1,所以,最後需要再加一,變為2^N^。上邊的過程也很好理解,例如:傳入的是9,n=8,轉為二進位制,000 000 0000 .... 1000,只看後4位:
- 第一次操作之後變為 1100,
- 然後,右移兩位0011,與1100進行或運算,變為1111;
後邊兩步此時計算了也不會改變,因此最後結果是16;為什麼最後只移動到16位呢?因為我們知道int是32位,即時n=2^31^,經過這幾步也會變為 2^32^-1;
其他構造方法:
// 只傳入初始化容量
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);
}
複製程式碼
4. 主要方法
4.1 新增元素 put(K key, V value)
HashMap中最核心的就是新增元素方法,涉及到了擴容,Java8 中還有資料介面轉換。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
複製程式碼
新增元素,首先對 key 進行 hash 操作,key 在陣列中的索引為(n - 1) & hash(key)
;
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
複製程式碼
上邊的方法中,首先獲取 key 的 hashCode 值,然後,高16位和低16位進行異或操作,得到hash值,最後計算索引時,又使用(n-1) & hash;而我們知道 n (HashMap容量)是2的N次方,相當於和長度取模操作,防止索引超過了容量。
hash計算中,為什麼要使用高16位和低16位異或呢?因為元素的 hashCode 低位很多都是相同的,這樣在和容量進行取模運算時,可能造成同一個索引元素過多,發生碰撞。因此,使用高位進行異或運算之後,再取模,儘可能使元素均勻分佈。
回到 put 方法,其中主要呼叫了 putVal 方法:
/*
* onlyIfAbsent 如果為true, 新增的key如果存在,不改變原來的值
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// <1> 如果 table 為null,使用 resize()建立一個 雜湊表
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 如果新增到陣列中的索引處,節點為null, 直接在該索引位置建立一個新的節點
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 如果索引處,桶的第一個節點 key 和插入節點key相同,獲取該節點;後邊判斷是否需要替換
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果,第一個節點不匹配,並且已經是一個樹形結構,新增樹節點
else if (p instanceof TreeNode)
// <2> 新增樹節點
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 如果是連結串列結構,遍歷連結串列
for (int binCount = 0; ; ++binCount) {
// 如果遍歷到最後,都沒有key相同的,在連結串列末尾新增節點
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// <3> 如果超過了樹形結構閾值,轉換為紅黑樹
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 如果找到key匹配節點,獲取節點,用於後邊判斷是否替換
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 如果,上邊的過程中找到了key相同的節點
if (e != null) { // existing mapping for key
V oldValue = e.value;
// onlyIfAbsent 為false,即允許替換;或者舊值為null,替換節點處的值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 如果超過了容量,擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
複製程式碼
上邊程式碼展示了HashMap新增元素的基本過程:
- 首先,如果雜湊表為空,需要建立儲存元素的雜湊表;
- 然後,計算key對應的索引,如果雜湊表中該索引位置還未新增元素,直接在雜湊表中新增一個節點;
- 如果,該索引位置已經有值,需要判斷該節點的key是否和插入的key相同,如果相同獲取節點;
- 如果不同,且桶仍是連結串列結構,遍歷連結串列,找到key相同的節點,如果沒有找到,就在連結串列末尾新增元素;
- 如果該處桶已經是紅黑樹結構,想紅黑樹中新增元素。
流程圖大致如下:
(圖片來源:Java 8系列之重新認識HashMap)4.2 擴容操作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;
// 如果擴容前容量大於0
if (oldCap > 0) {
// 超過了最大容量,直接返回
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 如果擴容前容量,超過預設容量16;新容量為原來的2倍;
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 如果擴容前容量大於0,
// 並且擴容容量threshold > 0(初始化時,為初始化傳入的容量計算和的值)
else if (oldThr > 0) // initial capacity was placed in threshold
// 新容量等於 擴容容量,即初始化容量
newCap = oldThr;
// 如果初始化時,沒有傳入容量,設定為預設容量16
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的值
threshold = newThr;
// 新建一個長度為newCap的Node陣列
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
// table 指向新陣列
table = newTab;
// 如果原雜湊表不為空,將原來的資料複製過來
if (oldTab != null) {
// 遍歷原雜湊表
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 如果當前索引位置有節點,需要將節點新增到新雜湊表中
// 首先,獲取索引出節點
if ((e = oldTab[j]) != null) {
// 將原雜湊表中節點設定為null,方便GC
oldTab[j] = null;
// 如果原雜湊表中該位置,只有一個節點,直接將該節點重新rehash之後,插入新雜湊表
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;
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;
}
複製程式碼
上邊擴容的過程,相比 JDK1.7 變化很大, 首先引入了紅黑樹,擴容時,需要處理紅黑樹;對於連結串列的處理也不一樣了,但是,只是處理方式不同,最終結果還是相同的。JDK 1.7 中resize()
方法中,計算節點新的索引位置是通過 has & (newCapacity-1)
的方式來計算的,這和我們最開始新增元素時一樣,很好理解。
在上邊說過,每次擴容時擴容原來容量的兩倍,因此上邊操作過程,可以展示如下圖:
rehash
過程,索引位置從5變為5或者21(5+16);從圖中也可以看出來,之和最高一位有關,如果和最高一位與運算結果為0,那麼還是原來位置,如果為1,就是原來的索引加上擴容的長度,即原長度;因此,上邊的Java8 中的程式碼直接使用(e.hash & oldCap)
運算,判斷索引是在原來位置,還是需要移動原來的長度。另外上邊的程式碼中沒有打亂連結串列的順序,避免了原來多執行緒下出現死迴圈的問題;但是HashMap 仍是執行緒不安全的,多執行緒下建議使用ConcurrentHashMap。
4.3 獲取元素
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;
// 如果,雜湊表table為空,直接返回null;如果key對應的索引處為空,也直接返回null
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) {
// 如果是樹結構,查詢樹節點
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);
}
}
return null;
}
複製程式碼
上邊的get(Key key)
方法,主要思路,在雜湊表不為空的前提下,首先對 key 進行 hash 操作,然後根據 hash 值獲取對應雜湊表的索引,該過程和 put 方法中相同;找到雜湊表中的索引後,先看第一個節點是否匹配,不匹配的話就開始遍歷連結串列,需要注意連結串列已經轉換為樹的情況。
4.4 判斷元素是否存在
判斷key是否存在:
public boolean containsKey(Object key) {
return getNode(hash(key), key) != null;
}
複製程式碼
判斷key是否存在,比較方便,直接通過key查詢,如果查到元素就說明key是存在的。
判斷value 是否存在:
public boolean containsValue(Object value) {
Node<K,V>[] tab; V v;
if ((tab = table) != null && size > 0) {
for (int i = 0; i < tab.length; ++i) {
for (Node<K,V> e = tab[i]; e != null; e = e.next) {
if ((v = e.value) == value ||
(value != null && value.equals(v)))
return true;
}
}
}
return false;
}
複製程式碼
判斷是否包含 value 需要依次遍歷雜湊表的各個節點比對,相對而言比較麻煩,不過一般也很少使用。
4.5 遍歷HashMap
之前遍歷HashMap我們都是使用EntrySet,Java8 中新增了一個foreach方法,可以直接使用該方法遍歷HashMap。
public void forEach(BiConsumer<? super K, ? super 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.key, e.value);
}
// 遍歷過程中修改,會丟擲異常
if (modCount != mc)
throw new ConcurrentModificationException();
}
}
複製程式碼
使用示例:
public static void main(String[] args) {
HashMap<String, Object> hashMap = createHashMap();
hashMap.forEach((key, value) -> {
System.out.println(key+":"+value);
});
}
複製程式碼
總結
HashMap 是一個存放鍵值對的集合,其中的元素是無序的,允許key 和 value 為null的情況,但是不存在重複的 key。HashMap 的容量都是2的次冪,為了是元素更加均勻的分佈,另外,每次擴容時都是擴容為原來的2倍。Java8 中引入了紅黑樹資料結構,優化了 HashMap 的效率,解決了多執行緒擴容可能出現環的問題,但是 HashMap 仍然是執行緒不安全的,需要保證執行緒安全的情況下建議使用ConcurrentHashMap。
HashMap 細節還有很多,暫時整理到這裡,文中如有錯誤請大家指正。
參考:
美團技術網:Java 8系列之重新認識HashMap