Android 內存緩存框架 LruCache 的實現原理,手寫試試?

前言

大家好,我是小彭。

在之前的文章裏,我們聊到了 LRU 緩存淘汰算法,並且分析 Java 標準庫中支持 LUR 算法的數據結構 LinkedHashMap。當時,我們使用 LinkedHashMap 實現了簡單的 LRU Demo。今天,我們來分析一個 LRU 的應用案例 —— Android 標準庫的 LruCache 內存緩存。


思維導圖:


1. 回顧 LRU 和 LinkedHashMap

在具體分析 LruCache 的源碼之前,我們先回顧上一篇文章中討論的 LRU 緩存策略以及 LinkedHashMap 實現原理。

LRU (Least Recently Used)最近最少策略是最常用的緩存淘汰策略。LRU 策略會記錄各個數據塊的訪問 “時間戳” ,最近最久未使用的數據最先被淘汰。與其他幾種策略相比,LRU 策略利用了 “局部性原理”,平均緩存命中率更高。

FIFO 與 LRU 策略

經過總結,我們可以定義一個緩存系統的基本操作:

  • 操作 1 - 添加數據: 先查詢數據是否存在,不存在則添加數據,存在則更新數據,並嘗試淘汰數據;
  • 操作 2 - 刪除數據: 先查詢數據是否存在,存在則刪除數據;
  • 操作 3 - 查詢數據: 如果數據不存在則返回 null;
  • 操作 4 - 淘汰數據: 添加數據時如果容量已滿,則根據緩存淘汰策略一個數據。

我們發現,前 3 個操作都有 “查詢” 操作,所以緩存系統的性能主要取決於查找數據和淘汰數據是否高效。爲了實現高效的 LRU 緩存結構,我們會選擇採用雙向鏈表 + 散列表的數據結構,也叫 “哈希鏈表”,它能夠將查詢數據和淘汰數據的時間複雜度降低爲 O(1)。

  • 查詢數據: 通過散列表定位數據,時間複雜度爲 O(1);
  • 淘汰數據: 直接淘汰鏈表尾節點,時間複雜度爲 O(1)。

在 Java 標準庫中,已經提供了一個通用的哈希鏈表 —— LinkedHashMap。使用 LinkedHashMap 時,主要關注 2 個 API:

  • accessOrder 標記位: LinkedHashMap 同時實現了 FIFO 和 LRU 兩種淘汰策略,默認爲 FIFO 排序,可以使用 accessOrder 標記位修改排序模式。
  • removeEldestEntry() 接口: 每次添加數據時,LinkedHashMap 會回調 removeEldestEntry() 接口。開發者可以重寫 removeEldestEntry() 接口決定是否移除最早的節點(在 FIFO 策略中是最早添加的節點,在 LRU 策略中是最久未訪問的節點)。

LinkedHashMap 示意圖

LinkedHashMap#put 示意圖


2. 實現 LRU 內存緩存需要考慮什麼問題?

在閱讀 LruCache 源碼之前,我們先嚐試推導 LRU 內存緩存的實現思路,帶着問題和結論去分析源碼,也許收穫會更多。

2.1 如何度量緩存單元的內存佔用?

緩存系統應該實時記錄當前的內存佔用量,在添加數據時增加內存記錄,在移除或替換數據時減少內存記錄,這就涉及 “如何度量緩存單元的內存佔用” 的問題。計數 or 計量,這是個問題。比如說:

  • 舉例 1: 實現圖片內存緩存,如何度量一個圖片資源的內存佔用?
  • 舉例 2: 實現數據模型對象內存緩存,如何度量一個數據模型對象的內存佔用?
  • 舉例 3: 實現資源內存預讀,如何度量一個資源的內存佔用?

我將這個問題總結爲 2 種情況:

  • 1、能力複用使用計數: 這類內存緩存場景主要是爲了複用對象能力,對象本身持有的數據並不多,但是對象的結構卻有可能非常複雜。而且,再加上引用複用的因素,很難統計對象實際的內存佔用。因此,這類內存緩存場景應該使用計數,只統計緩存單元的個數,例如複用數據模型對象,資源預讀等;

  • 2、數據複用使用計量: 這類內存緩存場景主要是爲了複用對象持有的數據,數據對內存的影響遠遠大於對象內存結構對內存的影響,是否度量除了數據外的部分內存對緩存幾乎沒有影響。因此, 這裏內存緩存場景應該使用計量,不計算緩存單元的個數,而是計算緩存單元中主數據字段的內存佔用量,例如圖片的內存緩存就只記錄 Bitmap 的像素數據內存佔用。

還有一個問題,對象內存結構中的對象頭和對齊空間需要計算在內嗎?一般不考慮,因爲在大部分業務開發場景中,相比於對象的實例數據,對象頭和對齊空間的內存佔用幾乎可以忽略不計。

度量策略 舉例
計數 1、Message 消息對象池:最多緩存 50 個對象
2、OkHttp 連接池:默認最多緩存 5 個空閒連接
3、數據庫連接池
計量 1、圖片內存緩存
2、位圖池內存緩存

2.2 最大緩存容量應該設置多大?

網上很多資料都說使用最大可用堆內存的八分之一,這樣籠統地設置方式顯然並不合理。到底應該設置多大的空間沒有絕對標準的做法,而是需要開發者根據具體的業務優先級、用戶機型和系統實時的內存緊張程度做決定:

  • 業務優先級: 如果是高優先級且使用頻率很高的業務場景,那麼最大緩存空間適當放大一些也是可以接受的,反之就要考慮適當縮小;

  • 用戶機型: 在最大可用堆內存較小的低端機型上,最大緩存空間應該適當縮小;

  • 內存緊張程度: 在系統內存充足的時候,可以放大一些緩存空間獲得更好的性能,當系統內存不足時再及時釋放。

2.3 淘汰一個最早的節點就足夠嗎?

標準的 LRU 策略中,每次添加數據時最多隻會淘汰一個數據,但在 LRU 內存緩存中,只淘汰一個數據單元往往並不夠。例如在使用 “計量” 的內存圖片緩存中,在加入一個大圖片後,只淘汰一個圖片數據有可能依然達不到最大緩存容量限制。

因此,在複用 LinkedHashMap 實現 LRU 內存緩存時,前文提到的 LinkedHashMap#removeEldestEntry() 淘汰判斷接口可能就不夠看了,因爲它每次最多隻能淘汰一個數據單元。這個問題,我們後文再看看 Android LruCache 是如何解決的。

2.4 策略靈活性

LruCache 的淘汰策略是在緩存容量滿時淘汰,當緩存容量沒有超過最大限制時就不會淘汰。除了這個策略之外,我們還可以增加一些輔助策略,例如在 Java 堆內存達到某個閾值後,對 LruCache 使用更加激進的清理策略。

在 Android Glide 圖片框架中就有策略靈活性的體現:Glide 除了採用 LRU 策略淘汰最早的數據外,還會根據系統的內存緊張等級 onTrimMemory(level) 及時減少甚至清空 LruCache。

Glide · LruResourceCache.java

@Override
public void trimMemory(int level) {
    if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
        // Entering list of cached background apps
        // Evict our entire bitmap cache
        clearMemory();
    } else if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN || level == android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) {
        // The app's UI is no longer visible, or app is in the foreground but system is running
        // critically low on memory
        // Evict oldest half of our bitmap cache
        trimToSize(getMaxSize() / 2);
    }
}

2.5 線程同步問題

一個緩存系統往往會在多線程環境中使用,而 LinkedHashMap 與 HashMap 都不考慮線程同步,也會存在線程安全問題。這個問題,我們後文再看看 Android LruCache 是如何解決的。


3. LruCache 源碼分析

這一節,我們來分析 LruCache 中主要流程的源碼。

3.1 LruCache 的 API

LruCache 是 Android 標準庫提供的 LRU 內存緩存框架,基於 Java LinkedHashMap 實現,當緩存容量超過最大緩存容量限制時,會根據 LRU 策略淘汰最久未訪問的緩存數據。

用一個表格整理 LruCache 的 API:

public API 描述
V get(K) 獲取緩存數據
V put(K,V) 添加 / 更新緩存數據
V remove(K) 移除緩存數據
void evictAll() 淘汰所有緩存數據
void resize(int) 重新設置最大內存容量限制,並調用 trimToSize()
void trimToSize(int) 淘汰最早數據直到滿足最大容量限制
Map<K, V> snapshot() 獲取緩存內容的鏡像 / 拷貝
protected API 描述
void entryRemoved() 數據移除回調(可用於回收資源)
V create() 創建數據(可用於創建缺省數據)
Int sizeOf() 測量數據單元內存

3.2 LruCache 的屬性

LruCache 的屬性比較簡單,除了多個用於數據統計的屬性外,核心屬性只有 3 個:

  • 1、size: 當前緩存佔用;
  • 2、maxSize: 最大緩存容量;
  • 3、map: 複用 LinkedHashMap 的 LRU 控制能力。

LruCache.java

public class LruCache<K, V> {
    // LRU 控制
    private final LinkedHashMap<K, V> map;

    // 當前緩存佔用
    private int size;
    // 最大緩存容量
    private int maxSize;

    // 以下屬性用於數據統計

    // 設置數據次數
    private int putCount;
    // 創建數據次數
    private int createCount;
    // 淘汰數據次數
    private int evictionCount;
    // 緩存命中次數
    private int hitCount;
    // 緩存未命中數
    private int missCount;
}

3.3 LruCache 的構造方法

LruCache 只有 1 個構造方法。

由於緩存空間不可能設置無限大,所以開發者需要在構造方法中設置緩存的最大內存容量 maxSize

LinkedHashMap 對象也會在 LruCache 的構造方法中創建,並且會設置 accessOrder 標記位爲 true,表示使用 LRU 排序模式。

LruCache.java

// maxSize:緩存的最大內存容量
public LruCache(int maxSize) {
    if (maxSize <= 0) {
        throw new IllegalArgumentException("maxSize <= 0");
    }
    // 緩存的最大內存容量
    this.maxSize = maxSize;
    // 創建 LinkedHashMap 對象,並使用 LRU 排序模式
    this.map = new LinkedHashMap<K, V>(0, 0.75f, true /*LRU 模式*/);
}

使用示例

private static final int CACHE_SIZE = 4 * 1024 * 1024; // 4Mib
LruCache bitmapCache = new LruCache(CACHE_SIZE);

3.4 測量數據單元的內存佔用

開發者需要重寫 LruCache#sizeOf() 測量緩存單元的內存佔用量,否則緩存單元的大小默認視爲 1,相當於 maxSize 表示的是最大緩存數量。

LruCache.java

// LruCache 內部使用
private int safeSizeOf(K key, V value) {
    // 如果開發者重寫的 sizeOf 返回負數,則拋出異常
    int result = sizeOf(key, value);
    if (result < 0) {
        throw new IllegalStateException("Negative size: " + key + "=" + value);
    }
    return result;
}

// 測量緩存單元的內存佔用
protected int sizeOf(K key, V value) {
    // 默認爲 1
    return 1;
}

使用示例

private static final int CACHE_SIZE = 4 * 1024 * 1024; // 4Mib
LruCache bitmapCache = new LruCache(CACHE_SIZE){
    // 重寫 sizeOf 方法,用於測量 Bitmap 的內存佔用
    @Override
    protected int sizeOf(String key, Bitmap value) {
        return value.getByteCount();
    }
};

3.5 添加數據與淘汰數據

LruCache 添加數據的過程基本是複用 LinkedHashMap 的添加過程,我將過程概括爲 6 步:

  • 1、統計添加計數(putCount);
  • 2、size 增加新 Value 內存佔用;
  • 3、設置數據(LinkedHashMap#put);
  • 4、size 減去舊 Value 內存佔用;
  • 5、數據移除回調(LruCache#entryRemoved);
  • 6、自動淘汰數據:在每次添加數據後,如果當前緩存空間超過了最大緩存容量限制,則會自動觸發 trimToSize() 淘汰一部分數據,直到滿足限制。

淘汰數據的過程則是完全自定義,我將過程概括爲 5 步:

  • 1、取最找的數據(LinkedHashMap#eldest);
  • 2、移除數據(LinkedHashMap#remove);
  • 3、size 減去舊 Value 內存佔用;
  • 4、統計淘汰計數(evictionCount);
  • 5、數據移除回調(LruCache#entryRemoved);
  • 重複以上 5 步,滿足要求或者緩存爲空,纔會退出。

邏輯很好理解,不過還是攔不住一些小朋友出來舉手提問了🙋🏻♀️

  • 🙋🏻♀️疑問 1:爲什麼 LruCache 不支持 null 作爲 Key 或 Value?

其實並沒有一定不能爲 null 的理由,我的理解是 Google 希望降低 LruCache 的理解成本。如果允許 Value 爲 null,那麼當 LruCache 需要計算 Value 的 size 時,Value 爲 null 默認應該當作 0 還是當作 1呢?

再者,如果業務開發確實有 Key 或 Value 的需求,也可以選擇重寫 LruCache 的相關方法,或者直接自實現一個 LruCache,這都是可以接受的方案。例如,在 Android Glide 圖片框架中的 LruCache 就是自實現的。

  • 🙋🏻♀️疑問 2:爲什麼 LruCache 淘汰數據沒有重寫 LinkedHashMap#removeEldestEntry() 接口?

這個問題其實跟上一節的 “淘汰一個最早的節點就足夠嗎?” 問題相同。由於只淘汰一個數據後,有可能還不滿足最大容量限制的要求,所以 LruCache 直接放棄了 LinkedHashMap#removeEldestEntry() 接口,而是自己實現了 trimToSize() 淘汰方法。

LinkedHashMap#eldest() 是 Android SDK 添加的方法,在 OpenJDK 中沒有這個方法,這個方法會返回 LinkedHashMap 雙向鏈表的頭節點。由於我們使用的是 LRU 排序模式,所以頭節點自然是 LRU 策略要淘汰的最久未訪問的節點。

trimToSize() 方法中,會循環調用 LinkedHashMap#eldest() 取最早的節點,移除節點後再減去節點佔用的內存大小。所以 trimToSize() 將淘汰數據的邏輯放在 while(true) 循環中,直到滿足要求或者緩存爲空,纔會退出。

添加數據示意圖

LruCache.java

public final V put(K key, V value) {
    // 疑問 1:不支持 null 作爲 Key 或 Value
    if (key == null || value == null) {
        throw new NullPointerException("key == null || value == null");
    }

    // 被替換的數據
    V previous;
    synchronized (this) {
        // 1、統計添加計數
        putCount++;
        // 2、增加新 Value 內存佔用
        size += safeSizeOf(key, value);
        // 3、設置數據
        previous = map.put(key, value);
        // 4、減去舊 Value 內存佔用
        if (previous != null) {
            size -= safeSizeOf(key, previous);
        }
    }
    // 5、數據移除回調(previous -> value)
    if (previous != null) {
        entryRemoved(false /*非淘汰*/, key, previous, value);
    }
    // 6、自動淘汰數據
    trimToSize(maxSize);
    return previous;
}

// -> 6、自動淘汰數據
public void trimToSize(int maxSize) {
    // 淘汰數據直到不超過最大容量限制
    while (true) {
        K key;
        V value;
        synchronized (this) {
            if (size < 0 || (map.isEmpty() && size != 0)) {
                throw new IllegalStateException(getClass().getName() + ".sizeOf() is reporting inconsistent results!");
            }

            // 不超過最大容量限制,跳出
            if (size <= maxSize) {
                break;
            }

            // 6.1 取最早的數據
            Map.Entry<K, V> toEvict = map.eldest();
            // toEvict 爲 null 說明沒有更多數據
            if (toEvict == null) {
                break;
            }

            key = toEvict.getKey();
            value = toEvict.getValue();
            // 6.2 移除數據
            map.remove(key);
            // 6.3 減去舊 Value 內存佔用
            size -= safeSizeOf(key, value);
            // 6.4 統計淘汰計數
            evictionCount++;
        }
        // 6.5 數據移除回調(value -> null)
        entryRemoved(true /*淘汰*/, key, value, null);
    }
}

Android LinkedHashMap.java

// 提示:OpenJDK 中沒有這個方法,是 Android SDK 添加的

public Map.Entry<K, V> eldest() {
    return head;
}

3.6 LruCache 的獲取方法

在獲取數據時,LruCache 增加了自動創建數據的功能,區分 2 種 情況:

  • 1、緩存命中: 直接返回緩存的數據;
  • 2、緩存未命中: 調用 LruCache#create 嘗試創建數據,並將數據設置到緩存池中。這意味着 LruCache 不僅支持緩存數據,還支持創建數據。
public final V get(K key) {
    // 不支持 null 作爲 Key 或 Value
    if (key == null) {
        throw new NullPointerException("key == null");
    }

    V mapValue;
    synchronized (this) {
        // 1. 嘗試獲取緩存的數據
        // mapValue:舊數據
        mapValue = map.get(key);
        if (mapValue != null) { // <標記點>
            // 1.1 緩存命中計數
            hitCount++;
            // 1.2 緩存命中,返回緩存數據
            return mapValue;
        }
        missCount++;
    }

    // 疑問 3:爲什麼 create(key) 要放在 synchronized 塊外部?
    // 2. 嘗試自動創建緩存數據(類似對象池)
    V createdValue = create(key);
    if (createdValue == null) {
        return null;
    }

    synchronized (this) {
        // 3.1 創建數據計數
        createCount++;
        // 3.2 設置創建的緩存數據
        // mapValue:舊數據
        mapValue = map.put(key, createdValue);

        // 疑問 4:在 <標記點> 判斷 mapValue 爲 null,這裏再次 get 又有可能非 null,豈不是矛盾?
        if (mapValue != null) {
            // 3.3 如果 mapValue 舊數據不爲 null,說明在調用 create() 的過程中,有其他線程創建並添加了數據
            // 那麼放棄創建的數據,將 mapValue 重新設置回去。由於另一個線程在設置時已經累加 size 內存佔用,所以這裏不用重複累加
            map.put(key, mapValue);
        } else {
            // 3.4 如果 mapValue 舊數據爲 null,那麼累加 createdValue 的內存佔用
            size += safeSizeOf(key, createdValue);
        }
    }

    // 4. 後處理
    if (mapValue != null) {
        // 4.1 數據移除回調(createdValue -> mapValue)
        entryRemoved(false /*非淘汰*/, key, createdValue, mapValue);
        return mapValue;
    } else {
        // 4.2 增加了 createdValue 後,需要縮容
        trimToSize(maxSize);
        return createdValue;
    }
}

protected V create(K key) {
    return null;
}

不出意外的話又有小朋友出來舉手提問了🙋🏻♀️

  • 🙋🏻♀️疑問 3:爲什麼 create(key) 要放在 synchronized 塊外部?

這是爲了降低鎖的顆粒度。

由於 create(key) 創建數據的過程可能是耗時的,如果將 create(key) 放到 synchronized 同步塊內部,那麼在創建數據的過程中就會阻塞其他線程訪問緩存的需求,會降低緩存系統的吞吐量。

  • 🙋🏻♀️疑問 4:在 <標記點> 判斷 mapValue 爲 null,這裏再次 get 又有可能非 null,豈不是矛盾?

這個問題與上一個問題有關。

由於 create(key) 放在 synchronized 塊外部,那麼在執行 create(key) 的過程中,有可能其他線程已經創建並添加了目標數據,所以在 put(createdValue) 的時候就會出現 mapValue 不爲 null 的情況。

此時,會存在兩個 Value 的情況,應該選擇哪一個 Value 呢?LruCache 認爲其他線程添加的數據的優先級優於默認創建的缺省數據,所以在 3.3 分支放棄了缺省數據,重新將 mapValue 設置回去。

獲取數據示意圖

3.7 LruCache 的移除方法

LruCache 的移除方法是添加方法的逆運算,過程我概括爲 3 步:

  • 1、移除節點(LinkedHashMap#remove);
  • 2、size 減去 Value 的內存佔用;
  • 3、數據移除回調(LruCache#entryRemoved);
public final V remove(K key) {
    // 不支持 null 作爲 Key 或 Value
    if (key == null) {
        throw new NullPointerException("key == null");
    }

    V previous;
    synchronized (this) {
        // 1. 移除數據
        previous = map.remove(key);
        // 2. 減去移除 Value 內存佔用
        if (previous != null) {
            size -= safeSizeOf(key, previous);
        }
    }

    // 3. 數據移除回調(previous -> null)
    if (previous != null) {
        entryRemoved(false, key, previous, null);
    }

    return previous;
}

移除數據示意圖

至此,LruCache 源碼分析結束。


4. 總結

  • 1、LruCache 是 Android 標準庫提供的 LRU 內存緩存框架,基於 Java LinkedHashMap 實現,當緩存容量超過最大緩存容量限制時,會根據 LRU 策略淘汰最久未訪問的緩存數據;

  • 2、LruCache 需要重寫 sizeOf() 測量緩存單元的內存佔用量,否則緩存單元的大小默認視爲 1,相當於 maxSize 表示的是最大緩存數量;

  • 3、LruCache 放棄了 LinkedHashMap#removeEldestEntry() 接口,而是自己實現了 trimToSize() 淘汰方法;

今天,我們討論了 LRU 緩存淘汰策略和一些內存緩存的設計問題,並且分析了 Android LruCache 源碼。在我們熟悉的 Glide 圖片框架中,也深入使用了 LRU 內存緩存策略,你能說出它的設計原理嗎。這個問題我們在下一篇文章討論,請關注。


參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章