手把手教你從0到1寫一個簡單的緩存框架

目前市面上已經有很多開源的緩存框架,比如 Redis、Memcached、Ehcache 等,那爲什麼還要自己動手寫緩存?本文將帶領大家從 0 到 1 寫一個簡單的緩存框架,目的是讓大家對緩存的類型、緩存的標準、緩存的實現以及原理方面有一個系統的瞭解,做到知其然,知其所以然。注:本文代碼部分較長,建議在 PC 端打開閱讀。緩存定義的規範

JSR是 JavaSpecification Requests的縮寫,意思是 Java 規範提案,它已成爲 Java 界的一個重要標準。在 2012年 10月 26 日 JSR 規範委員會發布了 JSR 107(JCache API)的首個早期規範,該規範定義了一種對 Java 對象臨時在內存中進行緩存的方法,包括對象的創建、訪問、失效、一致性等。

新規範的主要內容及特性

新規範的主要內容如下:

爲應用程序提供緩存 Java 對象的功能。

  • 定義了一套通用的緩存概念和工具。

  • 最小化開發人員使用緩存的學習成本。

  • 最大化應用程序在使用不同緩存實現之間的可移植性。

  • 支持進程內和分佈式的緩存實現。

  • 支持 by-value 和 by-reference 的緩存對象。

  • 定義遵照 JSR-175 的緩存註解;定義一套 Java 編程語言的元數據。

從規範的設計角度來看,在 javax.cache 包中有一個 CacheManager 接口負責保存和控制一系列的緩存,主要特性包括:

能從緩存中讀取數據

  • 能將數據寫入到緩存中

  • 緩存具有原子性操作

  • 具有緩存事件監聽器

  • 具有緩存註解

  • 保存緩存的 KEY 和 VALUE 類型應該爲泛型

新規範的 API 介紹

在 JSR107 規範中將 Cache API(javax.cache)作爲實現的類庫,通過如下的 Maven 進行引入。

<dependency>
           <groupId>javax.cache</groupId>
           <artifactId>cache-api</artifactId>
           <version>1.0.0</version>
</dependency>

1. 核心概念

Cache API 定義了 4 個核心概念:

  • CachingProvider:定義了創建、配置、獲取、管理和控制多個 CacheManager。一個應用可以在運行期訪問多個 CachingProvider。

  • CacheManager:定義了創建、配置、獲取、管理和控制多個唯一命名的 Cache,這些 Cache 存在於 CacheManager 的上下文中。一個 CacheManager 僅被一個 CachingProvider 所擁有。

  • Cache:是一個類似 Map 的數據結構並臨時存儲以 Key 爲索引的值。一個 Cache 僅被一個 CacheManager 所擁有。

  • Entry:是一個存儲在 Cache 中的 key-value 對。

每一個存儲在 Cache 中的條目有一個定義的有效期,即 Expiry Duration。一旦超過這個時間,條目爲過期的狀態。一旦過期,條目將不可訪問、更新和刪除。緩存有效期可以通過 ExpiryPolicy 設置。

2. Store-By-Value和 Store-By-Reference

Store-By-Value 和 Store-By-Reference 是兩種不同的緩存實現。

  • Store-By-Value:指在 key/value 存入緩存時,將其值拷貝一份存入緩存。避免在其他程序修改 key 或 value 的值時,污染緩存內存儲的內容。

  • Store-By-Reference:指在 key/value 存入緩存時,直接將其引用存入緩存。

java常見的堆內緩存,一般使用 Store-By-Reference 方式,提升緩存性能。常見的堆外緩存和進程外緩存,一般由於使用引用在技術上比較複雜,通常使用 Store-By-Value 方式。

3. 緩存過期策略

如果緩存中的數據已經過期,那它將不能從緩存返回。如果緩存沒有配置過期政策,默認是永久有效的策略(Eternal)。

過期策略可以在配置時提供一個 ExpiryPolicy 實現的設置,見下面的定義

publicinterface ExpiryPolicy<K, V> {
 Duration getExpiryForCreatedEntry(Entry<?extends K, ? extends V>entry);
   DurationgetExpiryForAccessedEntry(Entry<? extends K, ? extends V>entry);
   Duration getExpiryForModifiedEntry(Entry<?extends K, ? extends V>entry);
}

其中:

  • getExpiryForCreatedEntry():當數據創建後的到期持續時間

  • getExpiryForAccessedEntry(): 當數據訪問後的到期持續時間

  • getExpiryForModifiedEntry():當數據修改後的到期持續時間

當這些方法被調用時,ExpiryPolicy 將返回下列值之一:

  • 持續時間等於緩存配置的過期時間

  • Duration.ZERO 表明數據目前已經是過期的

在特定的緩存操作執行後的一段時間後數據需要進行回收,該時間由 Duration類定義。Duration是由一個由 java.util.concurrent.TimeUnit 和時長 durationAmount 組成,TimeUnit 的最小值爲 TimeUnit.MILLISECONDS。

緩存框架的實現

基於上文緩存定義的規範,我們可以自己動手寫一個簡單的緩存框架,我們先對緩存框架做一個初步的規劃,實現一個具有如表 1 所描述的特性的簡單緩存。


表 1 緩存框架特性

下面,我們將遵循我們的規劃,由簡入繁逐步迭代我們的緩存組件,我們給組件取名叫做 CsCache(Cache Study)。

前期準備

參考開源緩存組件 EhCache 和 Guava,提取它們的公共方法,可以得到最核心的,也是我們最關心的一些方法,見表 2。


表 2 簡單緩存的常用方法

我們的緩存框架選取了最基本的 get(獲取緩存)、put(放入緩存)、remove(根據 key值刪除緩存)、clear(清空緩輩子)方法,這些方法是實際工作中當中最常用的功能。

緩存的架構介紹

通過上一小節的前期準備,我們確定了緩存框架的幾個基本的使用方法,那麼從這一小節,我們就由淺入深的介紹 CsCache 緩存框架。

通過 JSR107 規範,我們將框架定義爲客戶端層、緩存提供層、緩存管理層、緩存存儲層。其中緩存存儲層又分爲基本存儲層、LRU存儲層和 Weak 存儲層,如圖 1 所示。


圖 1 緩存分層圖

其中:

  • 客戶端層:使用者直接通過該層與數據進行交互。

  • 緩存提供層:主要對緩存管理層的生命週期進行維護,負責緩存管理層的創建,保存、獲取以及銷燬。

  • 緩存管理層:主要對緩存客戶端的生命週期進行維護,負責緩存客戶端的創建,保存、獲取以及銷燬

  • 緩存存儲層:負責數據以什麼樣的形式進行存儲。

  • 基本存儲層:是以普通的 ConcurrentHashMap 爲存儲核心,數據不淘汰。

  • LRU 存儲層:是以最近最少用爲原則進行的數據存儲和緩存淘汰機制。

  • Weak 存儲層:是以弱引用爲原則的數據存儲和緩存淘汰機制。

設計思路以及知識點詳解

本節開始深入介紹緩存框架的類圖以及相關知識點。圖 2 所示列出了緩存框架的工程結構。

圖 2 框架工程結構圖

整個工程結構的包結構分爲 JSR107 包和 store 包,JSR107 是與規範相關的一些類的封裝,store包是與數據存儲相關類的封裝。

1. 設計類圖

通過分析上文緩存架構介紹和圖 2 工程結構圖,我們能夠對框架的整體情況有一個概覽,本小節將以類圖的方式展現框架的設計理念,如圖 3 所示。


圖 3 類圖

根據規範,CacheProvider、CacheManager、Cache是抽象出來的最基礎的緩存接口。其中 Cache 是提供最終緩存實現的基礎接口,其實現類是 CsCache107,初始化時即持有一個 BasicDataStore 對象。完整的類列表見表 3 所示。


表 3 框架核心類列表

2. 緩存框架的 SPI機制

在工程結構中的 META-INF/services/ 下面有一個 javax.cache.spi.CachingProvider 配置文件,裏面有一個 org.cachestudy.writeitbyself.jsr107.CsCaching107Provider 實現類,這個配置文件實際上是利用的 JAVA SPI 機制進行組件的發現與加載。

(1)什麼是 SPI

SPI 的全名爲 Service Provider Interface,是 JDK 內置的一種服務提供發現機制,在 Java.util.ServiceLoader 的文檔裏有比較詳細的介紹。

JAVA SPI 機制的思想簡單來說是:在面向的對象的設計裏,我們一般推薦模塊之間基於接口編程,模塊之間不對實現類進行硬編碼。一旦代碼裏涉及具體的實現類,就違反了可拔插的原則,如果需要替換一種實現,就需要修改代碼。爲了實現在模塊裝配的時候能不在程序裏動態指明,這就需要一種服務發現機制。JAVA SPI 就是提供了這樣的一個機制,爲某個接口尋找服務實現的機制。有點類似 IOC 的思想,就是將裝配的控制權移到程序之外,在模塊化設計中這個機制尤其重要。

(2)SPI 的使用約定

當服務的提供者,提供了服務接口的一種實現之後,在 jar 包的 META-INF/services/ 目錄裏同時創建一個以服務接口命名的文件。該文件裏就是實現該服務接口的具體實現類。而當外部程序裝配這個模塊的時候,就能通過該 jar 包 META-INF/services/ 裏的配置文件找到具體的實現類名,並裝載實例化,完成模塊的注入。基於這樣一個約定就能很好的找到服務接口的實現類,而不需要再代碼裏制定。而在 jdk 裏面提供服查找工具類:java.util.ServiceLoader,如圖 4 所示。


圖 4 SPI 約定結構圖

3. 解讀緩存數據層

緩存數據層實際承擔的責任主要是緩存數據的存儲和緩存的淘汰機制,在圖 2 中可以看到數據的存儲和淘汰是基於 DataStore 這個接口來實現的,而這一實現也正是圖 1 提到的數據存儲層。目前框架一共實現了三個實現類分別是:LRUDataStore、WeakDataStore 和 BaseDataStore。

我們先來分析一下 LRUDataStore 的設計原理:

(1)基於引用的淘汰算法

基於引用的淘汰算法,是一種簡單有效的算法,由 JVM 的 GC 進行回收。Java 的引用主要分爲強引用、軟引用、弱引用、虛引用。

  • 強引用(StrongReference):強引用是使用最普遍的引用。如果一個對象具有強引用,那垃圾回收器絕不會回收它。當內存空間不足,Java 虛擬機寧願拋出 OutOfMemoryError 錯誤,使程序異常終止,也不會靠隨意回收具有強引用的對象來解決內存不足的問題。

  • 軟引用(SoftReference):如果一個對象只具有軟引用,則內存空間足夠,垃圾回收器就不會回收它;如果內存空間不足了,就會回收這些對象的內存。只要垃圾回收器沒有回收它,該對象就可以被程序使用。軟引用可用來實現內存敏感的高速緩存。軟引用可以和一個引用隊列(ReferenceQueue)聯合使用,如果軟引用所引用的對象被垃圾回收器回收,Java 虛擬機就會把這個軟引用加入到與之關聯的引用隊列中。

  • 弱引用(WeakReference):弱引用與軟引用的區別在於:只具有弱引用的對象擁有更短暫的生命週期。在垃圾回收器線程掃描它所管轄的內存區域的過程中,一旦發現了只具有弱引用的對象,不管當前內存空間足夠與否,都會回收它的內存。不過,由於垃圾回收器是一個優先級很低的線程,因此不一定會很快發現那些只具有弱引用的對象。弱引用可以和一個引用隊列(ReferenceQueue)聯合使用,如果弱引用所引用的對象被垃圾回收,Java 虛擬機就會把這個弱引用加入到與之關聯的引用隊列中。

  • 引用(PhantomReference):“虛引用”顧名思義,就是形同虛設,與其他幾種引用都不同,虛引用並不會決定對象的生命週期。如果一個對象僅持有虛引用,那麼它就和沒有任何引用一樣,在任何時候都可能被垃圾回收器回收。

我們的引用淘汰算法是基於弱引用來實現的,在圖 5 中展示了 store 包的類列表

圖 5 弱引用淘汰算法

其中 WeakValueDataStore 和 WeakValueHoler 是弱引用實現所需要的實現類。WeakValueDataStore 實現了 DataStore接口,提供基於弱引用的數據存儲,WeakValueHolder 實現 ValueHolder 接口,提供基於弱引用的實際值存儲邏輯。

WeakValueDataStore 類的代碼及實現原理如下:

//定義了使用簡單弱引用的數據存儲器,代碼經過剪裁,完整代碼請參考 github
public classWeakValueDataStore<K, V> implements DataStore<K, V> {
ConcurrentHashMap<K,ValueHolder<V>> map = new ConcurrentHashMap<K,ValueHolder<V>>();

          @Override
      public ValueHolder<V> get(K key)throws StoreAccessException {
               return map.get(key);
      }
      @Override
      public PutStatus put(K key, V value)throws StoreAccessException {
               ValueHolder<V> v = newWeakValueHolder<V>(value);
               map.put(key, v);
               return PutStatus.PUT;
      }

      @Override
      public ValueHolder<V> remove(K key)throws StoreAccessException {
               return map.remove(key);
      }

          @Override
      public void clear() throwsStoreAccessException {
               map.clear();
      }
}

WeakValueHolder 的代碼及實現原理如下:

//簡單的弱引用實現
public classWeakValueHolder<V> implements ValueHolder<V> {
      public WeakValueHolder(V value) {
/* 使用 JDK提供的 WeakReference,建立對象的弱引用
* 在沒有強引用時,JVM GC將回收對象,調用 WeakReference.get時
* 返回 null
   */
               this.v = new WeakReference<V>(value);
      }
      privateWeakReference<V> v;
      @Override
      public V value() {
               return this.v.get();
      }
}

測試用例驗證方法如下:

@Test
public voidTestWeakValue() throws InterruptedException {
      CsCache<String, User> cache = newCsCache<String, User>(new WeakValueDataStore<String, User>());
      String key = "leo";
      User user = new User();
      user.setName("leo");
      cache.put(key, user);
/* 釋放對象的強引用,等待 JVM GC */
      user = null;
      System.out.println("Hello " + cache.get(key).getName());
      System.gc();
      Thread.sleep(10000);
/* JVM調度顯式 GC後,回收了 name是 leo的 user
* get返回 null
*/
      System.out.println("Hello " + cache.get(key));
}

(2)基於 LRU的淘汰算法

LRU(Least recently used,最近最少使用)算法根據數據的歷史訪問記錄來進行淘汰數據,其核心思想是“如果數據最近被訪問過,那麼將來被訪問的機率也更高”。

CsCache的 LRU 簡單實現邏輯如下:我們通過維護 entry 的列表,在 get、put時維護 entry 列表實現,使最少訪問的鍵值對維持在 entry 列表的最尾部。在數據量超過緩存容量需要做 LRU 淘汰時,我們通過刪除鏈表尾部的數據,來實現簡單的 LRU 數據淘汰機制,如圖 6 所示。


圖 6 LRU 淘汰算法

其中 LRUDataStore和 LRUEntry是弱引用實現所需要的實現類。LRUDataStore 實現了 DataStore 接口。LRUEntry 對象則是 LRU 的數據存儲類

LRUDataStore 類的關鍵代碼及實現原理如下:

          @Override
      public ValueHolder<V> get(K key)throws StoreAccessException {
      LRUEntry<K, ValueHolder<?>>entry = (LRUEntry<K, ValueHolder<?>>) getEntry(key);
               if (entry == null) {
                        return null;
               }
                    /**
               在獲取數據的時候,將該 entity節點數據移動到列表頭。
               moveToFirst(entry);
               return (ValueHolder<V>)entry.getValue();
      }
      @Override
      public PutStatus put(K key, V value)throws StoreAccessException {
               LRUEntry<K,ValueHolder<?>> entry = (LRUEntry<K, ValueHolder<?>>)getEntry(key);
               PutStatus status =PutStatus.NOOP;
               if (entry == null) {
                              /**
                        數據緩存列表中的數據已經超過預定值,則刪除列表中
                              尾的節點數據,以實現 LRU算法
                        **/
                        if (map.size() >=maxSize) {
                                 map.remove(last.getKey());
                                        removeLast();
                        }
                        entry = newLRUEntry<K, ValueHolder<?>>(key, newBasicValueHolder<V>(value));
                        status = PutStatus.PUT;
               } else {
                        entry.setValue(newBasicValueHolder<V>(value));
                        status =PutStatus.UPDATE;
               }
               /**
               新添加的數據要加到列表的頭部
               **/
               moveToFirst(entry);
               map.put(key, entry);
               return status;
}

這段關鍵代碼的核心意思是,在 LRUDataStore 類中維護了一個 LRUEntity 的數據鏈表,當執行 put 操作的時,則將數據封裝成 LRUEntity 數據節點,加入到鏈表的頭部以表示數據是最新的,如果數據超出鏈表的設定的大小範圍,則從鏈表的尾部刪除最不活躍的數據節點。當執行 get 操作的時,首先將 LRUEntity 數據節點移到到鏈表的頭部,以表示該數據被最新請求訪問,然後將數據返回。

4. 解讀緩存管理層(CacheManager)

在上面圖 1 中我們介紹了框架的分層結構,其中接口類 CacheManager 所對應的正是緩存管理層,在 CsCache 框架中 CacheManager 的實現類是 CsCache107Manager,它主要負責管理多個 Cache 客戶端實例,以及負責緩存客戶端實例的創建、銷燬、獲取等。

下面具體介紹 CsCache107Manager 類的關鍵代碼及實現原理。

(1)緩存實例的創建

緩存實例創建的實現代碼如下:

//緩存客戶端實例的創建
//緩存池是用 ConcurrentMap來實現的,用以緩存已經創建好的緩存實例
synchronizedpublic <K, V, C extends Configuration<K, V>> Cache<K, V>createCache(String cacheName, C configuration)
                        throwsIllegalArgumentException {
               if (isClosed()) {
                        throw newIllegalStateException();
               }
                 //檢查緩存實例名稱是否爲空
               checkNotNull(cacheName,"cacheName");
                 //檢查配置信息是否爲空
               checkNotNull(configuration,"configuration");
                 //根據 cacheName獲取緩存客戶端實例
               CsCache107<?, ?> cache =caches.get(cacheName);

               if (cache == null) {
                        //如果無法從事先創建好的緩存池中獲取,則創建一個新的實例
                        cache = newCsCache107<K, V>(this, cacheName, configuration);
                        //將新創建的緩存實例加到緩存池中
                              caches.put(cache.getName(),cache);
                        return (Cache<K,V>) cache;
               } else {
                        throw newCacheException("A cache named " + cacheName + " alreadyexists.");
               }
}

上面的代碼只是針對 CsCache107Manager 類的 createCache 方法的代碼進行了解讀,完整的緩存實例的創建流程,如圖 7 所示。


圖 7 緩存實例創建

(2)緩存實例的獲取

緩存實例獲取的實現代碼如下:

public<K, V> Cache<K, V> getCache(String cacheName, Class<K>keyClazz, Class<V> valueClazz) {
               if (isClosed()) {
                        throw newIllegalStateException();
               }
               //判斷 key類型是否爲空
               checkNotNull(keyClazz, "keyType");
               //判斷值類型是否爲空
               checkNotNull(valueClazz,"valueType");
               //從緩存池中獲取緩存實例
              CsCache107<K,V> cache = (CsCache107<K, V>) caches.get(cacheName);
               //如果獲取爲空則返回 null
               if (cache == null) {
                        return null;
               } else {
                        //判斷傳入的對象和值類型是否與設定的類型一致
                        Configuration<?,?> configuration = cache.getConfiguration(Configuration.class);

                        if(configuration.getKeyType() != null &&configuration.getKeyType().equals(keyClazz)) {
                                 //如果一致則返回實例
                                 return cache;
                        } else {
                                 //如果不一致則拋出類型不一致異常
                                 throw new ClassCastException("Incompatiblecache key types specified, expected "
                                                    +configuration.getKeyType() + " but " + valueClazz + " wasspecified");
                        }
               }
}

完整的緩存實例獲取流程圖,如圖 8 所示。


圖 8 緩存實例的獲取

緩存實例的創建和獲取實際上主要是基於一個緩存池來實現的,在代碼中使用的是一個 ConcurrentHashMap 類,可以根據多個不同的緩存名稱創建多個緩存實例,從而可以併發的讀取。

5. 解讀數據客戶端層

緩存客戶端層主要是針對實際使用者的,在工程結構中主要涉及二個類,分別是:CsCache和 CsCache107,而 CsCache107使用的代理模式對 CsCache 進行的包裝,如圖 9 所示。用戶在使用的時候,通過緩存管理層的 CacheManager 對象就可以獲得 CsCache107 客戶端對象,從而可以實現對緩存的直接操作。


圖 9 數據客戶端層

CsCache 關鍵代碼和實現原理如下:

privatefinal DataStore<K, V> store;
      private static Logger logger =LoggerFactory.getLogger(CsCache.class);
      //構造方法,參數是傳入數據存儲和淘汰策略對象
      public CsCache(final DataStore<K, V>dataStore) {
               store = dataStore;
      }
      //根據 key值獲取緩存數據
      public V get(final K key) {
               try {
                        //從數據存儲和淘汰策略對象中獲取緩存數據
                        ValueHolder<V>value = store.get(key);
                        if (null == value) {
                                 return null;
                        }
                        //返回緩存數據
                        return value.value();
               } catch (StoreAccessException e){
                        logger.error("storeaccess error : ", e.getMessage());
                        logger.error(e.getStackTrace().toString());
                        return null;
               }
      }
      //緩存數據的存儲
      public void put(final K key, final Vvalue) {
               try {
                        將數據直接存放到數據和淘汰策略對象中
                        store.put(key, value);
               } catch (StoreAccessException e){
                        logger.error("storeaccess error : ", e.getMessage());
                        logger.error(e.getStackTrace().toString());
               }
}

整個過程其實較爲簡單對象的構造方法中有一個 DataStore 對象,這個對象正是緩數據存儲與淘汰策略對象,這個機制已經在解讀緩存數據層小節中進行了詳解,get 方法則是從 DataStore 中獲取緩存數據,put 方法則是往 DataStore 對象中存入數據。

CscCache107 對象實際上是對 CsCache 對象根據 JSR107 規範,使用了代理模式進行包裝,下面將展示幾個示例方法,原理與上面 CsCache 是一樣的,本節就不在說明。CsCache107 關鍵代碼和實現原理如下:

      //獲取緩存數據
      @Override
      public V get(K key) {
               return csCache.get(key);
      }
      //存放緩存數據
      @Override
      public void put(K key, V value) {
               this.csCache.put(key, value);
      }
      //刪除緩存數據
      @Override
      public boolean remove(K key) {
               csCache.remove(key);
               return true;
      }

通過上面代碼可以看到 put、get、remove方法都是調用的 CsCache 對象的相關方法進行的操作,其目的主要是在有特殊需求的時候可以對這幾個方法進行功能的擴展和增強。

 緩存框架的使用示例

緩存框架的實現以及原理到這裏就基本介紹完了,下面我們將以一個使用示例結束本文的講解。

     //獲取緩存提供層對象
CachingProvidercachingProvider = Caching.getCachingProvider();
//獲取緩存管理層對象
CacheManagermanager = cachingProvider.getCacheManager();
//創建緩存實例對象
     Cache<String, User> cache =(Cache<String, User>) manager.<String, User,     Configuration<String, User>>createCache("Test", new  MutableConfiguration<String,User>());
     Stringkey = "leo";
     User user = new User();
user.setName("leo");
//將 User數據對象存放到緩存中
  cache.put(key, user);
  System.out.println("Hello " +cache.get(key).getName());

爲方便讀者能夠完整的學習 CsCache 框架,本章實例的完整代碼放入在https://github.com/jsr107/jsr107spec,讀者可以自行下載學習。

欲瞭解更多有關分佈式緩存方面的內容,請閱讀《深入分佈式緩存:從原理到實踐》一書。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章