LinkedHashMap 原始碼分析
上週學習了 HashMap
的原始碼感覺收穫頗多,雖然紅黑樹這個坑自己還沒有填,但是我沒臉沒皮的先看了 LinkedHashMap
的原始碼。因為LinkedHashMap
的確跟HashMap
有很大關係,看完這篇文章相信大家也會有這種感覺。由於有了 HashMap
原始碼的分析鋪墊,這篇文章我們將從以下幾個方面來分析 LinkedHashMap
的原始碼:
- LinkedHashMap 與 HashMap 的關係
- LinkedHashMap 雙向連結串列的構建過程
- LinkedHashMap 刪除節點的過程
- LinkedHashMap 如何維持訪問順序
- LinkedHashMap - LRU (Least Recently Used) 最簡單的構建方式
LinkedHashMap 與 HashMap 的關係
我們先來看下 LinkedHashMap
的體系圖:
圖片很直接的說明了一個問題,那就是 LinkedHashMap
直接繼承自HashMap
,這也就說明了上文中我們說到的 HashMap
一切重要的概念 LinkedHashMap
都是擁有的,這就包括了,hash 演算法定位 hash 桶位置,雜湊表由陣列和單連結串列構成,並且當單連結串列長度超過 8 的時候轉化為紅黑樹,擴容體系,這一切都跟 HashMap
一樣。那麼除了這麼多關鍵的相同點以外,LinkedHashMap
比 HashMap
更加強大,這體現在:
LinkedHashMap
內部維護了一個雙向連結串列,解決了HashMap
不能隨時保持遍歷順序和插入順序一致的問題LinkedHashMap
元素的訪問順序也提供了相關支援,也就是我們常說的 LRU(最近最少使用)原則。
接下介紹中也貫穿著這兩個不同點的原始碼分析以及如何應用。
LinkedHashMap 雙向連結串列的構建過程
為了便於理解,在看具體原始碼之前,我們先看一張圖,這張圖可以很好的體現 LinkedHashMap
中個各個元素關係:
假設圖片中紅黃箭頭代表元素新增順序,藍箭頭代表單連結串列各個元素的儲存順序。head 表示雙向連結串列頭部,tail 代表雙向連結串列尾部
上篇文章分析的 HashMap
原始碼的時候我們有一張示意圖,說明了 HashMap
的儲存結構為,陣列 + 單連結串列 + 紅黑樹,從上邊的圖片我們也可以看出 LinkedHashMap
底層的儲存結構並沒有發生變化。
唯一變化的是使用雙向連結串列(圖中紅黃箭頭部分)記錄了元素的新增順序,我們知道 HashMap
中的 Node 節點只有 next 指標,對於雙向連結串列而言只有 next 指標是不夠的,所以 LinkedHashMap
對於 Node 節點進行了擴充:
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<K,V>
繼承自 HashMap.Node<K,V>
,並在此基礎上新增了 before 和 after 這兩個指標變數。這 before 變數在每次新增元素的時候將會連結上一次新增的元素,而上一次新增的元素的 after 變數將指向該次新增的元素,來形成雙向連結。值得注意的是 LinkedHashMap
並沒有覆寫任何關於 HashMap put 方法。所以呼叫 LinkedHashMap
的 put 方法實際上呼叫了父類 HashMap 的方法。為了方便理解我們這裡放一下 HashMap
的 putVal 方法。
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);
else {// 發生 hash 碰撞了
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode){....}
else {
//hash 值計算出的陣列索引相同,但 key 並不同的時候 迴圈整個單連結串列
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {//遍歷到尾部
// 建立新的節點,拼接到連結串列尾部
p.next = newNode(hash, key, value, null);
....
break;
}
//如果遍歷過程中找到連結串列中有個節點的 key 與 當前要插入元素的 key 相同,
//此時 e 所指的節點為需要替換 Value 的節點,並結束迴圈
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//移動指標
p = e;
}
}
//如果迴圈完後 e!=null 代表需要替換e所指節點 Value
if (e != null) {
V oldValue = e.value//儲存原來的 Value 作為返回值
// onlyIfAbsent 一般為 false 所以替換原來的 Value
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);//該方法在 LinkedHashMap 中的實現稍後說明
return oldValue;
}
}
//運算元增加
++modCount;
//如果 size 大於擴容閾值則表示需要擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
複製程式碼
可以看出每次新增新節點的時候實際上是呼叫 newNode
方法生成了一個新的節點,放到指定 hash 桶中,但是很明顯,HashMap
中 newNode
方法無法完成上述所講的雙向連結串列節點的間的關係,所以 LinkedHashMap
複寫了該方法:
// 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);
// 將 Entry 接在雙向連結串列的尾部
linkNodeLast(p);
return p;
}
複製程式碼
可以看出雙向連結串列的操作一定在 linkNodeLast
方法中實現:
/**
* 該引用始終指向雙向連結串列的頭部
*/
transient LinkedHashMap.Entry<K,V> head;
/**
* 該引用始終指向雙向連結串列的尾部
*/
transient LinkedHashMap.Entry<K,V> tail;
複製程式碼
// newNode 中新節點,放到雙向連結串列的尾部
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
// 新增元素之前雙向連結串列尾部節點
LinkedHashMap.Entry<K,V> last = tail;
// tail 指向新新增的節點
tail = p;
//如果之前 tail 指向 null 那麼集合為空新新增的節點 head = tail = p
if (last == null)
head = p;
else {
// 否則將新節點的 before 引用指向之前當前連結串列尾部
p.before = last;
// 當前連結串列尾部節點的 after 指向新節點
last.after = p;
}
}
複製程式碼
LinkedHashMap
連結串列建立步驟,可用上圖幾個步驟來描述,藍色部分是 HashMap
的方法,而橙色部分為 LinkedHashMap
獨有的方法。
當我們建立一個新節點之後,通過linkNodeLast
方法,將新的節點與之前雙向連結串列的最後一個節點(tail)建立關係,在這部操作中我們仍不知道這個節點究竟儲存在雜湊表表的何處,但是無論他被放到什麼地方,節點之間的關係都會加入雙向連結串列。如上述圖中節點 3 和節點 4 那樣彼此擁有指向對方的引用,這麼做就能確保了雙向連結串列的元素之間的關係即為新增元素的順序。
LinkedHashMap 刪除節點的操作
如插入操作一樣,LinkedHashMap
沒有重寫的 remove 方法,使用的仍然是 HashMap
中的程式碼,我們先來回憶一下 HashMap 中的 remove 方法:
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;
//判斷雜湊表是否為空,長度是否大於0 對應的位置上是否有元素
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
// node 用來存放要移除的節點, e 表示下個節點 k ,v 每個節點的鍵值
Node<K,V> node = null, e; K k; V v;
//如果第一個節點就是我們要找的直接賦值給 node
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);
}
}
// 如果找到了節點
// !matchValue 是否不刪除節點
// (v = node.value) == value ||
(value != null && value.equals(v))) 節點值是否相同,
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);// 注意這個方法 在 Hash表的刪除操作完成呼叫該方法
return node;
}
}
return null;
}
複製程式碼
LinkedHashMap 通過呼叫父類的 HashMap 的 remove 方法將 Hash 表的中節點的刪除操作完成即:
- 獲取對應 key 的雜湊值 hash(key),定位對應的雜湊桶的位置
- 遍歷對應的雜湊桶中的單連結串列或者紅黑樹找到對應 key 相同的節點,在最後刪除,並返回原來的節點。
對於 afterNodeRemoval(node)
HashMap 中是空實現,而該方法,正是 LinkedHashMap 刪除對應節點在雙向連結串列中的關係的操作:
// 從雙向連結串列中刪除對應的節點 e 為已經刪除的節點
void afterNodeRemoval(Node<K,V> e) {
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
// 將 p 節點的前後指標引用置為 null 便於記憶體釋放
p.before = p.after = null;
// p.before 為 null,表明 p 是頭節點
if (b == null)
head = a;
else//否則將 p 的前驅節點連線到 p 的後驅節點
b.after = a;
// a 為 null,表明 p 是尾節點
if (a == null)
tail = b;
else //否則將 a 的前驅節點連線到 b
a.before = b;
}
複製程式碼
因此 LinkedHashMap 節點刪除方式如下圖步驟一樣:
LinkedHashMap 維護節點訪問順序
上邊我們分析了 LinkedHashMap
與 HashMap
新增和刪除元素的不同,可以看出除了維護 Hash表中元素的關係以外,LinkedHashMap 還在新增和刪除元素的時候維護著一個雙向連結串列。那麼這個雙向連結串列究竟有何用呢?我們來看下邊這個例子,我們對比一下在相同元素新增順序的時候,遍歷 Map 得到的結果:
//Map<String, Integer> map = new HashMap<>();
Map<String, Integer> map = new LinkedHashMap<>();
// 使用三個引數的構造法方法來指定 accessOrder 引數的值
//Map<String, Integer> map = new LinkedHashMap<>(10,0.75f,true);
map.put("老大", 1);
map.put("老二", 2);
map.put("老三", 3);
map.put("老四", 4);
Set<Map.Entry<String, Integer>> entrySet = map.entrySet();
Iterator iter1 = entrySet.iterator();
while (iter1.hasNext()) {
Map.Entry entry = (Map.Entry) iter1.next();
System.out.print("key: " + entry.getKey() + " ");
System.out.println("value: " + entry.getValue());
}
System.out.println("老三的值為:" + map.get("老三"));
System.out.println("老大的值為:" + map.put("老大",1000));
Iterator iter2 = entrySet.iterator();
while (iter2.hasNext()) {
// 遍歷時,需先獲取entry,再分別獲取key、value
Map.Entry entry = (Map.Entry) iter2.next();
System.out.print("key: " + entry.getKey() + " ");
System.out.println("value: " + entry.getValue());
}
複製程式碼
/*** HashMap 遍歷結果*/
key: 老二 value: 2
key: 老四 value: 4
key: 老三 value: 3
key: 老大 value: 1
老三的值為:3
老大的值為:1
key: 老二 value: 2
key: 老四 value: 4
key: 老三 value: 3
key: 老大 value: 1000
/*** LinkedHashMap 遍歷結果*/
key: 老大 value: 1
key: 老二 value: 2
key: 老三 value: 3
key: 老四 value: 4
老三的值為:3
老大的值為:1
key: 老大 value: 1000
key: 老二 value: 2
key: 老三 value: 3
key: 老四 value: 4
複製程式碼
由上述方法結果可以看出:
HashMap
的遍歷結果是跟新增順序並無關係LinkedHashMap
的遍歷結果就是新增順序
這就是雙向連結串列的作用。雙向連結串列能做的不僅僅是這些,在介紹雙向連結串列維護訪問順序前我們看來看一個重要的引數:
final boolean accessOrder;// 是否維護雙向連結串列中的元素訪問順序
複製程式碼
該方法隨 LinkedHashMap 構造引數初始化,accessOrder 預設值為 false,我們可以通過三個引數構造方法指定該引數的值,引數定義為 final 說明外部不能改變。
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);
}
//可以指定 LinkedHashMap 雙向連結串列維護節點訪問順序的構造引數
public LinkedHashMap(int initialCapacity,
float loadFactor,
boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}
複製程式碼
我們試著使用三個引數的構造方法來建立上述例子中的 Map,並檢視結果如下
//第一次遍歷
key: 老大 value: 1
key: 老二 value: 2
key: 老三 value: 3
key: 老四 value: 4
老三的值為:3
老大的值為:1
//第二次遍歷
key: 老二 value: 2
key: 老四 value: 4
key: 老三 value: 3
key: 老大 value: 1000
複製程式碼
可以看出當我們使用 access 為 true 後,我們訪問元素的順序將會在下次遍歷的時候體現,最後訪問的元素將最後獲得。其實這一切在 HashMap 原始碼中也早有伏筆, 還記得我們在每次 putVal/get/repalce
最後都有一個 void afterNodeAccess(Node<K,V> e)
方法,該方法在 HashMap 中是空實現,但是在 LinkedHasMap 中該後置方法,將作為維護節點訪問順序的重要方法,我們來看下其實現:
//將被訪問節點移動到連結串列最後
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;
//訪問節點的後驅置為 null
p.after = null;
//如訪問節點的前驅為 null 則說明 p = head
if (b == null)
head = a;
else
b.after = a;
//如果 p 不為尾節點 那麼將 a 的前驅設定為 b
if (a != null)
a.before = b;
else
last = b;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
tail = p;// 將 p 接在雙向連結串列的最後
++modCount;
}
}
複製程式碼
我們以下圖舉例看下整個 afterNodeAccess
過程是是怎麼樣的,比如我們該次操作訪問的是 13 這個節點,而 14 是其後驅,11 是其前驅,且 tail = 14 。在通過 get 訪問 13 節點後, 13變成了 tail 節點,而14變成了其前驅節點,相應的 14的前驅變成 11 ,11的後驅變成了14, 14的後驅變成了13.
由此我們得知,LinkedHashMap
通過afterNodeAccess
這個後置操作,可以在 accessOrde = true
的時候,使雙向連結串列維護雜湊表中元素的訪問順序。
上述測試例子中是使用了 LinkedHashMap 的迭代器,由於有雙向連結串列的存在,它相比 HashMap 遍歷節點的方式更為高效,我們來對比看下兩者的迭代器中的 nextNode
方法:
// HashIterator nextNode 方法
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();
//遍歷 table 尋找下個存有元素的 hash桶
if ((next = (current = e).next) == null && (t = table) != null) {
do {} while (index < t.length && (next = t[index++]) == null);
}
return e;
}
// LinkedHashIterator nextNode 方法
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;
}
複製程式碼
更為明顯的我們可以檢視兩者的 containsValue 方法:
//LinkedHashMap 中 containsValue 的實現
public boolean containsValue(Object value) {
// 直接遍歷雙向連結串列去尋找對應的節點
for (LinkedHashMap.Entry<K,V> e = head; e != null; e = e.after) {
V v = e.value;
if (v == value || (value != null && value.equals(v)))
return true;
}
return false;
}
//HashMap 中 containsValue 的實現
public boolean containsValue(Object value) {
Node<K,V>[] tab; V v;
if ((tab = table) != null && size > 0) {
//遍歷 雜湊桶索引
for (int i = 0; i < tab.length; ++i)
//遍歷雜湊桶中連結串列或者紅黑樹
for (Node<K,V> e = tab[i]; e != null; e = e.next) {
if ((v = e.value) == value ||
(value != null && value.equals(v)))
return true;
}
}
}
return false;
}
複製程式碼
Java 中最簡單的 LRU 構建方式
LRU 是 Least Recently Used 的簡稱,即近期最少使用,相信做 Android 的同學一定知道 LruCache 這個東西, Glide 的三級快取中記憶體快取中也使用了這個 LruCache 類。 有興趣的同學可以去檢視一下Glide快取原始碼解析。
LRU 演算法實現的關鍵就像它名字一樣,當達到預定閾值的時候,這個閾值可能是記憶體不足,或者容量達到最大,找到最近最少使用的儲存元素進行移除,保證新新增的元素能夠儲存到集合中。
下面我們來講解下,Java 中 LRU 演算法的最簡單的實現。我們還記得在每次呼叫 HashMap 的 putVal 方法新增完元素後還有個後置操作,void afterNodeInsertion(boolean evict) { }
就是這個方法。 LinkedHashMap 重寫了此方法:
// HashMap 中 putVal 方法實現 evict 傳遞的 true,表示表處於建立模式。
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) { .... }
//evict 由上述說明大部分情況下都傳 true 表示表處於建立模式
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
//由於 evict = true 那麼當連結串列不為空的時候 且 removeEldestEntry(first) 返回 true 的時候進入if 內部
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);//移除雙向連結串列中處於 head 的節點
}
}
//LinkedHashMap 預設返回 false 則不刪除節點。 返回 true 雙向連結串列中處於 head 的節點
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}
複製程式碼
由上述原始碼可以看出,如果如果 removeEldestEntry(Map.Entry<K,V> eldest)
方法返回值為 true 的時候,當我們新增一個新的元素之後,afterNodeInsertion
這個後置操作,將會刪除雙向連結串列最初的節點,也就是 head 節點。那麼我們就可以從 removeEldestEntry
方法入手來構建我們的 LruCache 。
public class LruCache<K, V> extends LinkedHashMap<K, V> {
private static final int MAX_NODE_NUM = 2<<4;
private int limit;
public LruCache() {
this(MAX_NODE_NUM);
}
public LruCache(int limit) {
super(limit, 0.75f, true);
this.limit = limit;
}
public V putValue(K key, V val) {
return put(key, val);
}
public V getValue(K key) {
return get(key);
}
/**
* 判斷儲存元素個數是否預定閾值
* @return 超限返回 true,否則返回 false
*/
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > limit;
}
}
複製程式碼
我們構建了一個 LruCache
類, 他繼承自 LinkedHashMap
在構建的時候,呼叫了 LinkedHashMap
的三個引數的構造方法且 accessOrder
傳入 true,並覆寫了 removeEldestEntry
方法,當 Map 中的節點個數超過我們預定的閾值時候在 putValue
將會執行 afterNodeInsertion
刪除最近沒有訪問的元素。 下面我們來測試一下:
//構建一個閾值為 3 的 LruCache 類
LruCache<String,Integer> lruCache = new LruCache<>(3);
lruCache.putValue("老大", 1);
lruCache.putValue("老二", 2);
lruCache.putValue("老三", 3);
lruCache.getValue("老大");
//超過指定 閾值 3 再次新增元素的 將會刪除最近最少訪問的節點
lruCache.putValue("老四", 4);
System.out.println("lruCache = " + lruCache);
複製程式碼
執行結果當然是刪除 key 為 "老二" 的節點:
lruCache = {老三=3, 老大=1, 老四=4}
複製程式碼
總結
本文並沒有從以往的增刪改查四種操作上去分析 LinkedHashMap
的原始碼,而是通過 LinkedHashMap
中不同於 HashMap
的幾大特點來展開分析。
-
LinkedHashMap 擁有與 HashMap 相同的底層雜湊表結構,即陣列 + 單連結串列 + 紅黑樹,也擁有相同的擴容機制。
-
LinkedHashMap 相比 HashMap 的拉鍊式儲存結構,內部額外通過 Entry 維護了一個雙向連結串列。
-
HashMap 元素的遍歷順序不一定與元素的插入順序相同,而 LinkedHashMap 則通過遍歷雙向連結串列來獲取元素,所以遍歷順序在一定條件下等於插入順序。
-
LinkedHashMap 可以通過構造引數 accessOrder 來指定雙向連結串列是否在元素被訪問後改變其在雙向連結串列中的位置。
看完這篇文章我們也可以輕鬆的回答面試題之 LinkedHashMap 與 HashMap 的區別了。這篇文章就到此結束了。意猶未盡的朋友可以檢視我之前的關於其他集合原始碼的分析。(皮了一下,我很開心~)
參考
- JDK 1.8 LinkedHashMap & HashMap 原始碼
- 搞懂 Java HashMap 原始碼
- LinkedHashMap 原始碼詳細分析(JDK1.8)