Guava Cache源碼解析

概述:

Cache.png | center

本次主要是分析cache的源碼,基本概念官方簡介便可。html

基本類圖:

8acadba60c209389.png | center

在官方的文檔說明中,Guava Cache實現了三種加載緩存的方式:java

  • LoadingCache在構建緩存的時候,使用build方法內部調用CacheLoader方法加載數據
  • 在使用get方法的時候,若是緩存不存在該key或者key過時等,則調用get(K, Callable)方式加載數據;
  • 直接調用put方法來放置緩存

核心類及接口的說明,簡單的理解以下:git

  • Cache接口是Guava對外暴露的緩存接口,對外的方法以下圖,Cache定義的接口get接口中,必需要傳入Callable,Callable是若是不存在就加載的方式定義,這種就是第二種加載緩存的方式,若是緩存中key不存在或者過時的狀況,調用get(K,Callable)來實現
    952eb0f96f5f6d88.png | centergithub

  • LoadingCache是對Cache的進一步封裝,繼承自Cache接口,主要是實現了get(K)這種定義策略
    f03a8008709cb675.png | center算法

  • 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

LocalCache是線程安全的集合,爲了實現這個特性,使用了經典的細粒度鎖來控制,本質和ConcurrentHashMap的實現方式類型,在存儲中採用了多個Segment對應一個鎖,來分散全局鎖帶來的性能損失。當去put一個entry的時候,通常只須要擁有某一個segment鎖就能夠完成。下圖是ConcurrentHashMap和HashTable存儲的描述。
415f802c63073903.png | center

在實現上,LocalCache的併發策略和ConcurrentHashMap的併發策略一致,也是進行了分段,支持不一樣段的併發寫入。
67e80395eb6a239f.png | center

  • Segment中使用 volatile AtomicReferenceArray<ReferenceEntry<K, V>> table;
    來存儲對象,能夠這樣理解,每一段的Segment至關於一個HashTable的實現
  • guava的實例對象中存在一些Queue,這個是Guava擴展實現的各類引用對象回收的策略(Strong、Weak、Soft)類型,這塊不具體分析了,平時我並不怎麼用Weak、Soft,接觸過的只有ThreadLocal裏面,這塊能夠看我博客fail-fast分析。同時,Guava定義了ReferenceEntry,ValueReference也是爲GC回收策略作的
  • Segment中的table是一個array,每個元素都是RefenceEntry的鏈表,同時會將具體的value值封裝爲一個ValueReference
    9f526c73828fbda1.png | center
  • LocalCache的擴容是基於Segment的,也就是說是分片的擴展,單個Segment只須要關注本身的容量,與其餘的Segment無關的
  • Segment中使用的是LRU緩存回收算法,GuavaCache實現的LRU針對的是Segment來作回收的,不是針對整個LocaCache來作的

ReferenceEntry及LRU回收算法的實現

ReferenceEntry是Guava中對一個key-value節點的抽象,每個Segment中都包含這一個ReferenceEntry數組,每一個ReferenceEntry數組項都是一條ReferenceEntry鏈,其數據結構以下:

716b696071f13178.png | center

類繼承結構以下:
107448bf656adc5b.png | center

ReferenceEntry包裝了key-value節點的同時,主要的功能點是增長了引用數據類型回收機制(這個不討論),設置了accessQueue和writeQueue隊列,這個兩個實際上是雙向鏈表,分別經過previousAccess、nextAccess和previousWrite、nextWrite字段連接而成,這兩個隊列存在的目的是:實現LRU算法

涉及到一些概念說明:

  • accessQueue:這個隊列是按照最久未使用的順序存放的緩存對象(ReferenceEntry)的。因爲會常常進行元素的移動,例如把訪問過的對象放到隊列的最後。
  • writeQueue:保存按照寫入緩存前後時間的隊列,對於新寫入的節點或者更新的節點,都會將該節點ReferenceEntry加入到隊尾。對頭元素是長時間沒有變化的對象,而隊尾則是最近更新的節點。
  • recencyQueue:每次訪問操做(即客戶端每次調用get方法的時候)都會將該entry加入到隊列尾部,並更新accessTime。若是遇到寫入操做,則將給隊列內容排幹,若是accessQueue隊列中持有該這些 entry,而後將這些entry add到accessQueue隊列。如此看來,recencyQueue是爲 accessQueue服務的,一開始也不是很明白爲何有了accessQueue還有設置recencyQueue,下面的連接作了解釋,簡單講就是get的使用使用了ConcurrentLinkedQueue來記錄訪問的數據,這樣的好處是不須要lock()

https://github.com/google/guava/issues/1487

put操做

對於Segment的put,基本流程以下:

  • 加鎖lock
  • 判斷緩存是否超過了過時時間
  • 判斷當前存儲是否到了最大容量,若是到了,擴容
  • 將新元素進行封裝,加入到存儲中
  • 更新控制隊列,accessQueue、writeQueue
  • 判斷隊列中現有元素是否超過了maximumSize,進行容量的控制
  • 觸發時間通知,包括StatsCounter和RemovalNotification
  • 釋放鎖

Segment對鎖的控制

上面提到過LocalCacal對於併發的控制,粒度是Segment級別,而Segment當中鎖的操做相對來講比較頻繁,在設計的時候,爲了簡單,直接讓Segment繼承了java.util.concurrent.locks.ReentrantLock

segment對size的控制策略

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對失效時間expireTime的控制

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();
    }
  }
}

其餘功能點:

StatsCounter和CacheStatus

爲了紀錄Cache的使用狀況,若是命中次數、沒有命中次數、evict次數等,Guava Cache中定義了StatsCounter作這些統計信息,它有一個簡單的SimpleStatsCounter實現,咱們也能夠經過CacheBuilder配置本身的StatsCounter。

RemoveListener

put和get操做後都會通知removeListener,默認是同步的方式處理事件通知。也能夠經過RemoveListeners將 listener包裝成異步方式處理

參考文章:

  1. https://github.com/google/guava/wiki/CachesExplained
  2. http://www.blogjava.net/DLevin/archive/2013/10/20/404847.html
  3. https://github.com/google/guava/issues/1487
相關文章
相關標籤/搜索