1. 程式人生 > >LinkedHashMap 的核心就 2 點,搞清楚,也就掌握了

LinkedHashMap 的核心就 2 點,搞清楚,也就掌握了

HashMap 有一個不足之處就是在迭代元素時與插入順序不一致。而大多數人都喜歡按順序做某些事情,所以,LinkedHashMap 就是針對這一點對 HashMap 進行擴充套件,主要新增了「兩種迭代方式」:

  • 按插入順序 - 保證迭代元素的順序與插入順序一致
  • 按訪問順序 - 一種特殊的迭代順序,從最近最少訪問到最多訪問的元素訪問順序,非常適合構建 LRU 快取

LinkedHashMap 的重點就是這兩點,搞清楚了,也就掌握了。由於它繼承自 HashMap,所以在分析原始碼之前一定要先看看 HashMap 的原始碼,可檢視此文《深入分析 JDK8 中 HashMap 的原理、實現和優化》。

在 JDK 8 中,因為 LinkedHashMap 子類的存在,HashMap 引入的紅黑樹,在普通模式和樹模式之間的轉換變得複雜,那麼它是如何設計和簡化的呢?接下來看看 JDK 8 中的 LinkedHashMap 原始碼的設計和實現。

1. 按插入順序迭代

HashMap 為什麼在迭代時就雜亂無章了?看下它在迭代時獲取下一個元素的方式,你就明白了:

final Node<K,V> nextNode() {
  Node<K,V>[] t;
  Node<K,V> e = next; // 下一個元素要訪問節點
  if (modCount != expectedModCount)
    throw new ConcurrentModificationException();
  if (e == null) throw new NoSuchElementException();
  // 首先迭代自身所在連結串列
  if ((next = (current = e).next) == null && (t = table) != null) {
    // 如果連結串列訪問結束,遍歷雜湊桶陣列中下一個非空元素
    do {} while (index < t.length && (next = t[index++]) == null);
  }
  return e;
}

可以看到,HashMap 是按照雜湊桶陣列中非空的槽依次遍歷的,再加上雜湊衝突(連結串列)的情況,與插入順序相比,更加亂了。

那 LinkedHashMap 怎麼解決的?它維護了一個貫穿所有元素的雙向連結串列,遍歷時,迭代器直接對雙向連結串列進行迭代,從而保證了與插入順序一致,關鍵成員屬性和節點資訊定義如下:

// 雙向連結串列頭節點,也是最久沒有訪問的元素
transient LinkedHashMap.Entry<K,V> head;
// 雙向連結串列尾節點,也是最近剛剛訪問的元素
transient LinkedHashMap.Entry<K,V> tail;
// 迭代方式,true-按訪問順序迭代,false-按插入順序迭代,預設 false
final boolean accessOrder;
// 添加了構建雙向連結串列的前驅和後繼指標
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);
  }
}

HashMap 在元素插入、刪除和訪問時定義並呼叫了一些 Hook 方法,這些方法使得 LinkedHashMap 內部保持有序的機制相對獨立,降低了普通模式和樹模式轉換的複雜度。

此外,紅黑樹節點繼承的是 LinkedHashMap 的 Entry,固然可能多了兩個指標,但在實現時有助於避免操作指標出現混淆錯誤。

LinkedHashMap 在元素插入時,覆蓋的回撥方法主要有 newNode, replacementNode, replacementTreeNode, newTreeNode,主要就是保持雙向連結串列,核心是下面兩個方法:

// 插入到雙向連結串列的尾部
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
  LinkedHashMap.Entry<K,V> last = tail; // 臨時記住尾節點
  tail = p; // 將尾指標指向新節點
  if (last == null)
    head = p; // 第一個插入的節點
  else { // 關聯前後節點
    p.before = last;
    last.after = p;
  }
}

// 連線兩個節點之間的前後指標
private void transferLinks(LinkedHashMap.Entry<K,V> src,
                           LinkedHashMap.Entry<K,V> dst) {
  LinkedHashMap.Entry<K,V> b = dst.before = src.before;
  LinkedHashMap.Entry<K,V> a = dst.after = src.after;
  if (b == null)
    head = dst;
  else
    b.after = dst;
  if (a == null)
    tail = dst;
  else
    a.before = dst;
}

迭代器的實現就比較簡單了,直接對這個雙向連結串列迭代即可,此時的迭代順序就插入順序。

2. 按訪問順序迭代

為了讓元素按訪問順序排列,HashMap 定義了以下 Hook 方法,供 LinkedHashMap 實現:

void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }

各個方法實現的原理如下:

  • afterNodeAccess 的原理是:訪問的元素如果不是尾節點,那麼就把它與尾節點交換,所以隨著元素的訪問,訪問次數越多的元素越靠後
  • afterNodeRemoval 這個沒有特殊操作,正常的斷開鏈條
  • afterNodeInsertion 的原理是:元素插入後,可能會刪除最舊的、訪問次數最少的元素,也就是頭節點

重點看下插入之後的實現:

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

是否會刪除頭節點,是由 removeEldestEntry 方法決定的,預設返回 false。在覆蓋這個方法時,不能簡單的返回 true,因為這樣可能會導致一個空的 LinkedHashMap,通常的做法是在插入指定數量的元素後再刪除,具體見下面 LRU 快取的實現。

3. 實現一個 LRU 快取

藉助 LinkedHashMap 可以很方便的實現一個 LRU 快取資料結構,只需設定 accessOrder 為 true,並覆蓋 removeEldestEntry 方法即可,程式碼如下:

final int MAX_CACHE_SIZE = 100;
LinkedHashMap<Object, Object> lru = new LinkedHashMap<Object, Object>(MAX_CACHE_SIZE, 0.75f, true) {
    private static final long serialVersionUID = 1L;
    protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) {
      return size() > MAX_CACHE_SIZE;
    }
};

4. 小結

LinkedHashMap 程式碼比較簡單,難的都被 HashMap 實現了:),它們的異同點分別如下:

  • 底層都是陣列+連結串列+紅黑樹(廢話)
  • 迭代器都是快速失敗的,都是非執行緒安全
  • LinkedHashMap 有按插入和訪問兩種迭代順序,而HashMap 亂序,迭代順序不可預測

看這個類的原始碼,最主要的還是看 HashMap 定義 Hook 方法,使得 LinkedHashMap 保持有序的機制相對獨立的設計,這是模板模式的應用