LinkedHashMap 繼承自 HashMap,在 HashMap 基礎上,經過維護一條雙向鏈表,解決了 HashMap 不能隨時保持遍歷順序和插入順序一致的問題。除此以外,LinkedHashMap 對訪問順序也提供了相關支持。在一些場景下,該特性頗有用,好比緩存。在實現上,LinkedHashMap 不少方法直接繼承自 HashMap,僅爲維護雙向鏈表覆寫了部分方法。因此,要看懂 LinkedHashMap 的源碼,須要先看懂 HashMap 的源碼。關於 HashMap 的源碼分析,本文並不打算展開講了。你們能夠參考我以前的一篇文章「HashMap 源碼詳細分析(JDK1.8)」。在那篇文章中,我配了十多張圖幫助你們學習 HashMap 源碼。java
本篇文章的結構與我以前兩篇關於 Java 集合類(集合框架)的源碼分析文章不一樣,本文將再也不分析集合類的基本操做(查找、遍歷、插入、刪除),而是把重點放在雙向鏈表的維護上。包括鏈表的創建過程,刪除節點的過程,以及訪問順序維護的過程等。好了,接下里開始分析吧。node
上一章說了 LinkedHashMap 繼承自 HashMap,因此它的底層仍然是基於拉鍊式散列結構。該結構由數組和鏈表或紅黑樹組成,結構示意圖大體以下:數組
LinkedHashMap 在上面結構的基礎上,增長了一條雙向鏈表,使得上面的結構能夠保持鍵值對的插入順序。同時經過對鏈表進行相應的操做,實現了訪問順序相關邏輯。其結構可能以下圖:緩存
上圖中,淡藍色的箭頭表示前驅引用,紅色箭頭表示後繼引用。每當有新鍵值對節點插入,新節點最終會接在 tail 引用指向的節點後面。而 tail 引用則會移動到新的節點上,這樣一個雙向鏈表就創建起來了。數據結構
上面的結構並非很難理解,雖然引入了紅黑樹,致使結構看起來略爲複雜了一些。但你們徹底能夠忽略紅黑樹,而只關注鏈表結構自己。好了,接下來進入細節分析吧。app
在對核心內容展開分析以前,這裏先插隊分析一下鍵值對節點的繼承體系。先來看看繼承體系結構圖:框架
上面的繼承體系乍一看仍是有點複雜的,同時也有點讓人迷惑。HashMap 的內部類 TreeNode 不繼承它的了一個內部類 Node,卻繼承自 Node 的子類 LinkedHashMap 內部類 Entry。這裏這樣作是有必定緣由的,這裏先不說。先來簡單說明一下上面的繼承體系。LinkedHashMap 內部類 Entry 繼承自 HashMap 內部類 Node,並新增了兩個引用,分別是 before 和 after。這兩個引用的用途不難理解,也就是用於維護雙向鏈表。同時,TreeNode 繼承 LinkedHashMap 的內部類 Entry 後,就具有了和其餘 Entry 一塊兒組成鏈表的能力。可是這裏須要你們考慮一個問題。當咱們使用 HashMap 時,TreeNode 並不須要具有組成鏈表能力。若是繼承 LinkedHashMap 內部類 Entry ,TreeNode 就多了兩個用不到的引用,這樣作不是會浪費空間嗎?簡單說明一下這個問題(水平有限,不保證徹底正確),這裏這麼作確實會浪費空間,但與 TreeNode 經過繼承獲取的組成鏈表的能力相比,這點浪費是值得的。在 HashMap 的設計思路註釋中,有這樣一段話:ide
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。這個時候就得不償失了,浪費不少空間去獲取不必定用獲得的能力。post
說到這裏,你們應該能明白節點類型的繼承體系了。這裏單獨拿出來講一下,爲下面的分析作鋪墊。敘述略爲囉嗦,見諒。
鏈表的創建過程是在插入鍵值對節點時開始的,初始狀況下,讓 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 中,本節先不分析這些實現,相關分析會在後續章節中進行。
與插入操做同樣,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; }
刪除的過程並不複雜,上面這麼多代碼其實就作了三件事:
舉個例子說明一下,假如咱們要刪除下圖鍵值爲 3 的節點。
根據 hash 定位到該節點屬於3號桶,而後在對3號桶保存的單鏈表進行遍歷。找到要刪除的節點後,先從單鏈表中移除該節點。以下:
而後再雙向鏈表中移除該節點:
刪除及相關修復過程並不複雜,結合上面的圖片,你們應該很容易就能理解,這裏就很少說了。
前面說了插入順序的實現,本節來說講訪問順序。默認狀況下,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的節點將會被移動到雙向鏈表的最後位置,其前驅和後繼也會跟着更新。訪問後的結構以下:
前面介紹了 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 在其餘方面的應用。本節內容及相關代碼並不難理解,這裏就不在贅述了。
本文從 LinkedHashMap 維護雙向鏈表的角度對 LinkedHashMap 的源碼進行了分析,並在文章的結尾基於 LinkedHashMap 實現了一個簡單的 Cache。在平常開發中,LinkedHashMap 的使用頻率雖不及 HashMap,但它也個重要的實現。在 Java 集合框架中,HashMap、LinkedHashMap 和 TreeMap 三個映射類基於不一樣的數據結構,並實現了不一樣的功能。HashMap 底層基於拉鍊式的散列結構,並在 JDK 1.8 中引入紅黑樹優化過長鏈表的問題。基於這樣結構,HashMap 可提供高效的增刪改查操做。LinkedHashMap 在其之上,經過維護一條雙向鏈表,實現了散列數據結構的有序遍歷。TreeMap 底層基於紅黑樹實現,利用紅黑樹的性質,實現了鍵值對排序功能。我在前面幾篇文章中,對 HashMap 和 TreeMap 以及他們均使用到的紅黑樹進行了詳細的分析,有興趣的朋友能夠去看看。
到此,本篇文章就寫完了,感謝你們的閱讀!
本文在知識共享許可協議 4.0 下發布,轉載請註明出處
做者:coolblog
本文同步發佈在個人我的博客: http://www.coolblog.xyz
本做品採用知識共享署名-非商業性使用-禁止演繹 4.0 國際許可協議進行許可。