Guava 原始碼分析(Cache 原理)

crossoverJie發表於2019-01-19

1.jpeg

前言

Google 出的 Guava 是 Java 核心增強的庫,應用非常廣泛。

我平時用的也挺頻繁,這次就藉助日常使用的 Cache 元件來看看 Google 大牛們是如何設計的。

快取

本次主要討論快取。

快取在日常開發中舉足輕重,如果你的應用對某類資料有著較高的讀取頻次,並且改動較小時那就非常適合利用快取來提高效能。

快取之所以可以提高效能是因為它的讀取效率很高,就像是 CPU 的 L1、L2、L3 快取一樣,級別越高相應的讀取速度也會越快。

但也不是什麼好處都佔,讀取速度快了但是它的記憶體更小資源更寶貴,所以我們應當快取真正需要的資料。

其實也就是典型的空間換時間。

下面談談 Java 中所用到的快取。

JVM 快取

首先是 JVM 快取,也可以認為是堆快取。

其實就是建立一些全域性變數,如 Map、List 之類的容器用於存放資料。

這樣的優勢是使用簡單但是也有以下問題:

  • 只能顯式的寫入,清除資料。
  • 不能按照一定的規則淘汰資料,如 LRU,LFU,FIFO 等。
  • 清除資料時的回撥通知。
  • 其他一些定製功能等。

Ehcache、Guava Cache

所以出現了一些專門用作 JVM 快取的開源工具出現了,如本文提到的 Guava Cache。

它具有上文 JVM 快取不具有的功能,如自動清除資料、多種清除演算法、清除回撥等。

但也正因為有了這些功能,這樣的快取必然會多出許多東西需要額外維護,自然也就增加了系統的消耗。

分散式快取

剛才提到的兩種快取其實都是堆內快取,只能在單個節點中使用,這樣在分散式場景下就招架不住了。

於是也有了一些快取中介軟體,如 Redis、Memcached,在分散式環境下可以共享記憶體。

具體不在本次的討論範圍。

Guava Cache 示例

之所以想到 Guava 的 Cache,也是最近在做一個需求,大體如下:

從 Kafka 實時讀取出應用系統的日誌資訊,該日誌資訊包含了應用的健康狀況。
如果在時間視窗 N 內發生了 X 次異常資訊,相應的我就需要作出反饋(報警、記錄日誌等)。

對此 Guava 的 Cache 就非常適合,我利用了它的 N 個時間內不寫入資料時快取就清空的特點,在每次讀取資料時判斷異常資訊是否大於 X 即可。

虛擬碼如下:


    @Value("${alert.in.time:2}")
    private int time ;

    @Bean
    public LoadingCache buildCache(){
        return CacheBuilder.newBuilder()
                .expireAfterWrite(time, TimeUnit.MINUTES)
                .build(new CacheLoader<Long, AtomicLong>() {
                    @Override
                    public AtomicLong load(Long key) throws Exception {
                        return new AtomicLong(0);
                    }
                });
    }
    
    
    /**
     * 判斷是否需要報警
     */
    public void checkAlert() {
        try {
            if (counter.get(KEY).incrementAndGet() >= limit) {
                LOGGER.info("***********報警***********");

                //將快取清空
                counter.get(KEY).getAndSet(0L);
            }
        } catch (ExecutionException e) {
            LOGGER.error("Exception", e);
        }
    }   

首先是構建了 LoadingCache 物件,在 N 分鐘內不寫入資料時就回收快取(當通過 Key 獲取不到快取時,預設返回 0)。

然後在每次消費時候呼叫 checkAlert() 方法進行校驗,這樣就可以達到上文的需求。

我們來設想下 Guava 它是如何實現過期自動清除資料,並且是可以按照 LRU 這樣的方式清除的。

大膽假設下:

內部通過一個佇列來維護快取的順序,每次訪問過的資料移動到佇列頭部,並且額外開啟一個執行緒來判斷資料是否過期,過期就刪掉。有點類似於我之前寫過的 動手實現一個 LRU cache

胡適說過:大膽假設小心論證

下面來看看 Guava 到底是怎麼實現。

原理分析

看原理最好不過是跟程式碼一步步走了:

示例程式碼在這裡:

https://github.com/crossoverJie/Java-Interview/blob/master/src/main/java/com/crossoverjie/guava/CacheLoaderTest.java

8.png

為了能看出 Guava 是怎麼刪除過期資料的在獲取快取之前休眠了 5 秒鐘,達到了超時條件。

2.png

最終會發現在 com.google.common.cache.LocalCache 類的 2187 行比較關鍵。

再跟進去之前第 2182 行會發現先要判斷 count 是否大於 0,這個 count 儲存的是當前快取的數量,並用 volatile 修飾保證了可見性。

更多關於 volatile 的相關資訊可以檢視 你應該知道的 volatile 關鍵字

接著往下跟到:

3.png

2761 行,根據方法名稱可以看出是判斷當前的 Entry 是否過期,該 entry 就是通過 key 查詢到的。

4.png

這裡就很明顯的看出是根據根據構建時指定的過期方式來判斷當前 key 是否過期了。

5.png

如果過期就往下走,嘗試進行過期刪除(需要加鎖,後面會具體討論)。

6.png

到了這裡也很清晰了:

  • 獲取當前快取的總數量
  • 自減一(前面獲取了鎖,所以執行緒安全)
  • 刪除並將更新的總數賦值到 count。

其實大體上就是這個流程,Guava 並沒有按照之前猜想的另起一個執行緒來維護過期資料。

應該是以下原因:

  • 新起執行緒需要資源消耗。
  • 維護過期資料還要獲取額外的鎖,增加了消耗。

而在查詢時候順帶做了這些事情,但是如果該快取遲遲沒有訪問也會存在資料不能被回收的情況,不過這對於一個高吞吐的應用來說也不是問題。

總結

最後再來總結下 Guava 的 Cache。

其實在上文跟程式碼時會發現通過一個 key 定位資料時有以下程式碼:

7.png

如果有看過 ConcurrentHashMap 的原理 應該會想到這其實非常類似。

其實 Guava Cache 為了滿足併發場景的使用,核心的資料結構就是按照 ConcurrentHashMap 來的,這裡也是一個 key 定位到一個具體位置的過程。

先找到 Segment,再找具體的位置,等於是做了兩次 Hash 定位。

上文有一個假設是對的,它內部會維護兩個佇列 accessQueue,writeQueue 用於記錄快取順序,這樣才可以按照順序淘汰資料(類似於利用 LinkedHashMap 來做 LRU 快取)。

同時從上文的構建方式來看,它也是構建者模式來建立物件的。

因為作為一個給開發者使用的工具,需要有很多的自定義屬性,利用構建則模式再合適不過了。

Guava 其實還有很多東西沒談到,比如它利用 GC 來回收記憶體,移除資料時的回撥通知等。之後再接著討論。

掃碼關注微信公眾號,第一時間獲取訊息。

weixin.png

相關文章