LinkedHashMap詳解及其在LruCache中的應用

在之前的文章紅黑樹在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 與 原文鏈接。

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