目錄javascript
本次主要是分析cache的源碼,基本概念官方簡介便可。html
在官方的文檔說明中,Guava Cache實現了三種加載緩存的方式:java
核心類及接口的說明,簡單的理解以下:git
Cache接口是Guava對外暴露的緩存接口,對外的方法以下圖,Cache定義的接口get接口中,必需要傳入Callable,Callable是若是不存在就加載的方式定義,這種就是第二種加載緩存的方式,若是緩存中key不存在或者過時的狀況,調用get(K,Callable)來實現
github
LoadingCache是對Cache的進一步封裝,繼承自Cache接口,主要是實現了get(K)這種定義策略
算法
LocalManualCache其實是Cache的標準實現,注意LocalManualCache不包含無Callable參數的get方法,是一種能在鍵值找不到的時候手動調用獲取值的方式數組
LocalLoadingCache則是LoadingCache的實現,核心的區別在於支持在key找不到的狀況下自動加載value的功能點,實際上是保存了一個CacheLoading的初始值緩存
LocalCache是存儲層,是真正意義上數據存放的地方,繼承了java.util.AbstractMap同時也實現了ConcurrentMap接口,實現方式和ConcurrentHashMap的實現相同,都是採用分segment來細化管理HashMap中的節點Entry,細粒度鎖的方式來增大併發性能。安全
CacheLoader個人理解是緩存加載策略,即負責計算key-value的對應關係,是一個抽象類,須要業務定製本身的策略。在Guava的使用過程當中,get參數傳入的Callable接口最終會被封裝成匿名的CacheLoader,負責加載key到緩存中數據結構
CacheBuilder 因爲cache配置項衆多,典型的builder模式場景,複雜對象的構造與其對應配置屬性表示的分離。
LocalCache是線程安全的集合,爲了實現這個特性,使用了經典的細粒度鎖來控制,本質和ConcurrentHashMap的實現方式類型,在存儲中採用了多個Segment對應一個鎖,來分散全局鎖帶來的性能損失。當去put一個entry的時候,通常只須要擁有某一個segment鎖就能夠完成。下圖是ConcurrentHashMap和HashTable存儲的描述。
在實現上,LocalCache的併發策略和ConcurrentHashMap的併發策略一致,也是進行了分段,支持不一樣段的併發寫入。
ReferenceEntry是Guava中對一個key-value節點的抽象,每個Segment中都包含這一個ReferenceEntry數組,每一個ReferenceEntry數組項都是一條ReferenceEntry鏈,其數據結構以下:
類繼承結構以下:
ReferenceEntry包裝了key-value節點的同時,主要的功能點是增長了引用數據類型回收機制(這個不討論),設置了accessQueue和writeQueue隊列,這個兩個實際上是雙向鏈表,分別經過previousAccess、nextAccess和previousWrite、nextWrite字段連接而成,這兩個隊列存在的目的是:實現LRU算法
涉及到一些概念說明:
https://github.com/google/guava/issues/1487
對於Segment的put,基本流程以下:
上面提到過LocalCacal對於併發的控制,粒度是Segment級別,而Segment當中鎖的操做相對來講比較頻繁,在設計的時候,爲了簡單,直接讓Segment繼承了java.util.concurrent.locks.ReentrantLock
guava cache並不會開啓額外的線程去掃描當前的存儲,看是否達到了存儲上限,而是在每次put的時候進行判斷
/** * Performs eviction if the segment is full. This should only be called prior to adding a new * entry and increasing {@code count}. */ @GuardedBy("Segment.this") void evictEntries() { if (!map.evictsBySize()) { return; } drainRecencyQueue(); while (totalWeight > maxSegmentWeight) { ReferenceEntry<K, V> e = getNextEvictable(); if (!removeEntry(e, e.getHash(), RemovalCause.SIZE)) { throw new AssertionError(); } } } // TODO(fry): instead implement this with an eviction head ReferenceEntry<K, V> getNextEvictable() { for (ReferenceEntry<K, V> e : accessQueue) { int weight = e.getValueReference().getWeight(); if (weight > 0) { return e; } } throw new AssertionError(); }
以前有說到過accessQueue
,這個隊列是按照最久未使用的順序存放的緩存對象(ReferenceEntry)的。因爲會常常進行元素的移動,例如把訪問過的對象放到隊列的最後。而當元素超過了預設的maximumSize
,就會從accessQueue的隊頭取對應的數據,也就是最長時間沒有訪問到的那個元素,而後從Segment的table中剔除,一樣的也要從writeQueue、accessQueue中剔除
ReferenceEntry<K, V> removeValueFromChain(ReferenceEntry<K, V> first, ReferenceEntry<K, V> entry, @Nullable K key, int hash, ValueReference<K, V> valueReference, RemovalCause cause) { enqueueNotification(key, hash, valueReference, cause); writeQueue.remove(entry); accessQueue.remove(entry); if (valueReference.isLoading()) { valueReference.notifyNewValue(null); return first; } else { return removeEntryFromChain(first, entry); } }
segment對失效時間的控制也並非由單獨的線程去控制,而是在用戶每次請求的時候觸發檢測,這樣能夠有效的避免沒必要要的線程消耗。可是這樣也會有必定的問題,簡單講,若是大量的請求同時到,並且緩存內容所有都失效的話,至關於沒有作任何緩存控制,並且還延長了單次請求的時間。在大促的時候曾經遇到過,每隔一段時間都發現請求rt會出現毛刺,後來發現是用來本地緩存,大量的數據同時失效,並且剛好有不少請求同時來到,所有都去讀取DB,rt所有變高。
這種方式也臨時的解決方案,好比說,預熱緩存的時候分批進行。
若是真的存在一些數據須要常駐本地緩存,能夠考慮使用額外的線程進行定時刷新,簡單的作法是:假設設置的expireTime爲10分鐘,那麼每隔9分鐘,定時任務去讀取cache中的數據,而後更新。(以前看zk的代碼,zkserver對於client Session的控制是單獨線程控制的,那個實現感受是比較經典的,若是有必要作成是開啓額外線程失效的話,能夠參考那個實現)。
失效的代碼以下,和對數量的控制沒大的區別:
void expireEntries(long now) { drainRecencyQueue(); ReferenceEntry<K, V> e; while ((e = writeQueue.peek()) != null && map.isExpired(e, now)) { if (!removeEntry(e, e.getHash(), RemovalCause.EXPIRED)) { throw new AssertionError(); } } while ((e = accessQueue.peek()) != null && map.isExpired(e, now)) { if (!removeEntry(e, e.getHash(), RemovalCause.EXPIRED)) { throw new AssertionError(); } } }
爲了紀錄Cache的使用狀況,若是命中次數、沒有命中次數、evict次數等,Guava Cache中定義了StatsCounter作這些統計信息,它有一個簡單的SimpleStatsCounter實現,咱們也能夠經過CacheBuilder配置本身的StatsCounter。
put和get操做後都會通知removeListener,默認是同步的方式處理事件通知。也能夠經過RemoveListeners將 listener包裝成異步方式處理