Java集合框架分析(五)LinkedHashMap分析

crazyandcoder發表於2019-11-05

LinkedHashMap簡介

public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V>
複製程式碼

繼承自 HashMap,一個有序的 Map 介面實現,這裡的有序指的是元素可以按插入順序或訪問順序排列;與 HashMap 相比,因為 LinkedHashMap 是繼承自 HashMap,因此LinkedHashMap,同樣是基於雜湊表實現。同時實現了 Serializable 和 Cloneable 介面,支援序列化和克隆。並且同樣不是執行緒安全的。區別是其內部維護了一個雙向迴圈連結串列,該連結串列是有序的,可以按元素插入順序或元素最近訪問順序 (LRU) 排列。

LinkedHashMap資料結構

LinkedHashMap 不僅像 HashMap 那樣對其進行基於雜湊表和單連結串列的 Entry 陣列+ next 連結串列的儲存方式,而且還結合了 LinkedList 的優點,為每個 Entry 節點增加了前驅和後繼,並增加了一個為 header 頭結點,構造了一個雙向迴圈連結串列。也就是說,每次 put 進來 KV,除了將其儲存到對雜湊表中的對應位置外,還要將其插入到雙向迴圈連結串列的尾部。

在這裡插入圖片描述

上圖是 LinkedHashMap 的全部資料結構,包含雜湊表和迴圈雙向連結串列,由於迴圈雙向連結串列線條太多了,不好畫,簡單的畫了一個節點(黃色圈出來的)示意一下,注意左邊的紅色箭頭引用為 Entry 節點物件的 next 引用(雜湊表中的單連結串列),綠色線條為 Entry 節點物件的 before, after 引用(迴圈雙向連結串列的前後引用);

在這裡插入圖片描述

上圖專門把迴圈雙向連結串列抽取出來,直觀一點,注意該迴圈雙向連結串列的頭部存放的是最久訪問的節點或最先插入的節點,尾部為最近訪問的或最近插入的節點,迭代器遍歷方向是從連結串列的頭部開始到連結串列尾部結束,在連結串列尾部有一個空的 header 節點,該節點不存放 key-value 內容,為 LinkedHashMap 類的成員屬性,迴圈雙向連結串列的入口;

LinkedHashMap原始碼

上面是分析 LinkedHashMap 原始碼的常規知識點,瞭解一下,才能更好的分析它的原始碼,下面我們便開始正式的進行分析工作。

屬性:

//屬性設定,序列化ID
private static final long serialVersionUID = 3801124242820219131L;
//雙向連結串列的頭部
private transient LinkedHashMapEntry<K,V> header;
//迭代的時候所用到的順序,如果為FALSE,則按照插入的時候順序
private final boolean accessOrder;
複製程式碼

這些屬性雖然簡單,但是比較重要,一開始就直接詳細說明,不大好理解,等我們分析完了程式碼再來回顧一下它們所表示的意思。我們來分析分析它的建構函式。

構造器分析

設定初始容量和載入因子的構造器

/**
  * 設定初始容量和載入因子的構造器
  */
 public LinkedHashMap(int initialCapacity, float loadFactor) {
     super(initialCapacity, loadFactor);
     accessOrder = false;
 }
複製程式碼

設定初始容量的構造器

 /**
  * 設定初始容量的構造器
  * @param  initialCapacity the initial capacity
  * @throws IllegalArgumentException if the initial capacity is negative
  */
 public LinkedHashMap(int initialCapacity) {
     super(initialCapacity);
     accessOrder = false;
 }
複製程式碼

預設的空引數的構造器,預設容量為16以及載入因子為0.75

 /**
  * 預設的空引數的構造器,預設容量為16以及載入因子為0.75
  * with the default initial capacity (16) and load factor (0.75).
  */
 public LinkedHashMap() {
     super();
     accessOrder = false;
 }
複製程式碼

使用一個現有的Map來構造LinkedHashMap

 /**
  * 使用一個現有的Map來構造LinkedHashMap
  * @param  m the map whose mappings are to be placed in this map
  * @throws NullPointerException if the specified map is null
  */
 public LinkedHashMap(Map<? extends K, ? extends V> m) {
     super(m);
     accessOrder = false;
 }
複製程式碼

設定迭代順序的構造器

 /**
  * 設定迭代順序的構造器
  * @param  initialCapacity the initial capacity
  * @param  loadFactor      the load factor
  * @param  accessOrder     the ordering mode - <tt>true</tt> for
  *         access-order, <tt>false</tt> for insertion-order
  * @throws IllegalArgumentException if the initial capacity is negative
  *         or the load factor is nonpositive
  */
 public LinkedHashMap(int initialCapacity,
                      float loadFactor,
                      boolean accessOrder) {
     super(initialCapacity, loadFactor);
     this.accessOrder = accessOrder;
 }
複製程式碼

這些構造器都比較簡單,我們稍微提及一下,若未指定初始容量 initialCapacity,則預設為使用 HashMap 的初始容量,即 16。若未指定載入因子 loadFactor,則預設為 0.75。accessOrder 預設為 faslse。這裡需要介紹一下這個布林值,它是雙向連結串列中元素排序規則的標誌位。

accessOrder 若為 false,遍歷雙向連結串列時,是按照插入順序排序。 accessOrder 若為 true,表示雙向連結串列中的元素按照訪問的先後順序排列,最先遍歷到(連結串列頭)的是最近最少使用的元素。

從構造方法中可以看出,預設都採用插入順序來維持取出鍵值對的次序。所有構造方法都是通過呼叫父類的構造方法來建立物件的。

在父類的構造器中我們可以看到它呼叫了 init 方法,即在 Map 類中的構造器中呼叫了 init 方法,我們進入檢視一下內容

@Override
    void init() {
        header = new LinkedHashMapEntry<>(-1, null, null, null);
        header.before = header.after = header;
    }
複製程式碼

這個 init 方法主要是對 header 節點進行初始化的,構成一個雙向連結串列。分析完了構造器,接著我們分析一下最常見的一個屬性 Entry。

LinkedHashMapEntry分析

//這個Entry繼承自HashMapEntry
 private static class LinkedHashMapEntry<K,V> extends HashMapEntry<K,V> {
        //雙向節點的前後引用
        // These fields comprise the doubly linked list used for iteration.
        LinkedHashMapEntry<K,V> before, after;
        
        //構造器
        LinkedHashMapEntry(int hash, K key, V value, HashMapEntry<K,V> next) {
            super(hash, key, value, next);
        }
        //移除一個節點
        private void remove() {
            before.after = after;
            after.before = before;
        }
        /**
         * 在指定的位置前面插入一個節點
         */
        private void addBefore(LinkedHashMapEntry<K,V> existingEntry) {
            after  = existingEntry;
            before = existingEntry.before;
            before.after = this;
            after.before = this;
        }
        /*
         *在HashMap的put和get方法中,會呼叫該方法,在HashMap中該方法為空
         * 在LinkedHashMap中,當按訪問順序排序時,該方法會將當前節點插入到連結串列尾部(頭結點的前一個節點),否則不做任何事
         */
        void recordAccess(HashMap<K,V> m) {
            LinkedHashMap<K,V> lm = (LinkedHashMap<K,V>)m;
            //當LinkedHashMap按訪問排序時
            if (lm.accessOrder) {
                lm.modCount++;
                //移除當前節點
                remove();
                //將當前節點插入到頭結點前面
                addBefore(lm.header);
            }
        }
        void recordRemoval(HashMap<K,V> m) {
            remove();
        }
    }
複製程式碼

接著分析最常用的方法 put。

put分析

我們在使用 LinkedHashMap 的 put 方法時,發現它呼叫的是 HashMap 的 put 方法,自己本身沒有複寫 put 方法,所以這種情況下,我們就得分兩種情況來討論 LinkedHashMap 的 put 操作了。

Key已存在的情況

在 HashMap 的 put 方法中,在發現插入的 key 已經存在時,除了做替換工作,還會呼叫recordAccess() 方法,在 HashMap 中該方法為空。LinkedHashMap 覆寫了該方法,(呼叫LinkedHashmap 覆寫的 get 方法時,也會呼叫到該方法),LinkedHashMap 並沒有覆寫 HashMap 中的 put 方法,recordAccess() 在 LinkedHashMap 中的實現如下:

//如果當前標明的accessOrder為TRUE的話,則將當前訪問的Entry放置到雙向迴圈連結串列的尾部,以標明最近訪問 ,這也是為什麼在HashMap.Entry中有一個空的 recordAccess(HashMap<K,V> m)方法的原因
void recordAccess(HashMap<K,V> m) {
            LinkedHashMap<K,V> lm = (LinkedHashMap<K,V>)m;
            //LRU演算法,將訪問的節點插入到連結串列尾部
            if (lm.accessOrder) {
                lm.modCount++;
                //刪除當前節點
                remove();
                //將當前節點插入到頭結點前面
                addBefore(lm.header);
            }
        }
        
//將當前節點插入到頭結點前面
private void addBefore(LinkedHashMapEntry<K,V> existingEntry) {
            after  = existingEntry;
            before = existingEntry.before;
            before.after = this;
            after.before = this;
        }
     
複製程式碼

key不存在的情況下

在 put 新 Entry 的過程中,如果發現 key 不存在時,除了將新 Entry 放到雜湊表的相應位置,還會呼叫 addEntry 方法,它會呼叫 creatEntry 方法,該方法將新插入的元素放到雙向連結串列的尾部,這樣做既符合插入的先後順序,又符合了訪問的先後順序。

//建立節點,插入到LinkedHashMap中,該方法覆蓋HashMap的addEntry方法
void addEntry(int hash, K key, V value, int bucketIndex) {
        //注意頭結點的下個節點即header.after,存放於連結串列頭部,是最不經常訪問或第一個插入的節點,
        LinkedHashMapEntry<K,V> eldest = header.after;
        //如果有必要,則刪除掉該近期最少使用的節點
        if (eldest != header) {
            boolean removeEldest;
            size++;
            try {
                //removeEldestEntry方法的實現,這裡預設為false
                removeEldest = removeEldestEntry(eldest);
            } finally {
                size--;
            }
            if (removeEldest) {
                removeEntryForKey(eldest.key);
            }
        }
        //呼叫HashMap的addEntry方法
        super.addEntry(hash, key, value, bucketIndex);
    }
    
//建立節點,並將該節點插入到連結串列尾部
 void createEntry(int hash, K key, V value, int bucketIndex) {
        HashMapEntry<K,V> old = table[bucketIndex];
        LinkedHashMapEntry<K,V> e = new LinkedHashMapEntry<>(hash, key, value, old);
        table[bucketIndex] = e;
        //並將其移到雙向連結串列的尾部  
        e.addBefore(header);
        size++;
    }
複製程式碼

在上面的 addEntry 方法中有一個 removeEldestEntry 方法,這個方法可以被覆寫,比如可以將該方法覆寫為如果設定的記憶體已滿,則返回 true,這樣就可以將最近最少使用的節點(header 後的節點)刪除掉。

為什麼這個方法始終返回 false?

結合上面的 addEntry(int hash,K key,V value,int bucketIndex) 方法,這樣設計可以使LinkedHashMap 成為一個正常的 Map,不會去移除“最老”的節點。 為什麼不在程式碼中直接去除這部分邏輯而是設計成這樣呢?這為開發者提供了方便,若希望將 Map 當做 Cache 來使用,並且限制大小,只需繼承 LinkedHashMap 並重寫 removeEldestEntry(Entry<K,V> eldest) 方法,像這樣:

private static final int MAX_ENTRIES = 100;
protected boolean removeEldestEntry(Map.Entry eldest) {
      return size() > MAX_ENTRIES;
}
複製程式碼

總結一下 只要是 put 進來的新元素,不管 accessOrder 標誌位是什麼,均將新元素放到雙連結串列尾部,並且可以在需要實現Lru演算法時時覆寫 removeEldestEntry 方法,剔除最近最少使用的節點。

get分析

//覆寫HashMap中的get方法,通過getEntry方法獲取Entry物件。  
//注意這裡的recordAccess方法,  
//如果連結串列中元素的排序規則是按照插入的先後順序排序的話,該方法什麼也不做,  
//如果連結串列中元素的排序規則是按照訪問的先後順序排序的話,則將e移到連結串列的末尾處。
public V get(Object key) {
        LinkedHashMapEntry<K,V> e = (LinkedHashMapEntry<K,V>)getEntry(key);
        if (e == null)
            return null;
        e.recordAccess(this);
        return e.value;
    }
    
void recordAccess(HashMap<K,V> m) {
            LinkedHashMap<K,V> lm = (LinkedHashMap<K,V>)m;
            if (lm.accessOrder) {
                lm.modCount++;
                remove();
                addBefore(lm.header);
            }
        }
複製程式碼

get(Object key) 方法通過 HashMap 的 getEntry(Object key) 方法獲取節點,並返回該節點的 value 值,獲取節點如果為 null 則返回 null。通過 key 獲取 value,與 HashMap 的區別是:當 LinkedHashMap 按訪問順序排序的時候,會將訪問的當前節點移到連結串列尾部(頭結點的前一個節點)。

到這裡我們來具體總結一下 accessOrder 標誌位的作用原理。

1、accessOrder 不起作用

對於 put 操作時,不管 accessOrder 標誌位是什麼,我們都將節點插入到連結串列的尾部,但是呢,可以在需要實現 Lru 演算法時時覆寫 removeEldestEntry 方法,剔除最近最少使用的節點。

2、accessOrder 起作用

當我們進行 put 操作是,如果 key 不等於 null 的話,會呼叫 recordAccess 方法,在該方法中 accessOrder 就得起作用了,如果 accessOrder 為 fasle 時,什麼也不做,也就是說當我們放入已經存在 Key 的鍵值對,它在雙連結串列中的位置是不會變的。accessOrder 設定為 true 時, put 操作會將相關元素放置到雙連結串列的尾部。

另外一種情況就是 get 操作,get 操作我們同時也會呼叫 recordAccess 方法,對於這個方法,我們需要判斷 accessOrder 的狀態,如果 accessOrder 為 fasle 時,什麼也不做,也就是說當我們放入已經存在 Key 的鍵值對,它在雙連結串列中的位置是不會變的。accessOrder 設定為 true 時,put 操作會將相關元素放置到雙連結串列的尾部。在快取的角度來看,這就是所謂的“髒資料”,即最近被訪問過的資料,因此在需要清理記憶體時(新增進新元素時),就可以將雙連結串列頭節點(空節點)後面那個節點剔除。

不常用方法

到此為止,基本上 LinkedHashMap 比較重要的方法就分析過了,還剩一些比較不重要的方法,我們一次性給它注視下,稍微看下。

//
@Override
void transfer(HashMapEntry[] newTable) {
    int newCapacity = newTable.length;
    for (LinkedHashMapEntry<K,V> e = header.after; e != header; e = e.after) {
        int index = indexFor(e.hash, newCapacity);
        e.next = newTable[index];
        newTable[index] = e;
    }
}
複製程式碼

transfer(HashMap.Entry[] newTable) 方法和 init() 方法一樣也在 HashTable 中被呼叫。transfer(HashMap.Entry[] newTable) 方法在 HashMap 呼叫 resize(int newCapacity) 方法的時候被呼叫。根據連結串列節點 e 的雜湊值計算 e 在新容量的 table 陣列中的索引,並將 e 插入到計算出的索引所引用的連結串列中。

public boolean containsValue(Object value) {
        // Overridden to take advantage of faster iterator
        if (value==null) {
            for (LinkedHashMapEntry e = header.after; e != header; e = e.after)
                if (e.value==null)
                    return true;
        } else {
            for (LinkedHashMapEntry e = header.after; e != header; e = e.after)
                if (value.equals(e.value))
                    return true;
        }
        return false;
    }
複製程式碼

重寫父類的 containsValue(Object value) 方法,直接通過 header 遍歷連結串列判斷是否有值和 value 相等,利用雙向迴圈連結串列的特點進行查詢,少了對陣列的外層 for 迴圈 ,而不用查詢 table 陣列。

public void clear() {
       super.clear();
       header.before = header.after = header;
   }
複製程式碼

clear() 方法先呼叫父類的方法 clear() 方法,之後將連結串列的 header 節點的 before 和 after 引用都指向 header 自身,即 header 節點就是一個雙向迴圈連結串列。這樣就無法訪問到原連結串列中剩餘的其他節點,他們都將被 GC 回收。清空 HashMap 的同時,將雙向連結串列還原為只有頭結點的空連結串列。

以上便是 LinkedHashMap 原始碼主要方法的分析,到這裡就要結束了,我們來總結一下關於 HashMap 和 LinkedHashMap 的相關東西。

總結

對於 LinkedHashMap,我們總結了以下幾點內容:

1、由於 LinkedHashMap 繼承自 HashMap,所以它不僅像 HashMap 那樣對其進行基於雜湊表和單連結串列的 Entry 陣列+ next 連結串列的儲存方式,而且還結合了 LinkedList 的優點,為每個 Entry 節點增加了前驅和後繼,並增加了一個為 header 頭結點,構造了一個雙向迴圈連結串列。(多一個以 header 為頭結點的雙向迴圈連結串列,也就是說,每次 put 進來 KV,除了將其儲存到對雜湊表中的對應位置外,還要將其插入到雙向迴圈連結串列的尾部。)

2、LinkedHashMap 的屬性比 HashMap 多了一個 accessOrder 屬性。當它 false 時,表示雙向連結串列中的元素按照 Entry 插入 LinkedHashMap 到中的先後順序排序,即每次 put 到 LinkedHashMap 中的 Entry 都放在雙向連結串列的尾部,這樣遍歷雙向連結串列時,Entry 的輸出順序便和插入的順序一致,這也是預設的雙向連結串列的儲存順序;當它為 true 時,表示雙向連結串列中的元素按照訪問的先後順序排列,可以看到,雖然 Entry 插入連結串列的順序依然是按照其 put 到 LinkedHashMap 中的順序,但 put 和 get 方法均有呼叫 recordAccess 方法(put 方法在 key 相同,覆蓋原有的 Entry 的情況下呼叫 recordAccess 方法), 該方法判斷 accessOrder 是否為 true,如果是,則將當前訪問的 Entry(put 進來的 Entry 或 get 出來的 Entry)移到雙向連結串列的尾部(key 不相同時,put 新 Entry 時,會呼叫 addEntry,它會呼叫 creatEntry,該方法同樣將新插入的元素放入到雙向連結串列的尾部,既符合插入的先後順序,又符合訪問的先後順序,因為這時該 Entry 也被訪問了),否則,什麼也不做。

3、建構函式中有設定 accessOrder 的方法,如果我們需要實現 LRU 演算法時,就需要將 accessOrder 的值設定為 TRUE。

4、在 HashMap 的 put 方法中,如果 key 不為 null 時且雜湊表中已經在存在時,迴圈遍歷 table[i] 中的連結串列時會呼叫 recordAccess 方法,而在 HashMap 中這個方法是個空方法,在LinkedHashMap中則實現了該方法,該方法會判斷 accessOrder 是否為 true,如果為 true,它會將當前訪問的 Entry(在這裡指 put 進來的 Entry)移動到雙向迴圈連結串列的尾部,從而實現雙向連結串列中的元素按照訪問順序來排序(最近訪問的 Entry 放到連結串列的最後,這樣多次下來,前面就是最近沒有被訪問的元素,在實現 LRU 演算法時,當雙向連結串列中的節點數達到最大值時,將前面的元素刪去即可,因為前面的元素是最近最少使用的),否則什麼也不做。

關於作者

專注於 Android 開發多年,喜歡寫 blog 記錄總結學習經驗,blog 同步更新於本人的公眾號,歡迎大家關注,一起交流學習~

在這裡插入圖片描述

相關文章