LinkedHashMap 原始碼解析

語言: CN / TW / HK

highlight: atom-one-dark theme: vuepress


這是我參與11月更文挑戰的第10天,活動詳情檢視:2021最後一次更文挑戰

HashMap元素插入是無序的,為了讓遍歷順序和插入順序一致,我們可以使用LinkedHashMap,其內部維護了一個雙向連結串列來儲存元素順序,並且可以通過accessOrder屬性控制遍順序為插入順序或者為訪問順序。

類結構

LinkedHashMap類層級關係圖:

QQ20210225-143918@2x

LinkedHashMap繼承自HashMap,大部分方法都是直接使用HashMap的。接著檢視成員變數:

```java // 雙向連結串列的頭部節點(最早插入的,年紀最大的節點) transient LinkedHashMap.Entry head;

// 雙向連結串列的尾部節點(最新插入的,年紀最小的節點) transient LinkedHashMap.Entry tail;

// 用於控制訪問順序,為true時,按插入順序;為false時,按訪問順序 final boolean accessOrder; ```

head和tail使用transient修飾,原因在介紹HashMap原始碼的時候分析過。

LinkedHashMap繼承自HashMap,所以內部儲存資料的方式和HashMap一樣,使用陣列加連結串列(紅黑樹)的結構儲存資料,LinkedHashMap和HashMap相比,額外的維護了一個雙向連結串列,用於儲存節點的順序。這個雙向連結串列的型別為LinkedHashMap.Entry:

java 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.Entry類層級關係圖:

QQ20210225-145250@2x

LinkedHashMap.Entry繼承自HashMap的Node類,新增了before和after屬性,用於維護前繼和後繼節點,以此形成雙向連結串列。

建構函式

LinkedHashMap的建構函式其實沒什麼特別的,就是呼叫父類的構造器初始化HashMap的過程,只不過額外多了初始化LinkedHashMap的accessOrder屬性的操作:

```java public LinkedHashMap(int initialCapacity, float loadFactor) { super(initialCapacity, loadFactor); accessOrder = false; }

public LinkedHashMap(int initialCapacity) { super(initialCapacity); accessOrder = false; }

public LinkedHashMap() { super(); accessOrder = false; }

public LinkedHashMap(Map<? extends K, ? extends V> m) { super(); accessOrder = false; putMapEntries(m, false); }

public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) { super(initialCapacity, loadFactor); this.accessOrder = accessOrder; } ```

簡單使用

在分析LinkedHashMap方法實現之前,我們先通過例子感受下LinkedHashMap的特性:

```java LinkedHashMap map = new LinkedHashMap<>(16, 0.75f, false); map.put("1", "a"); map.put("6", "b"); map.put("3", "c"); System.out.println(map);

map.get("6"); System.out.println(map);

map.put("4", "d"); System.out.println(map);

//{1=a, 6=b, 3=c} //{1=a, 6=b, 3=c} //{1=a, 6=b, 3=c, 4=d} ```

可以看到元素的輸出順序就是我們插入的順序。

將accessOrder屬性改為true:

java {1=a, 6=b, 3=c} {1=a, 3=c, 6=b} {1=a, 3=c, 6=b, 4=d}

可以看到,一開始輸出{1=a, 6=b, 3=c}。當我們通過get方法訪問key為6的鍵值對後,程式輸出{1=a, 3=c, 6=b}。也就是說,當accessOrder屬性為true時,元素按訪問順序排列,即最近訪問的元素會被移動到雙向列表的末尾。所謂的“訪問”並不是只有get方法,符合“訪問”一詞的操作有put、putIfAbsent、get、getOrDefault、compute、computeIfAbsent、computeIfPresent和merge方法。

下面我們通過方法原始碼的分析就能清楚地知道LinkedHashMap是如何控制元素訪問順序的。

方法解析

put(K key, V value)

LinkedHashMap並沒有重寫put(K key, V value)方法,直接使用HashMap的put(K key, V value)方法。那麼問題就來了,既然LinkedHashMap沒有重寫put(K key, V value),那它是如何通過內部的雙向連結串列維護元素順序的?我們檢視put(K key, V value)方法原始碼就能發現原因(因為put(K key, V value)原始碼在Java-HashMap底層實現原理一節中已經剖析過,所以下面我們只在和LinkedHashMap功能相關的程式碼上添加註釋): ```java public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node[] tab; Node 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); else { Node e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode) // 方法內部包含newTreeNode的操作 e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value); else { for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { // 建立節點 p.next = newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); 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) e.value = value; // 節點訪問後續操作 afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); // 節點插入後續操作 afterNodeInsertion(evict); return null; } ```

newNode方法用於建立連結串列節點,LinkedHashMap重寫了newNode方法:

```java Node newNode(int hash, K key, V value, Node e) { // 建立LinkedHashMap.Entry例項 LinkedHashMap.Entry p = new LinkedHashMap.Entry(hash, key, value, e); // 將新節點放入LinkedHashMap維護的雙向連結串列尾部 linkNodeLast(p); return p; }

private void linkNodeLast(LinkedHashMap.Entry p) { LinkedHashMap.Entry last = tail; tail = p; // 如果尾節點為空,說明雙向連結串列是空的,所以將該節點賦值給頭節點,雙向連結串列得以初始化 if (last == null) head = p; else { // 否則將該節點放到雙向連結串列的尾部 p.before = last; last.after = p; } } ```

可以看到,對於LinkedHashMap例項,put操作內部建立的的節點型別為LinkedHashMap.Entry,除了往HashMap內部table插入資料外,還往LinkedHashMap的雙向連結串列尾部插入了資料。

如果是往紅黑樹結構插入資料,那麼put將呼叫putTreeVal方法往紅黑樹裡插入節點,putTreeVal方法內部通過newTreeNode方法建立樹節點。LinkedHashMap重寫了newTreeNode方法:

java TreeNode<K,V> newTreeNode(int hash, K key, V value, Node<K,V> next) { // 建立TreeNode例項 TreeNode<K,V> p = new TreeNode<K,V>(hash, key, value, next); // 將新節點放入LinkedHashMap維護的雙向連結串列尾部 linkNodeLast(p); return p; }

節點型別為TreeNode,那麼這個型別是在哪裡定義的呢?其實TreeNode為HashMap裡定義的,檢視其原始碼:

java 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); } ...... }

TreeNode繼承自LinkedHashMap.Entry:

QQ20210225-160155@2x

所以TreeNode也包含before和after屬性,即使插入的節點型別為TreeNode,依舊可以用LinkedHashMap雙向連結串列維護節點順序。

在put方法中,如果插入的key已經存在的話,還會執行afterNodeAccess操作,該方法在HashMap中為空方法:

java void afterNodeAccess(Node<K,V> p) { }

afterNodeAccess方法顧名思義,就是當節點被訪問後執行某些操作。LinkedHashMap重寫了這個方法:

java void afterNodeAccess(Node<K,V> e) { // move node to last LinkedHashMap.Entry<K,V> last; // 如果accessOrder屬性為true,並且當前節點不是雙向連結串列的尾節點的話執行if內邏輯 if (accessOrder && (last = tail) != e) { // 這部分邏輯也很好理解,就是將當前節點移動到雙向連結串列的尾部,並且改變相關節點的前繼後繼關係 LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after; p.after = null; 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.before = last; last.after = p; } tail = p; ++modCount; } }

所以當accessOrder為true時候,呼叫LinkedHashMap的put方法,插入相同key值的鍵值對時,該鍵值對會被移動到尾部:

```java LinkedHashMap map = new LinkedHashMap<>(16, 0.75f, true); map.put("1", "a"); map.put("6", "b"); map.put("3", "c"); System.out.println(map); map.put("6", "b"); System.out.println(map);

//{1=a, 6=b, 3=c} //{1=a, 3=c, 6=b} ```

在put方法尾部,還呼叫了afterNodeInsertion方法,方法顧名思義,用於插入節點後執行某些操作,該方法在HashMap中也是空方法:

java void afterNodeInsertion(boolean evict) { }

LinkedHashMap重寫了該方法:

```java // 這裡evict為true void afterNodeInsertion(boolean evict) { // possibly remove eldest LinkedHashMap.Entry first; // 如果頭部節點不為空並且removeEldestEntry返回true的話 if (evict && (first = head) != null && removeEldestEntry(first)) { // 獲取頭部節點的key K key = first.key; // 呼叫父類HashMap的removeNode方法,刪除節點 removeNode(hash(key), key, null, false, true); } }

protected boolean removeEldestEntry(Map.Entry eldest) { // 在LinkedHashMap中,該方法永遠返回false return false; } ```

基於這個特性,我們可以通過繼承LinkedHashMap的方式重寫removeEldestEntry方法,以此實現LRU,下面再做實現。

你可能會問,removeNode刪除的是HashMap的table中的節點,那麼用於維護節點順序的雙向連結串列不是也應該刪除頭部節點嗎?為什麼上面程式碼沒有看到這部分操作?其實當你檢視removeNode方法的原始碼就能看到這部分操作了:

java 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) node = ((TreeNode<K,V>)p).getTreeNode(hash, key); else { 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) ((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; }

afterNodeRemoval方法顧名思義,用於節點刪除後執行後續操作。該方法在HashMap中為空方法:

java void afterNodeRemoval(Node<K,V> p) { }

LinkedHashMap重寫了該方法:

java // 改變節點的前繼後繼引用 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; if (b == null) head = a; else b.after = a; if (a == null) tail = b; else a.before = b; }

通過該方法,我們就從LinkedHashMap的雙向連結串列中刪除了頭部結點。

其實通過put方法我們就已經搞清楚了LinkedHashMap內部是如何通過雙向連結串列維護鍵值對順序的,但為了讓文章更飽滿一點,下面繼續分析幾個方法原始碼。

get(Object key)

LinkedHashMap重寫了HashMap的get方法:

java public V get(Object key) { Node<K,V> e; if ((e = getNode(hash(key), key)) == null) return null; // 多了這一步操作,當accessOrder屬性為true時,將key對應的鍵值對節點移動到雙向列表的尾部 if (accessOrder) afterNodeAccess(e); return e.value; }

remove(Object key)

LinkedHashMap沒有重寫remove方法,檢視HashMap的remove方法:

java public V remove(Object key) { Node<K,V> e; // 呼叫removeNode刪除節點,removeNode方法內部呼叫了afterNodeRemoval方法,上面介紹put // 方法時分析過了,所以不再贅述 return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value; }

迭代器

既然LinkedHashMap內部通過雙向連結串列維護鍵值對順序的話,那麼我們可以猜測遍歷LinkedHashMap實際就是遍歷LinkedHashMap維護的雙向連結串列:

檢視LinkedHashMap類entrySet方法的實現:

```java public Set> entrySet() { Set> es; // 建立LinkedEntrySet return (es = entrySet) == null ? (entrySet = new LinkedEntrySet()) : es; }

final class LinkedEntrySet extends AbstractSet> { public final int size() { return size; } public final void clear() { LinkedHashMap.this.clear(); } public final Iterator> iterator() { // 迭代器型別為LinkedEntryIterator return new LinkedEntryIterator(); } ...... }

// LinkedEntryIterator繼承自LinkedHashIterator final class LinkedEntryIterator extends LinkedHashIterator implements Iterator> { // next方法內部呼叫LinkedHashIterator的nextNode方法 public final Map.Entry next() { return nextNode(); } }

abstract class LinkedHashIterator { LinkedHashMap.Entry next; LinkedHashMap.Entry current; int expectedModCount;

LinkedHashIterator() {
    // 初始化時,將雙向連結串列的頭部節點賦值給next,說明遍歷LinkedHashMap是從
    // LinkedHashMap的雙向連結串列頭部開始的
    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;
    // 不斷獲取當前節點的after節點,遍歷
    next = e.after;
    return e;
}
......

} ```

上述程式碼符合我們的猜測。

LRU簡單實現

LRU(Least Recently Used)指的是最近最少使用,是一種快取淘汰演算法,哪個最近不怎麼用了就淘汰掉。

我們知道LinkedHashMap內的removeEldestEntry方法固定返回false,並不會執行元素刪除操作,所以我們可以通過繼承LinkedHashMap,重寫removeEldestEntry方法來實現LRU。

假如我們現在有如下需求:

用LinkedHashMap實現快取,快取最多隻能儲存5個元素,當元素個數超過5的時候,刪除(淘汰)那些最近最少使用的資料,僅儲存熱點資料。

新建LRUCache類,繼承LinkedHashMap:

```java public class LRUCache extends LinkedHashMap {

/**
 * 快取允許的最大容量
 */
private final int maxSize;

public LRUCache(int initialCapacity, int maxSize) {
    // accessOrder必須為true
    super(initialCapacity, 0.75f, true);
    this.maxSize = maxSize;
}

@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
    // 當鍵值對個數超過最大容量時,返回true,觸發刪除操作
    return size() > maxSize;
}

public static void main(String[] args) {
    LRUCache<String, String> cache = new LRUCache<>(5, 5);
    cache.put("1", "a");
    cache.put("2", "b");
    cache.put("3", "c");
    cache.put("4", "d");
    cache.put("5", "e");
    cache.put("6", "f");
    System.out.println(cache);
}

}

// {2=b, 3=c, 4=d, 5=e, 6=f} ```

可以看到最早插入的1=a已經被刪除了。

通過LinkedHashMap實現LRU還是挺常見的,比如logback框架的LRUMessageCache:

```java class LRUMessageCache extends LinkedHashMap {

private static final long serialVersionUID = 1L;
final int cacheSize;

LRUMessageCache(int cacheSize) {
    super((int) (cacheSize * (4.0f / 3)), 0.75f, true);
    if (cacheSize < 1) {
        throw new IllegalArgumentException("Cache size cannot be smaller than 1");
    }
    this.cacheSize = cacheSize;
}

int getMessageCountAndThenIncrement(String msg) {
    // don't insert null elements
    if (msg == null) {
        return 0;
    }

    Integer i;
    // LinkedHashMap is not LinkedHashMap. See also LBCLASSIC-255
    synchronized (this) {
        i = super.get(msg);
        if (i == null) {
            i = 0;
        } else {
            i = i + 1;
        }
        super.put(msg, i);
    }
    return i;
}

// called indirectly by get() or put() which are already supposed to be
// called from within a synchronized block
protected boolean removeEldestEntry(Map.Entry eldest) {
    return (size() > cacheSize);
}

@Override
synchronized public void clear() {
    super.clear();
}

} ```