1. 程式人生 > >Guava 原始碼分析之Cache的實現原理

Guava 原始碼分析之Cache的實現原理

前言

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 到底是怎麼實現。

原理分析

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

示例程式碼在這裡:

8.png
8.png

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

2.png
2.png

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

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

接著往下跟到:

3.png
3.png

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

4.png
4.png

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

5.png
5.png

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

6.png
6.png

到了這裡也很清晰了:

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

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

應該是以下原因:

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

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

總結

最後再來總結下 Guava 的 Cache。

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

7.png
7.png

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

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

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

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

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

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

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

weixin.png