4. LruCache 原理与主线程安全使用
LruCache 是 Android 中最常用的内存缓存方案,其核心是利用 LinkedHashMap 的访问排序特性,实现“最近最少使用”的淘汰策略。本章剖析其底层原理,并重点解决在主线程中调用 put() / get() 可能引发的 ANR 问题。
4.1 核心数据结构:LinkedHashMap
LruCache 内部维护一个 LinkedHashMap,构造时开启 accessOrder=true。该标志使链表按访问顺序排列:每次 get() 或 put() 命中已有条目,该条目会被移动到链表尾部。因此,链表头部始终是“最久未访问”的候选淘汰项。
| 参数 | HashMap | LinkedHashMap (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 图的流程控制
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 风险。