從零開始手寫緩存之如何實現固定緩存大小

2020-09-28 老馬嘯西風

# 程式設計師的三高

前段時間有一位同事體檢,體檢醫生說他三高。

我打趣道,程式設計師三高不是高性能、高並發、高可用嗎?你是哪三高?

每一個追求性能的開發者,都對高性能孜孜不倦地追求著,而緩存是我們踏上這條高性能大道的必經之路。

小到 cpu 設計,大到服務分布式緩存,我們每時每刻都在接觸緩存,今天我們就一起學習下緩存的發展之路,以及如何如何手寫一個可以指定大小的 cache。

# cache 發展之路

## 古代社會 - HashMap

當我們應用有一定流量之後或者查詢資料庫特別頻繁,這個時候就可以祭出我們的java中自帶的HashMap或者ConcurrentHashMap。

我們可以在代碼中這麼寫:

```java

public class CustomerService {

private HashMap<String,String> hashMap = new HashMap<>();

private CustomerMapper customerMapper;

public String getCustomer(String name){

String customer = hashMap.get(name);

if ( customer == null){

customer = customerMapper.get(name);

hashMap.put(name,customer);

}

return customer;

}

}

```

但是這樣做就有個問題HashMap無法進行數據淘汰,內存會無限制的增長,所以hashMap很快也被淘汰了。

比如以前查詢,查詢 redis,但是希望可以本地緩存放一些熱點數據,使用 HashMap 顯然無法滿足這種需求。

當然,此處可以使用弱引用解決內存一直增長的問題。

當然並不是說他完全就沒用,就像我們古代社會也不是所有的東西都是過時的,比如我們中華名族的傳統美德是永不過時的,

就像這個hashMap一樣的可以在某些場景下作為緩存,當不需要淘汰機制的時候,比如我們利用反射,如果我們每次都通過反射去搜索 Method, Field,性能必定低效,這時我們用HashMap將其緩存起來,性能能提升很多。

## 近代社會 - LRUHashMap

在古代社會中難住我們的問題無法進行數據淘汰,這樣會導致我們內存無限膨脹,顯然我們是不可以接受的。

有人就說我把一些數據給淘汰掉唄,這樣不就對了,但是怎麼淘汰呢?隨機淘汰嗎?

當然不行,試想一下你剛把A裝載進緩存,下一次要訪問的時候就被淘汰了,那又會訪問我們的資料庫了,那我們要緩存幹嘛呢?

所以聰明的人們就發明了幾種淘汰算法,下面列舉下常見的三種FIFO,LRU,LFU(還有一些ARC,MRU感興趣的可以自行搜索):

### FIFO

先進先出,在這種淘汰算法中,先進入緩存的會先被淘汰。這種可謂是最簡單的了,但是會導致我們命中率很低。

試想一下我們如果有個訪問頻率很高的數據是所有數據第一個訪問的,而那些不是很高的是後面再訪問的,那這樣就會把我們的首個數據但是他的訪問頻率很高給擠出。

### LRU

最近最少使用算法。

在這種算法中避免了上面的問題,每次訪問數據都會將其放在我們的隊尾,如果需要淘汰數據,就只需要淘汰隊首即可。

但是這個依然有個問題,如果有個數據在1個小時的前59分鐘訪問了1萬次(可見這是個熱點數據),再後一分鐘沒有訪問這個數據,但是有其他的數據訪問,就導致了我們這個熱點數據被淘汰。

### LFU

最近最少頻率使用。

在這種算法中又對上面進行了優化,利用額外的空間記錄每個數據的使用頻率,然後選出頻率最低進行淘汰。這樣就避免了LRU不能處理時間段的問題。

上面列舉了三種淘汰策略,對於這三種,實現成本是一個比一個高,同樣的命中率也是一個比一個好。

而我們一般來說選擇的方案居中即可,即實現成本不是太高,而命中率也還行的LRU,如何實現一個LRUMap呢?

我們可以通過繼承 LinkedHashMap,重寫 removeEldestEntry 方法,即可完成一個簡單的 LRUMap。

```java

class LRUMap extends LinkedHashMap {

private final int max;

private Object lock;

public LRUMap(int max, Object lock) {

//無需擴容

super((int) (max * 1.4f), 0.75f, true);

this.max = max;

this.lock = lock;

}

/**

* 重寫LinkedHashMap的removeEldestEntry方法即可

* 在Put的時候判斷,如果為true,就會刪除最老的

* @param eldest

* @return

*/

@Override

protected boolean removeEldestEntry(Map.Entry eldest) {

return size() > max;

}

public Object getValue(Object key) {

synchronized (lock) {

return get(key);

}

}

public void putValue(Object key, Object value) {

synchronized (lock) {

put(key, value);

}

}

public boolean removeValue(Object key) {

synchronized (lock) {

return remove(key) != null;

}

}

public boolean removeAll(){

clear();

return true;

}

}

```

在LinkedHashMap中維護了一個entry(用來放key和value的對象)鍊表。在每一次get或者put的時候都會把插入的新entry,或查詢到的老entry放在我們鍊表末尾。

可以注意到我們在構造方法中,設置的大小特意設置到max*1.4,

在下面的removeEldestEntry方法中只需要size>max就淘汰,這樣我們這個map永遠也走不到擴容的邏輯了,

通過重寫LinkedHashMap,幾個簡單的方法我們實現了我們的LruMap。

## 現代社會 - Guava cache

在近代社會中已經發明出來了LRUMap,用來進行緩存數據的淘汰,但是有幾個問題:

- 鎖競爭嚴重,可以看見我的代碼中,Lock是全局鎖,在方法級別上面的,當調用量較大時,性能必然會比較低。

- 不支持過期時間

- 不支持自動刷新

所以谷歌的大佬們對於這些問題,按捺不住了,發明了Guava cache,在Guava cache中你可以如下面的代碼一樣,輕鬆使用:

```java

public static void main(String[] args) throws ExecutionException {

LoadingCache<String, String> cache = CacheBuilder.newBuilder()

.maximumSize(100)

//寫之後30ms過期

.expireAfterWrite(30L, TimeUnit.MILLISECONDS)

//訪問之後30ms過期

.expireAfterAccess(30L, TimeUnit.MILLISECONDS)

//20ms之後刷新

.refreshAfterWrite(20L, TimeUnit.MILLISECONDS)

//開啟weakKey key 當啟動垃圾回收時,該緩存也被回收

.weakKeys()

.build(createCacheLoader());

System.out.println(cache.get("hello"));

cache.put("hello1", "我是hello1");

System.out.println(cache.get("hello1"));

cache.put("hello1", "我是hello2");

System.out.println(cache.get("hello1"));

}

public static com.google.common.cache.CacheLoader<String, String> createCacheLoader() {

return new com.google.common.cache.CacheLoader<String, String>() {

@Override

public String load(String key) throws Exception {

return key;

}

};

}

```

當然,對於性能的追求是無極限的。

還有:

> Caffeine: [https://houbb.github.io/2018/09/09/cache-caffeine](https://houbb.github.io/2018/09/09/cache-caffeine)

> LevelDB: [https://houbb.github.io/2018/09/06/cache-leveldb-01-start](https://houbb.github.io/2018/09/06/cache-leveldb-01-start)

這些性能更加優越的實現,我們後續可以做一下深入學習。

本文,我們來看一下,如何實現一個固定大小的緩存。

# 代碼實現

## 接口定義

為了兼容 Map,我們定義緩存接口繼承自 Map 接口。

```java

/**

* 緩存接口

* @author binbin.hou

* @since 0.0.1

*/

public interface ICache<K, V> extends Map<K, V> {

}

```

## 核心實現

我們主要看一下 put 時的實現:

```java

@Override

public V put(K key, V value) {

//1.1 嘗試驅除

CacheEvictContext<K,V> context = new CacheEvictContext<>();

context.key(key).size(sizeLimit).cache(this);

cacheEvict.evict(context);

//2. 判斷驅除後的信息

if(isSizeLimit()) {

throw new CacheRuntimeException("當前隊列已滿,數據添加失敗!");

}

//3. 執行添加

return map.put(key, value);

}

```

這裡我們可以讓用戶動態指定大小,但是指定大小肯就要有對應的淘汰策略。

否則,固定大小的 map 肯定無法放入元素。

## 淘汰策略

淘汰策略可以有多種,比如 LRU/LFU/FIFO 等等,我們此處實現一個最基本的 FIFO。

所有實現以接口的方式實現,便於後期靈活替換。

```java

public class CacheEvictFIFO<K,V> implements ICacheEvict<K,V> {

/**

* queue 信息

* @since 0.0.2

*/

private Queue<K> queue = new LinkedList<>();

@Override

public void evict(ICacheEvictContext<K, V> context) {

final ICache<K,V> cache = context.cache();

// 超過限制,執行移除

if(cache.size() >= context.size()) {

K evictKey = queue.remove();

// 移除最開始的元素

cache.remove(evictKey);

}

// 將新加的元素放入隊尾

final K key = context.key();

queue.add(key);

}

}

```

FIFO 比較簡單,我們使用一個隊列,存儲每一次放入的元素,當隊列超過最大限制時,刪除最早的元素。

## 引導類

為了便於用戶使用,我們實現類似於 guava 的引導類。

所有參數都提供默認值,使用 fluent 流式寫法,提升用戶體驗。

```java

/**

* 緩存引導類

* @author binbin.hou

* @since 0.0.2

*/

public final class CacheBs<K,V> {

private CacheBs(){}

/**

* 創建對象實例

* @param <K> key

* @param <V> value

* @return this

* @since 0.0.2

*/

public static <K,V> CacheBs<K,V> newInstance() {

return new CacheBs<>();

}

/**

* map 實現

* @since 0.0.2

*/

private Map<K,V> map = new HashMap<>();

/**

* 大小限制

* @since 0.0.2

*/

private int size = Integer.MAX_VALUE;

/**

* 驅除策略

* @since 0.0.2

*/

private ICacheEvict<K,V> evict = CacheEvicts.fifo();

/**

* map 實現

* @param map map

* @return this

* @since 0.0.2

*/

public CacheBs<K, V> map(Map<K, V> map) {

ArgUtil.notNull(map, "map");

this.map = map;

return this;

}

/**

* 設置 size 信息

* @param size size

* @return this

* @since 0.0.2

*/

public CacheBs<K, V> size(int size) {

ArgUtil.notNegative(size, "size");

this.size = size;

return this;

}

/**

* 設置驅除策略

* @param evict 驅除策略

* @return this

* @since 0.0.2

*/

public CacheBs<K, V> evict(ICacheEvict<K, V> evict) {

this.evict = evict;

return this;

}

/**

* 構建緩存信息

* @return 緩存信息

* @since 0.0.2

*/

public ICache<K,V> build() {

CacheContext<K,V> context = new CacheContext<>();

context.cacheEvict(evict);

context.map(map);

context.size(size);

return new Cache<>(context);

}

}

```

## 測試使用

```java

ICache<String, String> cache = CacheBs.<String,String>newInstance()

.size(2)

.build();

cache.put("1", "1");

cache.put("2", "2");

cache.put("3", "3");

cache.put("4", "4");

Assert.assertEquals(2, cache.size());

System.out.println(cache.keySet());

```

默認為先進先出的策略,此時輸出 keys,內容如下:

```

[3, 4]

```

# 小結

到這裡,一個簡易版的可以指定大小的緩存就實現了。

完整代碼暫時本項目還沒開源,可以關注【老馬嘯西風】,後臺回復緩存,獲取源碼。

目前為止,這個緩存實現是比較簡單的,顯然難以滿足我們平時更加靈活的應用場景。

我們下一節將一起學習一下如何實現一個可以指定過期的緩存。


相關焦點

  • 從零手寫緩存框架(12)redis過期隨機特性詳解及實現
    前言java從零手寫實現redis(一)如何實現固定大小的緩存?java從零手寫實現redis(二)redis expire 過期原理java從零手寫實現redis(三)內存數據如何重啟不丟失?java從零手寫實現redis(四)添加監聽器java從零手寫實現redis(五)過期策略的另一種實現思路java從零手寫實現redis(六)AOF 持久化原理詳解及實現java從零開始手寫redis(七)LRU 緩存淘汰策略詳解java從零開始手寫redis(八)樸素 LRU 淘汰算法性能優化第二節中我們已經初步實現了類似
  • 從零開始手寫 redis(九)LRU 緩存淘汰避免緩存汙染
    前言java從零手寫實現redis(一)如何實現固定大小的緩存?java從零手寫實現redis(三)redis expire 過期原理java從零手寫實現redis(三)內存數據如何重啟不丟失?java從零手寫實現redis(四)添加監聽器java從零手寫實現redis(五)過期策略的另一種實現思路java從零手寫實現redis(六)AOF 持久化原理詳解及實現java從零手寫實現redis(七)LRU 緩存淘汰策略詳解從零開始手寫 redis(八)樸素 LRU 淘汰算法性能優化前兩節我們分別實現了 LRU
  • 從零手寫緩存框架(二)redis expire過期原理及實現
    前言我們在 從零手寫 cache 框架(一)實現固定大小的緩存 中已經初步實現了我們的 cache。本節,讓我們來一起學習一下如何實現類似 redis 中的 expire 過期功能。輪詢清理我們固定 100ms 清理一次,每次最多清理 100 個。
  • 從零開始手寫 redis(十)緩存淘汰LFU算法詳解
    前言java從零手寫實現redis(一)如何實現固定大小的緩存?java從零手寫實現redis(三)redis expire 過期原理java從零手寫實現redis(三)內存數據如何重啟不丟失?java從零手寫實現redis(四)添加監聽器java從零手寫實現redis(五)過期策略的另一種實現思路java從零手寫實現redis(六)AOF 持久化原理詳解及實現java從零手寫實現redis(七)LRU 緩存淘汰策略詳解java從零開始手寫 redis(八)樸素 LRU 淘汰算法性能優化本節一起來學習下另一個常用的緩存淘汰算法
  • 無鎖緩存,每秒10萬並發,究竟如何實現?
    多個線程如何保持A1B2C3等順序交替輸出?synchronized volatile的CPU原語是如何實現的?無鎖、偏向鎖、輕量級鎖、重量級鎖有什麼差別?如何正確的啟動和停止一個線程?線程和纖程的區別的是什麼?為什麼纖程比較輕量級?
  • 緩存如何工作,如何設計CPU緩存
    緩存如何工作,如何設計CPU緩存 EEtoday 發表於 2020-11-19 17:23:13 20世紀80年代,CPU性能有了顯著提升,但這受到板載內存訪問速度緩慢增長的阻礙
  • CPU中的L1,L2和L3緩存之間的區別:緩存是如何工作的?
    看一下內存映射 有了關於高速緩存的基本說明,讓我們討論一下系統內存如何與高速緩存進行通信。這稱為緩存或內存映射。高速緩衝存儲器分為塊或組。這些塊又分為n個64位元組行。系統存儲器被劃分為與高速緩存相同數量的塊(組),然後將兩者連結在一起。
  • 緩存雪崩緩存穿透緩存擊穿是什麼意思緩存潰之後會如何該如何應對
    例如,web應用中,對一些靜態頁面的呈現內容進行緩存能有效的節省資源,提高穩定性。而緩存數據也能降低對資料庫的訪問次數,降低資料庫的負擔和提高資料庫的服務能力; 可用性——有時,提供數據信息的服務可能會意外停止,如果使用了緩存技術,可以在一定時間內仍正常提供對最終用戶的支持,提高了系統的可用性。
  • 緩存 | 從本地緩存到分布式緩存
    緩存作為一種能加快程序性能的銀彈,它是典型的後者(以空間換時間).隨著用戶數和訪問量越來越大,我們的應用需要支撐更多的並發量,同時我們的應用伺服器和資料庫伺服器所做的計算也越來越多。但是往往我們的應用伺服器資源是有限的,資料庫每秒能接受的請求次數也是有限的(或者文件的讀寫也是有限的),如何能夠有效利用有限的資源來提供儘可能大的吞吐量?
  • Redis之緩存雪崩、緩存穿透、緩存預熱、緩存更新、緩存降級
    1、緩存雪崩發生場景:當Redis伺服器重啟或者大量緩存在同一時期失效時,此時大量的流量會全部衝擊到資料庫上面,資料庫有可能會因為承受不住而宕機解決辦法:1)隨機均勻設置失效時間2)設置過期標誌更新緩存3)並發量不是特別多的時候
  • 本地緩存高性能之王Caffeine
    軟體要做到用戶體驗好,響應速度快,緩存就是必不可少的一個神器。緩存又分進程內緩存和分布式緩存兩種:分布式緩存如redis、memcached等,還有本地(進程內)緩存如ehcache、GuavaCache、Caffeine等。說起Guava Cache,很多人都不會陌生,它是Google Guava工具包中的一個非常方便易用的本地化緩存實現,基於LRU算法實現,支持多種緩存過期策略。
  • 從零開始手寫 redis(11)時鐘淘汰算法詳解及實現
    前言java從零手寫實現redis(一)如何實現固定大小的緩存?java從零手寫實現redis(三)redis expire 過期原理java從零手寫實現redis(三)內存數據如何重啟不丟失?java從零手寫實現redis(四)添加監聽器java從零手寫實現redis(五)過期策略的另一種實現思路java從零手寫實現redis(六)AOF 持久化原理詳解及實現java從零開始手寫 redis(七)LRU 緩存淘汰策略詳解前面我們實現了 FIFO/LRU/LFU 等常見的淘汰策略,不過在作業系統中,實際上使用的是時鐘頁面置換算法
  • Mybatis基本知識十六:查詢緩存之第三方查詢緩存
    上一篇文章:《Mybatis基本知識十五:查詢緩存之二級查詢緩存(內置)》若文中有紕漏,請多多指正!!!timeToLiveSeconds - 緩存element的有效生命期,從開始創建的時間算起,默認是0.,也就是element存活時間無窮大。注意當timeToLiveSeconds>=timeToIdleSeconds,才有意義。diskSpoolBufferSizeMB 這個參數設置DiskStore(磁碟緩存)的緩存區大小.
  • 優化Windows的磁碟緩存
    優化磁碟緩存  磁碟緩存又稱為虛擬緩存,它的讀/寫速度比管理磁介質快得多,是改善硬碟性能的主要手段。在硬碟空閒時會把數據預先存入緩存,一旦程序請求到此段資料,可以馬上從緩存中得到,無須再讀/寫硬碟,特別是連續存取的操作之中,Cache能夠極大地提高系統的整體速度。
  • CPU一級緩存、二級緩存、三級緩存是什麼意思?CPU緩存有什麼用?
    所謂的CPU緩存就是CPU內部的緩存運行頻率,緩存的大小與結構對CPU速度的影響較大,因此緩存大小也是CPU重要的性能指標之一。
  • Caffeine Cache~高性能 Java 本地緩存之王
    如前所述,作為現代的緩存,它需要解決兩個挑戰:一個是如何避免維護頻率信息的高開銷;另一個是如何反應隨時間變化的訪問模式。首先來看前者,TinyLFU藉助了數據流Sketching技術,Count-Min Sketch顯然是解決這個問題的有效手段,它可以用小得多的空間存放頻率信息,而保證很低的False Positive Rate。
  • 從零開始手寫 redis(六)AOF 持久化原理詳解及實現
    前言java從零手寫實現redis(一)如何實現固定大小的緩存?java從零手寫實現redis(三)redis expire 過期原理java從零手寫實現redis(三)內存數據如何重啟不丟失?java從零手寫實現redis(四)添加監聽器java從零手寫實現redis(五)過期策略的另一種實現思路我們前面簡單實現了 redis 的幾個特性,java從零手寫實現redis(三)內存數據如何重啟不丟失? 中實現了類似 redis 的 RDB 模式。
  • 從零開始手寫 redis(三)內存數據重啟後如何不丟失?
    前言我們在 從零手寫 cache 框架(一)實現固定大小的緩存 中已經初步實現了我們的 cache。我們在 從零手寫 cache 框架(一)實現過期特性 中實現了 key 的過期特性。本節,讓我們來一起學習一下如何實現類似 redis 中的 rdb 的持久化模式。
  • 緩存工廠之Redis緩存,從搭建Redis服務端開始,並用客戶端連接
    封裝緩存父類,定義Get,Set等常用方法。定義RedisCache緩存類,執行Redis的Get,Set方法。構造出緩存工廠調用方法下面一步一個腳印的來分享:。封裝緩存父類,定義Get,Set等常用方法先來,上父類的代碼:這裡定義的方法沒有太多的注釋,更多的意思我想看方法名稱就明白了,這個父類主要實現了IDisposable
  • 如何解決Redis的緩存穿透、緩存雪崩和緩存擊穿
    相信大家在項目中都用到了Redis來做數據緩存,但有些問題我們在使用中不得不考慮,其中典型的問題就是:緩存穿透、緩存雪崩和緩存擊穿。緩存穿透緩存穿透,是指查詢一個資料庫不存在的數據。這個-1,就是不存在的數據,就會每次都去查詢資料庫,而每次查詢都是空,每次又都不會進行緩存。假如有惡意攻擊,就可以利用這個漏洞,對資料庫造成壓力,甚至壓垮資料庫。那麼,如何解決上述出現的問題呢?如果從資料庫查詢的數據為空,我們也把它放入緩存,只是設定的緩存過期時間較短,比如設置為60秒。