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

前言

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

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

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

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

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

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

咱們前面簡單實現了 redis 的幾個特性,java從零手寫實現redis(一)如何實現固定大小的緩存? 中實現了先進先出的驅除策略。ide

可是實際工做實踐中,通常推薦使用 LRU/LFU 的驅除策略。性能

淘汰

LRU 基礎知識

拓展學習

Apache Commons LRUMAP 源碼詳解學習

Redis 當作 LRU MAP 使用測試

LRU 是什麼

LRU 是由 Least Recently Used 的首字母組成,表示最近最少使用的含義,通常使用在對象淘汰算法上。

也是比較常見的一種淘汰算法。

其核心思想是若是數據最近被訪問過,那麼未來被訪問的概率也更高

連續性

在計算機科學中,有一個指導準則:連續性準則。

時間連續性:對於信息的訪問,最近被訪問過,被再次訪問的可能性會很高。緩存就是基於這個理念進行數據淘汰的。

空間連續性:對於磁盤信息的訪問,將頗有可能訪問連續的空間信息。因此會有 page 預取來提高性能。

實現步驟

  1. 新數據插入到鏈表頭部;
  2. 每當緩存命中(即緩存數據被訪問),則將數據移到鏈表頭部;
  3. 當鏈表滿的時候,將鏈表尾部的數據丟棄。

其實比較簡單,比起 FIFO 的隊列,咱們引入一個鏈表實現便可。

一點思考

咱們針對上面的 3 句話,逐句考慮一下,看看有沒有值得優化點或者一些坑。

如何判斷是新數據?

(1) 新數據插入到鏈表頭部;

咱們使用的是鏈表。

判斷新數據最簡單的方法就是遍歷是否存在,對於鏈表,這是一個 O(n) 的時間複雜度。

其實性能仍是比較差的。

固然也能夠考慮空間換時間,好比引入一個 set 之類的,不過這樣對空間的壓力會加倍。

什麼是緩存命中

(2)每當緩存命中(即緩存數據被訪問),則將數據移到鏈表頭部;

put(key,value) 的狀況,就是新元素。若是已有這個元素,能夠先刪除,再加入,參考上面的處理。

get(key) 的狀況,對於元素訪問,刪除已有的元素,將新元素放在頭部。

remove(key) 移除一個元素,直接刪除已有元素。

keySet() valueSet() entrySet() 這些屬於無差異訪問,咱們不對隊列作調整。

移除

(3)當鏈表滿的時候,將鏈表尾部的數據丟棄。

鏈表滿只有一種場景,那就是添加元素的時候,也就是執行 put(key, value) 的時候。

直接刪除對應的 key 便可。

java 代碼實現

接口定義

和 FIFO 的接口保持一致,調用地方也不變。

爲了後續 LRU/LFU 實現,新增 remove/update 兩個方法。

public interface ICacheEvict<K, V> {

    /**
     * 驅除策略
     *
     * @param context 上下文
     * @since 0.0.2
     * @return 是否執行驅除
     */
    boolean evict(final ICacheEvictContext<K, V> context);

    /**
     * 更新 key 信息
     * @param key key
     * @since 0.0.11
     */
    void update(final K key);

    /**
     * 刪除 key 信息
     * @param key key
     * @since 0.0.11
     */
    void remove(final K key);

}

LRU 實現

直接基於 LinkedList 實現:

/**
 * 丟棄策略-LRU 最近最少使用
 * @author binbin.hou
 * @since 0.0.11
 */
public class CacheEvictLRU<K,V> implements ICacheEvict<K,V> {

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

    /**
     * list 信息
     * @since 0.0.11
     */
    private final List<K> list = new LinkedList<>();

    @Override
    public boolean evict(ICacheEvictContext<K, V> context) {
        boolean result = false;
        final ICache<K,V> cache = context.cache();
        // 超過限制,移除隊尾的元素
        if(cache.size() >= context.size()) {
            K evictKey = list.get(list.size()-1);
            // 移除對應的元素
            cache.remove(evictKey);
            result = true;
        }
        return result;
    }


    /**
     * 放入元素
     * (1)刪除已經存在的
     * (2)新元素放到元素頭部
     *
     * @param key 元素
     * @since 0.0.11
     */
    @Override
    public void update(final K key) {
        this.list.remove(key);
        this.list.add(0, key);
    }

    /**
     * 移除元素
     * @param key 元素
     * @since 0.0.11
     */
    @Override
    public void remove(final K key) {
        this.list.remove(key);
    }

}

實現比較簡單,相對 FIFO 多了三個方法:

update():咱們作一點簡化,認爲只要是訪問,就是刪除,而後插入到隊首。

remove():刪除就是直接刪除。

這三個方法是用來更新最近使用狀況的。

那何時調用呢?

註解屬性

爲了保證核心流程,咱們基於註解實現。

添加屬性:

/**
 * 是否執行驅除更新
 *
 * 主要用於 LRU/LFU 等驅除策略
 * @return 是否
 * @since 0.0.11
 */
boolean evict() default false;

註解使用

有哪些方法須要使用?

@Override
@CacheInterceptor(refresh = true, evict = true)
public boolean containsKey(Object key) {
    return map.containsKey(key);
}

@Override
@CacheInterceptor(evict = true)
@SuppressWarnings("unchecked")
public V get(Object key) {
    //1. 刷新全部過時信息
    K genericKey = (K) key;
    this.expire.refreshExpire(Collections.singletonList(genericKey));
    return map.get(key);
}

@Override
@CacheInterceptor(aof = true, evict = true)
public V put(K key, V value) {
    //...
}

@Override
@CacheInterceptor(aof = true, evict = true)
public V remove(Object key) {
    return map.remove(key);
}

註解驅除攔截器實現

執行順序:放在方法以後更新,否則每次當前操做的 key 都會被放在最前面。

/**
 * 驅除策略攔截器
 * 
 * @author binbin.hou
 * @since 0.0.11
 */
public class CacheInterceptorEvict<K,V> implements ICacheInterceptor<K, V> {

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

    @Override
    public void before(ICacheInterceptorContext<K,V> context) {
    }

    @Override
    @SuppressWarnings("all")
    public void after(ICacheInterceptorContext<K,V> context) {
        ICacheEvict<K,V> evict = context.cache().evict();

        Method method = context.method();
        final K key = (K) context.params()[0];
        if("remove".equals(method.getName())) {
            evict.remove(key);
        } else {
            evict.update(key);
        }
    }

}

咱們只對 remove 方法作下特判,其餘方法都使用 update 更新信息。

參數直接取第一個參數。

測試

ICache<String, String> cache = CacheBs.<String,String>newInstance()
        .size(3)
        .evict(CacheEvicts.<String, String>lru())
        .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());
  • 日誌信息
[D, A, C]

經過 removeListener 日誌也能夠看到 B 被移除了:

[DEBUG] [2020-10-02 21:33:44.578] [main] [c.g.h.c.c.s.l.r.CacheRemoveListener.listen] - Remove key: B, value: world, type: evict

小結

redis LRU 淘汰策略,實際上並非真正的 LRU。

LRU 有一個比較大的問題,就是每次 O(n) 去查找,這個在 keys 數量特別多的時候,仍是很慢的。

若是 redis 這麼設計確定慢的要死了。

我的的理解是能夠用空間換取時間,好比添加一個 Map<String, Integer> 存儲在 list 中的 keys 和下標,O(1) 的速度去查找,可是空間複雜度翻倍了。

不過這個犧牲仍是值得的。這種後續統一作下優化,將各類優化點統一考慮,這樣能夠統籌全局,也便於後期統一調整。

下一節咱們將一塊兒來實現如下改進版的 LRU。

Redis 作的事情,就是將看起來的簡單的事情,作到一種極致,這一點值得每個開源軟件學習。

文中主要講述了思路,實現部分由於篇幅限制,沒有所有貼出來。

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

以爲本文對你有幫助的話,歡迎點贊評論收藏關注一波~

你的鼓勵,是我最大的動力~

深刻學習

相關文章
相關標籤/搜索