其他更多java基礎文章:
java基礎學習(目錄)
這篇文章我覺得已經講得很全面了,我在學習的過程中也沒看到有其他要注意的點,如果我再複述一次就實在是浪費時間,所以這篇文章轉載於田小波的文章LinkedHashMap 原始碼詳細分析(JDK1.8)。如果有新的注意點會考慮寫原始碼分析(二)
1. 概述
LinkedHashMap 繼承自 HashMap,在HashMap基礎上,通過維護一條雙向連結串列,解決了HashMap不能隨時保持遍歷順序和插入順序一致的問題。除此之外,LinkedHashMap對訪問順序也提供了相關支援。在一些場景下,該特性很有用,比如快取。在實現上,LinkedHashMap 很多方法直接繼承自 HashMap,僅為維護雙向連結串列覆寫了部分方法。所以,要看懂 LinkedHashMap 的原始碼,需要先看懂 HashMap 的原始碼。關於 HashMap 的原始碼分析,可以參考java基礎:HashMap — 原始碼分析
2. 原理
上一章說了 LinkedHashMap 繼承自 HashMap,所以它的底層仍然是基於拉鍊式雜湊結構。該結構由陣列和連結串列或紅黑樹組成,結構示意圖大致如下:
LinkedHashMap 在上面結構的基礎上,增加了一條雙向連結串列,使得上面的結構可以保持鍵值對的插入順序。同時通過對連結串列進行相應的操作,實現了訪問順序相關邏輯。其結構可能如下圖:
上圖中,淡藍色的箭頭表示前驅引用,紅色箭頭表示後繼引用。每當有新鍵值對節點插入,新節點最終會接在 tail 引用指向的節點後面。而 tail 引用則會移動到新的節點上,這樣一個雙向連結串列就建立起來了。
上面的結構並不是很難理解,雖然引入了紅黑樹,導致結構看起來略為複雜了一些。但大家完全可以忽略紅黑樹,而只關注連結串列結構本身。好了,接下來進入細節分析吧。
3. 原始碼分析
3.1 Entry 的繼承體系
在對核心內容展開分析之前,這裡先插隊分析一下鍵值對節點的繼承體系。先來看看繼承體系結構圖:
上面的繼承體系乍一看還是有點複雜的,同時也有點讓人迷惑。HashMap 的內部類TreeNode
不繼承它自己的一個內部類 Node
,卻繼承自Node的子類LinkedHashMap內部類Entry
。這裡這樣做是有一定原因的,這裡先不說。先來簡單說明一下上面的繼承體系。LinkedHashMap 內部類 Entry繼承自HashMap內部類Node,並新增了兩個引用,分別是before和after。這兩個引用的用途不難理解,也就是用於維護雙向連結串列。同時,TreeNode 繼承 LinkedHashMap 的內部類 Entry 後,就具備了和其他 Entry 一起組成連結串列的能力。但是這裡需要大家考慮一個問題。當我們使用 HashMap 時,TreeNode 並不需要具備組成連結串列能力。如果繼承 LinkedHashMap 內部類 Entry ,TreeNode就多了兩個用不到的引用,這樣做不是會浪費空間嗎?簡單說明一下這個問題(水平有限,不保證完全正確),這裡這麼做確實會浪費空間,但與 TreeNode 通過繼承獲取的組成連結串列的能力相比,這點浪費是值得的。在 HashMap 的設計思路註釋中,有這樣一段話:
Because TreeNodes are about twice the size of regular nodes, we use them only when bins contain enough nodes to warrant use (see TREEIFY_THRESHOLD). And when they become too small (due to removal or resizing) they are converted back to plain bins. In usages with well-distributed user hashCodes, tree bins are rarely used.
大致的意思是 TreeNode 物件的大小約是普通Node物件的2倍,我們僅在桶(bin)中包含足夠多的節點時再使用。當桶中的節點數量變少時(取決於刪除和擴容),TreeNode會被轉成Node。當使用者實現的hashCode方法具有良好分佈性時,樹型別的桶將會很少被使用。
通過上面的註釋,我們可以瞭解到。一般情況下,只要 hashCode 的實現不糟糕,Node 組成的連結串列很少會被轉成由 TreeNode 組成的紅黑樹。也就是說 TreeNode 使用的並不多,浪費那點空間是可接受的。假如 TreeNode 機制繼承自 Node 類,那麼它要想具備組成連結串列的能力,就需要 Node 去繼承 LinkedHashMap 的內部類 Entry。這個時候就得不償失了,浪費很多空間去獲取不一定用得到的能力。
說到這裡,大家應該能明白節點型別的繼承體系了。這裡單獨拿出來說一下,為下面的分析做鋪墊。敘述略為囉嗦,見諒。
3.2 連結串列的建立過程
連結串列的建立過程是在插入鍵值對節點時開始的,初始情況下,讓LinkedHashMap的head和tail引用同時指向新節點,連結串列就算建立起來了。隨後不斷有新節點插入,通過將新節點接在 tail 引用指向節點的後面,即可實現連結串列的更新。
Map 型別的集合類是通過 put(K,V)方法插入鍵值對,LinkedHashMap本身並沒有覆寫父類的put方法,而是直接使用了父類的實現。但在 HashMap 中,put 方法插入的是 HashMap 內部類 Node 型別的節點,該型別的節點並不具備與 LinkedHashMap 內部類 Entry 及其子型別節點組成連結串列的能力。那麼,LinkedHashMap 是怎樣建立連結串列的呢?在展開說明之前,我們先看一下 LinkedHashMap 插入操作相關的程式碼:
// HashMap 中實現
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// HashMap 中實現
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) {...}
// 通過節點 hash 定位節點所在的桶位置,並檢測桶中是否包含節點引用
if ((p = tab[i = (n - 1) & hash]) == 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) {...}
else {
// 遍歷連結串列,並統計連結串列長度
for (int binCount = 0; ; ++binCount) {
// 未在單連結串列中找到要插入的節點,將新節點接在單連結串列的後面
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) {...}
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) {...}
afterNodeAccess(e); // 回撥方法,後續說明
return oldValue;
}
}
++modCount;
if (++size > threshold) {...}
afterNodeInsertion(evict); // 回撥方法,後續說明
return null;
}
// HashMap 中實現
Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
return new Node<>(hash, key, value, next);
}
// LinkedHashMap 中覆寫
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);
// 將 Entry 接在雙向連結串列的尾部
linkNodeLast(p);
return p;
}
// LinkedHashMap 中實現
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
LinkedHashMap.Entry<K,V> last = tail;
tail = p;
// last 為 null,表明連結串列還未建立
if (last == null)
head = p;
else {
// 將新節點 p 接在連結串列尾部
p.before = last;
last.after = p;
}
}
複製程式碼
上面就是 LinkedHashMap 插入相關的原始碼,這裡省略了部分非關鍵的程式碼。我根據上面的程式碼,可以知道 LinkedHashMap 插入操作的呼叫過程。如下:
我把 newNode 方法紅色背景標註了出來,這一步比較關鍵。LinkedHashMap 覆寫了該方法。在這個方法中,LinkedHashMap 建立了 Entry,並通過 linkNodeLast 方法將Entry接在雙向連結串列的尾部,實現了雙向連結串列的建立。雙向連結串列建立之後,我們就可以按照插入順序去遍歷 LinkedHashMap,大家可以自己寫點測試程式碼驗證一下插入順序。
以上就是 LinkedHashMap 維護插入順序的相關分析。本節的最後,再額外補充一些東西。大家如果仔細看上面的程式碼的話,會發現有兩個以after
開頭方法,在上文中沒有被提及。在 JDK 1.8 HashMap 的原始碼中,相關的方法有3個:
// Callbacks to allow LinkedHashMap post-actions
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }
複製程式碼
根據這三個方法的註釋可以看出,這些方法的用途是在增刪查等操作後,通過回撥的方式,讓 LinkedHashMap 有機會做一些後置操作。上述三個方法的具體實現在 LinkedHashMap 中,本節先不分析這些實現,相關分析會在後續章節中進行。
3.3 連結串列節點的刪除過程
與插入操作一樣,LinkedHashMap 刪除操作相關的程式碼也是直接用父類的實現。在刪除節點時,父類的刪除邏輯並不會修復LinkedHashMap 所維護的雙向連結串列,這不是它的職責。那麼刪除及節點後,被刪除的節點該如何從雙連結串列中移除呢?當然,辦法還算是有的。上一節最後提到 HashMap 中三個回撥方法執行LinkedHashMap對一些操作做出響應。所以,在刪除及節點後,回撥方法afterNodeRemoval
會被呼叫。LinkedHashMap 覆寫該方法,並在該方法中完成了移除被刪除節點的操作。相關原始碼如下:
// HashMap 中實現
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
// HashMap 中實現
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;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
if (p instanceof TreeNode) {...}
else {
// 遍歷單連結串列,尋找要刪除的節點,並賦值給 node 變數
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) {...}
// 將要刪除的節點從單連結串列中移除
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node); // 呼叫刪除回撥方法進行後續操作
return node;
}
}
return null;
}
// 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 節點的前驅後後繼引用置空
p.before = p.after = null;
// b 為 null,表明 p 是頭節點
if (b == null)
head = a;
else
b.after = a;
// a 為 null,表明 p 是尾節點
if (a == null)
tail = b;
else
a.before = b;
}
複製程式碼
刪除的過程並不複雜,上面這麼多程式碼其實就做了三件事:
根據 hash 定位到桶位置 遍歷連結串列或呼叫紅黑樹相關的刪除方法 從 LinkedHashMap 維護的雙連結串列中移除要刪除的節點 舉個例子說明一下,假如我們要刪除下圖鍵值為 3 的節點。
根據 hash 定位到該節點屬於3號桶,然後在對3號桶儲存的單連結串列進行遍歷。找到要刪除的節點後,先從單連結串列中移除該節點。如下:
然後再雙向連結串列中移除該節點:
刪除及相關修復過程並不複雜,結合上面的圖片,大家應該很容易就能理解,這裡就不多說了。
3.4 訪問順序的維護過程
前面說了插入順序的實現,本節來講講訪問順序。預設情況下,LinkedHashMap 是按插入順序維護連結串列。不過我們可以在初始化 LinkedHashMap,指定 accessOrder
引數為 true,即可讓它按訪問順序維護連結串列。訪問順序的原理上並不複雜,當我們呼叫get/getOrDefault/replace等方法時,只需要將這些方法訪問的節點移動到連結串列的尾部即可。相應的原始碼如下:
// LinkedHashMap 中覆寫
public V get(Object key) {
Node<K,V> e;
if ((e = getNode(hash(key), key)) == null)
return null;
// 如果 accessOrder 為 true,則呼叫 afterNodeAccess 將被訪問節點移動到連結串列最後
if (accessOrder)
afterNodeAccess(e);
return e.value;
}
// LinkedHashMap 中覆寫
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMap.Entry<K,V> last;
if (accessOrder && (last = tail) != e) {
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
p.after = null;
// 如果 b 為 null,表明 p 為頭節點
if (b == null)
head = a;
else
b.after = a;
if (a != null)
a.before = b;
/*
* 這裡存疑,父條件分支已經確保節點 e 不會是尾節點,
* 那麼 e.after 必然不會為 null,不知道 else 分支有什麼作用
*/
else
last = b;
if (last == null)
head = p;
else {
// 將 p 接在連結串列的最後
p.before = last;
last.after = p;
}
tail = p;
++modCount;
}
}
複製程式碼
上面就是訪問順序的實現程式碼,並不複雜。下面舉例演示一下,幫助大家理解。假設我們訪問下圖鍵值為3的節點,訪問前結構為:
訪問後,鍵值為3的節點將會被移動到雙向連結串列的最後位置,其前驅和後繼也會跟著更新。訪問後的結構如下:
3.5 基於 LinkedHashMap 實現快取
前面介紹了 LinkedHashMap 是如何維護插入和訪問順序的,大家對 LinkedHashMap 的原理應該有了一定的認識。本節我們來寫一些程式碼實踐一下,這裡通過繼承 LinkedHashMap 實現了一個簡單的 LRU 策略的快取。在寫程式碼之前,先介紹一下前置知識。
在3.1節分析連結串列建立過程時,我故意忽略了部分原始碼分析。本節就把忽略的部分補上,先看原始碼吧:
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
// 根據條件判斷是否移除最近最少被訪問的節點
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
// 移除最近最少被訪問條件之一,通過覆蓋此方法可實現不同策略的快取
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}
複製程式碼
上面的原始碼的核心邏輯在一般情況下都不會被執行,所以之前並沒有進行分析。上面的程式碼做的事情比較簡單,就是通過一些條件,判斷是否移除最近最少被訪問的節點。看到這裡,大家應該知道上面兩個方法的用途了。當我們基於LinkedHashMap實現快取時,通過覆寫removeEldestEntry方法可以實現自定義策略的LRU快取。比如我們可以根據節點數量判斷是否移除最近最少被訪問的節點,或者根據節點的存活時間判斷是否移除該節點等。本節所實現的快取是基於判斷節點數量是否超限的策略。在構造快取物件時,傳入最大節點數。當插入的節點數超過最大節點數時,移除最近最少被訪問的節點。實現程式碼如下:
public class SimpleCache<K, V> extends LinkedHashMap<K, V> {
private static final int MAX_NODE_NUM = 100;
private int limit;
public SimpleCache() {
this(MAX_NODE_NUM);
}
public SimpleCache(int limit) {
super(limit, 0.75f, true);
this.limit = limit;
}
public V save(K key, V val) {
return put(key, val);
}
public V getOne(K key) {
return get(key);
}
public boolean exists(K key) {
return containsKey(key);
}
/**
* 判斷節點數是否超限
* @param eldest
* @return 超限返回 true,否則返回 false
*/
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > limit;
}
}
複製程式碼
測試程式碼如下:
public class SimpleCacheTest {
@Test
public void test() throws Exception {
SimpleCache<Integer, Integer> cache = new SimpleCache<>(3);
for (int i = 0; i < 10; i++) {
cache.save(i, i * i);
}
System.out.println("插入10個鍵值對後,快取內容:");
System.out.println(cache + "\n");
System.out.println("訪問鍵值為7的節點後,快取內容:");
cache.getOne(7);
System.out.println(cache + "\n");
System.out.println("插入鍵值為1的鍵值對後,快取內容:");
cache.save(1, 1);
System.out.println(cache);
}
}
複製程式碼
測試結果如下:
在測試程式碼中,設定快取大小為3。在向快取中插入10個鍵值對後,只有最後3個被儲存下來了,其他的都被移除了。然後通過訪問鍵值為7的節點,使得該節點被移到雙向連結串列的最後位置。當我們再次插入一個鍵值對時,鍵值為7的節點就不會被移除。
本節作為對前面內的補充,簡單介紹了 LinkedHashMap 在其他方面的應用。本節內容及相關程式碼並不難理解,這裡就不在贅述了。
4. 總結
本文從 LinkedHashMap 維護雙向連結串列的角度對 LinkedHashMap 的原始碼進行了分析,並在文章的結尾基於 LinkedHashMap 實現了一個簡單的 Cache。在日常開發中,LinkedHashMap 的使用頻率雖不及 HashMap,但它也個重要的實現。在 Java 集合框架中,HashMap、LinkedHashMap 和 TreeMap 三個對映類基於不同的資料結構,並實現了不同的功能。HashMap 底層基於拉鍊式的雜湊結構,並在 JDK 1.8 中引入紅黑樹優化過長連結串列的問題。基於這樣結構,HashMap 可提供高效的增刪改查操作。LinkedHashMap 在其之上,通過維護一條雙向連結串列,實現了雜湊資料結構的有序遍歷。TreeMap 底層基於紅黑樹實現,利用紅黑樹的性質,實現了鍵值對排序功能。我在前面幾篇文章中,對 HashMap 和 TreeMap 以及他們均使用到的紅黑樹進行了詳細的分析,有興趣的朋友可以去看看。
到此,本篇文章就寫完了,感謝大家的閱讀!