LinkedHashMap 源码详细分析(JDK1.8)
1. 概述
LinkedHashMap 繼承自 HashMap,在 HashMap 基礎上,通過維護一條雙向鏈表,解決了 HashMap 不能隨時保持遍歷順序和插入順序一致的問題。除此之外,LinkedHashMap 對訪問順序也提供了相關支持。在一些場景下,該特性很有用,比如緩存。在實現上,LinkedHashMap 很多方法直接繼承自 HashMap,僅為維護雙向鏈表覆寫了部分方法。所以,要看懂 LinkedHashMap 的源碼,需要先看懂 HashMap 的源碼。關于 HashMap 的源碼分析,本文并不打算展開講了。大家可以參考我之前的一篇文章“HashMap 源碼詳細分析(JDK1.8)”。在那篇文章中,我配了十多張圖幫助大家學習 HashMap 源碼。
本篇文章的結構與我之前兩篇關于 Java 集合類(集合框架)的源碼分析文章不同,本文將不再分析集合類的基本操作(查找、遍歷、插入、刪除),而是把重點放在雙向鏈表的維護上。包括鏈表的建立過程,刪除節點的過程,以及訪問順序維護的過程等。好了,接下里開始分析吧。
?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.1 鏈表的建立過程
鏈表的建立過程是在插入鍵值對節點時開始的,初始情況下,讓 LinkedHashMap 的 head 和 tail 引用同時指向新節點,鏈表就算建立起來了。隨后不斷有新節點插入,通過將新節點接在 tail 引用指向節點的后面,即可實現鏈表的更新。
Map 類型的集合類是通過 put(K,V) 方法插入鍵值對,LinkedHashMap 本身并沒有覆寫父類的 put 方法,而是直接使用了父類的實現。但在 HashMap 中,put 方法插入的是 HashMap 內部類 Node 類型的節點,該類型的節點并不具備與 LinkedHashMap 內部類 Entry 及其子類型節點組成鏈表的能力。那么,LinkedHashMap 是怎樣建立鏈表的呢?在展開說明之前,我們先看一下 LinkedHashMap 插入操作相關的代碼:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 | // 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 keyV 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個:
| 1 2 3 4 | // 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.2 鏈表節點的刪除過程
與插入操作一樣,LinkedHashMap 刪除操作相關的代碼也是直接用父類的實現。在刪除節點時,父類的刪除邏輯并不會修復 LinkedHashMap 所維護的雙向鏈表,這不是它的職責。那么刪除及節點后,被刪除的節點該如何從雙鏈表中移除呢?當然,辦法還算是有的。上一節最后提到 HashMap 中三個回調方法運行 LinkedHashMap 對一些操作做出響應。所以,在刪除及節點后,回調方法?afterNodeRemoval?會被調用。LinkedHashMap 覆寫該方法,并在該方法中完成了移除被刪除節點的操作。相關源碼如下:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 | // 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;elsep.next = node.next;++modCount;--size;afterNodeRemoval(node); // 調用刪除回調方法進行后續操作return node;}}return null; }// LinkedHashMap 中覆寫 void afterNodeRemoval(Node<K,V> e) { // unlinkLinkedHashMap.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;elseb.after = a;// a 為 null,表明 p 是尾節點if (a == null)tail = b;elsea.before = b; } |
刪除的過程并不復雜,上面這么多代碼其實就做了三件事:
舉個例子說明一下,假如我們要刪除下圖鍵值為 3 的節點。
根據 hash 定位到該節點屬于3號桶,然后在對3號桶保存的單鏈表進行遍歷。找到要刪除的節點后,先從單鏈表中移除該節點。如下:
然后再雙向鏈表中移除該節點:
刪除及相關修復過程并不復雜,結合上面的圖片,大家應該很容易就能理解,這里就不多說了。
?3.3 訪問順序的維護過程
前面說了插入順序的實現,本節來講講訪問順序。默認情況下,LinkedHashMap 是按插入順序維護鏈表。不過我們可以在初始化 LinkedHashMap,指定 accessOrder 參數為 true,即可讓它按訪問順序維護鏈表。訪問順序的原理上并不復雜,當我們調用get/getOrDefault/replace等方法時,只需要將這些方法訪問的節點移動到鏈表的尾部即可。相應的源碼如下:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | // 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 lastLinkedHashMap.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;elseb.after = a;if (a != null)a.before = b;/** 這里存疑,父條件分支已經確保節點 e 不會是尾節點,* 那么 e.after 必然不會為 null,不知道 else 分支有什么作用*/elselast = b;if (last == null)head = p;else {// 將 p 接在鏈表的最后p.before = last;last.after = p;}tail = p;++modCount;} } |
上面就是訪問順序的實現代碼,并不復雜。下面舉例演示一下,幫助大家理解。假設我們訪問下圖鍵值為3的節點,訪問前結構為:
訪問后,鍵值為3的節點將會被移動到雙向鏈表的最后位置,其前驅和后繼也會跟著更新。訪問后的結構如下:
?3.4 基于 LinkedHashMap 實現緩存
前面介紹了 LinkedHashMap 是如何維護插入和訪問順序的,大家對 LinkedHashMap 的原理應該有了一定的認識。本節我們來寫一些代碼實踐一下,這里通過繼承 LinkedHashMap 實現了一個簡單的 LRU 策略的緩存。在寫代碼之前,先介紹一下前置知識。
在3.1節分析鏈表建立過程時,我故意忽略了部分源碼分析。本節就把忽略的部分補上,先看源碼吧:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 | void afterNodeInsertion(boolean evict) { // possibly remove eldestLinkedHashMap.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 緩存。比如我們可以根據節點數量判斷是否移除最近最少被訪問的節點,或者根據節點的存活時間判斷是否移除該節點等。本節所實現的緩存是基于判斷節點數量是否超限的策略。在構造緩存對象時,傳入最大節點數。當插入的節點數超過最大節點數時,移除最近最少被訪問的節點。實現代碼如下:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | 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*/@Overrideprotected boolean removeEldestEntry(Map.Entry<K, V> eldest) {return size() > limit;} } |
測試代碼如下:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | public class SimpleCacheTest {@Testpublic 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 以及他們均使用到的紅黑樹進行了詳細的分析,有興趣的朋友可以去看看。
到此,本篇文章就寫完了,感謝大家的閱讀!
?附錄:映射類文章列表
- 紅黑樹詳細分析
- TreeMap源碼分析
- HashMap 源碼詳細分析(JDK1.8)
- 本文鏈接:?https://www.tianxiaobo.com/2018/01/24/LinkedHashMap-源碼詳細分析(JDK1-8)/
from:http://www.tianxiaobo.com/2018/01/24/LinkedHashMap-%E6%BA%90%E7%A0%81%E8%AF%A6%E7%BB%86%E5%88%86%E6%9E%90%EF%BC%88JDK1-8%EF%BC%89/?
總結
以上是生活随笔為你收集整理的LinkedHashMap 源码详细分析(JDK1.8)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 基于 Zookeeper 的分布式锁实现
- 下一篇: LinkedList 源码分析(JDK