簡介
LinkedHashMap內部維護了一個雙向鏈表,能保證元素按插入的順序訪問,也能以訪問順序訪問,可以用來實現LRU緩存策略。
LinkedHashMap可以看成是 LinkedList + HashMap。
類繼承體系
LinkedHashMap繼承HashMap,擁有HashMap的所有特性,並且額外增加了按一定順序訪問的特性。
Entry的繼承關係
Entry作爲基本的節點,可以看到LinkedHashMap的Entry繼承自HashMap的Node,在其基礎上加上了before和after兩個指針,而TreeNode作爲HashMap和LinkedHashMap的樹節點,繼承自LinkedHahsMap的Entry,並且加上了樹節點的相關指針,另外提一點:before和parent的兩個概念是不一樣的,before是相對於鏈表來的,parent是相對於樹操作來的,所以要分兩個。
Iterator的繼承關係
LinkedHashMap的迭代器爲遍歷節點提供了自己的實現——LinkedHashIterator,對於Key、Value、Entry的3個迭代器,都繼承自它。而且內部採用的遍歷方式就是在前面提到的Entry里加的新的指向下一個節點的指針after,後面我們將具體看它的代碼實現。
存儲結構
雙鏈表是鏈表的一種,由節點組成,每個數據結點中都有兩個指針,分別指向直接後繼和直接前驅
我們知道HashMap使用(數組 + 單鏈表 + 紅黑樹)的存儲結構,那LinkedHashMap是怎麼存儲的呢?
通過上面的繼承體系,我們知道它繼承了HashMap,所以它的內部也有這三種結構,但是它還額外添加了一種“雙向鏈表”的結構存儲所有元素的順序。
添加刪除元素的時候需要同時維護在HashMap中的存儲,也要維護在LinkedList中的存儲,所以性能上來說會比HashMap稍慢。
源碼解析
屬性
/**
* 雙向鏈表頭節點
*/
transient LinkedHashMap.Entry<K,V> head;
/**
* 雙向鏈表尾節點
*/
transient LinkedHashMap.Entry<K,V> tail;
/**
* 是否需要按訪問順序排序,用來指定LinkedHashMap的迭代順序。
* true則表示按照基於訪問的順序來排列,意思就是最近使用的entry,放在鏈表的最末尾
* false則表示按照插入順序排序
*/
final boolean accessOrder;
(1)head
雙向鏈表的頭節點,舊數據存在頭節點。
(2)tail
雙向鏈表的尾節點,新數據存在尾節點。
(3)accessOrder
是否需要按訪問順序排序, true則表示按照基於訪問的順序來排列,意思就是最近使用的entry,放在鏈表的最末尾 ;false則表示按照插入順序排序。
注意:accessOrder是
final關鍵字,說明我們要在構造方法裏給它初始化。
內部類
// 位於LinkedHashMap中
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中
static class Node<K, V> implements Map.Entry<K, V> {
final int hash;
final K key;
V value;
Node<K, V> next;
}
存儲節點,繼承自HashMap的Node類,next用於單鏈表存儲於桶中,before和after用於雙向鏈表存儲所有元素。
構造方法
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;
}
前四個構造方法accessOrder都等於false,說明雙向鏈表是按插入順序存儲元素。
最後一個構造方法accessOrder從構造方法參數傳入,如果傳入true,則就實現了按訪問順序存儲元素,這也是實現LRU緩存策略的關鍵。
get(Object key)方法
獲取元素。
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;
}
如果查找到了元素,且accessOrder爲true,則調用afterNodeAccess()方法把訪問的節點移到雙向鏈表的末尾。
afterNodeAccess(Node<K,V> e)方法
在節點訪問之後被調用,主要在put()已經存在的元素或get()時被調用,如果accessOrder爲true,調用這個方法把訪問到的節點移動到雙向鏈表的末尾。
// 此函數執行的效果就是將最近使用的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;// p爲頭部,前一個節點b不存在,那麼考慮到p要放到最後面,則設置p的後一個節點a爲head
else
b.after = a;// P不是頭部,前一個節點b存在,重新設置b的後一個節點爲a
if (a != null)
a.before = b;// P不是尾部,設置a的前一個節點爲b
// p的後一個節點不存在,情況二
else // ②
last = b;// p爲尾部,後一個節點a不存在,那麼考慮到統一操作,設置last爲b
// 情況三
if (last == null) // ③
head = p;// p爲鏈表裏的第一個節點,head=p
// 正常情況,將p設置爲尾節點的準備工作,p的前一個節點爲原先的last,last的after爲p
else {
p.before = last;
last.after = p;
}
// 將p設置爲尾節點
tail = p;
// 修改計數器+1
++modCount;
}
}
(1)如果accessOrder爲true,並且訪問的節點不是尾節點;
(2)從雙向鏈表中移除訪問的節點;
(3)把訪問的節點加到雙向鏈表的末尾;(末尾爲最新訪問的元素)
標註的情況如下圖所示(特別說明一下,這裏是顯示鏈表的修改後指針的情況,實際上在桶裏面的位置是不變的,只是前後的指針指向的對象變了):
下面來簡單說明一下:
-
正常情況下:查詢的p在鏈表中間,那麼將p設置到末尾後,它原先的前節點b和後節點a就變成了前後節點。
-
情況一:p爲頭部,前一個節點b不存在,那麼考慮到p要放到最後面,則設置p的後一個節點a爲head
-
情況二:p爲尾部,後一個節點a不存在,那麼考慮到統一操作,設置last爲b
-
情況三:p爲鏈表裏的第一個節點,head=p
put()方法
LinkedHashMap的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;
}
由於在之前深讀源碼-java集合之HashMap源碼分析分析過了put方法,這裏筆者就省略了部分代碼,LinkedHashMap將其中newNode
方法以及之前設置下的鉤子方法afterNodeAccess
和afterNodeInsertion
進行了重寫,從而實現了加入鏈表的目的:
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;
}
afterNodeInsertion(boolean evict)方法
在節點插入之後做些什麼,在HashMap中的putVal()方法中被調用,可以看到HashMap中這個方法的實現爲空。
// 插入後把最老的Entry刪除,不過removeEldestEntry總是返回false,所以不會刪除,估計又是一個鉤子方法給子類用的
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;
}
evict,驅逐的意思。
(1)如果evict爲true,且頭節點不爲空,且確定移除最老的元素,那麼就調用HashMap.removeNode()把頭節點移除(這裏的頭節點是雙向鏈表的頭節點,而不是某個桶中的第一個元素);
(2)HashMap.removeNode()從HashMap中把這個節點移除之後,會調用afterNodeRemoval()方法;
(3)afterNodeRemoval()方法在LinkedHashMap中也有實現,用來在移除元素後修改雙向鏈表,見下文;
(4)默認removeEldestEntry()方法返回false,也就是不刪除元素。
remove()方法
remove裏面設計者也設置了一個鉤子方法:
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
...
// node即是要刪除的節點
afterNodeRemoval(node);
...
}
afterNodeRemoval(Node<K,V> e)方法
在節點被刪除之後調用的方法。
void afterNodeRemoval(Node<K,V> e) { // unlink
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差不多邏輯
if (b == null)
head = a;
else
b.after = a;
if (a == null)
tail = b;
else
a.before = b;
}
經典的把節點從雙向鏈表中刪除的方法。
LinkedHashMap的迭代器
abstract class LinkedHashIterator {
// 記錄下一個Entry
LinkedHashMap.Entry<K,V> next;
// 記錄當前的Entry
LinkedHashMap.Entry<K,V> current;
// 記錄是否發生了迭代過程中的修改
int expectedModCount;
LinkedHashIterator() {
// 初始化的時候把head給next
next = head;
expectedModCount = modCount;
current = null;
}
public final boolean hasNext() {
return next != null;
}
// 這裏採用的是鏈表方式的遍歷方式,有興趣的園友可以去上一章看看HashMap的遍歷方式
final LinkedHashMap.Entry<K,V> nextNode() {
LinkedHashMap.Entry<K,V> e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
//記錄當前的Entry
current = e;
//直接拿after給next
next = e.after;
return e;
}
public final void remove() {
Node<K,V> p = current;
if (p == null)
throw new IllegalStateException();
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
current = null;
K key = p.key;
removeNode(hash(key), key, null, false, false);
expectedModCount = modCount;
}
}
總結
(1)LinkedHashMap繼承自HashMap,具有HashMap的所有特性;
(2)LinkedHashMap內部維護了一個雙向鏈表存儲所有的元素;
(3)如果accessOrder爲false,則可以按插入元素的順序遍歷元素;
(4)如果accessOrder爲true,則可以按訪問元素的順序遍歷元素;
(5)LinkedHashMap的實現非常精妙,很多方法都是在HashMap中留的鉤子(Hook),直接實現這些Hook就可以實現對應的功能了,並不需要再重寫put()等方法;
(6)默認的LinkedHashMap並不會移除舊元素,如果需要移除舊元素,則需要重寫removeEldestEntry()方法設定移除策略;
(7)LinkedHashMap可以用來實現LRU緩存淘汰策略;
彩蛋
LinkedHashMap如何實現LRU緩存淘汰策略呢?
首先,我們先來看看LRU是個什麼鬼。LRU,Least Recently Used,最近最少使用,也就是優先淘汰最近最少使用的元素。
如果使用LinkedHashMap,我們把accessOrder設置爲true是不是就差不多能實現這個策略了呢?答案是肯定的。請看下面的代碼:
package cn.com.sdd.study.list;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* @author suidd
* @name LRUTest
* @description LRU緩存淘汰策略測試
* @date 2020/5/9 16:54
* Version 1.0
**/
public class LRUTest {
public static void main(String[] args) {
// 創建一個只有5個元素的緩存
LRU<Integer, Integer> lru = new LRU<>(5, 0.75f);
lru.put(1, 1);
lru.put(2, 2);
lru.put(3, 3);
lru.put(4, 4);
lru.put(5, 5);
lru.put(6, 6);
lru.put(7, 7);
System.out.println(lru.get(4));
lru.put(6, 666);
// 輸出: {3=3, 5=5, 7=7, 4=4, 6=666}
// 可以看到最舊的元素被刪除了,且最近訪問的4被移到了後面
// 如果LRU構造accessOrder設置爲false,則輸出{3=3, 4=4, 5=5, 6=666, 7=7}
System.out.println(lru);
}
}
class LRU<K, V> extends LinkedHashMap<K, V> {
// 保存緩存的容量
private int capacity;
public LRU(int capacity, float loadFactor) {
//accessOrder:true:按訪問順序排序(LRU),false:按插入順序排序;
super(capacity, loadFactor, true);
this.capacity = capacity;
}
/**
* 重寫removeEldestEntry()方法設置何時移除舊元素
*
* @param eldest
* @return
*/
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
// 當元素個數大於了緩存的容量, 就移除元素
return size() > this.capacity;
}
}