4. LruCache 原理与主线程安全使用

LruCache 是 Android 中最常用的内存缓存方案,其核心是利用 LinkedHashMap 的访问排序特性,实现“最近最少使用”的淘汰策略。本章剖析其底层原理,并重点解决在主线程中调用 put() / get() 可能引发的 ANR 问题。

4.1 核心数据结构:LinkedHashMap

LruCache 内部维护一个 LinkedHashMap,构造时开启 accessOrder=true。该标志使链表按访问顺序排列:每次 get() 或 put() 命中已有条目,该条目会被移动到链表尾部。因此,链表头部始终是“最久未访问”的候选淘汰项。

参数HashMapLinkedHashMap (accessOrder=true)
迭代顺序无序按访问顺序(最近访问在尾部)
插入/访问性能O(1)O(1) (多维护双向链表指针)
内存开销较低多维护 before/after 引用
关键用途通用 KV 存储LRU 淘汰、缓存过期

4.2 最大容量与 eviction 回调

创建 LruCache 时必须指定 maxSize。每次 put() 后检查当前总大小是否超出,若超出则调用 trimToSize(),循环淘汰链表头部条目直至容量合规。每淘汰一个条目,回调 entryRemoved(),可用于释放资源或记录日志。

public class LruCache<K, V> {
    private final LinkedHashMap<K, V> map;
    private int size;          // 当前已用大小
    private int maxSize;       // 最大容量

    public LruCache(int maxSize) {
        this.maxSize = maxSize;
        this.map = new LinkedHashMap<K, V>(0, 0.75f, true);
    }

    protected int sizeOf(K key, V value) {
        return 1; // 默认按条目计数,可重写为按字节
    }

    protected void entryRemoved(boolean evicted, K key, V oldValue, V newValue) {
        // 被淘汰或被动替换时回调,适合释放 bitmap 等大对象
    }

    public final V put(K key, V value) {
        if (key == null || value == null) throw new NullPointerException();
        V previous;
        synchronized (this) {
            size += safeSizeOf(key, value);
            previous = map.put(key, value);
            if (previous != null) {
                size -= safeSizeOf(key, previous);
            }
        }
        if (previous != null) {
            entryRemoved(false, key, previous, value);
        }
        trimToSize(maxSize);
        return previous;
    }

    public final V get(K key) {
        if (key == null) throw new NullPointerException();
        synchronized (this) {
            return map.get(key);
        }
    }
}

4.3 主线程安全使用:耗时控制

核心问题:LruCache 本身是线程安全的(通过 synchronized 块),但在主线程中直接 put() / get() 的耗时取决于三个因素:

  • 键值计算:若 key 的 hashCode() 或 equals() 耗时过长(如字符串拼接、IO 读取),会阻塞主线程。
  • 淘汰回调:entryRemoved() 中若执行复杂资源回收(如大图 recycle、数据库写),可能造成掉帧或 ANR。
  • 容量校验:sizeOf() 如果每次都重新计算实际字节数(如序列化对象),会加剧主线程压力。
关键约束:主线程单次 put() / get() 应控制在 1ms 以内,否则叠加产生累积耗时导致 ANR(阈值 5s)。

4.4 基于 SG 图的流程控制

LruCache 主线程安全操作流程及耗时控制节点 展示从入参到 put/get、容量校验、淘汰回调、资源回收的完整链路,标注主线程耗时控制点。 LruCache 主线程安全操作流程 ① 入参 key / value ② hashCode / equals 计算 (耗时 < 0.5ms) ③ synchronized 块操作 map (LinkedHashMap.put / get) 是否超出 maxSize? ④ 淘汰头部条目 (调用 entryRemoved 回调) ⑤ 资源释放(需异步或极快) 主线程耗时注意 • hash/equals 简单 • sizeOf 不重计算 • entryRemoved 异步

4.5 主线程安全使用策略

策略实现方式主线程耗时
简化 key 的 hash/equals使用整型或预计算哈希的 String< 0.1ms
重写 sizeOf 为静态值如固定 1 或常量条目数无额外计算
entryRemoved 异步化将回调内容 post 到 HandlerThread回调返回快
批量 put 改用后台线程构建临时 map,一次同步写入单次操作 < 0.5ms
避免反复 trimToSize根据峰值+余量设定 maxSize减少淘汰次数

4.6 代码示例:主线程安全 LruCache 封装

public class SafeLruCache<K, V> {
    private final LruCache<K, V> cache;
    private final Handler backgroundHandler;

    public SafeLruCache(int maxSize, Handler backgroundHandler) {
        this.cache = new LruCache<K, V>(maxSize) {
            @Override
            protected int sizeOf(K key, V value) {
                // 若 value 是 Bitmap,应返回 byteCount;但避免在主线程计算
                // 可在构造时预先分配大小,或使用固定 1
                return 1;
            }

            @Override
            protected void entryRemoved(boolean evicted, K key, V oldValue, V newValue) {
                if (oldValue instanceof Releasable) {
                    // 后台释放以避免阻塞主线程
                    backgroundHandler.post(() -> ((Releasable) oldValue).release());
                }
            }
        };
    }

    public final V get(K key) {
        // 主线程调用,仅做 synchronized 读,不计算大小
        return cache.get(key);
    }

    public final void put(K key, V value) {
        // 主线程调用,但限制淘汰后无复杂回调
        cache.put(key, value);
    }
}
性能监测点:在 debug 模式下可埋点统计主线程 put() / get() 的耗时。若单次超过 2ms,应检查 sizeOf() 或 entryRemoved() 是否有非预期耗时操作。

4.7 常见错误与调优点

  • 错误1:在 entryRemoved() 中直接执行文件写入或网络请求,导致主线程 ANR。
    ✅ 改为 post 到 HandlerThread。
  • 错误2:sizeOf() 每次遍历集合计算字节数,随缓存增长耗时陡增。
    ✅ 改为估算或预分配常量。
  • 错误3:maxSize 设置过小导致频繁淘汰,淘汰流程本身也需要加锁。
    ✅ 根据内存余量设置 80% 水位线。
  • 错误4:未重写 entryRemoved(),导致 Bitmap 等大对象泄漏。
    ✅ 至少记录日志以便排查。

4.8 调试与验证

使用 Systrace 抓取主线程的 LruCache.put / LruCache.get 方法耗时。在 Trace 中关注:

  • trimToSize 段是否出现在主线程
  • entryRemoved 回调内是否有长时间运行片段
  • 若存在 HashMap.put 后紧接着 LinkedHashMap.removeEldestEntry,说明淘汰触发频繁
排查快速路径:adb shell dumpsys meminfo 查看缓存对象大小占比;若占比过高但命中率低,说明 maxSize 设置不合理,需调整淘汰策略。

4.9 本章小结

LruCache 依赖 LinkedHashMap 访问排序实现 O(1) 淘汰,其主线程安全使用的关键在于避免 sizeOf()、entryRemoved() 中的复杂计算。通过将资源释放异步化、简化 key 计算、合理设定 maxSize,可有效将单次 put/get 耗时控制在亚毫秒级,避免 ANR 风险。