刨死你係列——LinkedHashMap剖析(基於jdk1.8)

一、概述

  1.8版本的LinkedHashMap 繼承自 HashMap,在 HashMap(數組鏈表+紅黑樹) 基礎上,通過維護一條雙向鏈表,解決了 HashMap 不能隨時保持遍歷順序和插入順序一致的問題。除此之外,LinkedHashMap 對訪問順序也提供了相關支持。在一些場景下,該特性很有用,比如緩存。在實現上,LinkedHashMap 很多方法直接繼承自 HashMap,僅爲維護雙向鏈表覆寫了部分方法。所以,在學習LinkedHashMap前,你要先了解HashMap。其結構可能如下圖:

二、源碼解析

2.1 Entry的繼承體系

  LinkedHashMap數據結構相比較於HashMap來說,添加了雙向指針,分別指向前一個節點——before和後一個節點——after,從而將所有的節點已鏈表的形式串聯一起來,讓我們來看一下它們的繼承關係。

   HashMap 的內部類 TreeNode 不繼承它的了一個內部類 Node,卻繼承自 Node 的子類 LinkedHashMap 內部類 Entry。這裏這樣做是有一定原因的,這裏先不說。先來簡單說明一下上面的繼承體系。LinkedHashMap 內部類 Entry 繼承自 HashMap 內部類 Node,並新增了兩個引用,分別是 before 和 after。這兩個引用的用途不難理解,也就是用於維護雙向鏈表。同時,TreeNode 繼承 LinkedHashMap 的內部類 Entry 後,就具備了和其他 Entry 一起組成鏈表的能力。

2.2成員變量

具體看下面代碼:

private static final long serialVersionUID = 3801124242820219131L;

// 用於指向雙向鏈表的頭部
transient LinkedHashMap.Entry<K,V> head;
//用於指向雙向鏈表的尾部
transient LinkedHashMap.Entry<K,V> tail;
/**
 * 用來指定LinkedHashMap的迭代順序,
 * true則表示按照基於訪問的順序來排列,意思就是最近使用的entry,放在鏈表的最末尾
 * false則表示按照插入順序來
 */ 
final boolean accessOrder;

2.3構造方法

由於LinkedHashMap繼承HashMap,構造方法基本類似,唯一的區別就是添加了前面提到的accessOrder,默認賦值爲false——按照插入順序來排列,這裏主要說明一下不同的構造方法。

//多了一個 accessOrder的參數,用來指定按照LRU排列方式還是順序插入的排序方式
public LinkedHashMap(int initialCapacity,
                         float loadFactor,
                         boolean accessOrder) {
   super(initialCapacity, loadFactor);
   this.accessOrder = accessOrder;
 }

LRU(Least Recently Used)最近最久未使用算法。會在後面介紹該算法.

2.4 put()方法

讓我們來看一下LinkedHashMap是怎麼插入Entry的:LinkedHashMap的put方法調用的還是HashMap裏的put,不同的是重寫了裏面的部分方法,一起來看一下:

//HashMap的put方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    ...
    tab[i] = newNode(hash, key, value, null);
    ...
    e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
    ...
    if ((e = p.next) == null) {
      p.next = newNode(hash, key, value, null);
    ...
        afterNodeAccess(e);
    ...
        afterNodeInsertion(evict);
      return null;
}

由於在前面的文章HashMap, 分析過了put方法,這裏筆者就省略了部分代碼,LinkedHashMap將其中newNode方法以及之前設置下的鉤子方法afterNodeAccessafterNodeInsertion進行了重寫,從而實現了加入鏈表的目的。一起來看一下:

Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
  //祕密就在於 new的是自己的Entry類,然後調用了linkedNodeLast
  LinkedHashMap.Entry<K,V> p =
    new LinkedHashMap.Entry<K,V>(hash, key, value, e);
  linkNodeLast(p);
  return p;
}

//顧名思義就是把新加的節點放在鏈表的最後面
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
  //將tail給臨時變量last
  LinkedHashMap.Entry<K,V> last = tail;
  //把new的Entry給tail
  tail = p;
  //若沒有last,說明p是第一個節點,head=p
  if (last == null)
    head = p;
  //否則就做準備工作,你懂的 ( ̄▽ ̄)"
  else {
    p.before = last;
    last.after = p;
  }
}

//把TreeNode的重寫也加了進來,因爲putTreeVal裏有調用了這個
TreeNode<K,V> newTreeNode(int hash, K key, V value, Node<K,V> next) {
  TreeNode<K,V> p = new TreeNode<K,V>(hash, key, value, next);
  linkNodeLast(p);
  return p;
}

//插入後把最老的Entry刪除,不過removeEldestEntry總是返回false,所以不會刪除,估計又是一個鉤子方法給子類用的
void afterNodeInsertion(boolean evict) {
  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;
}

2.5 remove()方法

與插入操作一樣,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) {
  //與afterNodeAccess一樣,記錄e的前後節點b,a
  LinkedHashMap.Entry<K,V> p =
    (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
  //p已刪除,前後指針都設置爲null,便於GC回收
  p.before = p.after = null;
  //與afterNodeAccess一樣類似,一頓判斷,然後b,a互爲前後節點
  if (b == null)
    head = a;
  else
    b.after = a;
  if (a == null)
    tail = b;
  else
    a.before = b;
}

刪除的過程並不複雜,上面這麼多代碼其實就做了三件事:

  1. 根據 hash 定位到桶位置
  2. 遍歷鏈表或調用紅黑樹相關的刪除方法
  3. 從 LinkedHashMap 維護的雙鏈表中移除要刪除的節點

2.6 get()方法

默認情況下,LinkedHashMap 是按插入順序維護鏈表。不過我們可以在初始化 LinkedHashMap,指定 accessOrder 參數爲 true,即可讓它按訪問順序維護鏈表。訪問順序的原理上並不複雜,當我們調用get/getOrDefault/replace等方法時,只需要將這些方法訪問的節點移動到鏈表的尾部即可。相應的源碼如下:

public V get(Object key) {
  Node<K,V> e;
  //調用HashMap的getNode的方法,詳見上一篇HashMap源碼解析
  if ((e = getNode(hash(key), key)) == null)
    return null;
  //在取值後對參數accessOrder進行判斷,如果爲true,執行afterNodeAccess
  if (accessOrder)
    afterNodeAccess(e);
  return e.value;
}

從上面的代碼可以看到,LinkedHashMap的get方法,調用HashMap的getNode方法後,對accessOrder的值進行了判斷,我們之前提到:

//accessOrder爲true則表示按照基於訪問的順序來排列,意思就是最近使用的entry,放在鏈表的最末尾

由此可見,afterNodeAccess(e)就是基於訪問的順序排列的關鍵,讓我們來看一下它的代碼:

 

//此函數執行的效果就是將最近使用的Node,放在鏈表的最末尾
void afterNodeAccess(Node<K,V> e) {
  LinkedHashMap.Entry<K,V> last;
  //僅當按照LRU原則且e不在最末尾,才執行修改鏈表,將e移到鏈表最末尾的操作
  if (accessOrder && (last = tail) != e) {
    //將e賦值臨時節點p, b是e的前一個節點, a是e的後一個節點
    LinkedHashMap.Entry<K,V> p =
      (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
    //設置p的後一個節點爲null,因爲執行後p在鏈表末尾,after肯定爲null
    p.after = null;
    //p前一個節點不存在,情況一
    if (b == null) //
      head = a;
    else
      b.after = a;
    if (a != null) 
      a.before = b;
    //p的後一個節點不存在,情況二
    else //
      last = b;
    //情況三
    if (last == null) //
      head = p;
    //正常情況,將p設置爲尾節點的準備工作,p的前一個節點爲原先的last,last的after爲p
    else {
      p.before = last;
      last.after = p;
    }
    //將p設置爲將p設置爲尾節點
    tail = p;
    // 修改計數器+1
    ++modCount;
  }
}

 

標註的情況如下圖所示(特別說明一下,這裏是顯示鏈表的修改後指針的情況,實際上在桶裏面的位置是不變的,只是前後的指針指向的對象變了):

下面來簡單說明一下:

  • 正常情況下:查詢的p在鏈表中間,那麼將p設置到末尾後,它原先的前節點b和後節點a就變成了前後節點。

  • 情況一:p爲頭部,前一個節點b不存在,那麼考慮到p要放到最後面,則設置p的後一個節點a爲head
  • 情況二:p爲尾部,後一個節點a不存在,那麼考慮到統一操作,設置last爲b
  • 情況三:p爲鏈表裏的第一個節點,head=p

2.7 基於 LinkedHashMap 實現緩存

在上節中,說到LRU算法,我I們通過繼承LinkedHashMap實現了一個簡單的 LRU 策略的緩存。在實踐前我們要補充部分知識:

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 緩存。比如我們可以根據節點數量判斷是否移除最近最少被訪問的節點,或者根據節點的存活時間判斷是否移除該節點等。本節所實現的緩存是基於判斷節點數量是否超限的策略。在構造緩存對象時,傳入最大節點數。當插入的節點數超過最大節點數時,移除最近最少被訪問的節點。實現代碼如下:

//作者:https://segmentfault.com/a/1190000012964859
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);
    }
}

測試結果:

 不過筆者自己也通過繼承LinkedHashMap實現了LRU算法,感興趣的小夥伴可以看看!

2.8 小結

本文對 LinkedHashMap 的源碼put,get,remove進行了分析,並在文章的結尾基於 LinkedHashMap 實現了一個簡單的 Cache。在日常開發中,LinkedHashMap 的使用頻率雖不及 HashMap,但它也個重要的實現。在 Java 集合框架中,HashMap、LinkedHashMap 和 TreeMap 三個映射類基於不同的數據結構,並實現了不同的功能。HashMap 底層基於拉鍊式的散列結構,並在 JDK 1.8 中引入紅黑樹優化過長鏈表的問題。基於這樣結構,HashMap 可提供高效的增刪改查操作。LinkedHashMap 在其之上,通過維護一條雙向鏈表,實現了散列數據結構的有序遍歷。TreeMap 底層基於紅黑樹實現,利用紅黑樹的性質,實現了鍵值對排序功能。


 

由於個人能力問題,先學習這些,數據結構這個大山,我一定要刨平它。

參考博客:https://segmentfault.com/a/1190000012964859

    https://blog.csdn.net/ShelleyLittlehero/article/details/82957336

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章