通過分析LinkedHashMap瞭解LRU

cmazxiaoma發表於2018-09-03

我們都知道LRU是最近最少使用,根據資料的歷史訪問記錄來進行淘汰資料的。其核心思想是如果資料最近被訪問過,那麼將來訪問的機率也更高。在這裡提一下,Redis快取和MyBatis二級快取更新策略演算法中就有LRU。畫外音:LFU是頻率最少使用,根據資料歷史訪問的頻率來進行淘汰資料。其核心思想是如果資料過去被訪問多次,那麼將來訪問的機率也更高。

圖文無關.png

分析LinkedHashMap中的LRU

其實一提到LRU,我們就應該想到LinkedHashMap。LRU是通過雙向連結串列來實現的。當某個位置的資料被命中,通過調整該資料的位置,將其移動至尾部。新插入的元素也是直接放入尾部(尾插法)。這樣一來,最近被命中的元素就向尾部移動,那麼連結串列的頭部就是最近最少使用的元素所在的位置。

HashMap的afterNodeAccess()、afterNodeInsertion()、afterNodeRemoval()方法都是空實現,留著LinkedHashMap去重寫。LinkedHashMap靠重寫這3個方法就完成了核心功能的實現。不得不感嘆,HashMap和LinkedHashMap設計之妙。

    // Callbacks to allow LinkedHashMap post-actions
    void afterNodeAccess(Node<K,V> p) { }
    void afterNodeInsertion(boolean evict) { }
    void afterNodeRemoval(Node<K,V> p) { }
複製程式碼
    void afterNodeRemoval(Node<K,V> e) { // unlink
        LinkedHashMap.Entry<K,V> p =
            (LinkedHashMap.Entry<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;
    }

    void afterNodeInsertion(boolean evict) { // possibly remove eldest
        LinkedHashMap.Entry<K,V> first;
        if (evict && (first = head) != null && removeEldestEntry(first)) {
            K key = first.key;
            removeNode(hash(key), key, null, false, true);
        }
    }

    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;
            p.after = null;
            if (b == null)
                head = a;
            else
                b.after = a;
            if (a != null)
                a.before = b;
            else
                last = b;
            if (last == null)
                head = p;
            else {
                p.before = last;
                last.after = p;
            }
            tail = p;
            ++modCount;
        }
    }
複製程式碼

在LinkedHashMap的get()方法中,我們每次獲取元素的時候,都要呼叫afterNodeAccess(e)都要將元素移動到尾部。話外音:accessOrder為true,是基於訪問排序,accessOrder為基於插入排序。我們想要LinkedHashMap實現LRU功能,accessOrder必須為true。如果accessOrder為false,那就是FIFO了。

    public V get(Object key) {
        Node<K,V> e;
        if ((e = getNode(hash(key), key)) == null)
            return null;
        if (accessOrder)
            afterNodeAccess(e);
        return e.value;
    }
複製程式碼

我們可以看到插入資料的時候,如果removeEldestEntry(first)返回true,按照LRU策略,那麼會刪除頭節點。

    void afterNodeInsertion(boolean evict) { // possibly remove eldest
        LinkedHashMap.Entry<K,V> first;
        if (evict && (first = head) != null && removeEldestEntry(first)) {
            K key = first.key;
            removeNode(hash(key), key, null, false, true);
        }
    }

    protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
        return false;
    }
複製程式碼

LinkedHashMap大體的LRU架子都為我們搭好了。那我們怎麼去基於LinkedHashMap實現LRU呢。先別慌,我們先看看MyBatis中的LruCache是怎麼實現的。

public class LruCache implements Cache {

  private final Cache delegate;
  private Map<Object, Object> keyMap;
  private Object eldestKey;

  public LruCache(Cache delegate) {
    this.delegate = delegate;
    setSize(1024);
  }

  @Override
  public String getId() {
    return delegate.getId();
  }

  @Override
  public int getSize() {
    return delegate.getSize();
  }

  public void setSize(final int size) {
    keyMap = new LinkedHashMap<Object, Object>(size, .75F, true) {
      private static final long serialVersionUID = 4267176411845948333L;

      @Override
      protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) {
        boolean tooBig = size() > size;
        if (tooBig) {
          eldestKey = eldest.getKey();
        }
        return tooBig;
      }
    };
  }

  @Override
  public void putObject(Object key, Object value) {
    delegate.putObject(key, value);
    cycleKeyList(key);
  }

  @Override
  public Object getObject(Object key) {
    keyMap.get(key); //touch
    return delegate.getObject(key);
  }

  @Override
  public Object removeObject(Object key) {
    return delegate.removeObject(key);
  }

  @Override
  public void clear() {
    delegate.clear();
    keyMap.clear();
  }

  @Override
  public ReadWriteLock getReadWriteLock() {
    return null;
  }

  private void cycleKeyList(Object key) {
    keyMap.put(key, key);
    if (eldestKey != null) {
      delegate.removeObject(eldestKey);
      eldestKey = null;
    }
  }

}
複製程式碼

我們可以照葫蘆畫瓢,來手寫LRU。其實我們只要把accessOrder設定為true,重寫removeEldestEntry(eldest)即可。我們在removeEldestEntry(eldest)加上什麼時候執行LRU操作的邏輯,比如map裡面的元素數量超過指定的大小,開始刪除最近最少使用的元素,為後續新增的元素騰出位置來。

我們來看看自己手寫的LRU例子

1.首先往map裡面新增了5個元素,使用的是尾插法,順序應該是1,2,3,4,5。

2.呼叫了map.put("6", "6"),通過尾插法插入元素6,此時的順序是1,2,3,4,5,6,然後 LinkedHashMap呼叫removeEldestEntry(),map裡面的元素數量是6,大於指定的size,返回true。LinkedHashMap會刪除頭節點的元素,此時順序應該是2,3,4,5,6。

3.呼叫了map.get("2"),元素2被命中,元素2需要移動到連結串列尾部,此時的順序是3,4,5,6,2

4.呼叫了map.put("7", "7"),和步驟2一樣的操作。此時的順序是4,5,6,2,7

5.呼叫了map.get("4"),和步驟3一樣的操作。此時的順序是5,6,2,7,4

    @Test
    public void test1() {
        int size = 5;

        /**
         * false, 基於插入排序
         * true, 基於訪問排序
         */
        Map<String, String> map = new LinkedHashMap<String, String>(size, .75F,
                false) {

            @Override
            protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
                boolean tooBig = size() > size;

                if (tooBig) {
                    System.out.println("最近最少使用的key=" + eldest.getKey());
                }
                return tooBig;
            }
        };

        map.put("1", "1");
        map.put("2", "2");
        map.put("3", "3");
        map.put("4", "4");
        map.put("5", "5");
        System.out.println(map.toString());

        map.put("6", "6");
        map.get("2");
        map.put("7", "7");
        map.get("4");

        System.out.println(map.toString());
    }
複製程式碼

HashMap來實現LRU

上面我們是用LinkedHashMap裡面搭好的LRU架子來實現LRU的。現在我們脫離LinkedHashMap這個容器,手動去維護連結串列中元素的關係,也就是仿照LinkedHashMap裡面的LRU實現寫出屬於自己的afterNodeRemoval()、afterNodeInsertion()、afterNodeAccess()方法。其實也是照著葫蘆畫瓢,只不過這一次難度升了幾顆星。

話外音:HashMap的查詢、插入、修改、刪除平均時間複雜度都是O(1)。最壞的情況是所有的key都雜湊到一個Entry中,時間複雜度會退化成O(N)。這就是為什麼Java8的HashMap引入了紅黑樹的原因。當Entry中的連結串列長度超過8,連結串列會進化成紅黑樹。紅黑樹是一個自平衡二叉查詢樹,它的查詢/插入/修改/刪除的平均時間複雜度為O(log(N))。

尾插法

1.首先我們採用的是尾插法,也就是新插入的元素或者命中的元素往尾部移動,頭部的元素即是最近最少使用。

public class MyLru01<K, V> {

    private int maxSize;
    private Map<K, Entry<K, V>> map;
    private Entry head;
    private Entry tail;

    public MyLru01(int maxSize) {
        this.maxSize = maxSize;
        map = new HashMap<>();
    }

    public void put(K key, V value) {
        Entry<K, V> entry = new Entry<>();
        entry.key = key;
        entry.value = value;

        afterEntryInsertion(entry);
        map.put(key, entry);

        if (map.size() > maxSize) {
            map.remove(head.key);
            afterEntryRemoval(head);
        }
    }

    private void afterEntryInsertion(Entry<K, V> entry) {
        if (entry != null) {
            if (head == null) {
                head = entry;
                tail = head;
                return;
            }

            if (tail != entry) {
                Entry<K, V> pred = tail;
                entry.before = pred;
                tail = entry;
                pred.after = entry;
            }
        }
    }

    private void afterEntryAccess(Entry<K, V> entry) {
        Entry<K, V> last;

        if ((last = tail) != entry) {
            Entry<K, V> p = entry, b = p.before, a = p .after;
            p.before = p.after = null;

            if (b == null) {
                head = a;
            } else {
                b.after = a;
            }

            if (a == null) {
                last = b;
            } else {
                a.before = b;
            }

            if (last == null) {
                head = p;
            } else {
                p.before = last;
                last.after = p;
            }

            tail = p;
        }
    }

    private Entry<K, V> getEntry(K key) {
        return map.get(key);
    }

    public V get(K key) {
        Entry<K, V> entry = this.getEntry(key);

        if (entry == null) {
            return null;
        }
        afterEntryAccess(entry);
        return entry.value;
    }

    public void remove(K key) {
        Entry<K, V> entry = this.getEntry(key);
        afterEntryRemoval(entry);
    }

    private void afterEntryRemoval(Entry<K, V> entry) {
        if (entry != null) {
            Entry<K, V> p = entry, 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;
            }
        }
    }

    @Override
    public String toString() {
        StringBuffer sb = new StringBuffer();
        Entry<K, V> entry = head;

        while (entry != null) {
            sb.append(String.format("%s:%s", entry.key, entry.value));
            sb.append(" ");
            entry = entry.after;
        }

        return sb.toString();
    }

    static final class Entry<K, V> {
        private Entry before;
        private Entry after;
        private K key;
        private V value;
    }

    public static void main(String[] args) {
        MyLru01<String, String> map = new MyLru01<>(5);
        map.put("1", "1");
        map.put("2", "2");
        map.put("3", "3");
        map.put("4", "4");
        map.put("5", "5");
        System.out.println(map.toString());

        map.put("6", "6");
        map.get("2");
        map.put("7", "7");
        map.get("4");

        System.out.println(map.toString());
    }
}
複製程式碼

2.執行結果也是5,6,2,7,4,與之前用LinkedHashMap實現的LRU執行結果一致。後面會分析寫程式碼的思路。

image.png

3.定義Entry中的雙向連結串列結構。

    static final class Entry<K, V> {
        private Entry before;
        private Entry after;
        private K key;
        private V value;
    }
複製程式碼

4.把key,value包裝成Entry節點。呼叫afterEntryInsertion(entry)方法,把Entry節點移動到雙向連結串列尾部。然後將key,Entry放入到HashMap中。如果map中元素的數量大於maxSize,則刪除雙向連結串列中的頭結點(頭結點所在的元素就是最近最少使用的元素)。首先在map中刪除head.key對應著的元素,然後呼叫 afterEntryRemoval(head),在雙向連結串列中刪除頭節點。

    public void put(K key, V value) {
        Entry<K, V> entry = new Entry<>();
        entry.key = key;
        entry.value = value;

        afterEntryInsertion(entry);
        map.put(key, entry);

        if (map.size() > maxSize) {
            map.remove(head.key);
            afterEntryRemoval(head);
        }
    }
複製程式碼

5.如果雙向連結串列head節點為空的話,證明雙向連結串列為空。那麼我們把新插入的元素置為head節點和tail節點。否則我們把插入當前節點至尾部。這裡是怎麼插入呢?tail節點之前是尾部節點,現在突然要插入一個節點(entry節點)。那麼tail節點再也不能佔據尾部的位置,我們把置它為pre節點。pre節點也就是新的tail節點(也就是entry節點)的前一個節點。entry的先驅節點指向pre,pre節點的後繼節點指向entry,這樣就完成了尾插入。

    private void afterEntryInsertion(Entry<K, V> entry) {
        if (entry != null) {
            if (head == null) {
                head = entry;
                tail = head;
                return;
            }

            if (tail != entry) {
                Entry<K, V> pred = tail;
                entry.before = pred;
                tail = entry;
                pred.after = entry;
            }
        }
    }
複製程式碼

6.我們是怎麼在雙向連結串列中刪除一個節點呢?現在要刪除的節點是entry節點。我們首先獲取它的先驅節點b和後繼節點a。如果b等於null,那麼刪除entry節點後,head節點應該為a。如果b不等於null,b的後繼節點應該指向a。同樣如果a等於null,那麼刪除entry節點後,tail節點應該為b。如果a不等於null,a的先驅節點應該指向b。這樣就完成刪除操作,如果還沒明白的話,自己拿個筆畫張圖就差不多了。

    public void afterEntryRemoval(Entry<K, V> entry) {
        if (entry != null) {
            Entry<K, V> p = entry, 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;
            }
        }
    }
複製程式碼

7.我們通過get()方法命中了entry節點。那麼我們怎麼把entry節點移動至雙向連結串列中的尾部呢?如果當前節點已位於尾部,那麼我們什麼也不做。如果當前節點不在尾部,和上面操作一樣首先獲取它的先驅節點b和後繼節點a。然後把先驅節點和後繼節點都置為null,方便後續操作。

如果b節點等於null,那麼移動entry節點至尾部後,head節點應該為a節點。

如果b節點不等於null,那麼b的後繼節點應該指向a。

如果a節點等於null,那麼新的尾部節點的前一個節點應該為b。

如果a節點不等於null,那麼a的先驅節點應該指向b。

如果last節點(也就是新尾部節點的前一個節點)等於null的話,說明head節點應該為p節點。

如果last節點不等於null的話,我們把p的先驅節點指向last,last的後繼節點指向p。最後新的尾部節點就是p。

過程有點繞,如果不明白的話,可以動手畫圖。

    private void afterEntryAccess(Entry<K, V> entry) {
        Entry<K, V> last;

        if ((last = tail) != entry) {
            Entry<K, V> p = entry, b = p.before, a = p .after;
            p.before = p.after = null;

            if (b == null) {
                head = a;
            } else {
                b.after = a;
            }

            if (a == null) {
                last = b;
            } else {
                a.before = b;
            }

            if (last == null) {
                head = p;
            } else {
                p.before = last;
                last.after = p;
            }

            tail = p;
        }
    }

複製程式碼
頭插法

頭插法其實和尾插法大同小異,區別就是新插入的節點或者是命中的節點都移動至雙向連結串列的頭部,那麼雙向連結串列的尾部節點中所在的元素就是最近最少使用的元素。

頭插法.png

頭插法的程式碼實現和尾插法基本一致,只是afterEntryInsertion()和afterEntryAccess()方法有所改動。改動的地方其實可以用上面的文字概括了!

再來說說下面例子中元素位置變化的過程吧 1.因為頭插入法,5個元素插入完畢後。順序應該是5,4,3,2,1

2.執行map.put("6", "6")後,把元素6插入到頭部,並刪除掉尾部元素1,順序是6,5,4,3,2。

3.執行map.get("2")後,將元素2移動到頭部,順序是2,6,5,4,3

4.執行map.put("7", "7")後,把元素7插入到頭部,並刪除掉尾部元素,3,順序是7,2,6,5,4

5.執行map.get("4")後,把元素4移動到頭部,最後的順序是4,7,2,6,5

image.png

/**
 * @author cmazxiaoma
 * @version V1.0
 * @Description: TODO
 * @date 2018/9/3 9:19
 */
public class MyLru02<K, V> {

    private int maxSize;
    private Map<K, Entry<K, V>> map;
    private Entry<K, V> head;
    private Entry<K, V> tail;

    public MyLru02(int maxSize) {
        this.maxSize = maxSize;
        map = new HashMap<>();
    }

    public void put(K key, V value) {
        Entry<K, V> entry = new Entry<>();
        entry.key = key;
        entry.value = value;
        afterEntryInsertion(entry);
        map.put(key, entry);

        if (map.size() > maxSize) {
            map.remove(tail.key);
            afterEntryRemoval(tail);
        }
    }

    public void afterEntryInsertion(Entry<K, V> entry) {
        if (entry != null) {
            if (head == null) {
                head = entry;
                tail = head;
                return;
            }

            // if entry is not head
            if (head != entry) {
                entry.after = head;
                entry.before = null;
                head.before = entry;
                head = entry;
            }
        }
    }

    public void afterEntryRemoval(Entry<K, V> entry) {
        if (entry != null) {
            Entry<K, V> p = entry, 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;
            }
        }
    }

    public void afterEntryAccess(Entry<K, V> entry) {
        Entry<K, V> first;

        if ((first = head) != entry) {
            Entry<K, V> p = entry, b = p.before, a = p.after;
            p.before = p.after = null;

            if (b == null) {
                first = a;
            } else {
                b.after = a;
            }

            if (a == null) {
                tail = b;
            } else {
                a.before = b;
            }

            if (first == null) {
                tail = p;
            } else {
                p.after = first;
                first.before = p;
            }

            head = p;
        }
    }

    public void remove(K key) {
        Entry<K, V> entry = this.getEntry(key);
        afterEntryRemoval(entry);
    }

    public V get(K key) {
        Entry<K, V> entry = this.getEntry(key);

        if (entry == null) {
            return null;
        }
        afterEntryAccess(entry);
        return entry.value;
    }


    private Entry<K, V> getEntry(K key) {
        Entry<K, V> entry = map.get(key);

        if (entry == null) {
            return null;
        }

        return entry;
    }

    @Override
    public String toString() {
        Entry<K, V> p = head;
        StringBuffer sb = new StringBuffer();

        while(p != null) {
            sb.append(String.format("%s:%s", p.key, p.value));
            sb.append(" ");
            p = p.after;
        }

        return sb.toString();
    }

    static final class Entry<K, V> {
        private Entry<K, V> before;
        private Entry<K, V> after;
        private K key;
        private V value;
    }

    public static void main(String[] args) {
        MyLru02<String, String> map = new MyLru02<>(5);
        map.put("1", "1");
        map.put("2", "2");
        map.put("3", "3");
        map.put("4", "4");
        map.put("5", "5");
        System.out.println(map.toString());

        map.put("6", "6");
        map.get("2");
        map.put("7", "7");
        map.get("4");

        System.out.println(map.toString());
    }
}
複製程式碼

尾言

大家好,我是cmazxiaoma(寓意是沉夢昂志的小馬),感謝各位閱讀本文章。 小弟不才。 如果您對這篇文章有什麼意見或者錯誤需要改進的地方,歡迎與我討論。 如果您覺得還不錯的話,希望你們可以點個贊。 希望我的文章對你能有所幫助。 有什麼意見、見解或疑惑,歡迎留言討論。

最後送上:心之所向,素履以往。生如逆旅,一葦以航。

saoqi.png

相關文章