Android 内存缓存框架 LruCache 的实现原理

Posted 一叶飘舟

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了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

@OverridepublicvoidtrimMemory(int level) 
    if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) 
        // Entering list of cached background apps// Evict our entire bitmap cache
        clearMemory();
     elseif (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

publicclassLruCache<K, V> 
    // LRU 控制privatefinal LinkedHashMap<K, V> map;

    // 当前缓存占用privateint size;
    // 最大缓存容量privateint maxSize;

    // 以下属性用于数据统计// 设置数据次数privateint putCount;
    // 创建数据次数privateint createCount;
    // 淘汰数据次数privateint evictionCount;
    // 缓存命中次数privateint hitCount;
    // 缓存未命中数privateint missCount;

3.3 LruCache 的构造方法

LruCache 只有 1 个构造方法。

由于缓存空间不可能设置无限大,所以开发者需要在构造方法中设置缓存的最大内存容量 maxSize。

LinkedHashMap 对象也会在 LruCache 的构造方法中创建,并且会设置 accessOrder 标记位为 true,表示使用 LRU 排序模式。

LruCache.java

// maxSize:缓存的最大内存容量publicLruCache(int maxSize) 
    if (maxSize <= 0) 
        thrownewIllegalArgumentException("maxSize <= 0");
    
    // 缓存的最大内存容量this.maxSize = maxSize;
    // 创建 LinkedHashMap 对象,并使用 LRU 排序模式this.map = newLinkedHashMap<K, V>(0, 0.75f, true/*LRU 模式*/);

使用示例

privatestaticfinalintCACHE_SIZE=4 * 1024 * 1024; // 4MibLruCachebitmapCache=newLruCache(CACHE_SIZE);
复制代码

3.4 测量数据单元的内存占用

开发者需要重写 LruCache#sizeOf() 测量缓存单元的内存占用量,否则缓存单元的大小默认视为 1,相当于 maxSize 表示的是最大缓存数量。

LruCache.java

// LruCache 内部使用privateintsafeSizeOf(K key, V value) 
    // 如果开发者重写的 sizeOf 返回负数,则抛出异常intresult= sizeOf(key, value);
    if (result < 0) 
        thrownewIllegalStateException("Negative size: " + key + "=" + value);
    
    return result;


// 测量缓存单元的内存占用protectedintsizeOf(K key, V value) 
    // 默认为 1return1;

复制代码

使用示例

privatestaticfinalintCACHE_SIZE=4 * 1024 * 1024; // 4MibLruCachebitmapCache=newLruCache(CACHE_SIZE)
    // 重写 sizeOf 方法,用于测量 Bitmap 的内存占用@OverrideprotectedintsizeOf(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

publicfinal V put(K key, V value) 
    // 疑问 1:不支持 null 作为 Key 或 Valueif (key == null || value == null) 
        thrownewNullPointerException("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、自动淘汰数据publicvoidtrimToSize(int maxSize) 
    // 淘汰数据直到不超过最大容量限制while (true) 
        K key;
        V value;
        synchronized (this) 
            if (size < 0 || (map.isEmpty() && size != 0)) 
                thrownewIllegalStateException(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 不仅支持缓存数据,还支持创建数据。

publicfinal V get(K key) 
    // 不支持 null 作为 Key 或 Valueif (key == null) 
        thrownewNullPointerException("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. 尝试自动创建缓存数据(类似对象池)VcreatedValue= create(key);
    if (createdValue == null) 
        returnnull;
    

    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) 
    returnnull;

不出意外的话又有小朋友出来举手提问了🙋🏻‍♀️:

  • 🙋🏻‍♀️疑问 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);

publicfinal V remove(K key) 
    // 不支持 null 作为 Key 或 Valueif (key == null) 
        thrownewNullPointerException("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() 淘汰方法;


如何使用 LinkedHashMap 实现 LRU 缓存?

今天,我们就来讨论 LinkedHashMap 是如何实现 LRU 缓存的。

本文源码基于 Java 8 LinkedHashMap。


思维导图:


1. 认识 LRU 缓存淘汰算法

1.1 什么是缓存淘汰算法?

缓存是提高数据读取性能的通用技术,在硬件和软件设计中被广泛使用,例如 CPU 缓存、Glide 内存缓存,数据库缓存等。由于缓存空间不可能无限大,当缓存容量占满时,就需要利用某种策略将部分数据换出缓存,这就是缓存的替换策略 / 淘汰问题。常见缓存淘汰策略有:

  • 1、随机策略: 使用一个随机数生成器随机地选择要被淘汰的数据块;

  • 2、FIFO 先进先出策略: 记录各个数据块的访问时间,最早访问的数据最先被淘汰;

  • 3、LRU (Least Recently Used)最近最少策略: 记录各个数据块的访问 “时间戳” ,最近最久未使用的数据最先被淘汰。与前 2 种策略相比,LRU 策略平均缓存命中率更高,这是因为 LRU 策略利用了 “局部性原理”:最近被访问过的数据,将来被访问的几率较大,最近很久未访问的数据,将来访问的几率也较小;

  • 4、LFU (Least Frequently Used)最不经常使用策略: 与 LRU 相比,LFU 更加注重使用的 “频率” 。LFU 会记录每个数据块的访问次数,最少访问次数的数据最先被淘汰。但是有些数据在开始时使用次数很高,以后不再使用,这些数据就会长时间污染缓存。可以定期将计数器右移一位,形成指数衰减。

FIFO 与 LRU 策略

1.2 向外看:LRU 的变型

其实,在标准的 LRU 算法上还有一些变型实现,这是因为 LRU 算法本身也存在一些不足。例如,当数据中热点数据较多时,LRU 能够保证较高的命中率。但是当有偶发的批量的非热点数据产生时,就会将热点数据寄出缓存,使得缓存被污染。因此,LRU 也有一些变型:

  • LRU-K: 提供两个 LRU 队列,一个是访问计数队列,一个是标准的 LRU 队列,两个队列都按照 LRU 规则淘汰数据。当访问一个数据时,数据先进入访问计数队列,当数据访问次数超过 K 次后,才会进入标准 LRU 队列。标准的 LRU 算法相当于 LRU-1;

  • Two Queue: 相当于 LRU-2 的变型,将访问计数队列替换为 FIFO 队列淘汰数据数据。当访问一个数据时,数据先进入 FIFO 队列,当第 2 次访问数据时,才会进入标准 LRU 队列;

  • Multi Queue: 在 LRU-K 的基础上增加更多队列,提供多个级别的缓冲。

小彭在 Redis 和 Vue 中有看到这些 LRU 变型的应用,在 Android 领域的框架中还没有看到具体应用,你知道的话可以提醒我。

1.3 如何实现 LRU 缓存淘汰算法?

这一小节,我们尝试找到 LRU 缓存淘汰算法的实现方案。经过总结,我们可以定义一个缓存系统的基本操作:

  • 操作 1 - 添加数据: 先查询数据是否存在,不存在则添加数据,存在则更新数据,并尝试淘汰数据;

  • 操作 2 - 删除数据: 先查询数据是否存在,存在则删除数据;

  • 操作 3 - 查询数据: 如果数据不存在则返回 null;

  • 操作 4 - 淘汰数据: 添加数据时如果容量已满,则根据缓存淘汰策略一个数据。

我们发现,前 3 个操作都有 “查询” 操作, 所以缓存系统的性能主要取决于查找数据和淘汰数据是否高效。 下面,我们用递推的思路推导 LRU 缓存的实现方案,主要分为 3 种方案:

  • 方案 1 - 基于时间戳的数组: 在每个数据块中记录最近访问的时间戳,当数据被访问(添加、更新或查询)时,将数据的时间戳更新到当前时间。当数组空间已满时,则扫描数组淘汰时间戳最小的数据。

  • 查找数据: 需要遍历整个数组找到目标数据,时间复杂度为 O(n);

  • 淘汰数据: 需要遍历整个数组找到时间戳最小的数据,且在移除数组元素时需要搬运数据,整体时间复杂度为 O(n)。

  • 方案 2 - 基于双向链表: 不再直接维护时间戳,而是利用链表的顺序隐式维护时间戳的先后顺序。当数据被访问(添加、更新或查询)时,将数据插入到链表头部。当空间已满时,直接淘汰链表的尾节点。

  • 查询数据:需要遍历整个链表找到目标数据,时间复杂度为 O(n);

  • 淘汰数据:直接淘汰链表尾节点,时间复杂度为 O(1)。

  • 方案 3 - 基于双向链表 + 散列表: 使用双向链表可以将淘汰数据的时间复杂度降低为 O(1),但是查询数据的时间复杂度还是 O(n),我们可以在双向链表的基础上增加散列表,将查询操作的时间复杂度降低为 O(1)。

  • 查询数据:通过散列表定位数据,时间复杂度为 O(1);

  • 淘汰数据:直接淘汰链表尾节点,时间复杂度为 O(1)。

方案 3 这种数据结构就叫 “哈希链表或链式哈希表”,我更倾向于称为哈希链表,因为当这两个数据结构相结合时,我们更看重的是它作为链表的排序能力。

我们今天要讨论的 Java LinkedHashMap 就是基于哈希链表的数据结构。


2. 认识 LinkedHashMap 哈希链表

2.1 说一下 LinkedHashMap 的特点

需要注意:LinkedHashMap 中的 “Linked” 实际上是指双向链表,并不是指解决散列冲突中的分离链表法。

  • 1、LinkedHashMap 是继承于 HashMap 实现的哈希链表,它同时具备双向链表和散列表的特点。事实上,LinkedHashMap 继承了 HashMap 的主要功能,并通过 HashMap 预留的 Hook 点维护双向链表的逻辑。

  • 1.1 当 LinkedHashMap 作为散列表时,主要体现出 O(1) 时间复杂度的查询效率;

  • 1.2 当 LinkedHashMap 作为双向链表时,主要体现出有序的特性。

  • 2、LinkedHashMap 支持 2 种排序模式,这是通过构造器参数 accessOrder 标记位控制的,表示是否按照访问顺序排序,默认为 false 按照插入顺序。

  • 2.1 插入顺序(默认): 按照数据添加到 LinkedHashMap 的顺序排序,即 FIFO 策略;

  • 2.2 访问顺序: 按照数据被访问(包括插入、更新、查询)的顺序排序,即 LRU 策略。

  • 3、在有序性的基础上,LinkedHashMap 提供了维护了淘汰数据能力,并开放了淘汰判断的接口 removeEldestEntry()。在每次添加数据时,会回调 removeEldestEntry() 接口,开发者可以重写这个接口决定是否移除最早的节点(在 FIFO 策略中是最早添加的节点,在 LRU 策略中是最早未访问的节点);

  • 4、与 HashMap 相同,LinkedHashMap 也不考虑线程同步,也会存在线程安全问题。可以使用 Collections.synchronizedMap 包装类,其原理也是在所有方法上增加 synchronized 关键字。

2.2 说一下 HashMap 和 LinkedHashMap 的区别?

事实上,HashMap 和 LinkedHashMap 并不是平行的关系,而是继承的关系,LinkedHashMap 是继承于 HashMap 实现的哈希链表。

两者主要的区别在于有序性: LinkedHashMap 会维护数据的插入顺序或访问顺序,而且封装了淘汰数据的能力。在迭代器遍历时,HashMap 会按照数组顺序遍历桶节点,从开发者的视角看是无序的。而是按照双向链表的顺序从 head 节点开始遍历,从开发者的视角是可以感知到的插入顺序或访问顺序。

LinkedHashMap 示意图


3. HashMap 预留的 Hook 点

LinkedHashMap 继承于 HashMap,在后者的基础上通过双向链表维护节点的插入顺序或访问顺序。因此,我们先回顾下 HashMap 为 LinkedHashMap 预留的 Hook 点:

  • afterNodeAccess: 在节点被访问时回调;

  • afterNodeInsertion: 在节点被插入时回调,其中有参数 evict 标记是否淘汰最早的节点。在初始化、反序列化或克隆等构造过程中,evict 默认为 false,表示在构造过程中不淘汰。

  • afterNodeRemoval: 在节点被移除时回调。

HashMap.java

// 节点访问回调voidafterNodeAccess(Node<K,V> p)  
// 节点插入回调// evict:是否淘汰最早的节点voidafterNodeInsertion(boolean evict)  
// 节点移除回调voidafterNodeRemoval(Node<K,V> p)  
复制代码

除此了这 3 个空方法外,LinkedHashMap 也重写了部分 HashMap 的方法,在其中插入双链表的维护逻辑,也相当于 Hook 点。在 HashMap 的添加、获取、移除方法中,与 LinkedHashMap 有关的 Hook 点如下:

3.1 HashMap 的添加方法中的 Hook 点

LinkedHashMap 直接复用 HashMap 的添加方法,也支持批量添加:

  • HashMap#put: 逐个添加或更新键值对;

  • HashMap#putAll: 批量添加或更新键值对。

不管是逐个添加还是批量添加,最终都会先通过 hash 函数计算键(Key)的散列值,再通过 HashMap#putVal 添加或更新键值对,这些都是 HashMap 的行为。关键的地方在于:LinkedHashMap 在 HashMap#putVal 的 Hook 点中加入了双线链表的逻辑。区分 2 种情况:

  • 添加数据: 如果数据不存在散列表中,则调用 newNode() 或 newTreeNode() 创建节点,并回调 afterNodeInsertion();

  • 更新数据: 如果数据存在散列表中,则更新 Value,并回调 afterNodeAccess()。

HashMap.java

// 添加或更新键值对public V put(K key, V value) 
    return putVal(hash(key) /*计算散列值*/, key, value, false, true);


// hash:Key 的散列值(经过扰动)final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) 
    Node<K,V>[] tab; 
    Node<K,V> p; 
    int n;
    int i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // (n - 1) & hash:散列值转数组下标if ((p = tab[i = (n - 1) & hash]) == null)
        // 省略遍历桶的代码,具体分析见 HashMap 源码讲解// 1.1 如果节点不存在,则新增节点
        p.next = newNode(hash, key, value, null);
        // 2.1 如果节点存在更新节点 Valueif (e != null) 
            VoldValue= e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            // 2.2 Hook:访问节点回调
            afterNodeAccess(e);
            return oldValue;
        
    
    ++modCount;
    // 扩容if (++size > threshold)
        resize();
    // 1.2 Hook:新增节点回调
    afterNodeInsertion(evict);
    returnnull;

复制代码

HashMap#put 示意图

3.2 HashMap 的获取方法中的 Hook 点

LinkedHashMap 重写了 HashMap#get 方法,在 HashMap 版本的基础上,增加了 afterNodeAccess() 回调。

HashMap.java

public V get(Object key) 
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;

复制代码

LinkedHashMap.java

public V get(Object key) 
    Node<K,V> e;
    if ((e = getNode(hash(key), key)) == null)
        returnnull;
    // Hook:节点访问回调if (accessOrder)
        afterNodeAccess(e);
    return e.value;


public V getOrDefault(Object key, V defaultValue) 
    Node<K,V> e;
    if ((e = getNode(hash(key), key)) == null)
        return defaultValue;
    // Hook:节点访问回调if (accessOrder)
        afterNodeAccess(e);
    return e.value;

复制代码

HashMap#get 示意图

3.3 HashMap 的移除方法中的 Hook 点

LinkedHashMap 直接复用 HashMap 的移除方法,在移除节点后,增加 afterNodeRemoval() 回调。

HashMap.java

// 移除节点public V remove(Object key) 
    Node<K,V> e;
    return (e = removeNode(hash(key)/*计算散列值*/, key, null, false, true)) == null ? null : e.value;


final Node<K,V> removeNode(int hash, Object key, Object value,
				boolean matchValue, boolean movable) 
    Node<K,V>[] tab; 
    Node<K,V> p; 
    int n, index;
    // (n - 1) & hash:散列值转数组下标if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) 
        Node<K,V> node = null, e; K k; V v;
        // 省略遍历桶的代码,具体分析见 HashMap 源码讲解// 删除 node 节点if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) 
            // 省略删除节点的代码,具体分析见 HashMap 源码讲解
            ++modCount;
            --size;
            // Hook:删除节点回调
            afterNodeRemoval(node);
            return node;
        
    
    returnnull;

复制代码

HashMap#remove 示意图


4. LinkedHashMap 源码分析

这一节,我们来分析 LinkedHashMap 中主要流程的源码。

4.1 LinkedHashMap 的属性

  • LinkedHashMap 继承于 HashMap,并且新增 head 和 tail 指针指向链表的头尾节点(与 LinkedList 类似的头尾节点);

  • LinkedHashMap 的双链表节点 Entry 继承于 HashMap 的单链表节点 Node,而 HashMap 的红黑树节点 TreeNode 继承于 LinkedHashMap 的双链表节点 Entry。

节点继承关系

LinkedHashMap.java

publicclassLinkedHashMap<K,V> extendsHashMap<K,V> implementsMap<K,V> 
    // 头指针transient LinkedHashMap.Entry<K,V> head;
    // 尾指针transient LinkedHashMap.Entry<K,V> tail;
    // 是否按照访问顺序排序finalboolean accessOrder;

    // 双向链表节点staticclassEntry<K,V> extendsHashMap.Node<K,V> 
        // 前驱指针和后继指针(用于双向链表)
        Entry<K,V> before, after;
        Entry(int hash, K key, V value, Node<K,V> next/*单链表指针(用于散列表的冲突解决)*/) 
            super(hash, key, value, next);
        
    

复制代码

LinkedList.java

publicclassLinkedList<E> extendsAbstractSequentialList<E> implementsList<E>, Deque<E>, Cloneable, java.io.Serializable 
    // 头指针(// LinkedList 中也有类似的头尾节点)transient Node<E> first;
    // 尾指针transient Node<E> last;

    // 双向链表节点privatestaticclassNode<E> 
        // 节点数据// (类型擦除后:Object item;)
        E item;
        // 前驱指针
        Node<E> next;
        // 后继指针
        Node<E> prev;

        Node(Node<E> prev, E element, Node<E> next) 
            this.item = element;
            this.next = next;
            this.prev = prev;
        
    

复制代码

LinkedHashMap 的属性很好理解的,不出意外的话又有小朋友出来举手提问了:

  • 🙋🏻‍♀️疑问 1:HashMap.TreeNode 和 LinkedHashMap.Entry 的继承顺序是不是反了?

我的理解是作者希望简化节点类型,所以采用了非常规的做法(不愧是标准库)。由于 Java 是单继承的,如果按照常规的做法让 HashMap.TreeNode 直接继承 HashMap.Node,那么在 LinkedHashMap 中就需要区分 LinkedHashMap.Entry 和 LinkedHashMap.TreeEntry,再使用接口统一两种类型。

常规实现

4.2 LinkedHashMap 的构造方法

LinkedHashMap 有 5 个构造方法,作用与 HashMap 的构造方法基本一致,区别只在于对 accessOrder 字段的初始化。

// 带初始容量和装载因子的构造方法publicLinkedHashMap(int initialCapacity, float loadFactor) 
    super(initialCapacity, loadFactor);
    accessOrder = false;


// 带初始容量的构造方法publicLinkedHashMap(int initialCapacity) 
    super(initialCapacity);
    accessOrder = false;


// 无参构造方法publicLinkedHashMap() 
    super();
    accessOrder = false;


// 带 Map 的构造方法publicLinkedHashMap(Map<? extends K, ? extends V> m) 
    super();
    accessOrder = false;
    putMapEntries(m, false);


// 带初始容量、装载因子和 accessOrder 的构造方法// 是否按照访问顺序排序,为 true 表示按照访问顺序排序,默认为 falsepublicLinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) 
    super(initialCapacity, loadFactor);
    this.accessOrder = accessOrder;

复制代码

4.3 LinkedHashMap 如何维护双链表

现在,我们看下 LinkedHashMap 是如何维护双链表的。其实,我们将上一节所有的 Hook 点汇总,会发现这些 Hook 点正好组成了 LinkedHashMap 双向链表的行为:

  • 添加数据: 将数据链接到双向链表的尾节点,时间复杂度为 O(1);

  • 访问数据(包括添加、查询、更新): 将数据移动到双向链表的尾节点,亦相当于先移除再添加到尾节点,时间复杂度为 O(1);

  • 删除数据: 将数据从双向链表中移除,时间复杂度为 O(1);

  • 淘汰数据: 直接淘汰双向链表的头节点,时间复杂度为 O(1)。

LinkedHashMap.java

// -> 1.1 如果节点不存在,则新增节点
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) 
    // 新建双向链表节点
    LinkedHashMap.Entry<K,V> p = newLinkedHashMap.Entry<K,V>(hash, key, value, e);
    // 添加到双向链表尾部,等价于 LinkedList#linkLast
    linkNodeLast(p);
    return p;


// -> 1.1 如果节点不存在,则新增节点
TreeNode<K,V> newTreeNode(int hash, K key, V value, Node<K,V> next) 
    // 新建红黑树节点(继承于双向链表节点)
    TreeNode<K,V> p = newTreeNode<K,V>(hash, key, value, next);
    // 添加到双向链表尾部,等价于 LinkedList#linkLast
    linkNodeLast(p);
    return p;


// 添加到双向链表尾部,等价于 LinkedList#linkLastprivatevoidlinkNodeLast(LinkedHashMap.Entry<K,V> p) 
    LinkedHashMap.Entry<K,V> last = tail;
    tail = p;
    if (last == null)
        // last 为 null 说明首个添加的元素,需要修改 first 指针
        head = p;
    else 
        // 将新节点的前驱指针指向 last 
        p.before = last;
        // 将 last 的 next 指针指向新节点
        last.after = p;
    


// 节点插入回调// evict:是否淘汰最早的节点voidafterNodeInsertion(boolean evict)  // possibly remove eldest
    LinkedHashMap.Entry<K,V> first;
    // removeEldestEntry:是否淘汰最早的节点,即是否淘汰头节点(由子类实现)if (evict && (first = head) != null && removeEldestEntry(first)) 
        // 移除 first 节点,腾出缓存空间Kkey= first.key;
        removeNode(hash(key), key, null, false, true);
    


// 移除节点回调voidafterNodeRemoval(Node<K,V> e)  // unlink// 实现了标准的双

以上是关于Android 内存缓存框架 LruCache 的实现原理的主要内容,如果未能解决你的问题,请参考以下文章

Android-DiskLruCache

Android番外篇 LruCache缓存机制

Android内存缓存管理LruCache源码解析与示例

Android 缓存LruCache和DiskLruCache

彻底解析Android缓存机制——LruCache

LruCache缓存