Java原始碼系列3——LinkedHashMap
什麼是LinkedHashMap?
LinkedHashMap
是 HashMap
的有序實現。LinkedHashMap
用一條雙向連結串列來維護順序,迭代的時候也使用自己實現的迭代器。
public static void main(String[] args) { HashMap<String, Integer> h = new HashMap<>(33); h.put("one", 1); h.put("two", 2); h.put("three", 3); h.put("four", 4); for (String key : h.keySet()) { System.out.println("key:" + key + "value:" + h.get(key)); } LinkedHashMap<String, Integer> lh = new LinkedHashMap<>(33); lh.put("one", 1); lh.put("two", 2); lh.put("three", 3); lh.put("four", 4); for (String key : lh.keySet()) { System.out.println("key:" + key + "value:" + lh.get(key)); } }
輸出
key:twovalue:2
key:threevalue:3
key:fourvalue:4
key:onevalue:1
key:onevalue:1
key:twovalue:2
key:threevalue:3
key:fourvalue:4
底層陣列結構
HashMap的底層是由陣列,連結串列,紅黑樹組成的。陣列用來儲存節點,當出現雜湊碰撞時使用連結串列儲存,當連結串列超過一定長度後會優化成紅黑樹。
LinkedHashMap 的底層除了繼承自 HashMap 的陣列,連結串列,紅黑樹,還多了連結所有節點的雙向連結串列(圖中紅色和綠色箭頭),用於儲存各個節點的順序。
Entry的繼承關係
LinkedHashMap.Entry
繼承了 HashMap.Node
,多維護了 before 和 after 兩個指標,這兩個屬性指向該Entry的前一個Entry和後一個Entry,也就是那條用於儲存順序的雙向連結串列。
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 中 TreeNode 的程式碼,那紅黑樹中各個節點的順序是如何儲存的。
我們可以從 HashMap.TreeNode
的繼承關係中找出端倪:
呦吼,這一小家子也真夠亂的,子類繼承了父類的內部類,父類的內部類又繼承了子類的內部類,上演一出雞生蛋,蛋生雞的戲碼。
// 繼承了 LinkedHashMap.Entry
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
}
為什麼 HashMap.TreeNode
要繼承 LinkedHashMap.Entry
,繼承過來的 before 和 after 指標在 HashMap 中也沒有被用到,何不直接繼承 HashMap.Node
?
這樣的繼承關係其實並不是為 HashMap 設計的,在 HashMap 中確實沒什麼用。但在 LinkedHashMap 中,就可以直接使用繼承過來的 HashMap.TreeNode
,因為 TreeNode 這個類通過繼承已經擁有了 before 和 after 指標。
這就是為什麼,LinkedHashMap
中有一個繼承了 HashMap.Node
的內部類,卻沒有繼承 HashMap.TreeNode
的內部類。
連結串列的建立過程
連結串列的建立過程是在第一個元素插入的時候才開始的,一開始連結串列的頭部(head)和尾巴(tail)都為null。
LinkedHashMap
沒有覆寫父類的put方法,元素的插入流程基本相同,只是 HashMap
插入的是 Node
型別的節點,LinkedHashMap
插入的是 Entry
型別的節點,並且更新連結串列。
那麼 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)
n = (tab = resize()).length;
// 取模得到節點在桶中的索引位置,並且該位置沒有元素,直接插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 雜湊碰撞了,本節不介紹,可以看上一篇講 HashMap 的文章
else {
// ... 省略部分程式碼
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
// HashMap 中實現的 newNode
Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
return new Node<>(hash, key, value, next);
}
// LinkedHashMap 中覆寫的 newNode
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;
}
// LinkedHashMap 中實現
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
LinkedHashMap.Entry<K,V> last = tail;
tail = p;
// 如果連結串列為空,頭部和尾部都賦值為p
if (last == null)
head = p;
// 把新插入的節點放在連結串列尾部
else {
p.before = last;
last.after = p;
}
}
從程式碼裡可以很明顯的看出,LinkedHashMap 中索引的計算,桶的賦值,雜湊碰撞時連結串列或者紅黑樹的建立,都使用的 HashMap 的實現。LinkedHashMap 只需要覆寫節點的建立,並且在建立節點的時候,更新儲存順序的連結串列。真的是把複用利用到了極致。
節點的刪除
與插入操作一樣,LinkedHashMap 也是使用的父類的刪除操作,然後覆寫了回撥方法 afterNodeRemoval
,用於維護雙向連結串列。
// 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 (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
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.before = p.after = null;
// 如果b為空,則為頭節點
if (b == null)
head = a;
else
b.after = a;
// 如果a為空,則為尾節點
if (a == null)
tail = b;
else
a.before = b;
}
訪問順序的維護
如果我們在初始化 LinkedHashMap 時,把 accessOrder 引數設為 true,那麼我們不僅在插入的時候會維護連結串列,在訪問節點的時候也會維護連結串列。
當我們呼叫 get, getOrDefault, replace
等方法時,會更新連結串列,把訪問的節點移動到連結串列尾部。
// LinkedHashMap 中覆寫
public V get(Object key) {
Node<K,V> e;
// 呼叫了 HashMap 中的 getNode 方法
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為空,則為頭部
if (b == null)
head = a;
else
b.after = a;
if (a != null)
a.before = b;
// 尾部不會為空,不知為何要多一個判斷
else
last = b;
if (last == null)
head = p;
else {
// 把尾部賦值為p
p.before = last;
last.after = p;
}
tail = p;
++modCount;
}
}
使用測試程式碼體驗一下效果
public static void main(String[] args) {
LinkedHashMap<String, Integer> lh = new LinkedHashMap<>(33, 0.75f, true);
lh.put("one", 1);
lh.put("two", 2);
lh.put("three", 3);
lh.put("four", 4);
lh.get("two");
for (String key : lh.keySet()) {
System.out.println("key:" + key + "value:" + lh.get(key));
}
}
竟然報錯了
看一下 LinkedHashMap 覆寫的迭代器程式碼
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;
}
ConcurrentModificationException
這個報錯是為了防止併發條件下,遍歷的同時連結串列發生變化。因為我們在遍歷的時候又呼叫了 get 方法,導致連結串列發生變化,才會拋這個錯。
accessOrder 為 true 時的正確遍歷姿勢如下,使用 LinkedHashMap 覆寫forEach
方法,就不會在讀取值的時候修改順序連結串列了。
lh.forEach((String k, Integer v) -> {
System.out.println("key:" + k + ", value:" + v);
});
使用 LinkedHashMap 實現簡單的 LRU
LRU 全稱 Least Recently Used,也就是最近最少使用的意思,是一種記憶體管理演算法,該演算法最早應用於 Linux 作業系統。
這個演算法基於一種假設:長期不被使用的資料,在未來被用到的機率也不大。因此,當資料所佔記憶體達到一定閾值時,我們要移除最近最少被使用的資料。
下面我們介紹一下前置知識。
afterNodeInsertion
是一個回撥方法,在插入元素的時候回撥。LinkedHashMap 覆寫了這個方法,主要用來判斷是否需要將連結串列的 head 移除。
// LinkedHashMap 中覆寫
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
// 根據條件判斷是否移除連結串列的head節點
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
// LinkedHashMap 中實現,預設返回false
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}
下面我們將繼承 LinkedHashMap,通過覆寫 removeEldestEntry
,達到當 Map 的節點個數超過指定閾值時,刪除最少訪問的節點。從而實現 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;
}
/**
* 判斷節點數是否超出限制
* @param eldest
* @return boolean
*/
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > limit;
}
/**
* 測試
*/
public static void main(String[] args) {
SimpleCache<Integer, Integer> cache = new SimpleCache<>(3);
for (int i = 0; i < 10; i++) {
cache.put(i, i * i);
}
System.out.println("插入10個鍵值對後,快取內容:");
System.out.println(cache);
System.out.println("訪問鍵值為7的節點後,快取的內容:");
cache.get(7);
System.out.println(cache);
System.out.println("插入鍵值為1的鍵值對後,快取的內容:");
cache.put(1, 1);
System.out.println(cache);
}
}
測試結果如下:
總結
本文圍繞 LinkedHashMap 如何維護儲存順序的雙向連結串列展開,介紹了 LinkedHashMap 和 HashMap 節點類的繼承關係,介紹了新增,刪除,訪問時,LinkedHashMap 如何在複用 HashMap 的同時,維護雙向連結串列。最後通過繼承 LinkedHashMap 很簡單的實現了 LRU 快取策略。
全文的程式碼量較多,但都較為好理解。理解JDK的設計思路,探尋背後的實現原理,也是一件很有趣的事。
本文討論的原始碼都基於JDK1.8版本。
參考資料
【Java入門提高篇】Day28 Java容器類詳解(十)LinkedHashMap詳解
原始碼系列文章
本文首發於我的個人部落格 http://chaohang.top
作者張小超
轉載請註明出處
歡迎關注我的微信公眾號 【超超不會飛】,獲取第一時間的更新。