如何解決多線程高並發場景下的 Java 緩存問題?

2020-12-13 CSDN

作者 | LLLSQ

責編 | 郭芮

網際網路軟體神速發展,用戶的體驗度是判斷一個軟體好壞的重要原因,所以緩存就是必不可少的一個神器。在多線程高並發場景中往往是離不開cache的,需要根據不同的應用場景來需要選擇不同的cache,比如分布式緩存如redis、memcached,還有本地(進程內)緩存如ehcache、GuavaCache、Caffeine。

說起Guava Cache,很多人都不會陌生,它是Google Guava工具包中的一個非常方便易用的本地化緩存實現,基於LRU算法實現,支持多種緩存過期策略。由於Guava的大量使用,Guava Cache也得到了大量的應用。但是,Guava Cache的性能一定是最好的嗎?也許,曾經,它的性能是非常不錯的。但所謂長江後浪推前浪,總會有更加優秀的技術出現。今天,我就來介紹一個比Guava Cache性能更高的緩存框架:Caffeine。

官方性能比較

Google Guava工具包中的一個非常方便易用的本地化緩存實現,基於LRU算法實現,支持多種緩存過期策略。

EhCache 是一個純Java的進程內緩存框架,具有快速、精幹等特點,是Hibernate中默認的CacheProvider。

Caffeine是使用Java8對Guava緩存的重寫版本,在Spring Boot 2.0中將取代,基於LRU算法實現,支持多種緩存過期策略。

場景1:8個線程讀,100%的讀操作。

場景二:6個線程讀,2個線程寫,也就是75%的讀操作,25%的寫操作。

場景三:8個線程寫,100%的寫操作。

可以清楚地看到Caffeine效率明顯高於其他緩存。

如何使用?

1 public staticvoid main(String[] args) { 2 LoadingCache<String, String> build = CacheBuilder.newBuilder().initialCapacity(1).maximumSize(100).expireAfterWrite(1, TimeUnit.DAYS) 3 .build(new CacheLoader<String, String>() { 4//默認的數據加載實現,當調用get取值的時候,如果key沒有對應的值,就調用這個方法進行加載 5@Override 6 public String load(String key) { 7return""; 8 } 9 });10 }11 }參數方法:

initialCapacity(1) 初始緩存長度為1;maximumSize(100) 最大長度為100;expireAfterWrite(1, TimeUnit.DAYS) 設置緩存策略在1天未寫入過期緩存(後面講緩存策略)。

過期策略

在Caffeine中分為兩種緩存,一個是有界緩存,一個是無界緩存,無界緩存不需要過期並且沒有界限。

在有界緩存中提供了三個過期API:

expireAfterWrite:代表著寫了之後多久過期。(上面列子就是這種方式)expireAfterAccess:代表著最後一次訪問了之後多久過期。expireAfter:在expireAfter中需要自己實現Expiry接口,這個接口支持create、update、以及access了之後多久過期。注意這個API和前面兩個API是互斥的。這裡和前面兩個API不同的是,需要你告訴緩存框架,它應該在具體的某個時間過期,也就是通過前面的重寫create、update、以及access的方法,獲取具體的過期時間。

更新策略

何為更新策略?就是在設定多長時間後會自動刷新緩存。

Caffeine提供了refreshAfterWrite()方法來讓我們進行寫後多久更新策略:

1 LoadingCache<String, String> build = CacheBuilder.newBuilder().refreshAfterWrite(1, TimeUnit.DAYS)2 .build(new CacheLoader<String, String>() {3@Override4 public String load(String key) {5return"";6 }7 });8 }上面的代碼我們需要建立一個CacheLodaer來進行刷新,這裡是同步進行的,可以通過buildAsync方法進行異步構建。在實際業務中這裡可以把我們代碼中的mapper傳入進去,進行數據源的刷新。

但是實際使用中,你設置了一天刷新,但是一天後你發現緩存並沒有刷新。這是因為只有在1天後這個緩存再次訪問後才能刷新,如果沒人訪問,那麼永遠也不會刷新。

我們來看看自動刷新是怎麼做的呢?自動刷新只存在讀操作之後,也就是我們的afterRead()這個方法,其中有個方法叫refreshIfNeeded,它會根據你是同步還是異步然後進行刷新處理。

填充策略(Population)

Caffeine 為我們提供了三種填充策略:手動、同步和異步。

手動加載(Manual)

1Cache<String, Object> manualCache = Caffeine.newBuilder() 2 .expireAfterWrite(10, TimeUnit.MINUTES) 3 .maximumSize(10_000) 4 .build(); 5 6String key = "name1"; 7// 根據key查詢一個緩存,如果沒有返回NULL 8graph = manualCache.getIfPresent(key); 9// 根據Key查詢一個緩存,如果沒有調用createExpensiveGraph方法,並將返回值保存到緩存。10// 如果該方法返回Null則manualCache.get返回null,如果該方法拋出異常則manualCache.get拋出異常11graph = manualCache.get(key, k -> createExpensiveGraph(k));12// 將一個值放入緩存,如果以前有值就覆蓋以前的值13manualCache.put(key, graph);14// 刪除一個緩存15manualCache.invalidate(key);1617ConcurrentMap<String, Object> map = manualCache.asMap();18cache.invalidate(key);Cache接口允許顯式的去控制緩存的檢索、更新和刪除。我們可以通過cache.getIfPresent(key) 方法來獲取一個key的值,通過cache.put(key, value)方法顯示的將數控放入緩存,但是這樣子會覆蓋緩原來key的數據。更加建議使用cache.get(key,k - > value) 的方式,get 方法將一個參數為 key 的 Function (createExpensiveGraph) 作為參數傳入。

如果緩存中不存在該鍵,則調用這個 Function 函數,並將返回值作為該緩存的值插入緩存中。get 方法是以阻塞方式執行調用,即使多個線程同時請求該值也只會調用一次Function方法。這樣可以避免與其他線程的寫入競爭,這也是為什麼使用 get 優於 getIfPresent 的原因。

注意:如果調用該方法返回NULL(如上面的 createExpensiveGraph 方法),則cache.get返回null。如果調用該方法拋出異常,則get方法也會拋出異常。

可以使用Cache.asMap() 方法獲取ConcurrentMap進而對緩存進行一些更改。

同步加載(Loading)

1LoadingCache<String, Object> loadingCache = Caffeine.newBuilder() 2 .maximumSize(10_000) 3 .expireAfterWrite(10, TimeUnit.MINUTES) 4 .build(key -> createExpensiveGraph(key)); 5 6String key = "name1"; 7// 採用同步方式去獲取一個緩存和上面的手動方式是一個原理。在build Cache的時候會提供一個createExpensiveGraph函數。 8// 查詢並在缺失的情況下使用同步的方式來構建一個緩存 9Object graph = loadingCache.get(key);1011// 獲取組key的值返回一個Map12List<String> keys = new ArrayList<>();13keys.add(key);14Map<String, Object> graphs = loadingCache.getAll(keys);LoadingCache是使用CacheLoader來構建的緩存的值。批量查找可以使用getAll方法。默認情況下,getAll將會對緩存中沒有值的key分別調用CacheLoader.load方法來構建緩存的值。我們可以重寫CacheLoader.loadAll方法來提高getAll的效率。

注意:可以編寫一個CacheLoader.loadAll來實現為特別請求的key加載值。例如,如果計算某個組中的任何鍵的值將為該組中的所有鍵提供值,則loadAll可能會同時加載該組的其餘部分。

異步加載(Asynchronously Loading)

1AsyncLoadingCache<String, Object> asyncLoadingCache = Caffeine.newBuilder() 2 .maximumSize(10_000) 3 .expireAfterWrite(10, TimeUnit.MINUTES) 4// Either: Build with a synchronous computation that is wrapped as asynchronous 5 .buildAsync(key -> createExpensiveGraph(key)); 6// Or: Build with a asynchronous computation that returns a future 7// .buildAsync((key, executor) -> createExpensiveGraphAsync(key, executor)); 8 9String key = "name1";1011// 查詢並在缺失的情況下使用異步的方式來構建緩存12CompletableFuture<Object> graph = asyncLoadingCache.get(key);13// 查詢一組緩存並在缺失的情況下使用異步的方式來構建緩存14List<String> keys = new ArrayList<>();15keys.add(key);16CompletableFuture<Map<String, Object>> graphs = asyncLoadingCache.getAll(keys);17// 異步轉同步18loadingCache = asyncLoadingCache.synchronous();AsyncLoadingCache是繼承自LoadingCache類的,異步加載使用Executor去調用方法並返回一個CompletableFuture。異步加載緩存使用了響應式編程模型。

如果要以同步方式調用時,應提供CacheLoader。要以異步表示時,應該提供一個AsyncCacheLoader,並返回一個CompletableFuture。

synchronous()這個方法返回了一個LoadingCacheView視圖,LoadingCacheView也繼承自LoadingCache。調用該方法後就相當於你將一個異步加載的緩存AsyncLoadingCache轉換成了一個同步加載的緩存LoadingCache。

默認使用ForkJoinPool.commonPool()來執行異步線程,但是我們可以通過Caffeine.executor(Executor) 方法來替換線程池。

驅逐策略(eviction)

Caffeine提供三類驅逐策略:基於大小(size-based),基於時間(time-based)和基於引用(reference-based)。

基於大小(size-based)

基於大小驅逐,有兩種方式:一種是基於緩存大小,一種是基於權重。

1// Evict based on the number of entries in the cache 2// 根據緩存的計數進行驅逐 3LoadingCache<Key, Graph> graphs = Caffeine.newBuilder() 4 .maximumSize(10_000) 5 .build(key -> createExpensiveGraph(key)); 6 7// Evict based on the number of vertices in the cache 8// 根據緩存的權重來進行驅逐(權重只是用於確定緩存大小,不會用於決定該緩存是否被驅逐) 9LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()10 .maximumWeight(10_000)11 .weigher((Keykey, Graph graph) -> graph.vertices().size())12 .build(key -> createExpensiveGraph(key));我們可以使用Caffeine.maximumSize(long)方法來指定緩存的最大容量。當緩存超出這個容量的時候,會使用Window TinyLfu策略來刪除緩存。我們也可以使用權重的策略來進行驅逐,可以使用Caffeine.weigher(Weigher) 函數來指定權重,使用Caffeine.maximumWeight(long) 函數來指定緩存最大權重值。

注意:maximumWeight與maximumSize不可以同時使用。

基於時間(Time-based)

1// Evict based on a fixed expiration policy 2// 基於固定的到期策略進行退出 3LoadingCache<Key, Graph> graphs = Caffeine.newBuilder() 4 .expireAfterAccess(5, TimeUnit.MINUTES) 5 .build(key -> createExpensiveGraph(key)); 6LoadingCache<Key, Graph> graphs = Caffeine.newBuilder() 7 .expireAfterWrite(10, TimeUnit.MINUTES) 8 .build(key -> createExpensiveGraph(key)); 910// Evict based on a varying expiration policy11// 基於不同的到期策略進行退出12LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()13 .expireAfter(new Expiry<Key, Graph>() {14@Override15publiclongexpireAfterCreate(Key key, Graph graph, long currentTime){16// Use wall clock time, rather than nanotime, if from an external resource17long seconds = graph.creationDate().plusHours(5)18 .minus(System.currentTimeMillis(), MILLIS)19 .toEpochSecond();20return TimeUnit.SECONDS.toNanos(seconds);21 }2223@Override24publiclongexpireAfterUpdate(Key key, Graph graph, 25long currentTime, long currentDuration){26return currentDuration;27 }2829@Override30publiclongexpireAfterRead(Key key, Graph graph,31long currentTime, long currentDuration){32return currentDuration;33 }34 })35 .build(key -> createExpensiveGraph(key));基於引用(reference-based)

Java 4種引用的級別由高到低依次為:強引用 > 軟引用 > 弱引用 > 虛引用。

1// Evict when neither the key nor value are strongly reachable 2// 當key和value都沒有引用時驅逐緩存 3LoadingCache<Key, Graph> graphs = Caffeine.newBuilder() 4 .weakKeys() 5 .weakValues() 6 .build(key -> createExpensiveGraph(key)); 7 8// Evict when the garbage collector needs to free memory 9// 當垃圾收集器需要釋放內存時驅逐10LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()11 .softValues()12 .build(key -> createExpensiveGraph(key));我們可以將緩存的驅逐配置成基於垃圾回收器。為此,我們可以將key和 value配置為弱引用或只將值配置成軟引用。

注意:AsyncLoadingCache不支持弱引用和軟引用。

移除監聽器(Removal)

驅逐(eviction):由於滿足了某種驅逐策略,後臺自動進行的刪除操作;無效(invalidation):表示由調用方手動刪除緩存;移除(removal):監聽驅逐或無效操作的監聽器。手動刪除緩存:

在任何時候,都可能明確地使緩存無效,而不用等待緩存被驅逐。

1// individual key2cache.invalidate(key)3// bulkkeys4cache.invalidateAll(keys)5// all keys6cache.invalidateAll()Removal 監聽器:

可以通過Caffeine.removalListener(RemovalListener) 為緩存指定一個刪除偵聽器,以便在刪除數據時執行某些操作。 RemovalListener可以獲取到key、value和RemovalCause(刪除的原因)。

刪除偵聽器的裡面的操作是使用Executor來異步執行的。默認執行程序是ForkJoinPool.commonPool(),可以通過Caffeine.executor(Executor)覆蓋。當操作必須與刪除同步執行時,請改為使用CacheWrite,CacheWrite將在下面說明。

注意:由RemovalListener拋出的任何異常都會被記錄(使用Logger)並不會拋出。

統計(Statistics)

Cache<Key, Graph> graphs = Caffeine.newBuilder()2 .maximumSize(10_000)3 .recordStats()4 .build();使用Caffeine.recordStats(),可以打開統計信息收集。Cache.stats() 方法返回提供統計信息的CacheStats,如:

hitRate():返回命中與請求的比率;hitCount(): 返回命中緩存的總數;evictionCount():緩存逐出的數量;averageLoadPenalty():加載新值所花費的平均時間。

總結

Caffeine的調整不只有算法上面的調整,內存方面的優化也有很大進步,Caffeine的API的操作功能和Guava是基本保持一致的,並且Caffeine為了兼容之前是Guava的用戶,所以使用或者重寫緩存到Caffeine應該沒什麼問題,但是也要看項目情況,不要盲目使用。

作者:LLLSQ,一隻有著悲慘故事的北漂程式設計師,為讀者提供熱點技術文章和IT實時熱點新聞、架構、面試信息等最新訊息。聲明:本文為作者投稿,版權歸對方所有。

相關焦點

  • Java開發多線程是如何解決安全問題的?
    本篇文章將從這三個問題出發,結合實例詳解volatile如何保u證可見性及一定程序上保證順序性,同時例講synchronized如何同時保證可見性和原子性,最後對比volatile和synchronized的適用場景。
  • Java面試題解析(事務+緩存+資料庫+多線程+JVM)
    缺點:可以解決並發事務的所有問題。但是效率地下,消耗資料庫性能,一般不使用。緩存3、分布式緩存的典型應用場景?事件處理,分布式緩存提供了針對事件流的連續查詢(continuous query)處理技術,滿足實時性需求。極限事務處理,分布式緩存為事務型應用提供高吞吐率、低延時的解決方案,支持高並發事務請求處理,多應用於鐵路、金融服務和電信等領域。
  • Java 線程安全問題的本質
    所以,如果出現並發訪問getInstance()方法時,則可能會出現,線程二判斷singleton是否為空,此時由於當前該singleton已經分配了內存地址,但其實並沒有初始化對象,則會導致return 一個未初始化的對象引用暴露出來,以此可能會出現一些不可預料的代碼異常;當然,指令重排序的問題並非每次都會進行,在某些特殊的場景下,編譯器和處理器是不會進行重排序的,但上述的舉例場景則是大概率會出現指令重排序問題
  • 「原創」Java並發編程系列02|並發編程三大核心問題
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫寫在前面編寫並發程序是比較困難的,因為並發程序極易出現Bug,這些Bug有都是比較詭異的,很多都是沒辦法追蹤,而且難以復現。
  • 高並發下線程安全的單例模式(最全最經典,值得收藏)
    高並發下如何保證單例模式的線程安全性呢?如何保證序列化後的單例對象在反序列化後任然是單例的呢?這些問題在看了本文之後都會一一的告訴你答案,趕快來閱讀吧!什麼是單例模式?在文章開始之前我們還是有必要介紹一下什麼是單例模式。單例模式是為確保一個類只有一個實例,並為整個系統提供一個全局訪問點的一種模式方法。
  • Java面試題-多線程篇十三
    兩種方式:java.lang.Thread 類的實例就是一個線程但是它需要調用java.lang.Runnable接口來執行,由於線程類本身就是調用的Runnable接口所以你可以繼承java.lang.Thread 類或者直接調用Runnable接口來重寫run()方法實現線程。
  • JAVA面試題:你在項目中使用多線程的場景?
    背景在JAVA面試時,很多面試官都會問多線程在項目中的實際應用場景,這個時候我們通常不知道如何回答。因為我們大多數程式設計師通常都是和業務代碼打交道,需要用到多線程的地方我們容器和框架一般都替我們處理好了,所以我們很少有機會接觸到多線程編程。
  • 【堪稱經典】JAVA多線程和並發基礎面試問答
    在多線程程序中,多個線程被並發的執行以提高程序的效率,CPU不會因為某個線程需要等待資源而進入空閒狀態。多個線程共享堆內存(heap memory),因此創建多個線程去執行一些任務會比創建多個進程更好。舉個例子,Servlets比CGI更好,是因為Servlets支持多線程而CGI不支持。3. 用戶線程和守護線程有什麼區別?
  • Java多線程並發工具類-信號量Semaphore對象講解
    Java多線程並發工具類-Semaphore對象講解通過前面的學習,我們已經知道了Java多線程並發場景中使用比較多的兩個工具類:做加法的CycliBarrier對象以及做減法的CountDownLatch對象並對這兩個對象進行了比較。我們發現這兩個對象要麼是做加法,要麼是做減法的。那麼有沒有既做加法也做減法的呢?
  • Java並發包下Java多線程並發之讀寫鎖鎖學習第五篇-讀寫鎖
    Java多線程並發之讀寫鎖本文主要內容:讀寫鎖的理論;通過生活中例子來理解讀寫鎖;讀寫鎖的代碼演示;讀寫鎖總結。通過理論(總結)-例子-代碼-然後再次總結,這四個步驟來讓大家對讀寫鎖的深刻理解。本篇是《凱哥(凱哥Java:kagejava)並發編程學習》系列之《Lock系列》教程的第七篇:《Java並發包下鎖學習第七篇:讀寫鎖》。一:讀寫鎖的理論什麼是讀寫鎖?
  • 淺析JVM內存模型:虛擬機如何實現多線程而導致的並發問題
    而高並發高吞吐量也越來越成為服 務端普遍需求,所有能夠開發出高效並發的應用程式,也是成為一個高級程式設計師的必備技能。下面我們將從JVM內存模型的角度來分析虛擬機如何實現多線程、多線程之間由於共享和競爭數據而導致的並發問題及解 決思路。
  • 一碰就頭疼的緩存熱點 Key 問題,阿里的 Tair 是如何解決的?
    為什麼需要HotRingHotRing不是要解決緩存集群服務中單節點的並發能力上限,這一點一定要注意。它要解決的問題是緩存核心數據結構Hash中鍊表衝突導致性能衰減的問題。所以,我們需要一個輕量的方案來跟蹤這些熱點切換問題。並發訪問(Concurrent Access)。每個熱點數據的並發都會非常高,所以支持高並發的讀寫,才能達到令人滿意的性能。HotRing簡介讓我們看看阿里的HotRing是如何設計來優化衝突鍊表訪問性能問題的。
  • 40個Java多線程問題總結
    7、什麼是線程安全又是一個理論的問題,各式各樣的答案有很多,我給出一個個人認為解釋地最好的:如果你的代碼在多線程下執行和在單線程下執行永遠都能獲得一樣的結果,那麼你的代碼就是線程安全的。這個問題有值得一提的地方,就是線程安全也是有幾個級別的:(1)不可變像String、Integer、Long這些,都是final類型的類,任何一個線程都改變不了它們的值,要改變除非新創建一個,因此這些不可變對象不需要任何同步手段就可以直接在多線程環境下使用(2)絕對線程安全不管運行時環境如何,調用者都不需要額外的同步措施。
  • Java多線程並發編程中並發容器第二篇之List的並發類講解
    Java多線程並發編程中並發容器第二篇之List的並發類講解概述本文我們將詳細講解list對應的並發容器以及用代碼來測試ArrayList、vector以及CopyOnWriteArrayList在100個線程向list中添加1000個數據後的比較
  • Java編寫線程安全類的7個技巧
    您可以通過initialValue()方法為您的java.lang.ThreadLocal提供一個初始值。以下顯示如何使用實例變量:通過調用get()方法,您將可以獲取到與當前線程關聯的對象。在伺服器環境中,使用了許多線程池來處理請求,因此java.lang.ThreadLocal會導致在此環境中消耗大量內存。
  • Java 線程面試題 Top 50
    在典型的Java面試中, 面試官會從線程的基本概念問起, 如:為什麼你需要使用線程, 如何創建線程,用什麼方式創建線程比較好(比如:繼承thread類還是調用Runnable接口),然後逐漸問到並發問題像在Java並發編程的過程中遇到了什麼挑戰,Java內存模型,JDK1.5引入了哪些更高階的並發工具,並發編程常用的設計模式,經典多線程問題如生產者消費者,哲學家就餐,讀寫器或者簡單的有界緩衝區問題
  • JAVA並發編程:線程並發工具類Callable、Future 和FutureTask的使用
    Callable 位於 java.util.concurrent 包下,它也是一個接口,在它裡面也只聲明 了一個方法,只不過這個方法叫做 call(),這是一個泛型接口,call()函數返回的類型就是傳遞進來的 V 類型。  Future 就是對於具體的 Runnable 或者 Callable 任務的執行結果進行取消、查詢是否完成、獲取結果。
  • 每天兩小時學多線程、高並發、分布式、Redis,拿到騰訊T3 offer
    如何實現分布式鎖?單機鎖有哪些?它為什麼不能在分布式環境下使用?Redis 是如何實現分布式鎖?可能會遇到什麼問題?分布式鎖使用超時的話會有什麼問題?如何解決?…………面試中,十個公司有八個公司會像字節跳動一樣,拿著一個技術點不斷的追問。
  • Java多線程synchronized
    本篇主要介紹Java多線程中的同步,也就是如何在Java語言中寫出線程安全的程序,如何在Java語言中解決非線程安全的相關問題。
  • Java並發編程:多線程如何實現阻塞與喚醒
    線程的阻塞和喚醒在多線程並發過程中是一個關鍵點,當線程數量達到很大的數量級時,並發可能帶來很多隱蔽的問題。如何正確暫停一個線程,暫停後又如何在一個要求的時間點恢復,這些都需要仔細考慮的細節。