Java Cache系列之Guava Cache實現詳解

Guava做爲Google開源出來的工具庫,Google本身對Guava的描述:The Guava project contains several of Google's core libraries that we rely on in our Java-based projects: collections, caching, primitives support, concurrency libraries, common annotations, string processing, I/O, and so forth.做爲Google的core libraries,直接提供Cache實現,足以證實Cache應用的普遍程度。 然而做爲工具庫中的一部分,咱們天然不能期待Guava對Cache有比較完善的實現。於是Guava中的Cache只能用於一些把Cache做爲一種輔助設計的項目或者在項目的前期爲了實現簡單而引入。

在Guava CacheBuilder的註釋中給定Guava Cache如下的需求:
  1. automatic loading of entries into the cache
  2. least-recently-used eviction when a maximum size is exceeded
  3. time-based expiration of entries, measured since last access or last write
  4. keys automatically wrapped in WeakReference
  5. values automatically wrapped in WeakReference or SoftReference soft
  6. notification of evicted (or otherwise removed) entries
  7. accumulation of cache access statistics
對於這樣的需求,若是要咱們本身來實現,咱們應該怎麼設計?對於我來講,對於其核心實現我會作以下的設計:
  1. 定義一個CacheConfig類用於紀錄全部的配置,如CacheLoader,maximum size、expire time、key reference level、value reference level、eviction listener等。
  2. 定義一個Cache接口,該接口相似Map(或ConcurrentMap),可是爲了和Map區別開來,於是從新定義一個Cache接口。
  3. 定義一個實現Cache接口的類CacheImpl,它接收CacheConfig做爲參數的構造函數,並將CacheConfig實例保存在字段中。
  4. 在實現上模仿ConcurrentHashMap的實現方式,有一個Segment數組,其長度由配置的concurrencyLevel值決定。爲了實現最近最少使用算法(LRU),添加AccessQueue和WriteQueue字段,這兩個Queue內部採用雙鏈表,每次新建立一個Entry,就將這個Entry加入到這兩個Queue的末尾,而每讀取一個Entry就將其添加到AccessQueue的末尾,沒更新一個Entry將該Entry添加到WriteQueue末尾。爲了實現key和value上的WeakReference、SoftReference,添加ReferenceQueue<K>類型的keyReferenceQueue和valueReferenceQueue字段。
  5. 在每次調用方法以前都遍歷AccessQueue和WriteQueue,若是發現有Entry已經expire,就將該Entry從這兩個Queue上和Cache中移除。而後遍歷keyReferenceQueue和valueReference,若是發現有項存在,一樣將它們移除。在移除時若是有EvictionListener註冊着,則調用該listener。
  6. 對Segment實現,它時一個CacheEntry數組,CacheEntry是一個鏈節點,它包含hash、key、vlaue、next。CacheEntry根據是否須要包裝在WeakReference中建立WeakEntry或StrongEntry,而對value根據是否須要包裝在WeakReference、SoftReference中建立WeakValueReference、SoftValueReference、StrongValueReference。在get操做中對於須要使用CacheLoader加載的值先添加一個具備LoadingValueReference值的Entry,這樣能夠保證同一個Key只加載依次。在加載成功後將LoadingValueReference根據配置替換成其餘Weak、Soft、Strong ValueReference。
  7. 對於cache access statistics,只須要有一個類在須要的地方作一些統計計數便可。
  8. 最後我必須得認可以上的設計有不少是對Guava Cache的參考,我有點後悔沒有在看源碼以前考慮這個問題,等看過之後思路就被它的實現給羈絆了。。。。

Guava Cache的數據結構
由於新進一家公司,要熟悉新公司項目以及項目用到的第三方庫的代碼,於是幾個月來看了許多代碼。而後愈來愈發現要理解一個項目的最快方法是先搞清楚該項目的底層數據結構,而後再去看構建於這些數據結構以上的邏輯就會容易許多。記得在仍是學生的時候,有在一本書上看到過一個大牛說的一句話:程序=數據結構+算法;當時對這句話並非和理解,如今是很贊同這句話,我對算法接觸的很少,於是我更傾向於將這裏的算法理解長控制數據流動的邏輯。於是咱們先來熟悉一下Guava Cache的數據結構。

Cache相似於Map,它是存儲鍵值對的集合,然而它和Map不一樣的是它還須要處理evict、expire、dynamic load等邏輯,須要一些額外信息來實現這些操做。在面向對象思想中,常用類對一些關聯性比較強的數據作封裝,同時把操做這些數據相關的操做放到該類中。於是Guava Cache使用ReferenceEntry接口來封裝一個鍵值對,而用ValueReference來封裝Value值。這裏之因此用Reference命令,是由於Guava Cache要支持WeakReference Key和SoftReference、WeakReference value。

ValueReference
對於ValueReference,由於Guava Cache支持強引用的Value、SoftReference Value以及WeakReference Value,於是它對應三個實現類:StrongValueReference、SoftValueReference、WeakValueReference。爲了支持動態加載機制,它還有一個LoadingValueReference,在須要動態加載一個key的值時,先把該值封裝在LoadingValueReference中,以表達該key對應的值已經在加載了,若是其餘線程也要查詢該key對應的值,就能獲得該引用,而且等待改值加載完成,從而保證該值只被加載一次(能夠在evict之後從新加載)。在該只加載完成後,將LoadingValueReference替換成其餘ValueReference類型。對新建立的LoadingValueReference,因爲其內部oldValue的初始值是UNSET,它isActive爲false,isLoading爲false,於是此時的LoadingValueReference的isActive爲false,可是isLoading爲true。每一個ValueReference都紀錄了weight值,所謂weight從字面上理解是「該值的重量」,它由Weighter接口計算而得。weight在Guava Cache中由兩個用途:1. 對weight值爲0時,在計算由於size limit而evict是忽略該Entry(它能夠經過其餘機制evict);2. 若是設置了maximumWeight值,則當Cache中weight和超過了該值時,就會引發evict操做。可是目前還不知道這個設計的用途。最後,Guava Cache還定義了Stength枚舉類型做爲ValueReference的factory類,它有三個枚舉值:Strong、Soft、Weak,這三個枚舉值分別建立各自的ValueReference,而且根據傳入的weight值是否爲1而決定是否要建立Weight版本的ValueReference。如下是ValueReference的類圖:  java

這裏ValueReference之因此要有對ReferenceEntry的引用是由於在Value由於WeakReference、SoftReference被回收時,須要使用其key將對應的項從Segment的table中移除;copyFor()函數的存在是由於在expand(rehash)從新建立節點時,對WeakReference、SoftReference須要從新建立實例(我的感受是爲了保持對象狀態不會相互影響,可是不肯定是否還有其餘緣由),而對強引用來講,直接使用原來的值便可,這裏很好的展現了對彼變化的封裝思想;notifiyNewValue只用於LoadingValueReference,它的存在是爲了對LoadingValueReference來講能更加及時的獲得CacheLoader加載的值。

ReferenceEntry
ReferenceEntry是Guava Cache中對一個鍵值對節點的抽象。和ConcurrentHashMap同樣,Guava Cache由多個Segment組成,而每一個Segment包含一個ReferenceEntry數組,每一個ReferenceEntry數組項都是一條ReferenceEntry鏈。而且一個ReferenceEntry包含key、hash、valueReference、next字段。除了在ReferenceEntry數組項中組成的鏈,在一個Segment中,全部ReferenceEntry還組成access鏈(accessQueue)和write鏈(writeQueue),這兩條都是雙向鏈表,分別經過previousAccess、nextAccess和previousWrite、nextWrite字段連接而成。在對每一個節點的更新操做都會將該節點從新鏈到write鏈和access鏈末尾,而且更新其writeTime和accessTime字段,而沒找到一個節點,都會將該節點從新鏈到access鏈末尾,並更新其accessTime字段。這兩個雙向鏈表的存在都是爲了實現採用最近最少使用算法(LRU)的evict操做(expire、size limit引發的evict)。

Guava Cache中的ReferenceEntry能夠是強引用類型的key,也能夠WeakReference類型的key,爲了減小內存使用量,還能夠根據是否配置了expireAfterWrite、expireAfterAccess、maximumSize來決定是否須要write鏈和access鏈肯定要建立的具體Reference:StrongEntry、StrongWriteEntry、StrongAccessEntry、StrongWriteAccessEntry等。建立不一樣類型的ReferenceEntry由其枚舉工廠類EntryFactory來實現,它根據key的Strongth類型、是否使用accessQueue、是否使用writeQueue來決定不一樣的EntryFactry實例,並經過它建立相應的ReferenceEntry實例。ReferenceEntry類圖以下: 
算法

WriteQueue和AccessQueue 
爲了實現最近最少使用算法,Guava Cache在Segment中添加了兩條鏈:write鏈(writeQueue)和access鏈(accessQueue),這兩條鏈都是一個雙向鏈表,經過ReferenceEntry中的previousInWriteQueue、nextInWriteQueue和previousInAccessQueue、nextInAccessQueue連接而成,可是以Queue的形式表達。WriteQueue和AccessQueue都是自定義了offer、add(直接調用offer)、remove、poll等操做的邏輯,對於offer(add)操做,若是是新加的節點,則直接加入到該鏈的結尾,若是是已存在的節點,則將該節點連接的鏈尾;對remove操做,直接從該鏈中移除該節點;對poll操做,將頭節點的下一個節點移除,並返回。 數組

static final class WriteQueue<K, V> extends AbstractQueue<ReferenceEntry<K, V>> {
    final ReferenceEntry<K, V> head = new AbstractReferenceEntry<K, V>() ....
    @Override
    public boolean offer(ReferenceEntry<K, V> entry) {
      // unlink
      connectWriteOrder(entry.getPreviousInWriteQueue(), entry.getNextInWriteQueue());
      // add to tail
      connectWriteOrder(head.getPreviousInWriteQueue(), entry);
      connectWriteOrder(entry, head);
      return true;
    }
    @Override
    public ReferenceEntry<K, V> peek() {
      ReferenceEntry<K, V> next = head.getNextInWriteQueue();
      return (next == head) ? null : next;
    }
    @Override
    public ReferenceEntry<K, V> poll() {
      ReferenceEntry<K, V> next = head.getNextInWriteQueue();
      if (next == head) {
        return null;
      }
      remove(next);
      return next;
    }
    @Override
    public boolean remove(Object o) {
      ReferenceEntry<K, V> e = (ReferenceEntry) o;
      ReferenceEntry<K, V> previous = e.getPreviousInWriteQueue();
      ReferenceEntry<K, V> next = e.getNextInWriteQueue();
      connectWriteOrder(previous, next);
      nullifyWriteOrder(e);
      return next != NullEntry.INSTANCE;
    }
    @Override
    public boolean contains(Object o) {
      ReferenceEntry<K, V> e = (ReferenceEntry) o;
      return e.getNextInWriteQueue() != NullEntry.INSTANCE;
    }
....
  }

對於不須要維護WriteQueue和AccessQueue的配置(即沒有expire time或size limit的evict策略)來講,咱們可使用DISCARDING_QUEUE以節省內存:  數據結構

static final Queue<? extends Object> DISCARDING_QUEUE = new AbstractQueue<Object>() {
    @Override
    public boolean offer(Object o) {
      return true;
    }
    @Override
    public Object peek() {
      return null;
    }
    @Override
    public Object poll() {
      return null;
    }
....
  };

Segment中的evict
在解決了全部數據結構的問題之後,讓咱們來看看LocalCache中的核心類Segment的實現,首先從evict開始。在Guava Cache的evict時機上,它沒有使用另外一個後臺線程每隔一段時間掃瞄一次table以evict那些已經expire的entry。而是它在每次操做開始和結束時才作一遍清理工做,這樣能夠減小開銷,可是若是長時間不調用方法的話,會引發有些entry不能及時被evict出去。evict主要處理四個Queue:1. keyReferenceQueue;2. valueReferenceQueue;3. writeQueue;4. accessQueue。前兩個queue是由於WeakReference、SoftReference被垃圾回收時加入的,清理時只須要遍歷整個queue,將對應的項從LocalCache中移除便可,這裏keyReferenceQueue存放ReferenceEntry,而valueReferenceQueue存放的是ValueReference,要從LocalCache中移除須要有key,於是ValueReference須要有對ReferenceEntry的引用。這裏的移除經過LocalCache而不是Segment是由於在移除時由於expand(rehash)可能致使原來在某個Segment中的ReferenceEntry後來被移動到另外一個Segment中了。而對後兩個Queue,只須要檢查是否配置了相應的expire時間,而後從頭開始查找已經expire的Entry,將它們移除便可。有不一樣的是在移除時,還會註冊移除的事件,這些事件將會在接下來的操做調用註冊的RemovalListener觸發,這些代碼比較簡單,不詳述。
在put的時候,還會清理recencyQueue,即將recencyQueue中的Entry添加到accessEntry中,此時可能會發生某個Entry實際上已經被移除了,可是又被添加回accessQueue中了,這種狀況下,若是沒有使用WeakReference、SoftReference,也沒有配置expire時間,則會引發一些內存泄漏問題。recencyQueue在get操做時被添加,可是爲何會有這個Queue的存在一直沒有想明白。

Segment中的put操做
put操做相對比較簡單,首先它須要得到鎖,而後嘗試作一些清理工做,接下來的邏輯相似ConcurrentHashMap中的rehash,不詳述。須要說明的是當找到一個已存在的Entry時,須要先判斷當前的ValueRefernece中的值事實上已經被回收了,由於它們能夠時WeakReference、SoftReference類型,若是已經被回收了,則將新值寫入。而且在每次更新時註冊當前操做引發的移除事件,指定相應的緣由:COLLECTED、REPLACED等,這些註冊的事件在退出的時候統一調用LocalCache註冊的RemovalListener,因爲事件處理可能會有很長時間,於是這裏將事件處理的邏輯在退出鎖之後才作。最後,在更新已存在的Entry結束後都嘗試着將那些已經expire的Entry移除。另外put操做中還須要更新writeQueue和accessQueue的語義正確性。 app

V put(K key, int hash, V value, boolean onlyIfAbsent) {
      ....
        for (ReferenceEntry<K, V> e = first; e != null; e = e.getNext()) {
          K entryKey = e.getKey();
          if (e.getHash() == hash && entryKey != null && map.keyEquivalence.equivalent(key, entryKey)) {
            ValueReference<K, V> valueReference = e.getValueReference();
            V entryValue = valueReference.get();
            if (entryValue == null) {
              ++modCount;
              if (valueReference.isActive()) {
                enqueueNotification(key, hash, valueReference, RemovalCause.COLLECTED);
                setValue(e, key, value, now);
                newCount = this.count; // count remains unchanged
              } else {
                setValue(e, key, value, now);
                newCount = this.count + 1;
              }
              this.count = newCount; // write-volatile
              evictEntries();
              return null;
            } else if (onlyIfAbsent) {
              recordLockedRead(e, now);
              return entryValue;
            } else {
              ++modCount;
              enqueueNotification(key, hash, valueReference, RemovalCause.REPLACED);
              setValue(e, key, value, now);
              evictEntries();
              return entryValue;
            }
          }
        }
...
      } finally {
        ...
        postWriteCleanup();
      }
    }

Segment帶CacheLoader的get操做
這部分的代碼有點不知道怎麼說了,大概上的步驟是:1. 先查找table中是否已存在沒有被回收、也沒有expire的entry,若是找到,並在CacheBuilder中配置了refreshAfterWrite,而且當前時間間隔已經操做這個事件,則從新加載值,不然,直接返回原有的值;2. 若是查找到的ValueReference是LoadingValueReference,則等待該LoadingValueReference加載結束,並返回加載的值;3. 若是沒有找到entry,或者找到的entry的值爲null,則加鎖後,繼續table中已存在key對應的entry,若是找到而且對應的entry.isLoading()爲true,則表示有另外一個線程正在加載,於是等待那個線程加載完成,若是找到一個非null值,返回該值,不然建立一個LoadingValueReference,並調用loadSync加載相應的值,在加載完成後,將新加載的值更新到table中,即大部分狀況下替換原來的LoadingValueReference。

Segment中的其餘操做
其餘操做包括不含CacheLoader的get、containsKey、containsValue、replace等操做邏輯重複性很大,並且和ConcurrentHashMap的實現方式也相似,不在詳述。

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

public interface StatsCounter {
    public void recordHits(int count);
    public void recordMisses(int count);
    public void recordLoadSuccess(long loadTime);
    public void recordLoadException(long loadTime);
    public void recordEviction();

    public CacheStats snapshot();
  }

在獲得StatsCounter實例後,可使用CacheStats獲取具體的統計信息: 函數

public final class CacheStats {
  private final long hitCount;
  private final long missCount;
  private final long loadSuccessCount;
  private final long loadExceptionCount;
  private final long totalLoadTime;
  private final long evictionCount;

}

同ConcurrentHashMap,在知道Segment實現之後,其餘的方法基本上都是代理給Segment內部方法,於是在LocalCache類中的其餘方法看起來就比較容易理解,不在詳述。然而Guava Cache並無將ConcurrentMap直接提供給用戶使用,而是爲了區分Cache和Map,它自定義了一個本身的Cache接口和LoadingCache接口,咱們能夠經過CacheBuilder配置不一樣的參數,而後使用build()方法返回一個Cache或LoadingCache實例: 工具

public interface Cache<K, V> {
  V getIfPresent(Object key);
  V get(K key, Callable<? extends V> valueLoader) throws ExecutionException;
  ImmutableMap<K, V> getAllPresent(Iterable<?> keys);
  void put(K key, V value);
  void putAll(Map<? extends K,? extends V> m);
  void invalidate(Object key);
  void invalidateAll(Iterable<?> keys);
  void invalidateAll();
  long size();
  CacheStats stats();
  ConcurrentMap<K, V> asMap();
  void cleanUp();
}

public interface LoadingCache<K, V> extends Cache<K, V>, Function<K, V> {
  V get(K key) throws ExecutionException;
  V getUnchecked(K key);
  ImmutableMap<K, V> getAll(Iterable<? extends K> keys) throws ExecutionException;
  V apply(K key);
  void refresh(K key);
  ConcurrentMap<K, V> asMap();
}
相關文章
相關標籤/搜索