DiskLruCache 如何实现基于 LRU 算法的磁盘缓存淘汰?

解读

国内面试中,这道题既考察“磁盘缓存”这一高频性能优化手段,又验证候选人对 LRU 算法在本地持久化场景落地的深度理解。面试官通常从“为什么选 DiskLruCache”切入,逐步追问“如何保障原子性”“如何防止缓存击穿”“如何控制索引文件损坏后的自愈”,直至“与 Jetpack DataStore/Room 缓存策略的差异”。回答时须体现:

  1. 对文件系统与内存数据结构联动的认知;
  2. 对并发、快照、原子重命名等 Linux 语义细节的掌握;
  3. 对移动端 IO 耗时与 GC 压力的权衡。

知识点

  1. LRU 双向链表 + LinkedHashMap 的 O(1) 淘汰
  2. 磁盘索引文件 journal 的格式:DIRTY、CLEAN、REMOVE 行记录
  3. 原子提交:临时文件 → 重命名机制(rename 的 POSIX 原子语义)
  4. 快照读取:打开文件描述符后不受后续写入影响,解决“读同时写”问题
  5. 日志合并(trimToSize):当 journal 行数超过 2000 行触发重建,防止无限膨胀
  6. 大小计算:同一 key 多文件(index 0、1…)累加,按 CLEAN 记录为准
  7. 线程模型:全局 ReentrantLock 保证多线程安全;不支持多进程,需外层加文件锁
  8. 异常恢复:启动时扫描 journal,遇到不完整 DIRTY 记录即删除临时文件,保证最终一致性
  9. 国内 ROM 特殊场景:外置存储被 FUSE 拦截导致 rename 非原子,需降级到 Context.getFilesDir()

答案

DiskLruCache 将 LRU 算法从内存搬到磁盘,核心思路是“内存索引 + 日志驱动 + 文件级原子提交”。

  1. 内存索引:使用 LinkedHashMap<String, Entry> 按访问顺序排列,Entry 内保存每个 key 对应的干净文件数组与长度,实现 O(1) 的 LRU 队列。

  2. 日志文件(journal):首行记录版本、应用版本、valueCount、size,后续每行对应一次状态变更:

    • DIRTY key:标记开始写入,创建临时文件 tmp.0、tmp.1…
    • CLEAN key size0 size1…:写入完成,rename 临时文件为正式文件,更新内存大小
    • REMOVE key:删除文件,内存索引移除
      日志采用追加写,保证异常宕机后可重放。
  3. 原子提交:编辑阶段所有输出写到 *.tmp;调用 Editor.commit() 时,先执行 fsync 确保数据落盘,再逐文件 rename 为正式名称,最后追加一条 CLEAN 记录并再次 fsync journal。rename 的 POSIX 原子性保证任何时刻读者看到的都是旧文件或新文件,不存在中间状态。

  4. 淘汰触发:每次插入或更新后,若总大小超过 maxSize,遍历 LinkedHashMap 的 eldest 条目,执行 REMOVE 日志并删除文件,直到满足阈值。

  5. 快照隔离:get() 返回 Snapshot 对象,内部持有文件描述符数组,即使后续该 key 被更新或删除,仍可读旧数据,实现读写隔离。

  6. 自愈机制:初始化时按行解析 journal,遇到无对应 CLEAN 的 DIRTY 记录,直接删除残留 tmp 文件;若 journal 损坏,抛出 IOException,上层可删除缓存目录重建。

通过以上六步,DiskLruCache 在磁盘层面复刻了 LRU 的“最近最少使用”淘汰策略,同时兼顾了 Android 闪存寿命、并发安全与异常恢复。

拓展思考

  1. 多进程共享:DiskLruCache 自身无进程锁,若同一缓存目录被多进程访问,需在外层使用 FileLock 或改为基于 ContentProvider 的中央缓存服务;否则 journal 并发写会立即损坏。
  2. 加密缓存:对敏感图片缓存,可在 Editor 输出流外套 CipherOutputStream,但需自行管理密钥轮换与 journal 的明文长度字段同步。
  3. 分区存储适配:Android 11 强制分区存储,缓存目录应位于 Context.getCacheDir(),避免请求 MANAGE_EXTERNAL_STORAGE 权限被拒审。
  4. 与 OkHttp 内置 Cache 对比:OkHttp 采用类似 journal 机制,但支持 Vary 头、网络策略与 LRU 双重淘汰;若业务已用 OkHttp,可优先复用其缓存,减少重复实现。
  5. 性能调优:journal 重建会阻塞所有读写,可在后台线程预触发 trimToSize;对于折叠屏切换等配置变更导致的大规模缓存失效,可结合 Room 记录 key 的访问热度,实现“二次确认”后再批量删除,降低 IO 抖动。