如何設計一個本地緩存

前言

最近在看Mybatis的源碼,恰好看到緩存這一塊,Mybatis提供了一級緩存和二級緩存;一級緩存相對來講比較簡單,功能比較齊全的是二級緩存,基本上知足了一個緩存該有的功能;固然若是拿來和專門的緩存框架如ehcache來對比可能稍有差距;本文咱們未來整理一下實現一個本地緩存都應該須要考慮哪些東西。java

考慮點

考慮點主要在數據用何種方式存儲,能存儲多少數據,多餘的數據如何處理等幾個點,下面咱們來詳細的介紹每一個考慮點,以及該如何去實現;redis

1.數據結構

首要考慮的就是數據該如何存儲,用什麼數據結構存儲,最簡單的就直接用Map來存儲數據;或者複雜的如redis同樣提供了多種數據類型哈希,列表,集合,有序集合等,底層使用了雙端鏈表,壓縮列表,集合,跳躍表等數據結構;數據庫

2.對象上限

由於是本地緩存,內存有上限,因此通常都會指定緩存對象的數量好比1024,當達到某個上限後須要有某種策略去刪除多餘的數據;編程

3.清除策略

上面說到當達到對象上限以後須要有清除策略,常見的好比有LRU(最近最少使用)、FIFO(先進先出)、LFU(最近最不經常使用)、SOFT(軟引用)、WEAK(弱引用)等策略;緩存

4.過時時間

除了使用清除策略,通常本地緩存也會有一個過時時間設置,好比redis能夠給每一個key設置一個過時時間,這樣當達到過時時間以後直接刪除,採用清除策略+過時時間雙重保證;安全

5.線程安全

像redis是直接使用單線程處理,因此就不存在線程安全問題;而咱們如今提供的本地緩存每每是能夠多個線程同時訪問的,因此線程安全是不容忽視的問題;而且線程安全問題是不該該拋給使用者去保證;bash

6.簡明的接口

提供一個傻瓜式的對外接口是頗有必要的,對使用者來講使用此緩存不是一種負擔而是一種享受;提供經常使用的get,put,remove,clear,getSize方法便可;數據結構

7.是否持久化

這個其實不是必須的,是否須要將緩存數據持久化看需求;本地緩存如ehcache是支持持久化的,而guava是沒有持久化功能的;分佈式緩存如redis是有持久化功能的,memcached是沒有持久化功能的;併發

8.阻塞機制

在看Mybatis源碼的時候,二級緩存提供了一個blocking標識,表示當在緩存中找不到元素時,它設置對緩存鍵的鎖定;這樣其餘線程將等待此元素被填充,而不是命中數據庫;其實咱們使用緩存的目的就是由於被緩存的數據生成比較費時,好比調用對外的接口,查詢數據庫,計算量很大的結果等等;這時候若是多個線程同時調用get方法獲取的結果都爲null,每一個線程都去執行一遍費時的計算,其實也是對資源的浪費;最好的辦法是隻有一個線程去執行,其餘線程等待,計算一次就夠了;可是此功能基本上都交給使用者來處理,不多有本地緩存有這種功能;框架

如何實現

以上大體介紹了實現一個本地緩存咱們都有哪些須要考慮的地方,固然可能還有其餘沒有考慮到的點;下面繼續看看關於每一個點都應該如何去實現,重點介紹一下思路;

1.數據結構

本地緩存最多見的是直接使用Map來存儲,好比guava使用ConcurrentHashMap,ehcache也是用了ConcurrentHashMap,Mybatis二級緩存使用HashMap來存儲:

Map<Object, Object> cache = new ConcurrentHashMap<Object, Object>()
複製代碼

Mybatis使用HashMap自己是非線程安全的,因此能夠看到起內部使用了一個SynchronizedCache用來包裝,保證線程的安全性;
固然除了使用Map來存儲,可能還使用其餘數據結構來存儲,好比redis使用了雙端鏈表,壓縮列表,整數集合,跳躍表和字典;固然這主要是由於redis對外提供的接口很豐富除了哈希還有列表,集合,有序集合等功能;

2.對象上限

本地緩存常見的一個屬性,通常緩存都會有一個默認值好比1024,在用戶沒有指定的狀況下默認指定;當緩存的數據達到指定最大值時,須要有相關策略從緩存中清除多餘的數據這就涉及到下面要介紹的清除策略;

3.清除策略

配合對象上限以後使用,場景的清除策略如:LRU(最近最少使用)、FIFO(先進先出)、LFU(最近最不經常使用)、SOFT(軟引用)、WEAK(弱引用);
LRU:Least Recently Used的縮寫最近最少使用,移除最長時間不被使用的對象;常見的使用LinkedHashMap來實現,也是不少本地緩存默認使用的策略;
FIFO:先進先出,按對象進入緩存的順序來移除它們;常見使用隊列Queue來實現;
LFU:Least Frequently Used的縮寫大概也是最近最少使用的意思,和LRU有點像;區別點在LRU的淘汰規則是基於訪問時間,而LFU是基於訪問次數的;能夠經過HashMap而且記錄訪問次數來實現;
SOFT:軟引用基於垃圾回收器狀態和軟引用規則移除對象;常見使用SoftReference來實現;
WEAK:弱引用更積極地基於垃圾收集器狀態和弱引用規則移除對象;常見使用WeakReference來實現;

4.過時時間

設置過時時間,讓緩存數據在指定時間事後自動刪除;常見的過時數據刪除策略有兩種方式:被動刪除和主動刪除;
被動刪除:每次進行get/put操做的時候都會檢查一下當前key是否已通過期,若是過時則刪除,相似以下代碼:

if (System.currentTimeMillis() - lastClear > clearInterval) {
      clear();
}
複製代碼

主動刪除:專門有一個job在後臺按期去檢查數據是否過時,若是過時則刪除,這其實能夠有效的處理冷數據;

5.線程安全

儘可能用線程安全的類去存儲數據,好比使用ConcurrentHashMap代替HashMap;或者提供相應的同步處理類,好比Mybatis提供了SynchronizedCache:

public synchronized void putObject(Object key, Object object) {
    ...省略...
  }

  @Override
  public synchronized Object getObject(Object key) {
    ...省略...
  }
複製代碼

6.簡明的接口

提供經常使用的get,put,remove,clear,getSize方法便可,好比Mybatis的Cache接口:

public interface Cache {
  String getId();
  void putObject(Object key, Object value);
  Object getObject(Object key);
  Object removeObject(Object key);
  void clear();
  int getSize();
  ReadWriteLock getReadWriteLock();
}
複製代碼

再來看看guava提供的Cache接口,相對來講也是比較簡潔的:

public interface Cache<K, V> {
  V getIfPresent(@CompatibleWith("K") Object key);
  V get(K key, Callable<? extends V> loader) throws ExecutionException;
  ImmutableMap<K, V> getAllPresent(Iterable<?> keys);
  void put(K key, V value);
  void putAll(Map<? extends K, ? extends V> m);
  void invalidate(@CompatibleWith("K") Object key);
  void invalidateAll(Iterable<?> keys);
  void invalidateAll();
  long size();
  CacheStats stats();
  ConcurrentMap<K, V> asMap();
  void cleanUp();
}
複製代碼

7.是否持久化

持久化的好處是重啓以後能夠再次加載文件中的數據,這樣就起到相似熱加載的功效;好比ehcache提供了是否持久化磁盤緩存的功能,將緩存數據存放在一個.data文件中;

diskPersistent="false" //是否持久化磁盤緩存
複製代碼

redis更是將持久化功能發揮到極致,慢慢的有點像數據庫了;提供了AOF和RDB兩種持久化方式;固然不少狀況下能夠配合使用兩種方式;

8.阻塞機制

除了在Mybatis中看到了BlockingCache來實現此功能,以前在看**<<java併發編程實戰>>**的時候其中有實現一個很完美的緩存,大體代碼以下:

public class Memoizerl<A, V> implements Computable<A, V> {
    private final Map<A, Future<V>> cache = new ConcurrentHashMap<A, Future<V>>();
    private final Computable<A, V> c;

    public Memoizerl(Computable<A, V> c) {
        this.c = c;
    }

    @Override
    public V compute(A arg) throws InterruptedException, ExecutionException {
        while (true) {
            Future<V> f = cache.get(arg);
            if (f == null) {
                Callable<V> eval = new Callable<V>() {
                    @Override
                    public V call() throws Exception {
                        return c.compute(arg);
                    }
                };
                FutureTask<V> ft = new FutureTask<V>(eval);
                f = cache.putIfAbsent(arg, ft);
                if (f == null) {
                    f = ft;
                    ft.run();
                }
                try {
                    return f.get();
                } catch (CancellationException e) {
                    cache.remove(arg, f);
                }
            }
        }
    }
}
複製代碼

compute是一個計算很費時的方法,因此這裏把計算的結果緩存起來,可是有個問題就是若是兩個線程同時進入此方法中怎麼保證只計算一次,這裏最核心的地方在於使用了ConcurrentHashMap的putIfAbsent方法,同時只會寫入一個FutureTask;

總結

本文大體介紹了要設計一個本地緩存都須要考慮哪些點:數據結構,對象上限,清除策略,過時時間,線程安全,阻塞機制,實用的接口,是否持久化;固然確定有其餘考慮點,歡迎補充。

相關文章
相關標籤/搜索