Java集合系列之LinkedHashMap

我又不是架構師發表於2018-01-03

Java集合系列之LinkedHashMap

Hello,大家好,前面給大家講了HashMap,LinkedList,知道了HashMap為陣列+單向連結串列,LinkedList為雙向連結串列實現的。今天給大家介紹一個(HashMap+"LinkedList")的集合,LinkedHashMap,其中HashMap用於儲存資料,"LinkedList"用於儲存資料順序。OK,廢話少說,老套路,文章結構:

  1. LinkedHashMap和HashMap區別
  2. LinkedHashMap底層實現
  3. 利用LinkedHashMap實現LRU快取

1. LinkedHashMap和HashMap區別

大多數情況下,只要不涉及執行緒安全問題,Map基本都可以使用HashMap,不過HashMap有一個問題,就是迭代HashMap的順序並不是HashMap放置的順序,也就是無序。HashMap的這一缺點往往會帶來困擾,因為有些場景,我們期待一個有序的Map.這就是我們的LinkedHashMap,看個小Demo:

public static void main(String[] args) {
    Map<String, String> map = new LinkedHashMap<String, String>();
    map.put("apple", "蘋果");
    map.put("watermelon", "西瓜");
    map.put("banana", "香蕉");
    map.put("peach", "桃子");

    Iterator iter = map.entrySet().iterator();
    while (iter.hasNext()) {
        Map.Entry entry = (Map.Entry) iter.next();
        System.out.println(entry.getKey() + "=" + entry.getValue());
    }
}
複製程式碼
輸出為:
apple=蘋果
watermelon=西瓜
banana=香蕉
peach=桃子
複製程式碼

可以看到,在使用上,LinkedHashMap和HashMap的區別就是LinkedHashMap是有序的。 上面這個例子是根據插入順序排序,此外,LinkedHashMap還有一個引數決定是否在此基礎上再根據訪問順序(get,put)排序,記住,是在插入順序的基礎上再排序,後面看了原始碼就知道為什麼了。看下例子:

public static void main(String[] args) {
    Map<String, String> map = new LinkedHashMap<String, String>(16,0.75f,true);
    map.put("apple", "蘋果");
    map.put("watermelon", "西瓜");
    map.put("banana", "香蕉");
    map.put("peach", "桃子");

    map.get("banana");
    map.get("apple");

    Iterator iter = map.entrySet().iterator();
    while (iter.hasNext()) {
        Map.Entry entry = (Map.Entry) iter.next();
        System.out.println(entry.getKey() + "=" + entry.getValue());
    }
}
複製程式碼
輸出為:
watermelon=西瓜
peach=桃子
banana=香蕉
apple=蘋果
複製程式碼

可以看到香蕉和蘋果在原來排序的基礎上又排後了。

2. LinkedHashMap底層實現

我先說結論,然後再慢慢跟程式碼。

  • LinkedHashMap繼承自HashMap,它的新增(put)和獲取(get)方法都是複用父類的HashMap的程式碼,只是自己重寫了put給get內部的某些介面來搞事情,這個特性在C++中叫鉤子技術,在Java裡面大家喜歡叫多型,其實多型這個詞並不能很好的形容這種現象。
  • LinkedHashMap的資料儲存和HashMap的結構一樣採用(陣列+單向連結串列)的形式,只是在每次節點Entry中增加了用於維護順序的before和after變數維護了一個雙向連結串列來儲存LinkedHashMap的儲存順序,當呼叫迭代器的時候不再使用HashMap的的迭代器,而是自己寫迭代器來遍歷這個雙向連結串列即可。
  • HashMap和LinkedHashMap內部邏輯圖如下:
    Java集合系列之LinkedHashMap
    Java集合系列之LinkedHashMap

好了,大家肯定會覺得很神奇,如圖所示,本來HashMap的資料是0-7這樣的無須的,而LinkedHashMap卻把它變成了如圖所示的1.6.5.3.。。2這樣的有順序了。到底是如何做到的了?其實說白了,就一句話,鉤子技術,在put和get的時候維護好了這個雙向連結串列,遍歷的時候就有序了。好了,一步一步的跟。 先看一下LinkedHashMap中的Entry(也就是每個元素):

private static class Entry<K,V> extends HashMap.Entry<K,V> {
    // These fields comprise the doubly linked list used for iteration.
    Entry<K,V> before, after;

Entry(int hash, K key, V value, HashMap.Entry<K,V> next) {
        super(hash, key, value, next);
    }
    ...
}
複製程式碼

可以看到繼承自HashMap的Entry,並且多了兩個指標before和after,這兩個指標說白了,就是為了維護雙向連結串列新加的兩個指標。 列一下新Entry的所有成員變數吧:

  • K key
  • V value
  • Entry<K, V> next
  • int hash
  • Entry<K, V> before
  • Entry<K, V> after

其中前面四個,是從HashMap.Entry中繼承過來的;後面兩個,是是LinkedHashMap獨有的。不要搞錯了next和before、After,next是用於維護HashMap指定table位置上連線的Entry的順序的,before、After是用於維護Entry插入的先後順序的(為了維護雙向連結串列)。

2.1 初始化

 1 public LinkedHashMap() {
 2 super();
 3     accessOrder = false;
 4 }
複製程式碼
1 public HashMap() {
2     this.loadFactor = DEFAULT_LOAD_FACTOR;
3     threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
4     table = new Entry[DEFAULT_INITIAL_CAPACITY];
5     init();
6 }
複製程式碼
 1 void init() {
 2     header = new Entry<K,V>(-1, null, null, null);
 3     header.before = header.after = header;
 4 }
複製程式碼

這裡出現了第一個鉤子技術,儘管init()方法定義在HashMap中,但是由於LinkedHashMap重寫了init方法,所以根據多型的語法,會呼叫LinkedHashMap的init方法,該方法初始化了一個Header,這個Header就是雙向連結串列的連結串列頭..

2.2 LinkedHashMap新增元素

HashMap中的put方法:

 1 public V put(K key, V value) {
 2     if (key == null)
 3         return putForNullKey(value);
 4     int hash = hash(key.hashCode());
 5     int i = indexFor(hash, table.length);
 6     for (Entry<K,V> e = table[i]; e != null; e = e.next) {
 7         Object k;
 8         if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
 9             V oldValue = e.value;
10             e.value = value;
11             e.recordAccess(this);
12             return oldValue;
13         }
14     }
15 
16     modCount++;
17     addEntry(hash, key, value, i);
18     return null;
19 }
複製程式碼

LinkedHashMap中的addEntry(又是一個鉤子技術):

 1 void addEntry(int hash, K key, V value, int bucketIndex) {
 2     createEntry(hash, key, value, bucketIndex);
 3 
 4     // Remove eldest entry if instructed, else grow capacity if appropriate
 5     Entry<K,V> eldest = header.after;
 6     if (removeEldestEntry(eldest)) {
 7         removeEntryForKey(eldest.key);
 8     } else {
 9         if (size >= threshold)
10             resize(2 * table.length);
11     }
12 }
複製程式碼
1 void createEntry(int hash, K key, V value, int bucketIndex) {
2     HashMap.Entry<K,V> old = table[bucketIndex];
3     Entry<K,V> e = new Entry<K,V>(hash, key, value, old);
4     table[bucketIndex] = e;
5     e.addBefore(header);
6     size++;
7 }
複製程式碼
private void addBefore(Entry<K,V> existingEntry) {
    after  = existingEntry;
    before = existingEntry.before;
    before.after = this;
    after.before = this;
}
複製程式碼

好了,addEntry先把資料加到HashMap中的結構中(陣列+單向連結串列),然後呼叫addBefore,這個我就不和大家畫圖了,其實就是挪動自己和Header的Before與After成員變數指標把自己加到雙向連結串列的尾巴上。 同樣的,無論put多少次,都會把當前元素加到佇列尾巴上。這下大家知道怎麼維護這個雙向佇列的了吧。

上面說了LinkedHashMap在新增資料的時候自動維護了雙向列表,這要還要提一下的是LinkedHashMap的另外一個屬性,根據查詢順序排序,說白了,就是在get的時候或者put(更新時)把元素丟到雙向佇列的尾巴上。這樣不就排序了嗎?這裡涉及到LinkedHashMap的另外一個構造方法:

public LinkedHashMap(int initialCapacity,
         float loadFactor,
                     boolean accessOrder) {
    super(initialCapacity, loadFactor);
    this.accessOrder = accessOrder;
}
複製程式碼

第三個引數,accessOrder為是否開啟查詢排序功能的開關,預設為False。如果想開啟那麼必須呼叫這個構造方法。 然後看下get和put(更新操作)時是如何維護這個佇列的。

public V get(Object key) {
    Entry<K,V> e = (Entry<K,V>)getEntry(key);
    if (e == null)
        return null;
    e.recordAccess(this);
    return e.value;
}
複製程式碼

此外,在put的時候,程式碼11行(見上面的程式碼),也是呼叫了e.recordAccess(this);我們來看下這個方法:

void recordAccess(HashMap<K,V> m) {
    LinkedHashMap<K,V> lm = (LinkedHashMap<K,V>)m;
    if (lm.accessOrder) {
        lm.modCount++;
        remove();
        addBefore(lm.header);
    }
}
複製程式碼
private void remove() {
    before.after = after;
    after.before = before;
}
複製程式碼
private void addBefore(Entry<K,V> existingEntry) {
    after  = existingEntry;
    before = existingEntry.before;
    before.after = this;
    after.before = this;
}
複製程式碼

看到每次recordAccess的時候做了兩件事情:

  1. 把待移動的Entry的前後Entry相連
  2. 把待移動的Entry移動到尾部

當然,這一切都是基於accessOrder=true的情況下。 假設現在我們開啟了accessOrder,然後呼叫get("111");看下是如何操作的:

Java集合系列之LinkedHashMap

3. 利用LinkedHashMap實現LRU快取

LRU即Least Recently Used,最近最少使用,也就是說,當快取滿了,會優先淘汰那些最近最不常訪問的資料。我們的LinkedHashMap正好滿足這個特性,為什麼呢?當我們開啟accessOrder為true時,最新訪問(get或者put(更新操作))的資料會被丟到佇列的尾巴處,那麼雙向佇列的頭就是最不經常使用的資料了。比如:

如果有1 2 3這3個Entry,那麼訪問了1,就把1移到尾部去,即2 3 1。每次訪問都把訪問的那個資料移到雙向佇列的尾部去,那麼每次要淘汰資料的時候,雙向佇列最頭的那個資料不就是最不常訪問的那個資料了嗎?換句話說,雙向連結串列最頭的那個資料就是要淘汰的資料。

此外,LinkedHashMap還提供了一個方法,這個方法就是為了我們實現LRU快取而提供的,removeEldestEntry(Map.Entry<K,V> eldest) 方法。該方法可以提供在每次新增新條目時移除最舊條目的實現程式,預設返回 false

來,給大家一個簡陋的LRU快取:

public class LRUCache extends LinkedHashMap
{
    public LRUCache(int maxSize)
    {
        super(maxSize, 0.75F, true);
        maxElements = maxSize;
    }

    protected boolean removeEldestEntry(java.util.Map.Entry eldest)
    {
        //邏輯很簡單,當大小超出了Map的容量,就移除掉雙向佇列頭部的元素,給其他元素騰出點地來。
        return size() > maxElements;
    }

    private static final long serialVersionUID = 1L;
    protected int maxElements;
}
複製程式碼

是不是很簡單。。

結語

其實 LinkedHashMap 幾乎和 HashMap 一樣:從技術上來說,不同的是它定義了一個 Entry<K,V> header,這個 header 不是放在 Table 裡,它是額外獨立出來的。LinkedHashMap 通過繼承 hashMap 中的 Entry<K,V>,並新增兩個屬性 Entry<K,V> before,after,和 header 結合起來組成一個雙向連結串列,來實現按插入順序或訪問順序排序。如何維護這個雙向連結串列了,就是在get和put的時候用了鉤子技術(多型)呼叫LinkedHashMap重寫的方法來維護這個雙向連結串列,然後迭代的時候直接迭代這個雙向連結串列即可,好了LinkedHashMap算是給大家分享完了,Over,Have a good day .

相關文章