磁盘缓存目录与存储结构
OkHttp 的磁盘缓存设计精巧,兼顾了读写速度、空间利用与并发安全。理解其目录布局、文件格式与淘汰策略,是正确配置缓存、排查缓存失效问题的前提。本章围绕这三个核心展开:默认与自定义缓存目录、.0 / .1 双文件存储结构、LRU 驱逐触发时机与算法细节。
android.permission.READ_EXTERNAL_STORAGE 与 android.permission.WRITE_EXTERNAL_STORAGE(API 29 以下),或已适配分区存储。3.1 缓存目录的默认位置与自定义
OkHttp 使用 Cache 对象的构造函数接收一个 File 目录作为磁盘缓存根路径。若不指定,则默认不启用磁盘缓存。推荐的 Android 路径为应用专属缓存目录:context.cacheDir。
| 属性 | 典型值 / 说明 |
|---|---|
| 默认目录 | 无默认值,必须显式设置 File 对象 |
| 推荐 Android 路径 | /data/data/<包名>/cache/okhttp_cache/ |
| 获取方式 | new File(context.cacheDir, "okhttp_cache") |
| 缓存大小上限 | 推荐 10MB ~ 50MB,通过 Cache 构造方法中的 maxSize 参数指定 |
| 权限(API < 29) | 无需额外存储权限,因 cacheDir 属应用私有目录 |
// 自定义缓存目录与大小
File cacheDir = new File(context.getCacheDir(), "okhttp_cache");
int cacheSize = 20 * 1024 * 1024; // 20MB
Cache customCache = new Cache(cacheDir, cacheSize);
OkHttpClient client = new OkHttpClient.Builder()
.cache(customCache)
.build();
创建 Cache 对象后,OkHttp 会在指定目录下自动生成以下结构:
okhttp_cache/
├── journal (索引文件,记录缓存条目的元数据与状态)
├── 00000000000000000000.0 (响应体文件体)
├── 00000000000000000000.1 (响应头 + 元数据)
├── 00000000000000000001.0
├── 00000000000000000001.1
└── ...
3.2 缓存文件格式详解
每个缓存条目对应两个文件:.0(响应体)和 .1(响应头与元数据)。文件名是一个 20 位十进制序列号(从 0 递增),保证唯一性。
3.2.1 .1 文件结构(响应头 + 元数据)
//
// 第一条 HTTP 状态行
http://api.example.com/data 200
//
// 缓存元数据(OkHttp 内部使用)
0
2025-06-01T10:00:00.000Z
2025-06-01T12:00:00.000Z
content-type: application/json
cache-control: max-age=7200
date: Mon, 01 Jun 2025 10:00:00 GMT
//
// 空行后是裸响应头
content-type: application/json
content-length: 256
accept-ranges: bytes
结构解析:
- 第 1 行: 请求方法 + URL + 状态码,用于构建 Key 及校验缓存命中时的方法匹配。
- 第 2 行:
0固定占位(保留字段,未来版本可能用于标识压缩方式)。 - 第 3 行: 缓存保存时间(ISO 8601 格式,UTC)。
- 第 4 行: 缓存过期时间(服务器
Expires头解析后的时间,UTC)。 - 第 5 行以后: 原始的 HTTP 响应头(K-V 格式),每行一条。头部区域以空行或文件末尾结束。
3.2.2 .0 文件结构(响应体)
// 纯二进制流。无任何包装、无长度前缀、无校验和。
// 字节片段示例(UTF-8 解码后):
{"users": [{"id": 1, "name": "Alice"}]}
.0 文件直接存储原始响应体数据。OkHttp 在读取时通过 .1 文件中的 content-length 或 chunked 传输解码信息确定读取长度。若响应体为空(204 No Content),.0 文件不存在。
3.3 LRU 淘汰策略实现细节
OkHttp 的磁盘缓存基于 近似的 LRU(Least Recently Used) 算法,具体实现在 DiskLruCache 内部。核心思想:当写入新缓存导致总大小超过 maxSize 时,优先删除最久未被访问的条目。
3.3.1 淘汰触发条件
- 写入新条目: 每次
put()操作完成后,检查当前目录总大小。 - 更新已有条目: 替换响应体文件时,可能因为新数据更大而超出上限。
- journal 文件重写: 当 journal 文件大小超过 32KB 时,OkHttp 会触发一次 compact 操作,此时也会执行淘汰。
3.3.2 算法模拟
// 简化后的淘汰流程,非 OkHttp 完整源码
void trimToSize(long maxSize) {
while (currentSize > maxSize && !lruEntries.isEmpty()) {
// 获取最旧访问的条目(链表头部)
Entry oldest = lruEntries.removeFirst();
// 删除 .0 与 .1 文件
oldest.file0.delete();
oldest.file1.delete();
// 更新大小
currentSize -= oldest.size;
// 从 directory 中移除条目记录
journal.remove(oldest.key);
}
}
3.3.3 访问顺序维护
LRU 顺序通过 journal 文件中的 READ、WRITE 操作日志维护。每次读取缓存时,OkHttp 会将对应条目的 Key 标记为“最近使用”,并在内存中的 LinkedHashMap 里将该条目移动到链表尾部。淘汰时,直接从链表头部(最久未访问)开始驱逐。
| 日志操作 | 含义 | 对 LRU 顺序的影响 |
|---|---|---|
| DIRTY | 条目正在被写入或更新 | 暂不影响,写入完成时转为 CLEAN |
| CLEAN | 条目写入完成,可被读取 | 加入 LRU 链表尾部 |
| READ | 缓存命中,条目被读取 | 移动到 LRU 链表尾部(延长寿命) |
| REMOVE | 条目被显式删除或淘汰 | 从 LRU 链表中移除 |
3.3.4 journal 文件示例
// 4 个字段:操作类型 (DIRTY/CLEAN/READ/REMOVE) + Key + .0 文件大小 + .1 文件大小
CLEAN 00000000000000000000 256 128
READ 00000000000000000000
DIRTY 00000000000000000001
CLEAN 00000000000000000001 512 140
READ 00000000000000000001
REMOVE 00000000000000000002
3.4 并发安全与稳定性
OkHttp 的 DiskLruCache 使用 ReentrantReadWriteLock 来保证多线程环境下对 journal 文件和缓存文件的数据一致性。关键规则:
- 写操作(DIRTY/CLEAN/REMOVE)获取写锁,阻止所有其他线程的读写。
- 读操作(READ)获取读锁,允许多个线程并发读取,但禁止写操作。
- journal 文件在每次写操作后通过
writeJournal()同步刷盘(FileDescriptor.sync()),保证崩溃后可恢复。 - 当检测到 journal 文件损坏或版本不匹配时,OkHttp 会清空整个缓存目录并重新创建。
OkHttpClient 实例共用同一个 Cache 对象时,如果各自独立创建 Cache 指向同一目录,会导致 journal 文件竞争、文件锁冲突、数据损坏。解决方案:所有 OkHttpClient 共享同一个 Cache 单例实例。3.5 性能优化建议
- 选择合理的 maxSize: 50MB 以下使用
LRU性能最佳。超过 100MB 的缓存文件遍历与 journal 紧凑操作会显著增加 GC 压力。 - 使用专用缓存目录: 避免与图片库或数据库缓存混用,防止文件系统 inode 耗尽。
- 定期监控 journal 文件大小: 若 journal 文件经常超过 64KB,表明缓存条目频繁变更,可考虑增大
maxSize或缩短缓存有效期以减少替换频率。 - 配合
CacheStrategy减少无效写入: 对于已经过期的缓存,OkHttp 会在网络请求成功后才删除旧文件并写入新文件,这个过程会触发一次淘汰检查。合理设置Cache-Control可以降低写放大。