從零開始手寫 redis(八)樸素 LRU 淘汰演算法效能優化
前言
java從零手寫實現redis(一)如何實現固定大小的快取?
java從零手寫實現redis(三)redis expire 過期原理
java從零手寫實現redis(三)記憶體資料如何重啟不丟失?
java從零手寫實現redis(五)過期策略的另一種實現思路
java從零手寫實現redis(六)AOF 持久化原理詳解及實現
我們前面簡單實現了 redis 的幾個特性,java從零手寫實現redis(一)如何實現固定大小的快取? 中實現了先進先出的驅除策略。
但是實際工作實踐中,一般推薦使用 LRU/LFU 的驅除策略。
LRU 基礎知識
是什麼
LRU演算法全稱是最近最少使用演算法(Least Recently Use),廣泛的應用於快取機制中。
當快取使用的空間達到上限後,就需要從已有的資料中淘汰一部分以維持快取的可用性,而淘汰資料的選擇就是通過LRU演算法完成的。
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 實現
實際上,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 演算法。
文中主要講述了思路,實現部分因為篇幅限制,沒有全部貼出來。
覺得本文對你有幫助的話,歡迎點贊評論收藏關注一波~
你的鼓勵,是我最大的動力~
相關文章
- 從零開始手寫 redis(七)LRU 快取淘汰策略詳解Redis快取
- java 從零開始手寫 redis(十)快取淘汰演算法 LFU 最少使用頻次JavaRedis快取演算法
- 【redis前傳】自己手寫一個LRU策略 | redis淘汰策略Redis
- java 從零開始手寫 RPC (04) -序列化JavaRPC
- 從零開始手寫Koa2框架框架
- 【React技術棧】從零開始手寫reduxReactRedux
- Redis 為何使用近似 LRU 演算法淘汰資料,而不是真實 LRU?Redis演算法
- 最短路-樸素版Dijkstra演算法&堆優化版的Dijkstra演算法優化
- 從零開始手寫一個微前端框架-渲染篇前端框架
- 從零開始寫一個ExporterExport
- 從零開始搭建腳手架
- java 從零開始手寫 RPC (01) 基於 websocket 實現JavaRPCWeb
- 從零開始編寫指令碼引擎指令碼
- 從零開始仿寫一個抖音App——開始APP
- 樸素貝葉斯演算法演算法
- Dijkstra演算法詳解(樸素演算法+堆最佳化)演算法
- 從零開始寫 Docker(八)---實現 mydocker run -d 支援後臺執行容器Docker
- Redis 效能優化Redis優化
- Redis淘汰演算法Redis演算法
- 從零開始
- mysql調優從書寫sql開始MySql
- 不怕從零開始,只怕從未開始!
- 從零開始編寫一個babel外掛Babel
- 從零開始寫一個node爬蟲(一)爬蟲
- 從零開始寫Java Web框架——maven 外掛JavaWeb框架Maven
- 從零開始寫一個Javascript解析器JavaScript
- 【Java EE】從零開始寫專案【總結】Java
- 分類演算法-樸素貝葉斯演算法
- 04_樸素貝葉斯演算法演算法
- 從零開始,使用Dapr簡化微服務微服務
- 從零開始學Python:第八課-函式和模組Python函式
- 從零開始入門 K8s | etcd 效能最佳化實踐K8S
- LRU 快取淘汰演算法的兩種實現快取演算法
- 用PyTorch從零開始編寫DeepSeek-V2PyTorch
- Cursor 寫一個 Flutter Unsplash 桌布工具 | 從零開始Flutter
- 從零開始寫一個微前端框架-沙箱篇前端框架
- LRU工程實現原始碼(一):Redis 記憶體淘汰策略原始碼Redis記憶體
- 效能優化之關於畫素管道及優化(二)優化