java從零手寫實現redis(一)如何實現固定大小的緩存?java
java從零手寫實現redis(三)redis expire 過時原理node
java從零手寫實現redis(三)內存數據如何重啓不丟失?git
java從零手寫實現redis(四)添加監聽器github
java從零手寫實現redis(五)過時策略的另外一種實現思路redis
java從零手寫實現redis(六)AOF 持久化原理詳解及實現算法
咱們前面簡單實現了 redis 的幾個特性,java從零手寫實現redis(一)如何實現固定大小的緩存? 中實現了先進先出的驅除策略。數組
可是實際工做實踐中,通常推薦使用 LRU/LFU 的驅除策略。緩存
LRU算法全稱是最近最少使用算法(Least Recently Use),普遍的應用於緩存機制中。數據結構
當緩存使用的空間達到上限後,就須要從已有的數據中淘汰一部分以維持緩存的可用性,而淘汰數據的選擇就是經過LRU算法完成的。ide
LRU算法的基本思想是基於局部性原理的時間局部性:
若是一個信息項正在被訪問,那麼在近期它極可能還會被再次訪問。
java 從零開始手寫 redis(七)redis LRU 驅除策略詳解及實現
方案:爲每個數據附加一個額外的屬性——時間戳,當每一次訪問數據時,更新該數據的時間戳至當前時間。
當數據空間已滿後,則掃描整個數組,淘汰時間戳最小的數據。
不足:維護時間戳須要耗費額外的空間,淘汰數據時須要掃描整個數組。
這個時間複雜度太差,空間複雜度也很差。
方案:訪問一個數據時,當數據不在鏈表中,則將數據插入至鏈表頭部,若是在鏈表中,則將該數據移至鏈表頭部。當數據空間已滿後,則淘汰鏈表最末尾的數據。
不足:插入數據或取數據時,須要掃描整個鏈表。
這個就是咱們上一節實現的方式,缺點仍是很明顯,每次確認元素是否存在,都要消耗 O(n) 的時間複雜度去查詢。
方案:爲了改進上面須要掃描鏈表的缺陷,配合哈希表,將數據和鏈表中的節點造成映射,將插入操做和讀取操做的時間複雜度從O(N)降至O(1)
缺點:這個使咱們上一節提到的優化思路,不過仍是有缺點的,那就是空間複雜度翻倍。
(1)基於數組的實現
這裏不建議選擇 array 或者 ArrayList,由於讀取的時間複雜度爲 O(1),可是更新相對是比較慢的,雖然 jdk 使用的是 System.arrayCopy。
(2)基於鏈表的實現
若是咱們選擇鏈表,HashMap 中仍是不能簡單的存儲 key, 和對應的下標。
由於鏈表的遍歷,實際上仍是 O(n) 的,雙向鏈表理論上能夠優化一半,可是這並非咱們想要的 O(1) 效果。
(3)基於雙向列表
雙向鏈表咱們保持不變。
Map 中 key 對應的值咱們放雙向鏈表的節點信息。
那實現方式就變成了實現一個雙向鏈表。
/** * 雙向鏈表節點 * @author binbin.hou * @since 0.0.12 * @param <K> key * @param <V> value */ public class DoubleListNode<K,V> { /** * 鍵 * @since 0.0.12 */ private K key; /** * 值 * @since 0.0.12 */ private V value; /** * 前一個節點 * @since 0.0.12 */ private DoubleListNode<K,V> pre; /** * 後一個節點 * @since 0.0.12 */ private DoubleListNode<K,V> next; //fluent get & set }
咱們保持和原來的接口不變,實現以下:
public class CacheEvictLruDoubleListMap<K,V> extends AbstractCacheEvict<K,V> { private static final Log log = LogFactory.getLog(CacheEvictLruDoubleListMap.class); /** * 頭結點 * @since 0.0.12 */ private DoubleListNode<K,V> head; /** * 尾巴結點 * @since 0.0.12 */ private DoubleListNode<K,V> tail; /** * map 信息 * * key: 元素信息 * value: 元素在 list 中對應的節點信息 * @since 0.0.12 */ private Map<K, DoubleListNode<K,V>> indexMap; public CacheEvictLruDoubleListMap() { this.indexMap = new HashMap<>(); this.head = new DoubleListNode<>(); this.tail = new DoubleListNode<>(); this.head.next(this.tail); this.tail.pre(this.head); } @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()) { // 獲取尾巴節點的前一個元素 DoubleListNode<K,V> tailPre = this.tail.pre(); if(tailPre == this.head) { log.error("當前列表爲空,沒法進行刪除"); throw new CacheRuntimeException("不可刪除頭結點!"); } K evictKey = tailPre.key(); V evictValue = cache.remove(evictKey); result = new CacheEntry<>(evictKey, evictValue); } return result; } /** * 放入元素 * * (1)刪除已經存在的 * (2)新元素放到元素頭部 * * @param key 元素 * @since 0.0.12 */ @Override public void update(final K key) { //1. 執行刪除 this.remove(key); //2. 新元素插入到頭部 //head<->next //變成:head<->new<->next DoubleListNode<K,V> newNode = new DoubleListNode<>(); newNode.key(key); DoubleListNode<K,V> next = this.head.next(); this.head.next(newNode); newNode.pre(this.head); next.pre(newNode); newNode.next(next); //2.2 插入到 map 中 indexMap.put(key, newNode); } /** * 移除元素 * * 1. 獲取 map 中的元素 * 2. 不存在直接返回,存在執行如下步驟: * 2.1 刪除雙向鏈表中的元素 * 2.2 刪除 map 中的元素 * * @param key 元素 * @since 0.0.12 */ @Override public void remove(final K key) { DoubleListNode<K,V> node = indexMap.get(key); if(ObjectUtil.isNull(node)) { return; } // 刪除 list node // A<->B<->C // 刪除 B,須要變成: A<->C DoubleListNode<K,V> pre = node.pre(); DoubleListNode<K,V> next = node.next(); pre.next(next); next.pre(pre); // 刪除 map 中對應信息 this.indexMap.remove(key); } }
實現起來不難,就是一個簡易版本的雙向列表。
只是獲取節點的時候,藉助了一下 map,讓時間複雜度下降爲 O(1)。
咱們驗證一下本身的實現:
ICache<String, String> cache = CacheBs.<String,String>newInstance() .size(3) .evict(CacheEvicts.<String, String>lruDoubleListMap()) .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 09:37:41.007] [main] [c.g.h.c.c.s.l.r.CacheRemoveListener.listen] - Remove key: B, value: world, type: evict [D, A, C]
由於咱們訪問過一次 A,因此 B 已經變成最少被訪問的元素。
實際上,LinkedHashMap 自己就是對於 list 和 hashMap 的一種結合的數據結構,咱們能夠直接使用 jdk 中 LinkedHashMap 去實現。
public class LRUCache extends LinkedHashMap { private int capacity; public LRUCache(int capacity) { // 注意這裏將LinkedHashMap的accessOrder設爲true super(16, 0.75f, true); this.capacity = capacity; } @Override protected boolean removeEldestEntry(Map.Entry eldest) { return super.size() >= capacity; } }
默認LinkedHashMap並不會淘汰數據,因此咱們重寫了它的removeEldestEntry()方法,當數據數量達到預設上限後,淘汰數據,accessOrder設爲true意爲按照訪問的順序排序。
整個實現的代碼量並不大,主要都是應用LinkedHashMap的特性。
咱們對這個方法簡單改造下,讓其適應咱們定義的接口。
ICache<String, String> cache = CacheBs.<String,String>newInstance() .size(3) .evict(CacheEvicts.<String, String>lruLinkedHashMap()) .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());
ICache<String, String> cache = CacheBs.<String,String>newInstance() .size(3) .evict(CacheEvicts.<String, String>lruLinkedHashMap()) .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 10:20:57.842] [main] [c.g.h.c.c.s.l.r.CacheRemoveListener.listen] - Remove key: B, value: world, type: evict [D, A, C]
上一節中提到的數組 O(n) 遍歷的問題,本節已經基本解決了。
但其實這種算法依然存在必定的問題,好比當偶發性的批量操做時,會致使熱點數據被非熱點數據擠出緩存,下一節咱們一塊兒學習如何進一步改進 LRU 算法。
文中主要講述了思路,實現部分由於篇幅限制,沒有所有貼出來。
開源地址: https://github.com/houbb/cache
以爲本文對你有幫助的話,歡迎點贊評論收藏關注一波~
你的鼓勵,是我最大的動力~