引言
HashMap<K,V>和TreeMap<K,V>都是從鍵對映到值的一組物件,不同的是,HashMap<K,V>是無序的,而TreeMap<K,V>是有序的,相應的他們在資料結構上區別也很大。
HashMap<K,V>在鍵的資料結構上採用了陣列,而值在資料結構上採用了連結串列或紅黑樹這兩種資料結構。 HashSet<K,V>同HashMap<K,V>的關係與TreeSet<E>同TreeMap<K,V>的關係類似,在內部實現上也是使用了HashMap<K,V>的鍵集,這點我們同樣通過HashSet<K,V>的建構函式可以發現。所以在文章中只會詳細解說HashMap<K,V>,對HashSet<K,V>就不做分析。
public HashSet() {
map = new HashMap<>();
}
public HashSet(int initialCapacity) {
map = new HashMap<>(initialCapacity);
}
複製程式碼
框架結構
HashMap<K,V>在繼承結構上和TreeMap<K,V>類似,都繼承自AbstractMap<K,V>,同時也都實現了Map<K,V>介面,所以在功能上區別不大,不同的是實現功能的底層資料結構。同時由於HashMap<K,V>是無序的,沒有繼承自SortedMap<K,V>,相應的少了一些根據順序查詢的功能。
雜湊
在分析HashMap<K,V>的具體實現之前,先來看下什麼是雜湊? 雜湊又叫“雜湊”,是將任意物件或者資料根據指定的雜湊演算法運算之後,輸出一段固定長度的資料,通過這段固定長度的資料來作為這個物件或者資料的特徵,這就是雜湊。這句話可能比較繞口,舉個例子。
在一篇文章中有10000個單詞,需要查詢這10000個單詞中是否存在“hello”這個單詞,最直觀的辦法當然是遍歷這個陣列,每個單詞跟“hello”進行比較,最壞的情況下可能要比較10000次才能找到需要的結果,如果這個陣列無限大,那要比較的次數就會無限上升。那有沒有更快速的查詢途徑呢? 答案就是雜湊表。首先將這10000個單詞根據一種指定的雜湊演算法計算出每個單詞的雜湊值,然後將這些雜湊值對映到一個長度為100的陣列內,如果對映足夠均勻的話大概陣列的每個值對應100個單詞,這樣我們在查詢的時候只需要計算出“hello”的雜湊值對應在陣列中的索引,然後遍歷這個位置中對應的100個單詞即可。當對映的陣列足夠大,比如10000,雜湊演算法足夠好,對映一對一,每個雜湊值都不相同,這樣理論上最優可以在一次查詢就得道想到的結果,最壞的查詢次數就是陣列的每個位置所對應的單詞數。這樣相比較直接遍歷陣列要快速的多。
雜湊可以大大提高查詢指定元素的效率,但受限於雜湊演算法的好壞。一個好的雜湊演算法可以將元素均勻分佈在固定長度的陣列中,相應的如果演算法不夠好,對效能就會產生很大影響。
那有沒有一個演算法可以讓任意一個給定的元素,都輸出一個唯一的雜湊值呢?答案是暫時沒有發現這樣的演算法。如果不能每個元素都對應到一個唯一的雜湊值,就會產生多個元素對應到一個雜湊值的情況,這種情況就叫“雜湊衝突”。
雜湊衝突
下圖中通過一個簡單的雜湊演算法,每個單詞取首字母雜湊時,air和airport雜湊值一樣就產生了雜湊衝突。 還是用之前的例子,當10000個單詞存放於一個長度為100的陣列中時,如果雜湊演算法足夠好,單詞分佈的足夠均勻,每個雜湊值就會對應100個左右的元素,也就是每個位置會發生100次左右的雜湊衝突。儘管我們可以通過提高陣列長度來減小衝突的概率,比如將100變為10000,這樣有可能會一個元素對應一個雜湊值。但如果需要儲存的單詞量足夠大的情況下,無論陣列多大都可能不夠用,同時很多時候記憶體或者硬碟也不可能無限擴大。雜湊演算法也不能保證2個不同元素的雜湊值一定不相同,這時雜湊衝突就不可避免,就需要想辦法來解決雜湊衝突。 一般解決雜湊衝突有兩種通用的辦法:拉鍊法和開放定址法。 拉鍊法顧名思義就是將同一位置出現衝突的所有元素組成一個連結串列,每出現一次衝突,就將新的元素放置在連結串列末尾。當通過元素的雜湊值查詢到指定位置時會返回一個連結串列,再通過迴圈連結串列來查詢完全相等的元素。 開放定址法就是當衝突出現時,直接去尋找下一個空的雜湊地址,將值存入其中即可。當雜湊陣列足夠大,總會有空的地址,空地址不夠用時,可以擴大陣列容量。 在HashMap<K,V>中使用的是第一種的拉鍊法。
建構函式
在HashMap<K,V>中有幾個重要欄位。 Node<K,V>[] table,這個陣列用來儲存雜湊值以及雜湊值對應的元素,又叫雜湊桶陣列。 loadFactor是預設的填充因子,當雜湊桶陣列中儲存的元素達到填充因子乘以雜湊桶陣列總大小時就需要擴大雜湊桶陣列的容量。比如桶陣列長度為16當儲存的數量達到16*0.75=12時則要擴大雜湊桶陣列的容量。一般取預設的填充因子DEFAULT_LOAD_FACTOR = 0.75,不需要更改。
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
//預設填充因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//雜湊桶陣列
transient Node<K,V>[] table;
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);
}
//預設填充因子 threshold(第一次臨界值為轉換後的容量大小)
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//預設填充因子 threshold臨界值為0
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
}
複製程式碼
在建構函式中有個tableSizeFor方法,這個方法是用來將輸入的容量轉換為2的整數次冪,這樣無論輸入的數值是多少,我們都會得到一個2的整數次冪長度的雜湊桶陣列。比如輸入13,返回16,輸入120返回128。
static final int tableSizeFor(int cap) {
//避免出現輸入8變成16這種情況
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
//低位全變為1之後,進行n + 1可以將低位全變為0,得到2的冪次方
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
複製程式碼
通過對輸入的引數001x xxxx xxxx xxxx位移1位後0001 xxxx xxxx xxxx與原值進行或運算,得到0011 xxxx xxxx xxxx,最高位的1與低一位都變為1。 位移2位後0000 11xx xxxx xxxx與原值0011 xxxx xxxx xxxx進行或運算,得到0011 11xx xxxx xxxx,最高2位的1與低2位都變為1。 位移4位後0000 0011 11xx xxxx與原值0011 11xx xxxx xxxx進行或運算,得到0011 1111 11xx xxxx,最高4位的1與低4位都變為1。 位移8位後0000 0000 0011 1111與原值0011 1111 11xx xxxx進行或運算,得到0011 1111 1111 1111,最高8位的1與低8位都變為1。 位移16位類似。結果就是從最高位開始所有後面的位都變為了1。然後n + 1,得到0100 0000 0000 0000。 可以看下面的例子: 當輸入13時: 當輸入118時: 這裡要注意n = cap - 1,為什麼要對輸入引數減一,是為了避免輸入2的冪次方時容量會翻倍,比如輸入8時如果不進行減一的操作,最終會輸出16,讀者可以自行測試。
雜湊值
那為什麼一定要用2的整數次冪來初始化雜湊桶陣列的長度呢?這就要說到雜湊值的計算問題。 在HashMap<K,V>中計算元素的雜湊值程式碼如下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
複製程式碼
在這段程式碼就是用來獲取雜湊值的,其中首先獲取了key的hashCode,這個hashCode如果元素有重新實現hashCode函式則會使用自己實現的hashCode,在沒有自己實現時,hashCode函式大部分情況下會返回元素在記憶體中的地址,但也不是絕對的,需要根據各個JVM的內在實現來判斷,但大部分實現就算沒直接使用記憶體地址,也和記憶體地址存在一定的關聯。
在獲取到key的hashCode之後將hashCode的值的低16位和hashCode的高16位進行異或運算,這就是這個函式非常巧妙的地方,異或運算會同時採用高16位和低16位所有的特徵,這樣就大大增加了低位的隨機性,在取索引的時候tab[(n - 1) & hash],將包含所有特徵的雜湊值和雜湊桶長度減1進行與執行,可以得到雜湊桶長度的低位值。
使用2的整數次冪可以很方便的通過tab[(n - 1) & hash]獲取到雜湊桶所需要的低位值,由於低位和高位進行了異或運算,保留了高低位的特徵,也就減少了雜湊值衝突的可能性。這就是為什麼這裡會使用2的整數次冪來初始化雜湊桶陣列長度的原因。新增元素
通過HashMap<K,V>在新增元素的過程,可以發現HashMap<K,V>使用了陣列+連結串列+紅黑樹的方式來儲存資料。
當新增元素過程中出現雜湊衝突時會在衝突的位置採用拉鍊法生成一個連結串列來儲存衝突的資料,如果同一位置衝突的資料量大於8則會將雜湊桶陣列擴容或將連結串列轉換成紅黑樹來儲存資料。同時,在每次新增完資料後,都會檢查雜湊桶資料的容量,超出臨界值時會擴容。
對紅黑樹不太理解的可以檢視前兩篇文章。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//雜湊桶陣列為空時,通過resize初始化雜湊桶陣列
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//雜湊值所對應的位置為空,代表不會產生衝突,直接賦值即可
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
//產生雜湊衝突
Node<K,V> e; K k;
//如果雜湊值相等,並且key也相等,則直接覆蓋值
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//p為紅黑樹 使用紅黑樹邏輯進行新增(可以檢視TreeMap)
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//p為連結串列
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;
}
//遍歷連結串列過程中存在相等元素則直接覆蓋value
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//覆蓋value
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//雜湊桶中的資料大於臨界值時擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//雜湊桶陣列小於64則擴容雜湊桶陣列
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;
//將所有Node<K,V>節點型別的連結串列轉換成TreeNode<K,V>節點型別的連結串列
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);
//將TreeNode<K,V>連結串列轉換成紅黑樹
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
複製程式碼
擴容
新增元素的過程中,以下2種情況會出現擴容:單個雜湊桶儲存超過8個元素會檢查雜湊桶陣列,如果整個雜湊桶陣列容量小於64則會進行擴容;在每次新增完元素後也會檢查整個雜湊桶陣列容量,超過臨界值也會進行擴容。擴容原始碼分析如下:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
//雜湊桶陣列已經初始化 則直接向左位移1位 相當於擴容一倍
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//向左位移1位 擴容一倍
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;
//雜湊桶陣列未初始化 並且未初始化容量 則使用預設容量DEFAULT_INITIAL_CAPACITY
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;
//擴容後最高位為0,則不需要移動到新的位置
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//擴容後最高位為1,則需要移動
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;
}
複製程式碼
在擴容的過程中,有一個非常巧妙的地方,因為擴容後每個元素的雜湊值需要重新計算並放入新的雜湊桶陣列中,在雜湊值計算的過程中,由於是乘以2來擴容的,也就是整數次冪。
這樣在每次擴容後會多使用一位特徵,這樣當多使用的這一位特徵為0時((e.hash & oldCap) == 0),雜湊值其實是沒有變化的,就不需要移動,這一位特徵為1時,只需要將位置移動舊的容量大小的即可(newTab[j + oldCap] = hiHead),這樣就可以減少移動元素的次數。紅黑樹和連結串列結構都是如此。
查詢元素
明白HashMap<K,V>的插入以及擴容原理,再來看查詢就非常容易理解了,只是簡單的通過在連結串列或者紅黑樹中查詢到相等的值即可。
在查詢中一個值是否是我們需要的值,首先是通過hash來判斷,如果hash相等再通過==或者equals來來判斷。
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) {
//雜湊值相等,並且key也相等,則返回查詢到的值
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//雜湊值存在衝突,第一個不是要找的key
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;
}
複製程式碼
刪除元素
刪除元素時首先查詢到需要的元素,其次根據查詢到元素的資料結構來分別進行刪除。
public V remove(Object key) {
Node<K,V> e;
//計算雜湊值
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
//雜湊值相等,並且key也相等,則node查詢到的節點
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
//雜湊值存在衝突,第一個不是要找的key
else if ((e = p.next) != null) {
//衝突結構為紅黑樹
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
//衝突結構為連結串列
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
//節點為紅黑樹節點,按紅黑樹邏輯刪除
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
//節點為桶中第一個節點
else if (node == p)
tab[index] = node.next;
//節點為後續節點
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
複製程式碼
HashMap的Key
講解完整個HashMap的實現,我們可以發現大部分情況下影響HashMap效能最核心的地方還是在雜湊演算法上面。儘管理論上HashMap在新增、刪除和查詢上的時間複雜度都可以達到O(1),但在實際應用過程中還受到很多因素影響,有時候時間複雜度為O(1)的HashMap可能比,時間複雜度為O(log n)的TreeMap效能更差,原因就在雜湊演算法上面。
如果使用一個物件預設的雜湊演算法,前面我們說過,大部分JVM雜湊演算法的實現都和記憶體地址有直接關係,為了減小碰撞的概率,可能雜湊演算法極其複雜,複雜到影響效率的程度。所以在實際使用過程中,需要儘量使用簡單型別來作為HashMap的Key,比如int,這樣在進行雜湊時可以大大縮短雜湊的時間。如果使用自己實現的雜湊演算法,在使用前需要先測試雜湊演算法的效率,減小對HashMap效能的影響。
總結
Java集合系列到這裡就結束了,整個系列從集合整體框架說到了幾個常用的集合類,當然還有很多沒有說到的地方,比如Queue,Stack,LinkHashMap等等。雖然這是對自己Java學習過程中的總結,但也希望這個集合系列對大家理解Java集合有一定幫助,如果文章中有錯誤、疑問或者需要完善地方,希望大家不吝指出。接下來打算對java.util.concurrent包下的內容做一個系列進行系統總結,有什麼建議也可以留言給我。