1. 程式人生 > 程式設計 >閒來無事,動手寫一個LRU本地快取

閒來無事,動手寫一個LRU本地快取

學習java併發的時候,書上的例子是基於快取展開的,於是就想可以寫一個通用的本地快取

寫在前面

寫一個快取,需要考慮快取底層儲存結構、快取過期、快取失效、併發讀寫等問題,因此自己動手寫的本地快取將圍繞這幾點進行設計

快取失效

快取失效指的是快取過期了,需要對過期的快取資料進行刪除。刪除可以分為主動刪除和被動刪除兩種

主動刪除
  • 定時刪除
    在設定鍵值對過期時間的同時,建立一個定時器,讓定時器在鍵過期時間來臨時,立即執行對鍵的刪除操作
    優點:對記憶體最友好,保證過期的鍵值對儘可能地被刪除,釋放過期鍵值對所佔用的記憶體
    缺點:對CPU不友好,如果過期鍵值對比較多,刪除過期鍵值對會佔用相當一部分CPU
    執行時間
  • 定期刪除
    每隔一段時間執行一次刪除過期鍵操作,並通過限制刪除操作執行的時長和頻率來減少刪除操作對CPU時間的影響【難點,執行時長和頻率比較難設定】
被動刪除
  • 惰性刪除
    只有在取出鍵的時候才會對鍵進行過期檢查 優缺點和定時刪除相反
    優點:對CPU友好
    缺點:對記憶體不友好 同時使用惰性刪除+定期刪除,可以取得CPU和記憶體的平衡,因此本地快取的快取失效採用惰性刪除+定期刪除兩種

快取淘汰

快取淘汰指的是快取的數量達到一定值時按照某種規則刪除某個資料,不考慮該資料是否過期。常見的快取淘汰演演算法有:

  • 先進先出演演算法【FIFO
    最先存入快取的資料將最先被淘汰
  • 最不經常使用演演算法【LFU

    淘汰使用次數最少的資料,一般實現是對每個資料進行計數,每使用一次就進行計算一次,淘汰計數次數最少的
  • 最近最少使用演演算法【LRU
    最近不使用的資料最先被淘汰,一般實現是通過連結串列,將最新訪問、新插入的元素移到連結串列頭部,淘汰連結串列最後一個元素 本地快取將選擇LRU演演算法實現快取淘汰

快取結構定義

選擇好了快取失效和快取淘汰的演演算法以後就可以確定快取結構了,原先考略的是執行緒安全的K-V結構的ConcurrentHashMap再加+雙向連結串列的結構,但何甜甜最近沉迷記英語單詞,同時瞭解到LinkedHashMap可以實現LRU,偷懶使用了LinkedHashMapLinkedHashMap

可以基於插入順序儲存【預設】,也可以根據訪問順序儲存【最近讀取的會放在最前面,最最不常讀取的會放在最後】,將插入順序儲存改為訪問順序儲存只需將accssOrder設定為true即可,預設為false。同時LinkedHashMap提供了一個用於判斷是否需要移除最不常讀取資料的方法【removeEldestEntry(Map.Entry<K,CacheNode<K,V>> eldest)預設返回false不移除】,需要移除重寫該方法就可以了

快取節點的定義

public class CacheNode<K,V> {
    /**
     * 儲存的鍵
     */
    private K key;

    /**
     * 儲存的值
     */
    private V value;

    /**
     * 儲存時間
     */
    private long gmtCreate;

    /**
     * 過期時間,單位為毫秒,預設永久有效
     */
    private long expireTime = Long.MAX_VALUE;
}
複製程式碼

快取結構初始化

    /**
     * 底層快取結構
     */
    private LinkedHashMap<K,V>> localCache;

    /**
     * 負載因子
     */
    private final float DEFAULT_LOAD_FACTOR = 0.75f;

    /**
     * 快取過期清理策略
     */
    private ExpireStrategy<K,V> lazyExpireStrategy = new LazyExpireStrategy<>();

    private ExpireStrategy<K,V> regularExpireStrategy;

    private int maxCacheSie;

    /**
     * 建構函式
     *
     * @param expireStrategy 快取失效策略實現類,針對的是定期失效快取,傳入null,定期失效快取類為預設配置值
     * @param maxCacheSie    快取最大允許存放的數量,快取失效策略根據這個值觸發
     */
    public LocalCache(int maxCacheSize,ExpireStrategy<K,V> expireStrategy) {
        //快取最大容量為初始化的大小
        this.maxCacheSize = maxCacheSize;
        //快取最大容量 => initialCapacity * DEFAULT_LOAD_FACTOR,避免擴容操作
        int initialCapacity = (int) Math.ceil(maxCacheSie / DEFAULT_LOAD_FACTOR) + 1;
        //accessOrder設定為true,根據訪問順序而不是插入順序
        this.localCache = new LinkedHashMap<K,V>>(initialCapacity,DEFAULT_LOAD_FACTOR,true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<K,V>> eldest) {
                return size() > maxCacheSie;
            }
        };
        this.regularExpireStrategy = (expireStrategy == null ? new RegularExpireStrategy<>() : expireStrategy);
        //啟動定時清除過期鍵任務
        regularExpireStrategy.removeExpireKey(localCache,null);
    }
複製程式碼

說明:

  • 重寫了removeEldestEntry方法,當快取大小超過了設定的maxCacheSize才會移除不常使用的元素
  • 建構函式中設定accessOrdertrue,根絕訪問順序儲存
  • 快取容量大小由(int) Math.ceil(maxCacheSie / DEFAULT_LOAD_FACTOR) + 1計算得到,這樣即使達到設定的maxCacheSize也不會觸發擴容操作
  • regularExpireStrategy.removeExpireKey(localCache,null);啟動定期刪除任務

定期刪除策略實現:

public class RegularExpireStrategy<K,V> implements ExpireStrategy<K,V> {
    Logger logger = LoggerFactory.getLogger(getClass());
    /**
     * 定期任務每次執行刪除操作的次數
     */
    private long executeCount = 100;

    /**
     * 定期任務執行時常 【1分鐘】
     */
    private long executeDuration = 1000 * 60;

    /**
     * 定期任務執行的頻率
     */
    private long executeRate = 60;

    //get and set
    public long getExecuteCount() {
        return executeCount;
    }

    public void setExecuteCount(long executeCount) {
        this.executeCount = executeCount;
    }

    public long getExecuteDuration() {
        return executeDuration;
    }

    public void setExecuteDuration(long executeDuration) {
        this.executeDuration = executeDuration;
    }

    public long getExecuteRate() {
        return executeRate;
    }

    public void setExecuteRate(long executeRate) {
        this.executeRate = executeRate;
    }

    /**
     * 清空過期Key-Value
     *
     * @param localCache 本地快取底層使用的儲存結構
     * @param key 快取的鍵
     * @return 過期的值
     */
    @Override
    public V removeExpireKey(LinkedHashMap<K,V>> localCache,K key) {
        logger.info("開啟定期清除過期key任務");
        ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
        //定時週期任務,executeRate分鐘之後執行,預設1小時執行一次
        executor.scheduleAtFixedRate(new MyTask(localCache),executeRate,TimeUnit.MINUTES);
        return null;
    }

    /**
     * 自定義任務
     */
    private class MyTask<K,V> implements Runnable {
        private LinkedHashMap<K,V>> localCache;

        public MyTask(LinkedHashMap<K,V>> localCache) {
            this.localCache = localCache;
        }

        @Override
        public void run() {
            long start = System.currentTimeMillis();
            List<K> keyList = localCache.keySet().stream().collect(Collectors.toList());
            int size = keyList.size();
            Random random = new Random();

            for (int i = 0; i < executeCount; i++) {
                K randomKey = keyList.get(random.nextInt(size));
                if (localCache.get(randomKey).getExpireTime() - System.currentTimeMillis() < 0) {
                    logger.info("key:{}已過期,進行定期刪除key操作",randomKey);
                    localCache.remove(randomKey);
                }

                //超時執行退出
                if (System.currentTimeMillis() - start > executeDuration) {
                    break;
                }
            }
        }
    }
}
複製程式碼

說明:

  • 使用ScheduledExecutorServicescheduleAtFixedRate實現定時週期任務
  • 預設1小時執行一次,每次執行的時間為1分鐘,每次隨機嘗試刪除100個元素【如果時間允許、鍵過期】

懶載入刪除策略實現:LazyExpireStrategy.java

public class LazyExpireStrategy<K,V> {
    private final Logger logger = LoggerFactory.getLogger(getClass());

    /**
     * 清空過期Key-Value
     *
     * @param localCache 本地快取底層使用的儲存結構
     * @param key 快取的鍵
     * @return 過期的值
     */
    @Override
    public V removeExpireKey(LinkedHashMap<K,K key) {
        CacheNode<K,V> baseCacheValue = localCache.get(key);
        //值不存在
        if (baseCacheValue == null) {
            logger.info("key:{}對應的value不存在",key);
            return null;
        } else {
            //值存在並且未過期
            if (baseCacheValue.getExpireTime() - System.currentTimeMillis() > 0) {
                return baseCacheValue.getValue();
            }
        }

        logger.info("key:{}已過期,進行懶刪除key操作",key);
        localCache.remove(key);
        return null;
    }
}
複製程式碼

說明:

  • 判斷鍵是否存在,不存在返回null
  • 如果鍵存在,判斷是否過期,不過期返回,過期刪除並返回null

快取操作方法的實現

  • 刪除
    public synchronized V removeKey(K key) {
      CacheNode<K,V> cacheNode = localCache.remove(key);
      return cacheNode != null ? cacheNode.getValue() : null;
    }
    複製程式碼
  • 查詢
    public synchronized V getValue(K key) {
      return lazyExpireStrategy.removeExpireKey(localCache,key);
    }
    複製程式碼
    查詢的時候會走懶刪除策略
  • 存入
    存入的值不失效:
    public synchronized V putValue(K key,V value) {
        CacheNode<K,V> cacheNode = new CacheNode<>();
        cacheNode.setKey(key);
        cacheNode.setValue(value);
        localCache.put(key,cacheNode);
        // 返回新增的值
        return value;
    }
    複製程式碼
    存入的值失效:
    public synchronized V putValue(K key,V value,long expireTime) {
      CacheNode<K,V> cacheNode = new CacheNode<>();
      cacheNode.setKey(key);
      cacheNode.setValue(value);
      cacheNode.setGmtCreate(System.currentTimeMillis() + expireTime);
      localCache.put(key,cacheNode);
      // 返回新增的值
      return value;
    }
    複製程式碼
  • 設定快取失效時間
    public synchronized void setExpireKey(K key,long expireTime) {
      if (localCache.get(key) != null) {
        localCache.get(key).setExpireTime(System.currentTimeMillis() + expireTime);
      }
    }
    複製程式碼
  • 獲取快取大小
    public synchronized int getLocalCacheSize() {
            return localCache.size();
    }
    複製程式碼

所有方法為了保證執行緒安全都使用了synchronize關鍵字【執行緒安全,何甜甜只會synchronize,沒有想到其他更好的加鎖方式、考慮了讀寫鎖但是行不通、、、】

使用姿勢

  • 建立LocalCache物件
    • 姿勢一
      LocalCache<Integer,Integer> localCache = new LocalCache<>(4,null);
      複製程式碼
      第一個引數快取的大小,允許存放快取的數量 第二個引數定期刪除物件,如果為null,使用預設的定期刪除物件【執行週期、執行時間、執行次數都為預設值】
    • 姿勢二
      RegularExpireStrategy<Integer,Integer> expireStrategy = new RegularExpireStrategy<>();
      expireStrategy.setExecuteRate(1); //每隔1分鐘執行一次
      LocalCache<Integer,expireStrategy);
      複製程式碼
      傳入自定義的定期刪除物件
  • 存入快取
    for (int i = 0; i < 16; i++) {
      localCache.putValue(i,i);
    }
    複製程式碼
  • 存入快取並設定失效時間
     localCache.putValue(i,i,1000);
    複製程式碼
  • 從快取中讀取值
    localCache.getValue(i)
    複製程式碼
  • 設定已有快取中資料的過期時間
    localCache.setExpireKey(i,1000)
    複製程式碼
  • 獲取快取的大小
    localCache.getLocalCacheSize()
    複製程式碼
  • 刪除快取
    localCache.removeKey(i)
    複製程式碼

基於學習的目的寫了一個本地快取,實際應用中還是推薦使用GoogleGuava Cache,如果你對我的程式碼足夠自信,當然也歡迎使用提Bug

優化點

  • 使用ConcurrentHashMap再加+雙向連結串列
  • 快取失效、定時任務時間選擇多樣性,目前使用快取失效單位預設為毫秒,定時任務預設單位為分鐘,通過在方法中加入TimeUnit引數時間選擇更多樣性
  • 併發支援較差,實現的是同步【何甜甜太菜了!!!】

TODO

  • 上傳私服,提供依賴的方式在其他專案中使用,順便學習一下如何上傳私服


最後附:專案完整程式碼,歡迎forkstar
如有錯誤,歡迎指正交流【何甜甜真的太菜了!!!】