Map學習
Map是java中存儲key-value數據的容器類,按照慣例我們將從最底層的Map接口開始學習,瞭解其該擁有哪些基本的方法。
一 頂層接口
1. Map接口
先從接口註釋學起,
-
Map是一個存儲key-value映射關係的容器,key不可重複,keyvalue是一一對應的關係。
-
可返回key集合、value集合、及映射關係集合,返回順序取決於迭代器,或者根據子類特殊指定的順序。
-
Map對象本身不能當做key,但可以作爲value
public interface Map<K,V> {
// 最大返回值也只能是Integer.MAX_VALUE
int size();
boolean isEmpty();
// key 爲null會報異常,key類型符合要也會拋ClassCastException
boolean containsKey(Object key);
boolean containsValue(Object value);
// 增刪改查的邏輯
V get(Object key);
V put(K key, V value);
V remove(Object key);
void putAll(Map<? extends K, ? extends V> m);
void clear();
// 三個視圖方法
Set<K> keySet();
Collection<V> values();
Set<Map.Entry<K, V>> entrySet();
boolean equals(Object o);
int hashCode();
}
以上是Map中擁有的基本方法,從中可以看出最初的設計思路,
- 作爲一個容器類,需要考慮該容器存儲什麼類型的數組,這決定了增刪改查方法的設計。
- 還需要對提供容器的判空、是否包含指定元素、容器之間的比較等方法。
- 最後容器類不僅關心容器內元素的進出,更需要考慮容器作爲一個整體如何對外提供容器內數據展示,這就是三個視圖方法的目的。
注意到上面entrySet方法返回的是一個Entry對象,這是一個鍵值對。
interface Entry<K,V> {
K getKey();
V getValue();
V setValue(V value);
boolean equals(Object o);
int hashCode();
}
當然java8 在Map接口中新增了很多有默認實現的方法,這些方法在不修改子類的情況下擴展了Map的能力,下面將挑部分進行講解。
getOrDefault
這是可以設置默認值的查詢方法,當未找到key對於的value時返回默認值,而不是null
default V getOrDefault(Object key, V defaultValue) {
V v;
return (((v = get(key)) != null) || containsKey(key))
? v
: defaultValue;
}
forEach
內部遍歷map,並對每個元素執行action的處理,
default void forEach(BiConsumer<? super K, ? super V> action) {
Objects.requireNonNull(action);
for (Map.Entry<K, V> entry : entrySet()) {
K k;
V v;
try {
k = entry.getKey();
v = entry.getValue();
} catch(IllegalStateException ise) {
// this usually means the entry is no longer in the map.
throw new ConcurrentModificationException(ise);
}
action.accept(k, v);
}
}
以上默認方法的實現都依賴於,接口中基礎抽象方法,相當於把在外部經常使用map的一些模式提出來加入到Map中,方便了使用。
以前是磚塊決定了樓層的搭建,現在是樓層搭建方式改變了磚塊。
2.AbstractMap抽象類
AbstractMap中提供了Map接口中大部分的默認實現,但都依賴entrySet()方法獲取kv集合。
二 具體實現
1.HashMap
先從註釋看起
-
非線程安全,允許key 或 value爲null,不保證返回順序。
-
loadFactor 是加載因子,默認爲0.75 該變量意義就是
就會分配進行擴容。
- 如果開始就有很多數據需要存儲,則最好初始化HashMap時就指定一個較大的容量。
- 可以通過Collections中的synchronizedMap方法得到一個線程安全的Map。
- 和collection集合類一樣,在迭代時發生結構性變化會終止迭代,拋出ConcurrentModificationException
HashMap主要做的工作提供合理的計算哈希值的函數,且儘可能減少哈希衝突概率。這樣就能使元素均勻散列在不同的bucket上,最大化體現複合型數據結構的優勢。
存儲結構
通過查看類中的成員變量我們可以快速瞭解HashMap的存儲結構。
public class HashMap<K,V> extends AbstractMap<K,V>implements Map<K,V>, Cloneable, Serializable {
// 默認初始化容量爲16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 哈希表最大尺寸
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默認裝填因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 當一個桶上鍊表長度大於8時會轉換成紅黑樹
static final int TREEIFY_THRESHOLD = 8;
// 當一個桶上紅黑樹小於等於6時會轉換爲鏈表
static final int UNTREEIFY_THRESHOLD = 6;
// 當數組容量大於等於64時,纔會將桶中的符合條件的鏈表轉換爲紅黑樹
static final int MIN_TREEIFY_CAPACITY = 64;
// 實際存放數據的數組
transient Node<K,V>[] table;
// 鍵值對的副本
transient Set<Map.Entry<K,V>> entrySet;
// size實際大小
transient int size;
// 修改次數 版本號
transient int modCount;
// 數組擴容的閥值
int threshold;
// 裝填因子
final float loadFactor;
}
打臉了,看了上面的代碼也不清楚HashMap是怎麼存儲數據的,數據結構中的哈希表使用哈希算法計算得出索引,基本方式是對數組容量取餘,那就看下hash算法源碼。
hash算法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 得出
index = (n-1) & (h = key.hashCode()) ^ (h >>> 16)
hash算法就是根據hash值生成一個數組索引。
index = node.hashCode % n
這種方式並不高效,採用實際採用的如下方式:
index = (n - 1) & hash(key);
首先我們的hash數組的length一定是2的冪,假設hashCode是51(0011 0011) 數組長度爲16(0001 0000);由於length是2的冪則其二進制表示中肯定只有一個1,如:0000 1000 ,hashCode中高於這個1的位置的數據都能整除length,低於這個1的部分就是餘數。
0011 0011 = 51
0001 0000 = 16
————————————————
0000 0011 = 3
通過位運算可以保留餘數部分
0011 0011 & 0000 1111 = 0000 0011
而
0000 1111 = 0001 0000 - 0000 0001
所以 index = hashCode & (n-1) n爲2的冪,得到了求餘的方式。
上面根據取餘的方式計算索引有個問題,當只有大於length的高位在變化時,求得的餘數都是一樣的。
即高位的變化影響不到餘數,導致的結果就是衝突概率很高。
求解hash的部分,解決辦法就是右移16位再與自身進行與運算
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
求解hash的部分,解決辦法就是右移16位再與自身進行與運算,hashCode 是32位的,右移16位將
高位數據和低位數據拉到了一起,進行與運算產生最終的hashCode。
舉例
hashCode1 = 0101 0011 = 83
hashCode2 = 0001 0011 = 19
n = 0001 0000 = 16
index1 = hashCode1 & n-1 = 0101 0011 & 0000 1111 = 0011 = 3
index2 = hashCode2 & n-1 = 0001 0011 & 0000 1111 = 0011 = 3
# 此處我們實例使用8位,所以用4,。
hashCode1 = hashCode1 ^ (hashCode1 >>> 4) = 0101 0011 ^ 0000 0101 = 0101 0110
hashCode2 = hashCode2 ^ (hashCode2 >>> 4) = 0001 0011 ^ 0000 0001 = 0001 0010
index1 = hashCode1 & n-1 = 0101 0110 & 0000 1111 = 0000 0110 = 6
index2 = hashCode2 & n-1 = 0001 0010 & 0000 1111 = 0000 0010 = 2
經過以上算法處理,產生的hash索引分到了不同的位置。
小結:
(1)原始hash值與無符號右移一半位置的hash值做異或操作,得到結合高低位的新hash值。
(2)新hash值與n-1 做與操作得到最終索引。
插入節點
在插入流程基本可以看到HashMap的存儲結構。
putVal
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
// tab是節點數組,i爲hash對應下標,p爲下標爲i的位置對應節點,
// i下標可能存在多個節點,p會依次向下變動
Node<K,V>[] tab; Node<K,V> p; int n, i;
//1.判斷節點數組長度,如果爲空或者length爲0,則進行初始化。
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//2.判斷hash值對應座標是否爲空,如果爲空則創建新節點並賦值。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
//3.不爲空則說明座標衝突,則先判斷hash值在進行key比較。
// e是與傳入key相同的節點(暫定),k是相同節點對應key
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//4.判斷節點是不是樹節點,是則走樹節點插入
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//5.循環遍歷鏈表節點,尾部爲null時插入新節點
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 6.如果鏈表節點長度大於等於8則將其鏈表轉換爲樹
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//7. 如果找到匹配節點則中斷遍歷。
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//8. 沒有找到匹配節點,p則向下移動
p = e;
}
}
//9. 找到匹配節點e,則返回舊節點值。
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 查詢到節點後的回調方法。
afterNodeAccess(e);
return oldValue;
}
}
//10. 前面兩個分支,當p節點不爲空則前面就返回了。
// 當p節點爲null時,插入了新節點(插入了數組中),對原有存儲結構進行了變動(尺寸變動)。
++modCount;
//11. 當數組尺寸大於擴容閥值,則觸發數組擴容。
if (++size > threshold)
resize();
// 在數組中插入元素後的回調方法
afterNodeInsertion(evict);
return null;
}
小結一下:
- 先判hashmap是否初始化過,否則進行首次擴容初始化。
- 判斷Key的hash值計算得出的數組下標是否爲null,如果爲null則創建新節點插入數組。
- 如果不爲空,則說明和已有節點索引衝突,需要將該節點插入到衝突節點字鏈表中或子樹中。
- 如果是樹結構,走單獨插入邏輯。
- 如果是鏈表,則向下匹配,未找到則插入到鏈表末尾;判斷鏈表長度,大於等於8則將其轉換爲樹。
- 找到則暫存遊標終止遍歷,根據onlyIfAbsent決定是否覆蓋,最後返回找到節點的舊值。
- 如果在數組中插入了新節點,需要判斷是否進行擴容。
插入元素涉及三種數據結構,節點數組、節點鏈表、節點紅黑樹。插入元素先在數組中插入,下標有衝突則考慮插入鏈表中,鏈表長度>=8則轉化爲紅黑樹。
學習的小冊中找到一張圖,可以清晰的展示存儲結構。
putTreeVal
普通插入算法中,如果是槽內根節點是紅黑樹,則需要走紅黑樹插入邏輯,putTreeVal是TreeNode中的方法,代碼如下:
/**
* map 是要插入節點的hashmap
* tab 是存儲節點的槽數組
* h k v 是要插入節點的hash值、key 和 value
*/
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,int h, K k, V v) {
Class<?> kc = null;
// 標記是否已經便利過一次樹
boolean searched = false;
// 首先知道根節點
TreeNode<K,V> root = (parent != null) ? root() : this;
// 自旋
for (TreeNode<K,V> p = root;;) {
int dir, ph; K pk;
// p是遍歷節點,ph是p節點hash值,dir是方向
if ((ph = p.hash) > h)
dir = -1;// 小於0說明要插入的h應該在p的左側
else if (ph < h)
dir = 1;// 大於0說明要插入的h應該在p的右側
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;// 匹配說明找到替換的節點了p,外層方法進行值替換操作。滿足hash值相等和key相等兩個條件
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
// 此時兩種情況:(1)k沒有實現comparable接口,(2)k實現了comparable接口並且和p節點pk相同。
// serarched標記p節點是否被遍歷過,沒有遍歷則進入遞歸遍歷邏輯。
if (!searched) {
// q是子樹中找到的節點,ch是p的子節點。
TreeNode<K,V> q, ch;
searched = true;
// 紅黑樹是平衡二叉樹,這邊使用短路邏輯,先從左側遍歷,再從右側遍歷,先找到先終止。
if (((ch = p.left) != null &&
(q = ch.find(h, k, kc)) != null) ||
((ch = p.right) != null &&
(q = ch.find(h, k, kc)) != null))
return q;
}
// 沒找到可替換節點,需要進行插入邏輯,這裏計算從哪邊開始插入。
dir = tieBreakOrder(k, pk);
}
// 這裏首先根據插入方向dir對p進行更新,如果爲空則進行新節點插入
TreeNode<K,V> xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
Node<K,V> xpn = xp.next; //獲取更新前p節點的next
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);// 創建新的樹節點
if (dir <= 0)
xp.left = x;
else
xp.right = x;
xp.next = x;
x.parent = x.prev = xp;
if (xpn != null)
((TreeNode<K,V>)xpn).prev = x;
//balanceInsertion 對紅黑樹進行着色或旋轉,以達到更多的查找效率,着色或旋轉的幾種場景如下
//着色:新節點總是爲紅色;
//如果新節點的父親是黑色,則不需要重新着色;
//如果父親是紅色,那麼必須通過重新着色或者旋轉的方法,再次達到紅黑樹的5個約束條件
//旋轉: 父親是紅色,叔叔是黑色時,進行旋轉
//如果當前節點是父親的右節點,則進行左旋
//如果當前節點是父親的左節點,則進行右旋
moveRootToFront(tab, balanceInsertion(root, x));// 重新平衡
return null; // 返回null 說明插入了操作產生了一個新的節點。
}
}
}
擴容算法
上面流程中涉及到了數組的擴容,這裏也學習一下。擴容需要先了解下需要擴多大,
- 初次擴容使用tableSizeFor方法就是用於計算容量,得出的值總是2的冪。
tableSizeFor
計算容量,返回的是結果肯定是2的冪。其中右移後進行或操作。這個方法可以計算出大於等於cap 的最近的那個2的冪。
static final int tableSizeFor(int cap) {
int n = cap - 1; // 假設n = 9 = 00001001
n |= n >>> 1;// n = 00001001 | 00000100 = 00001101 = 13
n |= n >>> 2;// n = 00001101 | 00000011 = 00001111 = 15
n |= n >>> 4;// n = 00001111 | 00000000 = 00001111 = 15
n |= n >>> 8;// ...
n |= n >>> 16;// ...
// 最後在對 n+1 得到 16
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
- 使用resize()方法進行擴容,
final Node<K,V>[] resize() {
// 複製舊有的Node數組
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;
}
// 如果新容量和閥值都是舊容量和閥值的兩倍,<<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
// oldCap = 0 但 oldThr > 0 ,這裏應該是減小容量的邏輯。
newCap = oldThr;
else { // zero initial threshold signifies using defaults
// 如果oldCap=0則說明未初始化。
newCap = DEFAULT_INITIAL_CAPACITY;
// 使用裝填因子*默認容量得到,初次擴容閥值。
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
// 這裏對應了oldCap = 0 但 oldThr > 0的情況,上面的邏輯沒有對newThr賦值。
float ft = (float)newCap * loadFactor;
// 設置新的擴容閥值。
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 修改總體閥值
threshold = newThr;
// 創建新的容量的數組,並修改總體table。
@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)
// 如果j位置只有一個節點(不是鏈表或樹)
// 將舊節點填充到新數組相應位置
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;
// oldCap是2的冪,所以只有一位是1其他位都是0
// 這個算法將一個長鏈表拆分再連爲兩部分。
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;
}
// 對擴充後j + oldCap 的桶賦值,使用剛拆封出來的高位鏈表。
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
2.TreeMap
TreeMap是基於紅黑樹實現的,確保了其containsKey、get、put、remove 等操作的複雜度都是log(n)
public class TreeMap<K,V> extends AbstractMap<K,V> implements NavigableMap<K,V>, Cloneable, java.io.Serializable{
// 可通過構造函數傳入比較器,決定了map中key的順序,否則使用元素原生的比較器。
private final Comparator<? super K> comparator;
// 紅黑樹根節點
private transient Entry<K,V> root;
// map內元素個數
private transient int size = 0;
// 結構性修改次數
private transient int modCount = 0;
// TreeMap的視圖結構
private transient EntrySet entrySet;
private transient KeySet<K> navigableKeySet;
private transient NavigableMap<K,V> descendingMap;
}
小結:
- TreeMap中key的順序取決於比較器,優先使用外部傳入比較器,其次使用key自帶的比較方法。
- TreeMap實現了NavigableMap接口,擁有一系列導航定位的方法。
- TreeMap實現了Cloneable接口和Serializable接口,可以被序列化和克隆。
存儲結構
既然是紅黑樹,這裏看下節點結構。
static final class Entry<K,V> implements Map.Entry<K,V> {
K key;// 當前節點key
V value;// 當前節點value
Entry<K,V> left;
Entry<K,V> right;
Entry<K,V> parent;
boolean color = BLACK;// 節點默認顏色爲黑色
}
紅黑樹是一種含有紅黑結點並能自平衡的二叉查找樹。它必須滿足下面性質:
- 性質1:每個節點要麼是黑色,要麼是紅色。
- 性質2:根節點是黑色。
- 性質3:每個葉子節點(NIL)是黑色。
- 性質4:每個紅色結點的兩個子結點一定都是黑色。
- 性質5:任意一結點到每個葉子結點的路徑都包含數量相同的黑結點。
插入節點
put方法會將新節點插入樹中,如有相同key則進行替換操作並返回舊value,如果是新增節點則返回null。
public V put(K key, V value) {
// 1.根節點如果爲空則創建新節點
Entry<K,V> t = root;
if (t == null) {
compare(key, key); // 確保key不能爲null
root = new Entry<>(key, value, null);
size = 1;
modCount++;
return null;
}
int cmp;
Entry<K,V> parent;
// split comparator and comparable paths
Comparator<? super K> cpr = comparator;
// 2.如果TreeMap自帶比較器則使用自帶比較器進行比較。
if (cpr != null) {
do { // 自旋遍歷紅黑樹,依次進行比較,parnent 記錄了未匹配到key,終止循環時的父節點
parent = t;
cmp = cpr.compare(key, t.key);
if (cmp < 0)
// key 小於t.key則說明匹配位置應該在左子樹上
t = t.left;
else if (cmp > 0)
// key 大於t.key則說明匹配位置應該在右子樹上
t = t.right;
else
// key相同則進行替換賦值
return t.setValue(value);
} while (t != null);
}
else {
// 使用元素自帶比較器進行遍歷
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable<? super K> k = (Comparable<? super K>) key;
do {
parent = t;
cmp = k.compareTo(t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t != null);
}
// 2.此時說明樹中沒有key相同節點,創建新節點。
Entry<K,V> e = new Entry<>(key, value, parent);
// 在最後的父節點中插入新節點
if (cmp < 0)
parent.left = e;
else
parent.right = e;
// 插入節點後進行旋轉着色操作,維護平衡性。
fixAfterInsertion(e);
size++;
modCount++;
return null;
}
小結:
- 首先是查找過程,利用紅黑樹特性快速查找與key匹配的節點,找到匹配節點則替換新值返回舊值,未找到則記錄了可供插入的葉子節點的父節點(子節點爲null的節點)。
- 插入過程,創建新節點插入到父節點中,着色旋轉維護平衡性。
- TreeMap不允許使用null爲key。
插入自平衡
private void fixAfterInsertion(Entry<K,V> x) {
// 首先將新插入節點置爲紅色
x.color = RED;
while (x != null && x != root && x.parent.color == RED) {
if (parentOf(x) == leftOf(parentOf(parentOf(x)))) {
Entry<K,V> y = rightOf(parentOf(parentOf(x)));
if (colorOf(y) == RED) {
setColor(parentOf(x), BLACK);
setColor(y, BLACK);
setColor(parentOf(parentOf(x)), RED);
x = parentOf(parentOf(x));
} else {
if (x == rightOf(parentOf(x))) {
x = parentOf(x);
rotateLeft(x);
}
setColor(parentOf(x), BLACK);
setColor(parentOf(parentOf(x)), RED);
rotateRight(parentOf(parentOf(x)));
}
} else {
Entry<K,V> y = leftOf(parentOf(parentOf(x)));
if (colorOf(y) == RED) {
setColor(parentOf(x), BLACK);
setColor(y, BLACK);
setColor(parentOf(parentOf(x)), RED);
x = parentOf(parentOf(x));
} else {
if (x == leftOf(parentOf(x))) {
x = parentOf(x);
rotateRight(x);
}
setColor(parentOf(x), BLACK);
setColor(parentOf(parentOf(x)), RED);
rotateLeft(parentOf(parentOf(x)));
}
}
}
root.color = BLACK;
}
刪除節點
刪除節點使用的remove方法,內部實現是deleteEntry方法,這裏可以瞭解一下:
private void deleteEntry(Entry<K,V> p) {
modCount++;
size--;
// 情況一:p有兩個子節點
// 當左右節點都不爲null時,使用successor找到p節點對應的前驅或者後繼節點。
// 當把紅黑樹上的節點投射到與葉子層平行的水平線上時,節點序列是按從左到右的順序依次遞增的。
// 前驅節點就是p的前一個相鄰節點,後繼節點就是p的後一個相鄰節點。
// 刪除p節點相當於把s節點移動到p的位置上,再在原有位置上刪除s節點。
if (p.left != null && p.right != null) {
Entry<K,V> s = successor(p);
p.key = s.key;
p.value = s.value;
p = s;
}
// 如果最開始p擁有兩個子節點,經過上面過程,p已經被替換爲前驅或後繼節點s。
// 而前驅節點肯定沒有right節點,後置節點肯定沒有left節點。
// 所以:此時的p 只有一個子節點,另一個子節點爲null
// 情況二:p只有一個子節點 ,replacement就是要替換p的子節點。
Entry<K,V> replacement = (p.left != null ? p.left : p.right);
if (replacement != null) {
// 將子節點replacement連接到parent節點上。
replacement.parent = p.parent;
if (p.parent == null)
root = replacement;
else if (p == p.parent.left)
p.parent.left = replacement;
else
p.parent.right = replacement;
// Null out links so they are OK to use by fixAfterDeletion.
p.left = p.right = p.parent = null;
// 如果p.color是RED,其子節點一定是黑色的,刪除一個紅色節點替換爲黑色子節點,
// 對該子節點而言通往root路徑上的黑色節點數量不變,平衡不變。
// 如果p.color是BLACK,刪除一個黑色節點就需要調整平衡性。
if (p.color == BLACK)
fixAfterDeletion(replacement);
} else if (p.parent == null) { // return if we are the only node.
root = null;
} else { // No children. Use self as phantom replacement and unlink.
// 情況三:p沒有子節點
// p爲黑色葉子節點,刪除p需要調整平衡性。
if (p.color == BLACK)
fixAfterDeletion(p);
// p的left和right都是null了,此處要清空p和紅黑樹的關係。
if (p.parent != null) {
if (p == p.parent.left)
p.parent.left = null;
else if (p == p.parent.right)
p.parent.right = null;
p.parent = null;
}
}
}
刪除自平衡
以上插入和刪除操作都會涉及到自平衡方法fixAfterDeletion,它對刪除後補位的元素進行調整,這裏看下該方法的實現過程。
private void fixAfterDeletion(Entry<K,V> x) {
// x是被刪除所在位置的節點,(有可能是補位的節點,也有可能是被刪除的節點)
// 紅黑樹的平衡是從底部到頂部(根部)的過程
while (x != root && colorOf(x) == BLACK) {
// 情況1:被替換的節點是左節點
if (x == leftOf(parentOf(x))) {
// x的兄弟節點
Entry<K,V> sib = rightOf(parentOf(x));
// 1.1 兄弟節點爲紅色
if (colorOf(sib) == RED) {
setColor(sib, BLACK);// 將兄弟節點置爲黑色
setColor(parentOf(x), RED);// 將父節點置爲紅色
rotateLeft(parentOf(x)); // 父節點的右側黑色節點層數變多,以父節點爲中心左旋調整。
sib = rightOf(parentOf(x));// 左旋後原來父節點right節點會變動,更新兄弟節點sib。
}
// 1.2 替換節點的兄弟節點是黑色(原本是黑色或經上一步改爲黑色),
// 兄弟節點的子節點都是黑色
if (colorOf(leftOf(sib)) == BLACK &&
colorOf(rightOf(sib)) == BLACK) {
setColor(sib, RED);// 將兄弟節點改爲紅色
x = parentOf(x);// 將x指向x節點的父節點
} else {
if (colorOf(rightOf(sib)) == BLACK) {
// 1.3 兄弟節點的右子節點是黑色,左子節點是紅色
setColor(leftOf(sib), BLACK);// 將左節點置爲黑色
setColor(sib, RED);// 兄弟節點由黑轉紅
rotateRight(sib);// 兄弟節點的left黑色節點層數變多,右旋調整
sib = rightOf(parentOf(x));// 更新兄弟節點
}
// 1.4 兄弟節點左子節點
setColor(sib, colorOf(parentOf(x)));
setColor(parentOf(x), BLACK);
setColor(rightOf(sib), BLACK);
rotateLeft(parentOf(x));
x = root;
}
} else { // symmetric
// 被替換的節點是右節點
Entry<K,V> sib = leftOf(parentOf(x));
if (colorOf(sib) == RED) {
setColor(sib, BLACK);
setColor(parentOf(x), RED);
rotateRight(parentOf(x));
sib = leftOf(parentOf(x));
}
if (colorOf(rightOf(sib)) == BLACK &&
colorOf(leftOf(sib)) == BLACK) {
setColor(sib, RED);
x = parentOf(x);
} else {
if (colorOf(leftOf(sib)) == BLACK) {
setColor(rightOf(sib), BLACK);
setColor(sib, RED);
rotateLeft(sib);
sib = leftOf(parentOf(x));
}
setColor(sib, colorOf(parentOf(x)));
setColor(parentOf(x), BLACK);
setColor(leftOf(sib), BLACK);
rotateRight(parentOf(x));
x = root;
}
}
}
setColor(x, BLACK);
}
查找元素
查找元素邏輯和插入元素的邏輯類似,代碼如下:
final Entry<K,V> getEntry(Object key) {
// 同樣優先使用TreeMap中的比較器進行查找
if (comparator != null)
return getEntryUsingComparator(key);
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable<? super K> k = (Comparable<? super K>) key;
// 從根節點進行遍歷查找
Entry<K,V> p = root;
while (p != null) {
int cmp = k.compareTo(p.key);
if (cmp < 0)
p = p.left;
else if (cmp > 0)
p = p.right;
else
return p;
}
return null;
}
3.LinkedHashMap
HashMap是無序的,TreeMap是按照Key的大小進行的排序,而LinkedHashMap是按照插入的順序進行排序的,其最重要的兩個特性是:
- 可以按照插入順序進行訪問
- 可以實現LRU
下面的學習主要關注以上兩點的實現。
存儲結構
public class LinkedHashMap<K,V> extends HashMap<K,V>implements Map<K,V>{
// 雙向鏈表頭結點,維護的是最早插入的節點
transient LinkedHashMap.Entry<K,V> head;
// 雙向鏈表尾節點,維護的是最新插入的節點
transient LinkedHashMap.Entry<K,V> tail;
// 訪問順序標記符,false爲按插入順序方法
// true爲按訪問順序,會調整節點順序,將經常訪問節點放到尾部
final boolean accessOrder;
// 擴展節點,增加了前驅節點before 和 後繼節點after
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
}
LinkedHashMap繼承HashMap,所以底層數據存儲依舊是數組+鏈表+紅黑樹,不同的地方在於其擴展了節點,
按插入順序維護了一個雙向鏈表,這是實現按插入順序進行訪問的關鍵。
插入節點
上面可知LinkedHashMap中擴展的節點有兩個新增屬性,這兩個屬性是什麼時候賦值的呢?LinkedHashMap自己沒有實現插入方法,使用的是HashMap中的put方法,但覆寫了newNode和newTreeNode方法,在裏面完成了前驅和後繼的連接過程。
調用過程如:put->putVal->newNode/newTreeNode->linkNodeLast
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
LinkedHashMap.Entry<K,V> p =new LinkedHashMap.Entry<K,V>(hash, key, value, e);
linkNodeLast(p);// 連接節點
return p;
}
TreeNode<K,V> newTreeNode(int hash, K key, V value, Node<K,V> next) {
TreeNode<K,V> p = new TreeNode<K,V>(hash, key, value, next);
linkNodeLast(p);// 連接節點
return p;
}
// 雙向鏈表尾插法
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
LinkedHashMap.Entry<K,V> last = tail;
tail = p;
// 插入第一個節點時,頭尾指向同一個節點
if (last == null)
head = p;
else {
// 完成p的前驅節點和鏈表尾節點連接
p.before = last;
last.after = p;
}
}
通過以上過程,每個新增節點都會被按插入順序插入到鏈表中。
刪除節點
刪除節點的方法同樣使用HashMap中的remove方法,但還需要從雙向鏈表中刪除該節點纔算完全刪除。
這裏就用到了在HashMap中刪除元素後的回調方法afterNodeRemoval,linkedHashMap覆寫了這個方法,其中實現了從雙向鏈表中刪除節點的邏輯。
void afterNodeRemoval(Node<K,V> e) { // unlink
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
// p是待刪除節點,b是前驅節點,a是後繼節點
p.before = p.after = null;
if (b == null)
head = a;// 前驅節點爲null,則將head指向後繼節點
else
b.after = a;// 前驅節點不爲null,則將前驅節點和後繼節點連起來
if (a == null)
tail = b;// 後繼節點爲null,則將tail指向前驅節點
else
a.before = b;// 後繼節點不爲null,則將後繼節點和前驅節點連起來
}
順序訪問
通過LinkedHashIterator 可以依次訪問LinkedHashMap中所有元素,訪問過程如下:
LinkedHashIterator() {
next = head;// 頭結點作爲訪問的第一個節點
expectedModCount = modCount;
current = null;
}
public final boolean hasNext() {
return next != null;
}
final LinkedHashMap.Entry<K,V> nextNode() {
LinkedHashMap.Entry<K,V> e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
current = e;
next = e.after;
return e;
}
因爲在插入節點時,已經按照插入順序將節點插入到雙向鏈表當中,所以在遍歷時只需要按順序依次訪問e.after就得到了節點插入順序。
LRU實現
LRU是指最近最少使用,是一種頁面置換算法,會將不經常使用的頁面刪除,經常用於設計緩存。Android中的LruCache其底層數據存儲就是基於LinkedHashMap。
- 將訪問過的節點放到隊尾(隊尾是最新插入的節點)
- 插入元素後如果滿足刪除策略,就刪除最少訪問的節點,新節點插入到隊尾
下面使用一個demo來展示如何使用linkedHashMap來實現LRU
public static void testLru(){
LinkedHashMap<Integer,Integer> lruMap =
// 調用構造函數,指定accessOrder爲true
new LinkedHashMap<Integer, Integer>(0,0.75f,true){
// 覆寫刪除策略,這裏指定元素個數超過3則刪除舊節點
@Override
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
return size()>3;
}
};
lruMap.put(1,1);
lruMap.put(2,2);
lruMap.put(3,3);
lruMap.put(4,4);
lruMap.put(5,5);
System.out.println(lruMap);
// {3=3, 4=4, 5=5} 插入了5個元素,根據刪除策略,最早的兩個節點被刪除了
lruMap.get(5);
lruMap.get(4);
lruMap.get(3);
System.out.println(lruMap);
// {5=5, 4=4, 3=3} 最近訪問的節點會被移動到隊尾
}
問題1:插入節點時,何時刪除了舊節點?
linkedHashMap 沒有實現put方法,當調用父類HashMap的put方法時會調用afterNodeInsertion方法,通過覆寫該方法實現刪除邏輯。
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
// 在允許刪除並且刪除策略爲true時,刪除頭部節點
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
問題2:查詢節點時,何時將訪問節點移動到了隊尾?
在調用get或getOrDefault 方法時都會回調afterNodeAccess方法,LinkedHashMap覆寫了該方法,並在其中完成
public V get(Object key) {
Node<K,V> e;
// 使用HashMap中getNode方法獲取節點
if ((e = getNode(hash(key), key)) == null)
return null;
if (accessOrder)// 如果設置了按訪問順序讀取
afterNodeAccess(e);// 將訪問的節點放置到隊列尾部
return e.value;
}
public V getOrDefault(Object key, V defaultValue) {
Node<K,V> e;
if ((e = getNode(hash(key), key)) == null)
return defaultValue;
if (accessOrder)
afterNodeAccess(e);
return e.value;
}
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMap.Entry<K,V> last;
if (accessOrder && (last = tail) != e) {
// p是要移動的節點,b是p前置節點,a是p後置節點
LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>)e
, b = p.before, a = p.after;
p.after = null;// 先斷開p的後置連接
if (b == null)
head = a;// 如果前置節點b,不存在則將後置節點a設爲頭節點
else
b.after = a;// 否則將前置節點b和後置節點a連起來
if (a != null) // 後置節點a不爲null則將後置節點a和前置節點b連起來
a.before = b;
else
last = b;// 後置節點a爲null則將前置節點b設爲尾節點
if (last == null)
head = p;// 尾節點爲null則將p置爲頭節點
else {
p.before = last;// 尾節點不爲null則將p節點插入到尾節點,
last.after = p;
}
tail = p;// 尾節點指向p
++modCount;
}
}
上面調整的過程可分爲兩部分,先把p節點摘下來,再將p節點插入到鏈表尾部。
三 總結
上面介紹了Java集合類中Map相關的代碼,首先從Map接口講起,Map集合用於存儲Key-Value格式的數據,作爲一個數據集合,提供了增刪改查的接口規範以及描述集合整體數據的視圖方法。具體實現部分先講了HashMap,其底層數據存儲是數組+鏈表+紅黑樹 ,圍繞該存儲結構講解了hash算法和擴容算法。TreeMap底層是紅黑樹,節點按照key的大小進行了排序,這裏的重點是插入刪除節點後紅黑樹的自平衡。最後是LinkedHashMap是對Hashmap 的擴展,通過將節點維護成一個雙向鏈表,保留了節點的插入順序,通過覆寫幾個回調方法實現了LRU算法。