java 從零開始手寫 redis(十)緩存淘汰算法 LFU 最少使用頻次

前言

java從零手寫實現redis(一)如何實現固定大小的緩存?javascript

java從零手寫實現redis(三)redis expire 過時原理java

java從零手寫實現redis(三)內存數據如何重啓不丟失?node

java從零手寫實現redis(四)添加監聽器git

java從零手寫實現redis(五)過時策略的另外一種實現思路程序員

java從零手寫實現redis(六)AOF 持久化原理詳解及實現github

java從零手寫實現redis(七)LRU 緩存淘汰策略詳解redis

從零開始手寫 redis(八)樸素 LRU 淘汰算法性能優化算法

本節一塊兒來學習下另外一個經常使用的緩存淘汰算法,LFU 最少使用頻次算法。緩存

LFU 基礎知識

概念

LFU(Least Frequently Used)即最近最不經常使用.看名字就知道是個基於訪問頻次的一種算法。性能優化

LRU是基於時間的,會將時間上最不常訪問的數據給淘汰,在算法表現上是放到列表的頂部;LFU爲將頻率上最不常訪問的數據淘汰.

既然是基於頻率的,就須要有存儲每一個數據訪問的次數.

從存儲空間上,較LRU會多出一些持有計數的空間.

核心思想

若是一個數據在最近一段時間內使用次數不多,那麼在未來一段時間內被使用的可能性也很小。

實現思路

O(N) 的刪除

爲了可以淘汰最少使用的數據,我的第一直覺就是直接一個 HashMap<String, Interger>, String 對應 key 信息,Integer 對應次數。

每次訪問到就去+1,設置和讀取的時間複雜度都是 O(1);不過刪除就比較麻煩了,須要所有遍歷對比,時間複雜度爲 O(n);

O(logn) 的刪除

另外還有一種實現思路就是利用小頂堆+hashmap,小頂堆插入、刪除操做都能達到O(logn)時間複雜度,所以效率相比第一種實現方法更加高效。好比 TreeMap。

O(1) 的刪除

是否可以更進一步優化呢?

其實 O(1) 的算法是有的,參見這篇 paper:

An O(1) algorithm for implementing the LFU cache eviction scheme

簡單說下我的的想法:

咱們要想實現 O(1) 的操做,確定離不開 Hash 的操做,咱們 O(N) 的刪除中就實現了 O(1) 的 put/get。

可是刪除性能比較差,由於須要尋找次數最少的比較耗時。

private Map<K, Node> map; // key和數據的映射
private Map<Integer, LinkedHashSet<Node>> freqMap; // 數據頻率和對應數據組成的鏈表

class Node {
    K key;
    V value;
    int frequency = 1;
}

咱們使用雙 Hash 基本上就能夠解決這個問題了。

map 中存放 key 和節點之間的映射關係。put/get 確定都是 O(1) 的。

key 映射的 node 中,有對應的頻率 frequency 信息;相同的頻率都會經過 freqMap 進行關聯,能夠快速經過頻率獲取對應的鏈表。

刪除也變得很是簡單了,基本能夠肯定須要刪除的最低頻次是1,若是不是最多從 1...n 開始循環,最小 freq 選擇鏈表的第一個元素開始刪除便可。

至於鏈表自己的優先級,那麼能夠根據 FIFO,或者其餘你喜歡的方式。

paper 的核心內容介紹

他山之石,能夠攻玉。

咱們在實現代碼以前,先來讀一讀這篇 O(1) 的 paper。

介紹

本文的結構以下。

對LFU用例的描述,它能夠證實優於其餘緩存逐出算法

LFU緩存實現應支持的字典操做。 這些是肯定策略運行時複雜度的操做

當前最著名的LFU算法及其運行時複雜度的描述

提出的LFU算法的說明; 每一個操做的運行時複雜度爲O(1)

LFU的用途

考慮用於HTTP協議的緩存網絡代理應用程序。

該代理一般位於Internet與用戶或一組用戶之間。

它確保全部用戶都可以訪問Internet,並實現全部可共享資源的共享,以實現最佳的網絡利用率和響應速度。

這樣的緩存代理應該嘗試在可支配的有限數量的存儲或內存中最大化其能夠緩存的數據量。

一般,在將靜態資源(例如圖像,CSS樣式表和javascript代碼)替換爲較新版本以前,能夠很容易地將它們緩存很長時間。

這些靜態資源或程序員所謂的「資產」幾乎包含在每一個頁面中,所以緩存它們是最有益的,由於幾乎每一個請求都將須要它們。

此外,因爲要求網絡代理每秒處理數千個請求,所以應將這樣作所需的開銷保持在最低水平。

爲此,它應該僅驅逐那些不常用的資源。

所以,應該將常用的資源保持在不那麼頻繁使用的資源上,由於前者已經證實本身在一段時間內是有用的。

固然,有一個說法與之相反,它說未來可能不須要大量使用的資源,可是咱們發如今大多數狀況下狀況並不是如此。

例如,頻繁使用頁面的靜態資源始終由該頁面的每一個用戶請求。

所以,當內存不足時,這些緩存代理可使用LFU緩存替換策略來驅逐其緩存中使用最少的項目。

LRU在這裏也多是適用的策略,可是當請求模式使得全部請求的項目都沒有進入緩存而且以循環方式請求這些項目時,LRU將會失敗。

ps: 數據的循環請求,會致使 LRU 恰好不適應這個場景。

在使用LRU的狀況下,項目將不斷進入和離開緩存,而沒有用戶請求訪問緩存。

可是,在相同條件下,LFU算法的性能會更好,大多數緩存項會致使緩存命中。

LFU算法的病理行爲並不是沒有可能。

咱們不是在這裏提出LFU的案例,而是試圖證實若是LFU是適用的策略,那麼比之前發佈的方法有更好的實現方法。

LFU緩存支持的字典操做

當咱們談到緩存逐出算法時,咱們主要須要對緩存數據進行3種不一樣的操做。

  1. 在緩存中設置(或插入)項目
  2. 檢索(或查找)緩存中的項目; 同時增長其使用計數(對於LFU)
  3. 從緩存中逐出(或刪除)最少使用(或做爲逐出算法的策略)

LFU算法的當前最著名的複雜性

在撰寫本文時,針對LFU緩存逐出策略的上述每一個操做的最著名的運行時以下:

插入:O(log n)

查找:O(log n)

刪除:O(log n)

這些複雜度值直接從二項式堆實現和標準無衝突哈希表中得到。

使用最小堆數據結構和哈希圖能夠輕鬆有效地實施LFU緩存策略。

最小堆是基於(項目的)使用計數建立的,而且經過元素的鍵爲哈希表創建索引。

無衝突哈希表上的全部操做的順序均爲O(1),所以LFU緩存的運行時間由最小堆上的操做的運行時間控制。

將元素插入高速緩存時,它將以1的使用計數進入,因爲插入最小堆的開銷爲O(log n),所以將其插入LFU高速緩存須要O(log n)時間。

在查找元素時,能夠經過哈希函數找到該元素,該哈希函數將鍵哈希到實際元素。同時,使用計數(最大堆中的計數)加1,這致使最小堆的重組,而且元素從根移開。

因爲元素在任何階段均可以向下移動至log(n)電平,所以此操做也須要時間O(log n)。

當選擇一個元素將其逐出並最終從堆中刪除時,它可能致使堆數據結構的重大重組。

使用計數最少的元素位於最小堆的根。

刪除最小堆的根包括將根節點替換爲堆中的最後一個葉節點,並將該節點起泡到正確的位置。

此操做的運行時複雜度也爲O(log n)。

提出的LFU算法

對於能夠在LFU緩存上執行的每一個字典操做(插入,查找和刪除),提出的LFU算法的運行時複雜度爲O(1)。

這是經過維護2個連接列表來實現的。一個用於訪問頻率,另外一個用於具備相同訪問頻率的全部元素。

哈希表用於按鍵訪問元素(爲清楚起見,下圖中未顯示)。

雙鏈表用於將表明一組具備相同訪問頻率的節點的節點連接在一塊兒(在下圖中顯示爲矩形塊)。

咱們將此雙重連接列表稱爲頻率列表。具備相同訪問頻率的這組節點其實是此類節點的雙向連接列表(在下圖中顯示爲圓形節點)。

咱們將此雙向連接列表(在特定頻率本地)稱爲節點列表。

節點列表中的每一個節點都有一個指向其父節點的指針。

頻率列表(爲清楚起見,未在圖中顯示)。所以,節點x和您將有一個指向節點1的指針,節點z和a將有一個指向節點2的指針,依此類推...

輸入圖片說明

下面的僞代碼顯示瞭如何初始化LFU緩存。

用於按鍵定位元素的哈希表由按鍵變量表示。

爲了簡化實現,咱們使用SET代替鏈表來存儲具備相同訪問頻率的元素。

變量項是標準的SET數據結構,其中包含具備相同訪問頻率的此類元素的鍵。

它的插入,查找和刪除運行時複雜度爲O(1)。

輸入圖片說明

僞代碼

後面的都是一些僞代碼了,咱們條國內。

理解其最核心的思想就好了,下面咱們上真代碼。

感覺

這個 O(1) 的算法最核心的地方實際上很少,放在 leetcode 應該算是一箇中等難度的題目。

不過很奇怪,這篇論文是在 2010 年提出的,估計之前都覺得 O(logn) 是極限了?

java 代碼實現

基本屬性

public class CacheEvictLfu<K,V> extends AbstractCacheEvict<K,V> {

    private static final Log log = LogFactory.getLog(CacheEvictLfu.class);

    /**
     * key 映射信息
     * @since 0.0.14
     */
    private final Map<K, FreqNode<K,V>> keyMap;

    /**
     * 頻率 map
     * @since 0.0.14
     */
    private final Map<Integer, LinkedHashSet<FreqNode<K,V>>> freqMap;

    /**
     *
     * 最小頻率
     * @since 0.0.14
     */
    private int minFreq;

    public CacheEvictLfu() {
        this.keyMap = new HashMap<>();
        this.freqMap = new HashMap<>();
        this.minFreq = 1;
    }

}

節點定義

  • FreqNode.java
public class FreqNode<K,V> {

    /**
     * 鍵
     * @since 0.0.14
     */
    private K key;

    /**
     * 值
     * @since 0.0.14
     */
    private V value = null;

    /**
     * 頻率
     * @since 0.0.14
     */
    private int frequency = 1;

    public FreqNode(K key) {
        this.key = key;
    }

    //fluent getter & setter
    // toString() equals() hashCode()
}

移除元素

/**
 * 移除元素
 *
 * 1. 從 freqMap 中移除
 * 2. 從 keyMap 中移除
 * 3. 更新 minFreq 信息
 *
 * @param key 元素
 * @since 0.0.14
 */
@Override
public void removeKey(final K key) {
    FreqNode<K,V> freqNode = this.keyMap.remove(key);
    //1. 根據 key 獲取頻率
    int freq = freqNode.frequency();
    LinkedHashSet<FreqNode<K,V>> set = this.freqMap.get(freq);
    //2. 移除頻率中對應的節點
    set.remove(freqNode);
    log.debug("freq={} 移除元素節點:{}", freq, freqNode);
    //3. 更新 minFreq
    if(CollectionUtil.isEmpty(set) && minFreq == freq) {
        minFreq--;
        log.debug("minFreq 下降爲:{}", minFreq);
    }
}

更新元素

/**
 * 更新元素,更新 minFreq 信息
 * @param key 元素
 * @since 0.0.14
 */
@Override
public void updateKey(final K key) {
    FreqNode<K,V> freqNode = keyMap.get(key);
    //1. 已經存在
    if(ObjectUtil.isNotNull(freqNode)) {
        //1.1 移除原始的節點信息
        int frequency = freqNode.frequency();
        LinkedHashSet<FreqNode<K,V>> oldSet = freqMap.get(frequency);
        oldSet.remove(freqNode);
        //1.2 更新最小數據頻率
        if (minFreq == frequency && oldSet.isEmpty()) {
            minFreq++;
            log.debug("minFreq 增長爲:{}", minFreq);
        }
        //1.3 更新頻率信息
        frequency++;
        freqNode.frequency(frequency);
        //1.4 放入新的集合
        this.addToFreqMap(frequency, freqNode);
    } else {
        //2. 不存在
        //2.1 構建新的元素
        FreqNode<K,V> newNode = new FreqNode<>(key);
        //2.2 固定放入到頻率爲1的列表中
        this.addToFreqMap(1, newNode);
        //2.3 更新 minFreq 信息
        this.minFreq = 1;
        //2.4 添加到 keyMap
        this.keyMap.put(key, newNode);
    }
}

/**
 * 加入到頻率 MAP
 * @param frequency 頻率
 * @param freqNode 節點
 */
private void addToFreqMap(final int frequency, FreqNode<K,V> freqNode) {
    LinkedHashSet<FreqNode<K,V>> set = freqMap.get(frequency);
    if (set == null) {
        set = new LinkedHashSet<>();
    }
    set.add(freqNode);
    freqMap.put(frequency, set);
    log.debug("freq={} 添加元素節點:{}", frequency, freqNode);
}

數據淘汰

@Override
protected ICacheEntry<K, V> doEvict(ICacheEvictContext<K, V> context) {
    ICacheEntry<K, V> result = null;
    final ICache<K,V> cache = context.cache();
    // 超過限制,移除頻次最低的元素
    if(cache.size() >= context.size()) {
        FreqNode<K,V> evictNode = this.getMinFreqNode();
        K evictKey = evictNode.key();
        V evictValue = cache.remove(evictKey);
        log.debug("淘汰最小頻率信息, key: {}, value: {}, freq: {}",
                evictKey, evictValue, evictNode.frequency());
        result = new CacheEntry<>(evictKey, evictValue);
    }
    return result;
}

/**
 * 獲取最小頻率的節點
 *
 * @return 結果
 * @since 0.0.14
 */
private FreqNode<K, V> getMinFreqNode() {
    LinkedHashSet<FreqNode<K,V>> set = freqMap.get(minFreq);
    if(CollectionUtil.isNotEmpty(set)) {
        return set.iterator().next();
    }
    throw new CacheRuntimeException("未發現最小頻率的 Key");
}

測試

代碼

ICache<String, String> cache = CacheBs.<String,String>newInstance()
        .size(3)
        .evict(CacheEvicts.<String, String>lfu())
        .build();
cache.put("A", "hello");
cache.put("B", "world");
cache.put("C", "FIFO");
// 訪問一次A
cache.get("A");
cache.put("D", "LRU");

Assert.assertEquals(3, cache.size());
System.out.println(cache.keySet());

日誌

[DEBUG] [2020-10-03 21:23:43.722] [main] [c.g.h.c.c.s.e.CacheEvictLfu.addToFreqMap] - freq=1 添加元素節點:FreqNode{key=A, value=null, frequency=1}
[DEBUG] [2020-10-03 21:23:43.723] [main] [c.g.h.c.c.s.e.CacheEvictLfu.addToFreqMap] - freq=1 添加元素節點:FreqNode{key=B, value=null, frequency=1}
[DEBUG] [2020-10-03 21:23:43.725] [main] [c.g.h.c.c.s.e.CacheEvictLfu.addToFreqMap] - freq=1 添加元素節點:FreqNode{key=C, value=null, frequency=1}
[DEBUG] [2020-10-03 21:23:43.727] [main] [c.g.h.c.c.s.e.CacheEvictLfu.addToFreqMap] - freq=2 添加元素節點:FreqNode{key=A, value=null, frequency=2}
[DEBUG] [2020-10-03 21:23:43.728] [main] [c.g.h.c.c.s.e.CacheEvictLfu.doEvict] - 淘汰最小頻率信息, key: B, value: world, freq: 1
[DEBUG] [2020-10-03 21:23:43.731] [main] [c.g.h.c.c.s.l.r.CacheRemoveListener.listen] - Remove key: B, value: world, type: evict
[DEBUG] [2020-10-03 21:23:43.732] [main] [c.g.h.c.c.s.e.CacheEvictLfu.addToFreqMap] - freq=1 添加元素節點:FreqNode{key=D, value=null, frequency=1}
[D, A, C]

LFU vs LRU

區別

LFU是基於訪問頻次的模式,而LRU是基於訪問時間的模式。

優點

在數據訪問符合正態分佈時,相比於LRU算法,LFU算法的緩存命中率會高一些。

劣勢

  • LFU的複雜度要比LRU更高一些。
  • 須要維護數據的訪問頻次,每次訪問都須要更新。
  • 早期的數據相比於後期的數據更容易被緩存下來,致使後期的數據很難被緩存。
  • 新加入緩存的數據很容易被剔除,像是緩存的末端發生「抖動」。

小結

不過實際實踐中,LFU 的應用場景實際並無那麼普遍。

由於真實的數據都是有傾斜的,熱點數據纔是常態,因此 LRU 的性能通常狀況下優於 LFU。

開源地址: https://github.com/houbb/cache

以爲本文對你有幫助的話,歡迎點贊評論收藏關注一波,你的鼓勵,是我最大的動力~

目前咱們實現了性能比較優異的 LRU 和 LFU 算法,可是操做系統實際採用的卻不是這兩種算法,咱們下一節將一塊兒學習下操做系統青睞的 clock 淘汰算法。

不知道你有哪些收穫呢?或者有其餘更多的想法,歡迎留言區和我一塊兒討論,期待與你的思考相遇。

深刻學習

相關文章
相關標籤/搜索