Java 基礎學習筆記 —— 集合框架中的Map
引言
前兩篇文章我們介紹了集合中的列表和佇列,接下來要介紹的也是一個使用非常廣泛的類——Map
。
Map
儲存了一對對的鍵值對映關係,每一個鍵在Map
中都是唯一的。Map
預設使用Object.equals
來判斷是否包含某個鍵,所以我們要儘量避免使用equals
方法會隨物件發生變化而變化的物件作為鍵。
使用Map
的時候,有兩個關鍵引數我們是需要注意的。
capacity Map的初始容量
loadFactor 負載因子,決定了Map需要擴容時,元素數量和容量的比例
下面是Map
的類圖。
從類圖中我們能夠發現,Map
的分化主要分為兩個維度,第一個維度是是否有序,這裡的有序是指鍵是否有序,鍵有序的物件都會繼承SortedMap
ConcurrentMap
介面確定。
具體實現
這裡的具體實現主要會針對幾個比較常用的實現進行描述,分別是HashTable
,HashMap
,ConcurrentHashMap
,TreeMap
,ConcurrentSkipListMap
。
HashTable
HashTable
是一個執行緒安全的Map
,因為其所有方法都被關鍵字synchronized
修飾。但是,其迭代器並沒有設計成為支援多執行緒訪問的,若使用迭代器訪問的同時對HashTable
進行了修改,就會引發ConcurrentModificationException
還是瞭解一下其成員變數
public class Hashtable<K,V>
extends Dictionary<K,V>
implements Map<K,V>, Cloneable, java.io.Serializable {
private transient Entry<?,?>[] table;//使用一個一維陣列進行元素儲存
private transient int count;//記錄元素數量
private int threshold; //需要resize的閾值,元素數量超過閾值後會引發擴容和重雜湊,值為初始化大小*負載因子
private float loadFactor;//負載因子
private transient int modCount = 0;//版本號,版本號的存在表明了HashTable不能很好地支援多併發(不能在迭代器遍歷的同時進行修改)
接下來了解一下一些具體的方法。
put
方法
public synchronized V put(K key, V value) {
//HashTable不儲存null元素
if (value == null) {
throw new NullPointerException();
}
//首先需要檢查key是否已經存在
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length; //這裡要和0x7FFFFFFF進行與操作,是為了保證int的首位(符號為)為0,也就是index一定是一個正整數
@SuppressWarnings("unchecked")
Entry<K,V> entry = (Entry<K,V>)tab[index];
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
addEntry(hash, key, value, index);
return null;
}
再來看一下addEntry
方法
private void addEntry(int hash, K key, V value, int index) {
Entry<?,?> tab[] = table;
if (count >= threshold) {
// 如果當前元素數量已經大於閾值,那麼首先要進行重雜湊。重雜湊會做兩件事情,第一個是將陣列容量擴大一倍,第二個是將原來的元素按雜湊結果重新分佈。
rehash();
tab = table;
hash = key.hashCode();
//通過取餘的方式確認bucket位置
index = (hash & 0x7FFFFFFF) % tab.length;
}
@SuppressWarnings("unchecked")
Entry<K,V> e = (Entry<K,V>) tab[index];
tab[index] = new Entry<>(hash, key, value, e);//這裡採用鏈式雜湊法對新建元素進行儲存
count++;
modCount++;
}
再瞭解一下get
方法
public synchronized V get(Object key) {
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
//需要兩個條件,雜湊值和equals方法都相等
if ((e.hash == hash) && e.key.equals(key)) {
return (V)e.value;
}
}
return null;
}
綜上可以知道,HashTable
通過synchronized
關鍵字來保證其執行緒安全。但是又因為其迭代器未對多執行緒訪問進行優化,因此使用迭代器的同時如果進行了修改操作,還是會導致ConcurrentModfiyException
的丟擲。除此之外,HashTable
通過一維陣列對鍵值對進行儲存,使用鏈式雜湊法解決衝突。
HashMap
和HashTable
不一樣,HashMap
中的方法併為使用sychronized
關鍵字進行修飾,另外HashMap
也支援儲存Null的key和value。
依然看一下其成員變數
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
transient Node<K,V>[] table;//仍然使用一維陣列對元素進行儲存
transient Set<Map.Entry<K,V>> entrySet;//儲存了鍵值對的集合
transient int size;//集合大小
transient int modCount;//版本號
int threshold;//擴容閾值
final float loadFactor;//負載因子
}
然後看一下其put
方法
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;
if ((tab = table) == null || (n = tab.length) == 0)
//若Map為空,首先需要進行初始化
n = (tab = resize()).length;
//這裡通過與的方式來確定Bucket的位置
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 TreeNode)
e = ((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);
//如果鏈式雜湊法中元素數量已經超過閾值,那麼將其轉化成紅黑樹
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;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//版本號+1
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
從上面的方法我們能夠看到,HashMap
解決衝突的方式比HashTable
要複雜一下。首先通過鏈式雜湊解決,但若這個鏈達到一定長度之後,就會將鏈轉化成樹,以確保其元素獲取時間。
所以接下來的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;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//首先檢查對應bucket有無元素
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);
}
}
return null;
}
這就是HashMap,通過更復雜一些的資料結構,保證了效能。
TreeMap
TreeMap
,顧名思義,這是一個基於紅黑樹的Map。紅黑樹本身就是一種二叉查詢樹,因此通過中序遍歷就能夠得到關鍵字有序的序列,這也是TreeMap
實現了SortedMap
的原因。
對於其新增、刪除、查詢節點的方式,實際上就是紅黑樹的刪除、新增、查詢的實現,在這裡就不展開敘述了,有興趣的可以通過這篇部落格來了解紅黑樹。
這裡主要講的是其遍歷元素的EntrySet
相關的方法及類。
public Set<Map.Entry<K,V>> entrySet() {
EntrySet es = entrySet;
return (es != null) ? es : (entrySet = new EntrySet());
}
class EntrySet extends AbstractSet<Map.Entry<K,V>> {
public Iterator<Map.Entry<K,V>> iterator() {
//getFirstEntry就是通過中序遍歷獲取最小的元素
return new EntryIterator(getFirstEntry());
}
public boolean contains(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry<?,?> entry = (Map.Entry<?,?>) o;
Object value = entry.getValue();
Entry<K,V> p = getEntry(entry.getKey());
return p != null && valEquals(p.getValue(), value);
}
public boolean remove(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry<?,?> entry = (Map.Entry<?,?>) o;
Object value = entry.getValue();
Entry<K,V> p = getEntry(entry.getKey());
if (p != null && valEquals(p.getValue(), value)) {
deleteEntry(p);
return true;
}
return false;
}
public int size() {
return TreeMap.this.size();
}
public void clear() {
TreeMap.this.clear();
}
public Spliterator<Map.Entry<K,V>> spliterator() {
return new EntrySpliterator<K,V>(TreeMap.this, null, null, 0, -1, 0);
}
}
//Entry的迭代器,直接繼承PrivateEntryIterator(TreeMap迭代器的預設實現)
final class EntryIterator extends PrivateEntryIterator<Map.Entry<K,V>> {
EntryIterator(Entry<K,V> first) {
super(first);
}
public Map.Entry<K,V> next() {
return nextEntry();
}
}
abstract class PrivateEntryIterator<T> implements Iterator<T> {
Entry<K,V> next;
Entry<K,V> lastReturned;
int expectedModCount;
PrivateEntryIterator(Entry<K,V> first) {
expectedModCount = modCount;
lastReturned = null;
next = first;
}
public final boolean hasNext() {
return next != null;
}
final Entry<K,V> nextEntry() {
Entry<K,V> e = next;
if (e == null)
throw new NoSuchElementException();
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
next = successor(e);
lastReturned = e;
return e;
}
final Entry<K,V> prevEntry() {
Entry<K,V> e = next;
if (e == null)
throw new NoSuchElementException();
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
next = predecessor(e);
lastReturned = e;
return e;
}
public void remove() {
if (lastReturned == null)
throw new IllegalStateException();
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
// 進行節點刪除時,若該節點有兩個子節點,則會把其中一個子節點的值上移,因此這裡將next改為lastReturned
if (lastReturned.left != null && lastReturned.right != null)
next = lastReturned;
//lastReturned即為迭代器當前呼叫nextEntry返回的值,將這個節點進行刪除
deleteEntry(lastReturned);
expectedModCount = modCount;
lastReturned = null;
}
}
從上面能夠看到,對於TreeMap
來說,實際上還是通過樹的遍歷方式來進行entrySet的遍歷。而PrivateEntryIterator
的存在,實際上就是為從任意一個節點進行遍歷提供了可能(實際上,TreeMap還提供了DescendingKeyIterator
這種倒敘遍歷的類)。
ConcurrentHashMap
ConcurrentHashMap
是支援併發訪問的Map
,它也是通過陣列來儲存元素,然後在遇到衝突的時候,首先通過連結串列其次通過紅黑樹來解決雜湊衝突同時保證訪問的速度。它的併發控制也是通過Synchronized
來實現的,但與HashTable
一次性會鎖住整個物件不一樣,遇到併發寫操作的時候,ConcurrentHashMap
選擇鎖住陣列中的某個元素,分段鎖的策略保證了其高併發的修改操作。
在擴容的時候,ConcurrentHashMap
首先會新建一個容量為原來兩倍的陣列,然後通過複製節點以及重雜湊的方式將原有節點移動至新的陣列中。擴容也是分段進行的,若A執行緒在擴容的時候了遇到了B執行緒進行元素新增/刪除/修改的操作,那麼B執行緒也會參與到擴容中,加快擴容的速度。待擴容完成後繼續進行原操作。
接下來看一下putVal
的具體實現
final V putVal(K key, V value, boolean onlyIfAbsent) {
//不允許新增key為null或者是value為null的物件
if (key == null || value == null) throw new NullPointerException();
//計算雜湊值
int hash = spread(key.hashCode());
//這裡的binCount用於記錄雜湊鏈中的元素個數,若超過一定值,需要將雜湊鏈轉化為雜湊樹
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();
//這裡是判斷對應的bucket上是否有元素了,如果沒有元素的話,直接通過cas完成新增
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
}
//在擴容的時候,舊的陣列上所有的節點都會被替換成ForwardingNode,而這個Node的雜湊值就是MOVED(-1),因此遇到了這種節點的時候,就意味著Map正在做擴容操作,這個時候此執行緒可以參與到擴容中
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
//首先,將bucket中的第一個元素加鎖
synchronized (f) {
if (tabAt(tab, i) == f) {
//fh >= 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;
}
}
}
//如果是紅黑樹的話,那麼按照紅黑樹的邏輯進行元素修改
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
//判斷是否需要轉化成為雜湊樹
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
其次再看一下replaceNode
方法,實際上這個方法與putVal
方法大同小異
final V replaceNode(Object key, V value, Object cv) {
//首先,計算雜湊值
int hash = spread(key.hashCode());
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//其次,判斷Map是否為空,若Map已經為空,則沒有必要進行處理了
if (tab == null || (n = tab.length) == 0 ||
(f = tabAt(tab, i = (n - 1) & hash)) == null)
break;
//再次,判斷是否處於擴容狀態,若處於擴容狀態則主動參與到擴容中
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
boolean validated = false;
//同樣,對第一個元素進行加鎖
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
validated = true;
for (Node<K,V> e = f, pred = null;;) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
V ev = e.val;
if (cv == null || cv == ev ||
(ev != null && cv.equals(ev))) {
oldVal = ev;
if (value != null)
e.val = value;
else if (pred != null)
pred.next = e.next;
else
setTabAt(tab, i, e.next);
}
break;
}
pred = e;
if ((e = e.next) == null)
break;
}
}
//如果是紅黑樹,則進行樹的元素移除操作
else if (f instanceof TreeBin) {
validated = true;
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> r, p;
if ((r = t.root) != null &&
(p = r.findTreeNode(hash, key, null)) != null) {
V pv = p.val;
if (cv == null || cv == pv ||
(pv != null && cv.equals(pv))) {
oldVal = pv;
if (value != null)
p.val = value;
else if (t.removeTreeNode(p))
setTabAt(tab, i, untreeify(t.first));
}
}
}
}
}
if (validated) {
if (oldVal != null) {
if (value == null)
addCount(-1L, -1);
return oldVal;
}
break;
}
}
}
return null;
}
以上就是ConcurrentHasMap
的兩個主要操作。實際上ConcurrentHashMap
在Java7和Java8中有兩個完全不同的實現,Java7的實現是為陣列劃分了Segment
,然後針對Segment
進行加鎖。這樣做的話實際上就限制了ConcurrentHashMap
的最大併發數了,而且也有可能在某個分割槽內的元素較為集中,進而導致併發量進一步下降。
關於Java7中的實現,可以參考這裡。
ConcurrentSkipListMap
ConcurrentSkipListMap
,基於跳躍列表實現的Map
。跳躍表的原理實際上是建立了一個多層的連結串列,然後在多層的連結串列中,又會隨機地跳過某些元素(也因此得名跳躍列表)。這樣的多層連結串列控制,有兩個好處。第一查詢元素的時間不再是O(n)了,可以提高效率。第二個就是擴容時的效率提高,相比於樹插入/刪除時需要進行擴容或者是樹的調整/重雜湊,跳躍表示不需要進行這些步驟的。在維基百科上是這樣描述跳躍列表的。
跳躍列表不像某些傳統平衡樹資料結構那樣提供絕對的最壞情況效能保證,因為用來建造跳躍列表的扔硬幣方法總有可能(儘管概率很小)生成一個糟糕的不平衡結構。但是在實際中它工作的很好,隨機化平衡方案比在平衡二叉查詢樹中用的確定性平衡方案容易實現。跳躍列表在平行計算中也很有用,這裡的插入可以在跳躍列表不同的部分並行的進行,而不用全域性的資料結構重新平衡。
那麼,我們還是瞭解一下其內部重要的成員變數
public class ConcurrentSkipListMap<K,V> extends AbstractMap<K,V>
implements ConcurrentNavigableMap<K,V>, Cloneable, Serializable {
//頭部指標,跳躍列表實際上還是多層的連結串列結構,這裡的head指的是level最大的連結串列的首元素
private transient volatile HeadIndex<K,V> head;
//比較器,如果跳躍列表中的元素沒有實現Comparable介面,也可以通過設定Comparator來確認排序
final Comparator<? super K> comparator;
}
接下來就是新增元素的doPut
方法,這個方法主要分為兩個步驟,第一個步驟首先是找到元素在底層node列表中的順序,並將元素插入。第二個則是建立高層的跳躍錶鏈接關係。
private V doPut(K key, V value, boolean onlyIfAbsent) {
Node<K,V> z;
if (key == null)
throw new NullPointerException();
Comparator<? super K> cmp = comparator;
outer: for (;;) {
//首先需要找到其前置元素,
for (Node<K,V> b = findPredecessor(key, cmp), n = b.next;;) {
if (n != null) {
Object v; int c;
Node<K,V> f = n.next;
if (n != b.next)
break;
//如果前置元素處理被刪除的過程中,這裡需要繼續進行刪除(狀態機中的某個狀態)
if ((v = n.value) == null) {
n.helpDelete(b, f);
break;
}
if (b.value == null || v == n)
break;
if ((c = cpr(cmp, key, n.key)) > 0) {
b = n;
n = f;
continue;
}
//原來的key在跳躍列表已經存在,使用cas替換值
if (c == 0) {
if (onlyIfAbsent || n.casValue(v, value)) {
@SuppressWarnings("unchecked") V vv = (V)v;
return vv;
}
break;
}
}
//原來的key在跳躍列表中不存在,通過cas進行元素新增
z = new Node<K,V>(key, value, n);
if (!b.casNext(n, z))
break; // restart if lost race to append to b
break outer;
}
}
//開始進行列表跳躍層節點的新增
int rnd = ThreadLocalRandom.nextSecondarySeed();
//隨機決定是否需要將跳躍列表層數+1,也決定了當前這個節點需要建立幾層連結串列
if ((rnd & 0x80000001) == 0) { // test highest and lowest bits
int level = 1, max;
while (((rnd >>>= 1) & 1) != 0)
++level;
Index<K,V> idx = null;
HeadIndex<K,V> h = head;
if (level <= (max = h.level)) {
//初始化這個節點的上層連結串列節點
for (int i = 1; i <= level; ++i)
idx = new Index<K,V>(z, idx, null);
}
else {
//這裡是整個列表的層數+1
level = max + 1;
@SuppressWarnings("unchecked")Index<K,V>[] idxs =
(Index<K,V>[])new Index<?,?>[level+1];
for (int i = 1; i <= level; ++i)
idxs[i] = idx = new Index<K,V>(z, idx, null);
for (;;) {
h = head;
int oldLevel = h.level;
if (level <= oldLevel) // lost race to add level
break;
HeadIndex<K,V> newh = h;
Node<K,V> oldbase = h.node;
for (int j = oldLevel+1; j <= level; ++j)
newh = new HeadIndex<K,V>(oldbase, newh, idxs[j], j);
if (casHead(h, newh)) {
h = newh;
idx = idxs[level = oldLevel];
break;
}
}
}
// 將剛剛插入的跳躍層節點與其他節點連結起來
splice: for (int insertionLevel = level;;) {
int j = h.level;
for (Index<K,V> q = h, r = q.right, t = idx;;) {
if (q == null || t == null)
break splice;
if (r != null) {
Node<K,V> n = r.node;
int c = cpr(cmp, key, n.key);
if (n.value == null) {
if (!q.unlink(r))
break;
r = q.right;
continue;
}
if (c > 0) {
q = r;
r = r.right;
continue;
}
}
if (j == insertionLevel) {
//插入層進行連結
if (!q.link(r, t))
break; // restart
if (t.node.value == null) {
findNode(key);
break splice;
}
//同時對insertionLevel-1,也就是下面的層級也需要進行連結
if (--insertionLevel == 0)
break splice;
}
if (--j >= insertionLevel && j < level)
t = t.down;
q = q.down;
r = q.right;
}
}
}
return null;
}
然後再具體看一下,其獲取前置元素的方法,這個方法在get
和remove
方法中也被呼叫了,是跳躍列表的關鍵查詢演算法。
private Node<K,V> findPredecessor(Object key, Comparator<? super K> cmp) {
if (key == null)
throw new NullPointerException();
for (;;) {
//首先獲取頭部節點(最上層連結串列的第一個節點),然後向右進行查詢
for (Index<K,V> q = head, r = q.right, d;;) {
if (r != null) {
Node<K,V> n = r.node;
K k = n.key;
//n.value = null,帶白哦節點處於刪除過程中,此時需要將節點刪除,然後繼續進行查詢
if (n.value == null) {
if (!q.unlink(r))
break;
r = q.right;
continue;
}
//如果搜尋的key比當前連結串列的下一個key要大,則繼續向右查詢
if (cpr(cmp, key, k) > 0) {
q = r;
r = r.right;
continue;
}
}
//如果搜尋的key比當前連結串列的下一個key小,則代表此層連結串列跳的太遠了,通過下層連結串列繼續搜尋
if ((d = q.down) == null)
return q.node;
q = d;
r = d.right;
}
}
}
瞭解完上述的方法,就對跳躍列表的具體實現有一個大致的瞭解了。
小結
這篇文章主要講了集合框架中的Map
,以及具體的幾個實現類。包括最初執行緒安全的HashTable
(但是其迭代器也不能和多執行緒併發同時使用),還有最常用的HashMap
,以及在併發下的ConcurrentHashMap
(通過對雜湊表中的首元素進行加鎖解決多併發讀寫問題,遇到擴容時,則使用sizeCtl進行全域性鎖),ConcurrentSkipListMap
(通過跳躍列表實現的Map
,這裡所有的併發讀寫都是通過cas來進行控制的,屬於無鎖的實現)。