這裏寫自定義目錄標題
caffeine、GuavaCache、EhCache 比較
Google Guava工具包中的一個非常方便易用的本地化緩存實現,基於LRU算法實現,支持多種緩存過期策略。
EhCache 是一個純Java的進程內緩存框架,具有快速、精幹等特點,是Hibernate中默認的CacheProvider。
Caffeine是使用Java8對Guava緩存的重寫版本,在Spring Boot 2.0中將取代,基於LRU算法實現,支持多種緩存過期策略。
場景1:8個線程讀,100%的讀操作
場景2:6個線程讀,2個線程寫,也就是75%的讀操作,25%的寫操作
場景3:8個線程寫,100%的寫操作
Caffeine 基礎使用
SpringBoot 集成
- 添加pom文件
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
- 添加配置文件
spring.cache.cache-names=ConfigCache
spring.cache.caffeine.spec=initialCapacity=50,maximumSize=500,expireAfterWrite=4s
spring.cache.type=caffeine
Application.class中添加 @EnableCaching 註解
@SpringBootApplication
@EnableCaching
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application .class, args);
}
}
- 添加需要緩存的方法
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
@Slf4j
public class CaffeineService {
@Cacheable(value = "IZUUL", key = "#key")
public String cacheIZUUL(String key) {
log.info("cacheIZUUL()方法執行");
return getCache(key);
}
@CachePut(value = "IZUUL", key = "#key")
public String cachePutIZUUL(String key) {
log.info("cachePutIZUUL()方法執行");
return "cachePutIZUUL--" + key;
}
private String getCache(String key) {
try {
log.info("getCache()方法執行");
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return key;
}
}
- 添加Controller 進行測試
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class CaffeineController {
@Autowired
private CaffeineService caffeineService;
@GetMapping("/cache-izuul/{key}")
public String cacheIZUUL(@PathVariable String key) {
return caffeineService.cacheIZUUL(key);
}
@GetMapping("/cache-put-izuul/{key}")
public String cachePutIZUUL(@PathVariable String key) {
return caffeineService.cachePutIZUUL(key);
}
}
Caffeine配置
- initialCapacity=[integer]: 初始的緩存空間大小
- maximumSize=[long]: 緩存的最大條數
- maximumWeight=[long]: 緩存的最大權重
- expireAfterAccess=[duration]: 最後一次寫入或訪問後經過固定時間過期
- expireAfterWrite=[duration]: 最後一次寫入後經過固定時間過期
- refreshAfterWrite=[duration]: 創建緩存或者最近一次更新緩存後經過固定的時間間隔,刷新緩存
- weakKeys: 打開key的弱引用
- weakValues:打開value的弱引用
- softValues:打開value的軟引用
- recordStats:開發統計功能
注意:
expireAfterWrite和expireAfterAccess同事存在時,以expireAfterWrite爲準。
maximumSize和maximumWeight不可以同時使用
weakValues和softValues不可以同時使用
註解
- @Cacheable 觸發緩存入口(這裏一般放在創建和獲取的方法上)
- @CacheEvict 觸發緩存的eviction(用於刪除的方法上)
- @CachePut 更新緩存且不影響方法執行(用於修改的方法上,該註解下的方法始終會被執行)
- @Caching 將多個緩存組合在一個方法上(該註解可以允許一個方法同時設置多個註解)
- @CacheConfig 在類級別設置一些緩存相關的共同配置(與其它緩存配合使用)
@Cacheable
先看看它的源碼
public @interface Cacheable {
/**
* 設定要使用的cache的名字,必須提前定義好緩存
*/
@AliasFor("cacheNames")
String[] value() default {};
/**
* 同value(),決定要使用那個/些緩存
*/
@AliasFor("value")
String[] cacheNames() default {};
/**
* 使用SpEL表達式來設定緩存的key,如果不設置默認方法上所有參數都會作爲key的一部分
*/
String key() default "";
/**
* 用來生成key,與key()不可以共用
*/
String keyGenerator() default "";
/**
* 設定要使用的cacheManager,必須先設置好cacheManager的bean,這是使用該bean的名字
*/
String cacheManager() default "";
/**
* 使用cacheResolver來設定使用的緩存,用法同cacheManager,但是與cacheManager不可以同時使用
*/
String cacheResolver() default "";
/**
* 使用SpEL表達式設定出發緩存的條件,在方法執行前生效
*/
String condition() default "";
/**
* 使用SpEL設置出發緩存的條件,這裏是方法執行完生效,所以條件中可以有方法執行後的value
*/
String unless() default "";
/**
* 用於同步的,在緩存失效(過期不存在等各種原因)的時候,如果多個線程同時訪問被標註的方法
* 則只允許一個線程通過去執行方法
*/
boolean sync() default false;
}
使用示例
/**
* condition條件判斷是否要走緩存,無法使用方法中出現的值(返回結果等),條件爲true放入緩存
* unless是方法執行後生效,決定是否放入緩存,返回true的放緩存
* */
@Cacheable(cacheNames = "outLimit",key = "#name",condition = "#value != null ")
public String getCaffeineServiceTest(String name,Integer age){
String value = name + " nihao "+ age;
logger.info("getCaffeineServiceTest value = {}",value);
return value;
}
sync 屬性:
用於保證緩存需要加載時,只會有一個線程計算數據,其他線程阻塞。caffeine 本身也有類似機制,但是使用 sync 屬性,其他線程由 spring 阻塞 ,而不是 caffeine。因爲 caffeine 的阻塞機制中 ,每個阻塞的線程仍要重複 “獲取鎖,計算加載緩存,釋放鎖” 類似的過程,而由 spring 阻塞,阻塞的線程會待計算數據的線程加載完緩存後,直接從緩存中獲取數據。
unless 屬性:
用於否決(veto)緩存,緩存計算結束後判斷,若滿足該表達式,則計算結果不會加入緩存中。如 unless = “#result == null” ,表示若計算結果爲空,則不加入緩存。
@CachePut
使用示例
@CachePut(value = "outLimit", key = "#key")
public String cachePutIZUUL(String key) {
log.info("cachePutIZUUL()方法執行");
return "cachePutIZUUL--" + key;
}
這是個一般用於修改方法上的註解,它的代碼跟Cacheable基本相同,這裏不做介紹。
現在說下CachePut和Cacheable的主要區別。
@Cacheable:它的註解的方法是否被執行取決於Cacheable中的條件,方法很多時候都可能不被執行。
@CachePut:這個註解不會影響方法的執行,也就是說無論它配置的條件是什麼,方法都會被執行,更多的時候是被用到修改上。
@CacheEvict
/**
* CacheEvict刪除key,會調用cache的evict
* */
@CacheEvict(cacheNames = "outLimit",key = "#name")
public String deleteCaffeineServiceTest(String name){
String value = name + " nihao";
logger.info("deleteCaffeineServiceTest value = {}",value);
return value;
}
跟上邊的兩個註解相比,源碼中多了兩個屬性
public @interface CacheEvict {
/**
* 是否刪除緩存中的所有數據,默認爲false,只會刪除被註解方法中傳入的key的緩存
*/
boolean allEntries() default false;
/**
* 設置緩存的刪除在方法執行前執行還是執行後執行。如果設置true,則無論該方法是否正常結束,緩存中的值都會被刪除。
*/
boolean beforeInvocation() default false;
}
@Caching
它是個組合上面三個註解的註解,之前我並沒有用到,現在結合spring文檔簡單說下。
源碼
public @interface Caching {
Cacheable[] cacheable() default {};
CachePut[] put() default {};
CacheEvict[] evict() default {};
}
它只是給出了三種註解的組合,並沒有給出限制條件,所以其使用也很簡單,如下
@Caching(evict = { @CacheEvict("primary"), @CacheEvict(cacheNames="secondary", key="#p0") })
public Book importBooks(String deposit, Date date)
@Caching
類級別的註解,可以設置某類中所有註解的相同部分,這個可以參考spring的類級別的@Mapping來理解。
其代碼很簡單
public @interface CacheConfig {
String[] cacheNames() default {};
String keyGenerator() default "";
String cacheManager() default "";
String cacheResolver() default "";
}
使用如下
@CacheConfig("books")
public class BookRepositoryImpl implements BookRepository {
@Cacheable
public Book findBook(ISBN isbn) {...}
}
其他功能
監聽器(Removal )
您可以通過Caffeine.removalListener(RemovalListener) 爲緩存指定一個刪除偵聽器,以便在刪除數據時執行某些操作。 RemovalListener可以獲取到key、value和RemovalCause(刪除的原因)。
統計(Statistics)
使用Caffeine.recordStats(),您可以打開統計信息收集。Cache.stats() 方法返回提供統計信息的CacheStats,如:
- hitRate():返回命中與請求的比率
- hitCount(): 返回命中緩存的總數
- evictionCount():緩存逐出的數量
- averageLoadPenalty():加載新值所花費的平均時間
補充說明
spring cache 使用基於動態生成子類的代理機制來對方法的調用進行切面,如果緩存的方法是內部調用而不是外部引用,會導致代理失敗,切面失效。
Caffeine 策略分析
過期策略
在Caffeine中分爲兩種緩存,一個是有界緩存,一個是無界緩存,無界緩存不需要過期並且沒有界限。在有界緩存中提供了三個過期API:
- expireAfterWrite:代表着寫了之後多久過期。
- expireAfterAccess: 代表着最後一次訪問了之後多久過期。
- expireAfter:在expireAfter中需要自己實現Expiry接口,這個接口支持create,update,以及access了之後多久過期。注意這個API和前面兩個API是互斥的。這裏和前面兩個API不同的是,需要你告訴緩存框架,他應該在具體的某個時間過期,也就是通過前面的重寫create,update,以及access的方法,獲取具體的過期時間。
更新策略
LoadingCache<String, String> build = Caffeine.newBuilder().refreshAfterWrite(1, TimeUnit.DAYS)
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) {
return "";
}
});
}
但是實際使用中,你設置了一天刷新,但是一天後你發現緩存並沒有刷新。這是因爲必有在1天后這個緩存再次訪問才能刷新,如果沒人訪問,那麼永遠也不會刷新。你明白了嗎?
我們來看看自動刷新他是怎麼做的呢?自動刷新只存在讀操作之後,也就是我們afterRead()這個方法,其中有個方法叫refreshIfNeeded,他會根據你是同步還是異步然後進行刷新處理。
填充策略
同步加載(Loading)
// 初始化緩存
LoadingCache<String, Object> loadingCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(key -> createExpensiveGraph(key));
String key = "name1";
// 採用同步方式去獲取一個緩存和上面的手動方式是一個原理。在build Cache的時候會提供一個createExpensiveGraph函數。
// 查詢並在缺失的情況下使用同步的方式來構建一個緩存
Object graph = loadingCache.get(key);
// 獲取組key的值返回一個Map
List<String> keys = new ArrayList<>();
keys.add(key);
Map<String, Object> graphs = loadingCache.getAll(keys);
異步加載(Asynchronously Loading)
AsyncLoadingCache<String, Object> asyncLoadingCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(10, TimeUnit.MINUTES)
// Either: Build with a synchronous computation that is wrapped as asynchronous
.buildAsync(key -> createExpensiveGraph(key));
// Or: Build with a asynchronous computation that returns a future
// .buildAsync((key, executor) -> createExpensiveGraphAsync(key, executor));
String key = "name1";
// 查詢並在缺失的情況下使用異步的方式來構建緩存
CompletableFuture<Object> graph = asyncLoadingCache.get(key);
// 查詢一組緩存並在缺失的情況下使用異步的方式來構建緩存
List<String> keys = new ArrayList<>();
keys.add(key);
CompletableFuture<Map<String, Object>> graphs = asyncLoadingCache.getAll(keys);
// 異步轉同步
loadingCache = asyncLoadingCache.synchronous();
AsyncLoadingCache是繼承自LoadingCache類的,異步加載使用Executor去調用方法並返回一個CompletableFuture。異步加載緩存使用了響應式編程模型。
如果要以同步方式調用時,應提供CacheLoader。要以異步表示時,應該提供一個AsyncCacheLoader,並返回一個CompletableFuture。
synchronous()這個方法返回了一個LoadingCacheView視圖,LoadingCacheView也繼承自LoadingCache。調用該方法後就相當於你將一個異步加載的緩存AsyncLoadingCache轉換成了一個同步加載的緩存LoadingCache。
默認使用ForkJoinPool.commonPool()來執行異步線程,但是我們可以通過Caffeine.executor(Executor) 方法來替換線程池。
驅逐策略
Caffeine提供三類驅逐策略:基於大小(size-based),基於時間(time-based)和基於引用(reference-based)。
基於大小(size-based)
基於大小驅逐,有兩種方式:一種是基於緩存大小,一種是基於權重。
// 根據緩存的計數進行驅逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.maximumSize(10_000)
.build(key -> createExpensiveGraph(key));
// 根據緩存的權重來進行驅逐(權重只是用於確定緩存大小,不會用於決定該緩存是否被驅逐)
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.maximumWeight(10_000)
.weigher((Key key, Graph graph) -> graph.vertices().size())
.build(key -> createExpensiveGraph(key));
我們可以使用Caffeine.maximumSize(long)方法來指定緩存的最大容量。當緩存超出這個容量的時候,會使用Window TinyLfu策略來刪除緩存。我們也可以使用權重的策略來進行驅逐,可以使用Caffeine.weigher(Weigher) 函數來指定權重,使用Caffeine.maximumWeight(long) 函數來指定緩存最大權重值。
讓我們看看如何計算緩存中的對象。當緩存初始化時,其大小等於零:
LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
.maximumSize(1)
.build(k -> DataObject.get("Data for " + k));
assertEquals(0, cache.estimatedSize());
當我們添加一個值時,大小明顯增加:
cache.get("A");
assertEquals(1, cache.estimatedSize());
我們可以將第二個值添加到緩存中,這導致第一個值被刪除:
cache.get("B");
assertEquals(1, cache.estimatedSize());
基於時間(Time-based)
// 基於固定的到期策略進行退出
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.expireAfterAccess(5, TimeUnit.MINUTES)
.build(key -> createExpensiveGraph(key));
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(key -> createExpensiveGraph(key));
// 要初始化自定義策略,我們需要實現 Expiry 接口
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.expireAfter(new Expiry<Key, Graph>() {
@Override
public long expireAfterCreate(Key key, Graph graph, long currentTime) {
// Use wall clock time, rather than nanotime, if from an external resource
long seconds = graph.creationDate().plusHours(5)
.minus(System.currentTimeMillis(), MILLIS)
.toEpochSecond();
return TimeUnit.SECONDS.toNanos(seconds);
}
@Override
public long expireAfterUpdate(Key key, Graph graph,
long currentTime, long currentDuration) {
return currentDuration;
}
@Override
public long expireAfterRead(Key key, Graph graph,
long currentTime, long currentDuration) {
return currentDuration;
}
})
.build(key -> createExpensiveGraph(key));
基於引用(reference-based)
強引用,軟引用,弱引用概念說明請點擊連接,這裏說一下各各引用的區別:
引用類型 | 被垃圾回收時間 | 用途 | 生存時間 |
---|---|---|---|
強引用 | 從來不會 | 對象的一般狀態 | JVM停止運行時終止 |
軟引用 | 在內存不足時 | 對象緩存 | 內存不足時終止 |
弱引用 | 被垃圾回收時 | 對象緩存 | GC運行後終止 |
虛引用 | Unknown | Unknown | Unknown |
// 當key和value都沒有引用時驅逐緩存
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.weakKeys()
.weakValues()
.build(key -> createExpensiveGraph(key));
// 當垃圾收集器需要釋放內存時驅逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.softValues()
.build(key -> createExpensiveGraph(key));
我們可以將緩存的驅逐配置成基於垃圾回收器。當沒有任何對對象的強引用時,使用 WeakRefence 可以啓用對象的垃圾收回收。SoftReference 允許對象根據 JVM 的全局最近最少使用(Least-Recently-Used)的策略進行垃圾回收。
注意:AsyncLoadingCache不支持弱引用和軟引用。
Caffeine 原理分析
緩存算法
緩存算法(FIFO 、LRU、LFU三種算法的區別)
FIFO
FIFO 算法是一種比較容易實現的算法。它的思想是先進先出(FIFO,隊列),這是最簡單、最公平的一種思想,即如果一個數據是最先進入的,那麼可以認爲在將來它被訪問的可能性很小。空間滿的時候,最先進入的數據會被最早置換(淘汰)掉。
FIFO 算法的描述:設計一種緩存結構,該結構在構造時確定大小,假設大小爲 K,並有兩個功能:
- set(key,value):將記錄(key,value)插入該結構。當緩存滿時,將最先進入緩存的數據置換掉。
- get(key):返回key對應的value值。
實現:維護一個FIFO隊列,按照時間順序將各數據(已分配頁面)鏈接起來組成隊列,並將置換指針指向隊列的隊首。再進行置換時,只需把置換指針所指的數據(頁面)順次換出,並把新加入的數據插到隊尾即可。
缺點:判斷一個頁面置換算法優劣的指標就是缺頁率,而FIFO算法的一個顯著的缺點是,在某些特定的時刻,缺頁率反而會隨着分配頁面的增加而增加,這稱爲Belady現象。產生Belady現象現象的原因是,FIFO置換算法與進程訪問內存的動態特徵是不相容的,被置換的內存頁面往往是被頻繁訪問的,或者沒有給進程分配足夠的頁面,因此FIFO算法會使一些頁面頻繁地被替換和重新申請內存,從而導致缺頁率增加。因此,現在不再使用FIFO算法。
LRU
LRU(The Least Recently Used,最近最久未使用算法)是一種常見的緩存算法,在很多分佈式緩存系統(如Redis, Memcached)中都有廣泛使用。
LRU算法的思想是:如果一個數據在最近一段時間沒有被訪問到,那麼可以認爲在將來它被訪問的可能性也很小。因此,當空間滿時,最久沒有訪問的數據最先被置換(淘汰)。
LRU算法的描述: 設計一種緩存結構,該結構在構造時確定大小,假設大小爲 K,並有兩個功能:
- set(key,value):將記錄(key,value)插入該結構。當緩存滿時,將最久未使用的數據置換掉。
- get(key):返回key對應的value值。
實現:最樸素的思想就是用數組+時間戳的方式,不過這樣做效率較低。因此,我們可以用雙向鏈表(LinkedList)+哈希表(HashMap)實現(鏈表用來表示位置,哈希表用來存儲和查找),在Java裏有對應的數據結構LinkedHashMap。
LFU
LFU(Least Frequently Used ,最近最少使用算法)也是一種常見的緩存算法。
顧名思義,LFU算法的思想是:如果一個數據在最近一段時間很少被訪問到,那麼可以認爲在將來它被訪問的可能性也很小。因此,當空間滿時,最小頻率訪問的數據最先被淘汰。
LFU 算法的描述:
設計一種緩存結構,該結構在構造時確定大小,假設大小爲 K,並有兩個功能:
- set(key,value):將記錄(key,value)插入該結構。當緩存滿時,將訪問頻率最低的數據置換掉。
- get(key):返回key對應的value值。
算法實現策略:考慮到 LFU 會淘汰訪問頻率最小的數據,我們需要一種合適的方法按大小順序維護數據訪問的頻率。LFU 算法本質上可以看做是一個 top K 問題(K = 1),即選出頻率最小的元素,因此我們很容易想到可以用二項堆來選擇頻率最小的元素,這樣的實現比較高效。最終實現策略爲小頂堆+哈希表。
Caffeine 源碼分析
caffeine的load put 和invalidate操作都是原子的,這個意思是這3個操作是互斥的,load和put是不能同時執行的,load和invalidate也是不能同時執行的。
先load再invalidate,invalidate操作是要等load操作執行完的。如果load操作執行比較慢,那invalidate操作就要等很久了。
caffeine的存儲就是ConcurrentHashMap,利用了ConcurrentHashMap自己的node節點鎖。