在之前的文章紅黑樹在HashMap中的應用中,我們分析了HashMap的實現原理以及查找,插入和刪除操作的源碼,這一篇我們就來看看HashMap的一個子類:LinkedHashMap。
LinkedHashMap
LinkedHashMap繼承自HashMap並實現了Map接口,它的API與HashMap完全一致,用法也大致相同,同樣是非線程安全的集合。我們 知道HashMap存儲的節點都是HashMap.Node類型的,到了LinkedHashMap中,節點類型變成了LinkedHashMapEntry,這是Node的一 個子類,在Node類的基礎上增加了before和after兩個指針,用於雙向鏈表的實現,下面代碼是LinkedHashMapEntry類的定義:
/** * HashMap.Node subclass for normal LinkedHashMap entries. */ static class LinkedHashMapEntry<K,V> extends HashMap.Node<K,V> { LinkedHashMapEntry<K,V> before, after; LinkedHashMapEntry(int hash, K key, V value, Node<K,V> next) { super(hash, key, value, next); } }
由於這兩個指針的存在,LinkedHashMap可以在保持HashMap存儲結構不變的前提下,將持有的節點額外以雙向鏈表的形式連接起來。這個 雙向鏈表也是LinkedHashMap與其父類最大的不同,雖然存儲方式完全相同,但是LinkedHashMap可以通過雙向鏈表做到有序遍歷,而這 個順序取決於accessOrder這個成員變量。
首先,老規矩,一起先來看看LinkedHashMap的全局成員變量:
/** * The head (eldest) of the doubly linked list. */ transient LinkedHashMapEntry<K,V> head; //指向雙向鏈表頭結點的指針 /** * The tail (youngest) of the doubly linked list. */ transient LinkedHashMapEntry<K,V> tail; //指向雙向鏈表尾結點的指針 /** * The iteration ordering method for this linked hash map: <tt>true</tt> * for access-order, <tt>false</tt> for insertion-order. * * @serial */ //LinkedHashMap對於雙向鏈表節點的順序有兩層維護方式。 //第一層:按照節點插入順序排序,先來先排 //第二層:若accessOrder變量被置位true(在構造函數中傳入),則每一次對節點的訪問或修改都會將該節點移動到隊尾 //這樣一來head指針指向的就是最久沒有被訪問的節點,這一個特性完全符合LRU(Least Recently Used)算法 final boolean accessOrder;
OK,成員變量不多,作用也很直觀。另外值得注意的是LinkedHashMap所重寫的HashMap中留下的幾個鉤子方法,這些方法都是在 HashMap結構發生變化或節點被訪問時被調用(例如put,get,remove),而HashMap中這些都是空方法,LinkedHashMap通過重寫這 些方法實現了對節點雙向鏈表結構的維護。下面就一起來看看這幾個鉤子方法的實現:
//這個方法是在節點被訪問或者被修改時被調用的 void afterNodeAccess(Node<K,V> e) { // move node to last LinkedHashMapEntry<K,V> last; if (accessOrder && (last = tail) != e) { //若accessOrder爲false或e已經在鏈表尾部,則無需調整 //暫存被操作節點e爲p,其前驅節點爲b,後繼節點爲a LinkedHashMapEntry<K,V> p = (LinkedHashMapEntry<K,V>)e, b = p.before, a = p.after; p.after = null; //將p的後繼置空 if (b == null) head = a; //若e的前驅節點爲空,則說明e之前爲鏈表的頭結點,現在將e原先的後繼結點變爲頭結點 else b.after = a; //前驅不爲空,則連接e的前驅和後繼節點,將e從鏈表中斷開 if (a != null) a.before = b; //e原先的後繼不爲空,則與原先的前驅b連接 else last = b; //否則的話將尾指針指向b if (last == null) head = p; //若尾指針此時爲空,說明e是鏈表中唯一的節點,則將頭指針重新指向它 else { p.before = last; //否則的話將其移到鏈表尾部 last.after = p; } tail = p; //尾指針指向尾節點 ++modCount; //結構操作數加一 } } //這個方法是在有新節點插入時被調用的 //evict這個變量如果爲true,說明需要在節點添加後進行一次eldest節點刪除。反之則是單增長模式 void afterNodeInsertion(boolean evict) { // possibly remove eldest LinkedHashMapEntry<K,V> first; //如果不爲單增長模式,鏈表不爲空,且removeEldestEntry的返回值爲true,就將頭結點刪除 //值得注意的是removeEldestEntry方法默認的返回是false,即LinkedHashMap默認插入時不刪除eldest節點 //若需修改,則在子類中重寫該方法 if (evict && (first = head) != null && removeEldestEntry(first)) { K key = first.key; removeNode(hash(key), key, null, false, true); //刪除鏈表頭結點 } } //這個方法當有節點被刪除時會被調用 //e爲待刪除節點,這裏做的也很簡單,就是將這個已經被HashMap存儲結構刪除的節點從雙向鏈表中移除 void afterNodeRemoval(Node<K,V> e) { // unlink LinkedHashMapEntry<K,V> p = (LinkedHashMapEntry<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重寫的其父類的三個鉤子方法,除了這三個方法外,LinkedHashMap還重寫了其他的一些HashMap的方 法,其主要目的有兩個,一是將節點類從Node包裝爲LinkedHashMapEntry, 二是維護自身的雙向鏈表。下面我們就一起來看下這幾個被 重寫的方法:
//這裏用LinkedHashMapEntry代替了HashMap.Node作爲鏈表節點 Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) { LinkedHashMapEntry<K,V> p = new LinkedHashMapEntry<K,V>(hash, key, value, e); linkNodeLast(p); //將新創建出的節點添加到鏈表尾部 return p; } //這是構建桶中的紅黑樹結構時用到的新建節點的方法,這裏相比HashMap會多做一步linkNodeLast,即將節點添加到鏈表尾部 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; } //這個方法在untreeify過程中會被調用,作用就是將傳入的TreeNode類型的p轉換爲普通的Node類型,當然這裏是LinkedHashMapEntry //需要說明的是,紅黑樹的節點類型TreeNode其實是LinkedHashMapEntry的子類,所以這裏直接進行了強轉 Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) { LinkedHashMapEntry<K,V> q = (LinkedHashMapEntry<K,V>)p; LinkedHashMapEntry<K,V> t = new LinkedHashMapEntry<K,V>(q.hash, q.key, q.value, next); transferLinks(q, t); //這裏是一個替換操作,用t替換原先鏈表中的q return t; } //這個方法會在treeifyBin方法中被調用,作用是將節點類型由LinkedHashMapEntry轉換成TreeNode TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) { LinkedHashMapEntry<K,V> q = (LinkedHashMapEntry<K,V>)p; TreeNode<K,V> t = new TreeNode<K,V>(q.hash, q.key, q.value, next); transferLinks(q, t); //與上相同,替換雙向鏈表中的節點 return t; } //LinkedHashMap還重寫了父類中的get方法 public V get(Object key) { Node<K,V> e; if ((e = getNode(hash(key), key)) == null) //前半部分跟HashMap完全一樣,也是通過getNode方法完成 return null; if (accessOrder) //若key匹配到了對應得value,且accessOrder爲true afterNodeAccess(e); //通過afterNodeAccess方法將被訪問節點移動到鏈表尾部 return e.value; }
可以看到,LinkedHashMap並沒有改變任何HashMap中的存儲結構和操作,只是將Node節點替換爲了LinkedHashMapEntry節點,並且 在每一個操作方法後都會對自身的雙向鏈表進行相應的維護。從這個角度看來,LinkedHashMap同時具有了Map和鏈表的特性,而這樣的特 性使得它在很多地方都被應用,比如下面要出場的LruCache。
LruCache
首先,LRU是Least Recently Used的縮寫,也即最少使用算法。而LruCache,顧名思義就是基於LRU算法思想的緩存策略。它會將有限的緩存對象以強引用的方式保存,當其中某一個緩存對象被訪問後,該對象就會被升級一次,而當隊列已滿且有新的添加請求時,最久沒有被訪問過的那個對象就會被移除出緩存隊列,LruCache對其的強引用也會斷開,以方便可能的GC。
看到這裏,是不是會有一種LinkedHashMap簡直就是爲了LRU算法而生的感覺��。。 言歸正傳,Android對於LruCache的實現確實就是基於LinkedHashMap完成的,上面所說的對象被訪問後升級其實就是LinkedHashMap中afterNodeAccess方法所做的事情——將該節點移動至鏈表尾部,這樣一來,鏈表的頭結點必定就是最久沒有被訪問的對象,也即LruCache需要騰地方時要移除的對象。下面先一起來看下LruCache的構造函數:
//maxSize是你所需要的緩存隊列的容量 public LruCache(int maxSize) { if (maxSize <= 0) { throw new IllegalArgumentException("maxSize <= 0"); } this.maxSize = maxSize; //重點在這裏,map成員變量是一個LinkedHashMap實例,其初始容量爲0,擴容因子爲0.75 //accessOrder爲true,即節點被訪問後需要調整位置到鏈表尾部 this.map = new LinkedHashMap<K, V>(0, 0.75f, true); } //順帶看一下這個擴容方法,與其他的集合類不同,LruCache並不存在自動擴容機制,這個resize方法也是由外界調用的 //如果你需要更大或更小的緩存隊列,可以調用這個public的resize方法 public void resize(int maxSize) { if (maxSize <= 0) { throw new IllegalArgumentException("maxSize <= 0"); } synchronized (this) { //對象鎖,線程安全 this.maxSize = maxSize; } trimToSize(maxSize); //這個方法很重要,用於調整LruCache緩存隊列的容量,下面會有詳述 }
構造函數很簡單,記錄一下最大緩存容量,創建一個初始容量爲0的LinkedHashMap。下面我們要重點來看看trimToSize這個內部方法,LruCache緩存隊列容量的變化都是靠它完成的,put,get和resize等方法中都會對其進行適時的調用。
//這裏的輸入參數maxSize指的是目標容量,方法體中的size變量是當前持有的緩存對象數 public void trimToSize(int maxSize) { while (true) { //開啓循環 K key; V value; synchronized (this) { if (size < 0 || (map.isEmpty() && size != 0)) { //size的合法性檢查 throw new IllegalStateException(getClass().getName() + ".sizeOf() is reporting inconsistent results!"); } //若當前持有的緩存數量還未到設置的最大容量,則退出循環,結束 if (size <= maxSize) { break; } //這裏用到了LinkedHashMap中的eldest方法,該方法會返回LinkedHashMap維護的雙向鏈表的頭結點 //也即最久沒有被訪問過的節點。注意這個方法是被@hide註解的,且從註釋來看,是專門爲Android添加的 Map.Entry<K, V> toEvict = map.eldest(); if (toEvict == null) { break; //若取到的對象爲空,即map爲空,則沒必要繼續進行調整 } key = toEvict.getKey(); value = toEvict.getValue(); map.remove(key); //從map中刪除之前取出的最老節點 size -= safeSizeOf(key, value); //當前存儲的節點數減一 evictionCount++; //移除的緩存個數加一 } //鉤子方法,在這裏可以對被移除的節點做一些事情,在LruCache中爲空方法,由子類按需實現 entryRemoved(true, key, value, null); } }
翻看LruCache的源碼我們會發現它的put,get等操作其實都是通過LinkedHashMap類型的map變量來完成的,因此它對於緩存對象的存儲結構與LinkedHashMap完全一致,並且得益於LinkedHashMap內部維護的雙向鏈表輕鬆實現了LRU算法的思想。
最後再提一句,LruCache中所有的public方法都含有對象鎖,因此它是線程安全的。但是在高併發的情況下其性能不會很好,因爲在一條線程獲得鎖時,不論它操作的是哪一個方法,該LruCache對象中的所有其他public方法都會被鎖住而對其他線程不可用。
感謝閱讀!
版權聲明:原創不易,轉載前請留言獲得作者許可,轉載後標明作者 Troy.Tang 與 原文鏈接。