前言
最近在阅读Glide的源码的缓存策略时,读到了DiskLruCache,决定单独写一篇博客,github地址:DiskLruCache 。实际上,Glide 的 DiskLruCache 是基于大神 Jake Wharton 的 DiskLruCache 并做了一点改动,大体上相同。所以本文也可用于帮助你理解 Jake Wharton 版的 DiskLruCache。
本文的思路是先对一些概念,重要的成员变量,内部类做一些讲解,对这些有一定的理解后对源码的阅读应该会有一些帮助。文章比较长,请耐心阅读。另外,文中有一些个人的理解,难免有些差错,还望多多包涵,指出错误,万分感谢!
key
每个存入磁盘缓存的图像,都应该有一个与之一一对应的key,类型为 String ,之后的存和取都依赖这个 key 。
缓存项
从文件层面上讲,每个缓存目录下的文件保存的图像就是一个缓存项。在内存中,就是 lruEntries 中的每一个 Entry 实例。
日志
日志文件是实现磁盘缓存的关键。了解日志文件有助于我们理解源码。
日志文件的意义
假设我们是 DiskLruCache 的设计者,我们应该考虑以下问题:
- 写入磁盘失败时,我们要怎么处理?
- 像正在被写入磁盘时,数据是不完整的,应该保证它在写入成功前不能被读取
日志文件就是为了解决这些问题而存在的。
日志文件的内容
DiskLruCache 的源码中有一段很长的关于日志文件的注释。
/*
* This cache uses a journal file named "journal". A typical journal file
* looks like this:
* libcore.io.DiskLruCache
* 1
* 100
* 2
*
* CLEAN 3400330d1dfc7f3f7f4b8d4d803dfcf6 832 21054
* DIRTY 335c4c6028171cfddfbaae1a9c313c52
* CLEAN 335c4c6028171cfddfbaae1a9c313c52 3934 2342
* REMOVE 335c4c6028171cfddfbaae1a9c313c52
* DIRTY 1ab96a171faeeee38496d8b330771a7a
* CLEAN 1ab96a171faeeee38496d8b330771a7a 1600 234
* READ 335c4c6028171cfddfbaae1a9c313c52
* READ 3400330d1dfc7f3f7f4b8d4d803dfcf6
*
* The first five lines of the journal form its header. They are the
* constant string "libcore.io.DiskLruCache", the disk cache's version,
* the application's version, the value count, and a blank line.
*
* Each of the subsequent lines in the file is a record of the state of a
* cache entry. Each line contains space-separated values: a state, a key,
* and optional state-specific values.
* o DIRTY lines track that an entry is actively being created or updated.
* Every successful DIRTY action should be followed by a CLEAN or REMOVE
* action. DIRTY lines without a matching CLEAN or REMOVE indicate that
* temporary files may need to be deleted.
* o CLEAN lines track a cache entry that has been successfully published
* and may be read. A publish line is followed by the lengths of each of
* its values.
* o READ lines track accesses for LRU.
* o REMOVE lines track entries that have been deleted.
*
* The journal file is appended to as cache operations occur. The journal may
* occasionally be compacted by dropping redundant lines. A temporary file named
* "journal.tmp" will be used during compaction; that file should be deleted if
* it exists when the cache is opened.
*/
总结一下就是下面的内容:
文件头
第一行:固定的字符串,libcore.io.DiskLruCache,类似java字节码的魔数,表示我们使用了 DiskLruCache。
第二行:磁盘缓存的版本号
第三行:APP的版本号
第四行:缓存项中,一个 key 对应的 value 的个数。
文件头的意义是保证日志文件的有效性
主要内容
对缓存项的每一个操作都会在日志文件中添加一行记录。
从第六行开始的每一行都记录了每个缓存项的状态,每一行的格式如下:
state key 可选的值
如注释给出的例子:
CLEAN 3400330d1dfc7f3f7f4b8d4d803dfcf6 832 21054
DIRTY 335c4c6028171cfddfbaae1a9c313c52
state 有 4 种:
DIRTY
表示缓存项正在被创建或者被更新,这时的缓存项应该是不可读的,所以可以理解为这时的数据是“脏”的,也就是 DIRTY 的意思。
CLEAN
表示缓存项成功写入缓存,这时的缓存是可读的,所以可以理解为这时的数据时“干净”的,也就是 CLEAN 的意思。
READ
表示对缓存项的一次访问
REMOVE
表示缓存项已从磁盘缓存中移除。
其他约束
- 同一个缓存项,应该是以 DIRTY 的状态开始,也就是被创建或者更新,之后的记录中应该有一个 CLEAN 或者 REMOVE 的记录与之对应,CLEAN 表示写入成功,REMOVE 表示写入失败,应该被删除。如果一个 DIRTY 记录没有与之对应的 CLEAN 或者 REMOVE 记录,那么这个缓存项就会被删掉。
备份文件,临时文件
由于对日志文件的读写可能出现差错,所以需要备份文件和临时文件来保证日志文件的正确性。备份文件在日志文件不存在时起“顶替”作用,临时文件在重建日志文件时使用,后面会讲解更具体的内容。
重要的成员变量
lruEntries
private final LinkedHashMap<String, Entry> lruEntries =
new LinkedHashMap<String, Entry>(0, 0.75f, true);
-
lruEntires 是可读缓存项的集合。
-
LRU 算法的关键:LinkedHashMap的构造函数的第三个参数 accessOrder 为 true ,表示使用访问顺序来排序。
-
Map 的键就是上面说的与图像一一对应的 key 。
-
Entry 是一个内部类,代表磁盘中的项,后面会讲到。
redundantOpCount
private int redundantOpCount;
每次向日志文件中写入一条记录都让 redundantOpCount +1。主要用于判断日志文件中的行数是否太多而需要重建。
内部类
Entry
缓存项。
Editor
编辑器,存入/更新磁盘缓存时使用。
Value
缓存项的快照,从磁盘缓存中“取”时使用。如果缓存项被更新(如图片换了),那么这个快照就应该是过时的,不能在用了。
Entry源码
private final class Entry {
private final String key;
// 文件的长度,用数组是因为一个key可能对应多个文件,不过在Glide中数组长度恒为1
private final long[] lengths;
//存储可读文件的File对象
File[] cleanFiles;
//存储不可读文件的File对象。
File[] dirtyFiles;
// 缓存项是否可读
private boolean readable;
/**
* 当图像正在被编辑, currentEditor不为null。
* 没有被编辑时,currentEditor为null
* The ongoing edit or null if this entry is not being edited. */
private Editor currentEditor;
/**
该缓存项最近一次被编辑时的序号。
The sequence number of the most recently committed edit to this entry. */
private long sequenceNumber;
private Entry(String key) {
this.key = key;
this.lengths = new long[valueCount];
cleanFiles = new File[valueCount];
dirtyFiles = new File[valueCount];
// The names are repetitive so re-use the same builder to avoid allocations.
StringBuilder fileBuilder = new StringBuilder(key).append('.');
int truncateTo = fileBuilder.length();
// Glide 中 valueCount为常量,值为1。
for (int i = 0; i < valueCount; i++) {
fileBuilder.append(i);
//* cleanFile的命名格式是: key.0
cleanFiles[i] = new File(directory, fileBuilder.toString());
fileBuilder.append(".tmp");
//* dirtyFile的命名格式是:key.0.tmp
dirtyFiles[i] = new File(directory, fileBuilder.toString());
fileBuilder.setLength(truncateTo);
}
}
// 以字符串的形式获取文件长度
public String getLengths() throws IOException {
StringBuilder result = new StringBuilder();
for (long size : lengths) {
result.append(' ').append(size);
}
return result.toString();
}
/**
* Set lengths using decimal numbers like "10123". */
private void setLengths(String[] strings) throws IOException {
if (strings.length != valueCount) {
throw invalidLengths(strings);
}
try {
for (int i = 0; i < strings.length; i++) {
lengths[i] = Long.parseLong(strings[i]);
}
} catch (NumberFormatException e) {
throw invalidLengths(strings);
}
}
private IOException invalidLengths(String[] strings) throws IOException {
throw new IOException("unexpected journal line: " + java.util.Arrays.toString(strings));
}
public File getCleanFile(int i) {
return cleanFiles[i];
}
public File getDirtyFile(int i) {
return dirtyFiles[i];
}
}
Editor源码
public final class Editor {
private final Entry entry;
private final boolean[] written;
private boolean committed;
private Editor(Entry entry) {
this.entry = entry;
this.written = (entry.readable) ? null : new boolean[valueCount];
}
/**
* 只会在测试时调用
* Returns an unbuffered input stream to read the last committed value,
* or null if no value has been committed.
*/
private InputStream newInputStream(int index) throws IOException {
synchronized (DiskLruCache.this) {
if (entry.currentEditor != this) {
//正在写入
throw new IllegalStateException();
}
if (!entry.readable) {
//不可读
return null;
}
try {
return new FileInputStream(entry.getCleanFile(index));
} catch (FileNotFoundException e) {
return null;
}
}
}
/**
* 只在测试时中调用
* Returns the last committed value as a string, or null if no value
* has been committed.
*/
public String getString(int index) throws IOException {
InputStream in = newInputStream(index);
return in != null ? inputStreamToString(in) : null;
}
// 调用这个这个方法获得File对象。获得File对象用来写入磁盘
public File getFile(int index) throws IOException {
synchronized (DiskLruCache.this) {
if (entry.currentEditor != this) {
throw new IllegalStateException();
}
if (!entry.readable) {
written[index] = true;
}
// 获得 DIRTY 的 File对象
File dirtyFile = entry.getDirtyFile(index);
if (!directory.exists()) {
directory.mkdirs();
}
return dirtyFile;
}
}
/**
* 只在测试文件中使用
* Sets the value at {@code index} to {@code value}. */
public void set(int index, String value) throws IOException {
Writer writer = null;
try {
OutputStream os = new FileOutputStream(getFile(index));
writer = new OutputStreamWriter(os, Util.UTF_8);
writer.write(value);
} finally {
Util.closeQuietly(writer);
}
}
/**
* 写入磁盘成功后调用
* Commits this edit so it is visible to readers. This releases the
* edit lock so another edit may be started on the same key.
*/
public void commit() throws IOException {
// The object using this Editor must catch and handle any errors
// during the write. If there is an error and they call commit
// anyway, we will assume whatever they managed to write was valid.
// Normally they should call abort.
completeEdit(this, true);
committed = true;
}
/**
* 终止编辑,写入磁盘失败,异常时调用
* Aborts this edit. This releases the edit lock so another edit may be
* started on the same key.
*/
public void abort() throws IOException {
completeEdit(this, false);
}
public void abortUnlessCommitted() {
if (!committed) {
try {
abort();
} catch (IOException ignored) {
}
}
}
}
Value
/**
* A snapshot of the values for an entry. */
public final class Value {
private final String key;
private final long sequenceNumber;
private final long[] lengths;
private final File[] files;
private Value(String key, long sequenceNumber, File[] files, long[] lengths) {
this.key = key;
this.sequenceNumber = sequenceNumber;
this.files = files;
this.lengths = lengths;
}
/**
* Returns an editor for this snapshot's entry, or null if either the
* entry has changed since this snapshot was created or if another edit
* is in progress.
*/
public Editor edit() throws IOException {
return DiskLruCache.this.edit(key, sequenceNumber);
}
public File getFile(int index) {
return files[index];
}
/** Returns the string value for {@code index}. */
public String getString(int index) throws IOException {
InputStream is = new FileInputStream(files[index]);
return inputStreamToString(is);
}
/** Returns the byte length of the value for {@code index}. */
public long getLength(int index) {
return lengths[index];
}
}
使用流程
- 开启: 调用
open(...)打开磁盘缓存,获得一个 DiskLruCache 对象实例。 - 存: 调用
edit(...)获得一个 Editor 实例,再调用 Editor 的getFile(...)获得一个 File 对象,通过这个 File 对象将图像写入磁盘。写入磁盘成功就调用 Editor 的commit()或者abort()/abortUnlessCommitted()。 - 取: 调用
get(...)获取一个 Value 实例,再调用 Value 的getFile(...)获得一个 File 对象,通过这个 File 对象读取磁盘缓存中的图像。 - 修改: 需要用到
get(...)获得的 Value 实例。然后调用这个 Vaule 实例的edit(...)获得一个 Editor 实例。只有的步骤就跟存一样了 - 删除:调用
remove(...)方法即可
流程分析
先来看看与打开磁盘缓存相关的流程:
open
public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
- directory: 磁盘缓存所使用的目录
- appVersion: APP的版本
- valueCount: 一个key对应的文件个数
- maxSize: 磁盘缓存可使用的最大空间,单位为字节
public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
throws IOException {
if (maxSize <= 0) {
throw new IllegalArgumentException("maxSize <= 0");
}
if (valueCount <= 0) {
throw new IllegalArgumentException("valueCount <= 0");
}
// If a bkp file exists, use it instead.
File backupFile = new File(directory, JOURNAL_FILE_BACKUP);
if (backupFile.exists()) {
File journalFile = new File(directory, JOURNAL_FILE);
// If journal file also exists just delete backup file.
if (journalFile.exists()) {
// 备份文件和日志文件同时存在就删除备份文件
backupFile.delete();
} else {
// 备份文件存在,但日志文件不存在,将备份文件改名为日志文件的名字,
// 可以理解为“顶替”
renameTo(backupFile, journalFile, false);
}
}
// Prefer to pick up where we left off.
DiskLruCache cache = new DiskLruCache(directory, appVersion, valueCount, maxSize);
if (cache.journalFile.exists()) {
try {
//读取日志文件
cache.readJournal();
//读取后还需要做一些操作
cache.processJournal();
return cache;
} catch (IOException journalIsCorrupt) {
System.out
.println("DiskLruCache "
+ directory
+ " is corrupt: "
+ journalIsCorrupt.getMessage()
+ ", removing");
cache.delete();
}
}
// 日志文件不存在的情况,一般是在该目录下第一次使用DiskLruCache
// Create a new empty cache.
directory.mkdirs();
// 就构造一个新的DiskLruCache
cache = new DiskLruCache(directory, appVersion, valueCount, maxSize);
// 重建日志文件
cache.rebuildJournal();
return cache;
}
大致流程如下:
- 检查参数
- 如果备份文件和日志文件同时存在,就删除备份文件(不需要备胎了);如果备份文件存在而日志文件不存在,就将备份文件改名为日志文件(备胎转正了)。
- 日志文件存在,就调用
readJournal(),processJournal(),再返回一个 DiskLruCache 实例。 - 日志文件不存在,可能是第一次用 DiskLruCache ,也可能是存在异常情况,所以需要重建日志文件,同样是返回一个 DiskLruCache 实例。
接下来再看看 readJournal() 和 processJournal()
readJournal
private void readJournal()
private void readJournal() throws IOException {
StrictLineReader reader = new StrictLineReader(new FileInputStream(journalFile), Util.US_ASCII);
try {
String magic = reader.readLine();
String version = reader.readLine();
String appVersionString = reader.readLine();
String valueCountString = reader.readLine();
String blank = reader.readLine();
// 检查日志文件的有效性
if (!MAGIC.equals(magic)
|| !VERSION_1.equals(version)
|| !Integer.toString(appVersion).equals(appVersionString)
|| !Integer.toString(valueCount).equals(valueCountString)
|| !"".equals(blank)) {
throw new IOException("unexpected journal header: [" + magic + ", " + version + ", "
+ valueCountString + ", " + blank + "]");
}
int lineCount = 0;
// 一行一行地读取日志文件
while (true) {
try {
readJournalLine(reader.readLine());
lineCount++;
} catch (EOFException endOfJournal) {
break;
}
}
redundantOpCount = lineCount - lruEntries.size();
/*
如果日志文件的最后一行不是以 "\n" 或者 "\r\n" 结尾的,
那么就认为这是非法的格式,出错了,需要重建日志文件,
然后才能向日志文件添加记录
*/
// If we ended on a truncated line, rebuild the journal before appending to it.
if (reader.hasUnterminatedLine()) {
rebuildJournal();
} else {
journalWriter = new BufferedWriter(new OutputStreamWriter(
new FileOutputStream(journalFile, true), Util.US_ASCII));
}
} finally {
Util.closeQuietly(reader);
}
}
大致流程如下:
- 检查日志文件的有效性。
- 循环调用
readJournalLine()来一行一行地读取日志。 - 看是否需要重建日志
再来看看readJournalLine(),在看之前可以先回顾前面说到的日志文件的内容。
readJournalLine
private void readJournalLine(String line) throws IOException {
int firstSpace = line.indexOf(' ');
if (firstSpace == -1) {
// 没有空格就抛出异常
throw new IOException("unexpected journal line: " + line);
}
int keyBegin = firstSpace + 1;
// 找第二个空格的位置
int secondSpace = line.indexOf(' ', keyBegin);
final String key;
if (secondSpace == -1) {
// CLEAN READ DIRTY 记录都只有一个空格
// 第一个空格之后剩下的就是key
key = line.substring(keyBegin);
if (firstSpace == REMOVE.length() && line.startsWith(REMOVE)) {
/*
读到的是一条 REMOVE 记录。
说明这个缓存项已经从磁盘中移除,只是日志文件中还有记录
那么对于这个缓存项(同样的key),之前应该还有 DIRTY,CLEAN,(READ) 的记录
只是第一次读到这个缓存项会加入到 lruEntries 中(因为不知道后面有没有REMOVE)
所以现在要把他从 lruEntries 中移除,不要浪费内存。
*/
lruEntries.remove(key);
return;
}
} else {
// 只有 CLEAN 记录才可能不止一个空格(后面有文件长度)。
// 这时key在第一个空格和第二个空格之间
key = line.substring(keyBegin, secondSpace);
}
Entry entry = lruEntries.get(key);
if (entry == null) {
/*
不管这个记录对应的文件是否还存在,
只要是第一个读到这个key的记录,就创建一个对应的 Entry 实例,
加入到lruEntries中
*/
entry = new Entry(key);
lruEntries.put(key, entry);
}
if (secondSpace != -1 && firstSpace == CLEAN.length() && line.startsWith(CLEAN)) {
// CLEAN 记录,设置为可读,没有编辑器,读取文件长度
String[] parts = line.substring(secondSpace + 1).split(" ");
entry.readable = true;
entry.currentEditor = null;
entry.setLengths(parts);
} else if (secondSpace == -1 && firstSpace == DIRTY.length() && line.startsWith(DIRTY)) {
/*
DIRTY 记录,分配一个编辑器实例。
如果后面有同一个 key 的 CLEAN 记录,那么会将 currentEditor 置为null
如果没有,可能是写入失败,需要移除的,
这时就可以用 currentEditor 不为null做判断(processJournal方法中)
*/
entry.currentEditor = new Editor(entry);
} else if (secondSpace == -1 && firstSpace == READ.length() && line.startsWith(READ)) {
// READ 记录,不需要操作
// This work was already done by calling lruEntries.get().
} else {
// 非法的记录
throw new IOException("unexpected journal line: " + line);
}
}
大致流程:
- 检查格式是否正确
- 从字符串中提取出 key
- 根据记录的类型做不同的处理
在 open() 中,调用 readJournal() 后就调用 processJournal() 。让我们来看看processJournal()。
processJournal
/**
* Computes the initial size and collects garbage as a part of opening the
* cache. Dirty entries are assumed to be inconsistent and will be deleted.
*/
private void processJournal() throws IOException {
// 尝试删掉临时文件
deleteIfExists(journalFileTmp);
for (Iterator<Entry> i = lruEntries.values().iterator(); i.hasNext(); ) {
Entry entry = i.next();
if (entry.currentEditor == null) {
//计算磁盘缓存中可读的文件大小
for (int t = 0; t < valueCount; t++) {
size += entry.lengths[t];
}
} else {
/*
编辑器不为null,说明写入磁盘失败或者出现异常,
在日志文件里就是 DIRTY 记录后面没有 CLEAN 记录与它匹配(同样的key)
这样的缓存项没有意义,要从 lruEntries 中删除,对应的文件也要删除。
*/
entry.currentEditor = null;
for (int t = 0; t < valueCount; t++) {
deleteIfExists(entry.getCleanFile(t));
deleteIfExists(entry.getDirtyFile(t));
}
i.remove();
}
}
}
大致流程:
- 尝试删除临时文件。
- 遍历 lurEntries 。计算所有可读文件的大小,把不可读文件删除并从 lruEntries 中删除。
再来看看与“存”相关的流程:
edit
- 在 DiskLruCache 中,没有 put()方法,有
edit()方法。 - edit() 方法有两个重载。如下:
public Editor edit(String key)
private synchronized Editor edit(String key, long expectedSequenceNumber)
内部类 Vaule 的edit()方法:
public Editor edit() throws IOException {
return DiskLruCache.this.edit(key, sequenceNumber);
}
- 在上面的使用流程中,“存”和“修改”的使用方法是不同的,“存”用的是 DiskLruCache 的 edit() 方法(只要一个参数),而“修改”用的是内部类 Value 的 edit() 方法。
先来看看“存”的流程:
static final long ANY_SEQUENCE_NUMBER = -1;
public Editor edit(String key) throws IOException {
return edit(key, ANY_SEQUENCE_NUMBER);
}
private synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException {
// 检查磁盘缓存有没有关闭
checkNotClosed();
Entry entry = lruEntries.get(key);
// 检查序号是否过时,后面会讲到
if (expectedSequenceNumber != ANY_SEQUENCE_NUMBER && (entry == null
|| entry.sequenceNumber != expectedSequenceNumber)) {
return null; // Value is stale.
}
if (entry == null) {
// 之前没有被加入到磁盘缓存中
entry = new Entry(key);
lruEntries.put(key, entry);
} else if (entry.currentEditor != null) {
// 正在被编辑,直接返回
return null; // Another edit is in progress.
}
// 分配一个编辑器
Editor editor = new Editor(entry);
entry.currentEditor = editor;
// 向日志文件中添加一条这个缓存项的 DIRTY 记录
// Flush the journal before creating files to prevent file leaks.
journalWriter.append(DIRTY);
journalWriter.append(' ');
journalWriter.append(key);
journalWriter.append('\n');
flushWriter(journalWriter);
return editor;
}
“修改的流程”:
public final class Value {
private final String key;
private final long sequenceNumber;
public Editor edit() throws IOException {
return DiskLruCache.this.edit(key, sequenceNumber);
}
}
可以看到 Value 的 edit() 方法也是调用 DiskLruCache 的 edit() 方法,那么它和“存”的不同就在于第二个参数序号上了。这与缓存项的快照是否过期有关,后面会讲到。
获得 Editor 实例后,调用getFile(...) 获得 File 实例后,完成写操作后,就应该调用 commit() 方法 或者 abort()/abortUnlessCommitted()
比如 DiskLruCacheWrapper 的 put() 方法中:
DiskLruCache.Editor editor = diskCache.edit(safeKey);
if (editor == null) {
throw new IllegalStateException("Had two simultaneous puts for: " + safeKey);
}
try {
File file = editor.getFile(0);
if (writer.write(file)) {
editor.commit();
}
} finally {
editor.abortUnlessCommitted();
}
可以看到,如果写入成功就调用 commit() ,无论成功还是失败都会调用 abortUnlessCommitted()。
那么来看看这两个方法:
commit
public void commit() throws IOException {
// The object using this Editor must catch and handle any errors
// during the write. If there is an error and they call commit
// anyway, we will assume whatever they managed to write was valid.
// Normally they should call abort.
completeEdit(this, true);
committed = true;
}
public void abortUnlessCommitted() {
if (!committed) {
try {
abort();
} catch (IOException ignored) {
}
}
}
public void abort() throws IOException {
completeEdit(this, false);
}
可以看到它们都调用了completeEdit()方法。
completeEdit()
private synchronized void completeEdit(Editor editor, boolean success)
这个方法是一个加锁的方法。写入磁盘成功是,success 为 true ;写入失败时,success 为 false。
private synchronized void completeEdit(Editor editor, boolean success) throws IOException {
Entry entry = editor.entry;
if (entry.currentEditor != editor) {
throw new IllegalStateException();
}
// If this edit is creating the entry for the first time, every index must have a value.
if (success && !entry.readable) {
for (int i = 0; i < valueCount; i++) {
// 调用getFile(index)会时written[i]为true
if (!editor.written[i]) {
editor.abort();
throw new IllegalStateException("Newly created entry didn't create value for index " + i);
}
if (!entry.getDirtyFile(i).exists()) {
// DIRTY文件不存在,就终止edit。DIRTY文件不存在,说明图像写入文件失败
editor.abort();
return;
}
}
}
for (int i = 0; i < valueCount; i++) {
File dirty = entry.getDirtyFile(i);
if (success) {
if (dirty.exists()) {
File clean = entry.getCleanFile(i);
// DIRTY文件改名为CLEAN文件的名
dirty.renameTo(clean);
// 之所以要更新长度和磁盘缓存的总大小
// 是因为“修改”操作也会调用这个方法
long oldLength = entry.lengths[i];
long newLength = clean.length();
entry.lengths[i] = newLength;
size = size - oldLength + newLength;
}
} else {
// 没有edit成功就删掉DIRTY文件
deleteIfExists(dirty);
}
}
redundantOpCount++;
// 无论成功与否,currentEditor置为null
entry.currentEditor = null;
if (entry.readable | success) {
// 添加一条 CLEAN 记录
entry.readable = true;
journalWriter.append(CLEAN);
journalWriter.append(' ');
journalWriter.append(entry.key);
journalWriter.append(entry.getLengths());
journalWriter.append('\n');
if (success) {
//TODO 这个“序号”是什么意思?
entry.sequenceNumber = nextSequenceNumber++;
}
} else {
// 既不可读,也没edit成功的情况
// 从 lruEntries 中移除,并把移除这个操作写入日志
lruEntries.remove(entry.key);
journalWriter.append(REMOVE);
journalWriter.append(' ');
journalWriter.append(entry.key);
journalWriter.append('\n');
}
flushWriter(journalWriter);
if (size > maxSize || journalRebuildRequired()) {
executorService.submit(cleanupCallable);
}
}
到这里,“存”或者“修改”的流程就结束了。
最后,再看看“取”的流程:
get
public synchronized Value get(String key) throws IOException {
// 检查磁盘缓存是否关闭
checkNotClosed();
Entry entry = lruEntries.get(key);
if (entry == null) {
// 没有这个缓存项,直接返回
return null;
}
if (!entry.readable) {
// 缓存项不可读,直接返回
return null;
}
for (File file : entry.cleanFiles) {
// 缓存的文件被人为地删除了,也返回
// A file must have been deleted manually!
if (!file.exists()) {
return null;
}
}
redundantOpCount++;
// 在日志文件中添加一条 READ 记录
journalWriter.append(READ);
journalWriter.append(' ');
journalWriter.append(key);
journalWriter.append('\n');
// 看是否需要重建
if (journalRebuildRequired()) {
executorService.submit(cleanupCallable);
}
return new Value(key, entry.sequenceNumber, entry.cleanFiles, entry.lengths);
}
remove
public synchronized boolean remove(String key) throws IOException {
checkNotClosed();
Entry entry = lruEntries.get(key);
if (entry == null || entry.currentEditor != null) {
//lruEntries中不存在 或者 正在被编辑的缓存项不会被删除
return false;
}
for (int i = 0; i < valueCount; i++) {
File file = entry.getCleanFile(i);
// 删除文件
if (file.exists() && !file.delete()) {
throw new IOException("failed to delete " + file);
}
// 更新硬盘缓存大小
size -= entry.lengths[i];
entry.lengths[i] = 0;
}
redundantOpCount++;
journalWriter.append(REMOVE);
journalWriter.append(' ');
journalWriter.append(key);
journalWriter.append('\n');
lruEntries.remove(key);
if (journalRebuildRequired()) {
executorService.submit(cleanupCallable);
}
return true;
}
到这里,主要流程的源码分析就结束了
但是,我们还有一些问题没有解决
重建日志文件
我们知道,每一次操作都会向日志文件中添加一条记录,那么日志文件越来越大怎么办?
实际上,DiskLruCache 也考虑到这个问题,让我们看看它是如何解决的。
除了 edit() 方法,remove(),get()在向日志文件后都有这么一段:
if (journalRebuildRequired()) {
executorService.submit(cleanupCallable);
}
executorService 和 cleanupCallable 是什么呢?
/** This cache uses a single background thread to evict entries. */
final ThreadPoolExecutor executorService =
new ThreadPoolExecutor(0, 1, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(),
new DiskLruCacheThreadFactory());
private final Callable<Void> cleanupCallable = new Callable<Void>() {
public Void call() throws Exception {
synchronized (DiskLruCache.this) {
if (journalWriter == null) {
return null; // Closed.
}
trimToSize();
if (journalRebuildRequired()) {
rebuildJournal();
redundantOpCount = 0;
}
}
return null;
}
};
可以看到 executorService 就是一个线程池,cleanupCallable 看名字是做“清理”工作的。主要是调用 trimToSize() 和 rebuildJournal()。
trimToSize
private void trimToSize() throws IOException {
// size 是当前磁盘缓存所使用的空间大小
// maxSize 是磁盘缓存允许使用的最大空间
while (size > maxSize) {
// lruEntries 是按访问顺序排序的 LinkedHashMap。
// 所以这里是用LRU算法移除缓存项,知道大小不超过最大值
Map.Entry<String, Entry> toEvict = lruEntries.entrySet().iterator().next();
remove(toEvict.getKey());
}
}
rebuildJournal
/**
* Creates a new journal that omits redundant information. This replaces the
* current journal if it exists.
*/
private synchronized void rebuildJournal() throws IOException {
//关闭日志文件的输出流
if (journalWriter != null) {
closeWriter(journalWriter);
}
//* 注意,这里流写入的文件是临时日志文件
Writer writer = new BufferedWriter(
new OutputStreamWriter(new FileOutputStream(journalFileTmp), Util.US_ASCII));
try {
// 写入文件头
writer.write(MAGIC);
writer.write("\n");
writer.write(VERSION_1);
writer.write("\n");
writer.write(Integer.toString(appVersion));
writer.write("\n");
writer.write(Integer.toString(valueCount));
writer.write("\n");
writer.write("\n");
// 写入主要内容
for (Entry entry : lruEntries.values()) {
if (entry.currentEditor != null) {
// currentEditor不为null,就是不可读的状态,也就是DIRTY
writer.write(DIRTY + ' ' + entry.key + '\n');
} else {
// currentEditor不为null,就是可读的状态,也就是CLEAN
// getLengths() 函数会返回的字符串以 空格 开头
writer.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n');
}
}
} finally {
closeWriter(writer);
}
if (journalFile.exists()) {
//* 如果日志文件存在,就删掉原备份文件,将日志文件改名为备份文件
renameTo(journalFile, journalFileBackup, true);
}
//* 把日志临时文件改名为日志文件
renameTo(journalFileTmp, journalFile, false);
//* 把备份文件删掉
journalFileBackup.delete();
//* 全局的journalWriter写入的是日志文件,之后的操作都靠journalWriter来刷新日志
journalWriter = new BufferedWriter(
new OutputStreamWriter(new FileOutputStream(journalFile, true), Util.US_ASCII));
}
大致流程:
- 关闭日志文件的输出流。
- 给临时文件添加文件头
- 再把当前 lruEntries 中的缓存项写入临时文件中。只有两种记录: CLEAN 和 DIRTY。
- 尝试把日志文件改名为备份文件
- 把刚刚写好的临时文件改名为日志文件
- 删除备份文件。
怎么重建日志文件我们知道了,那什么时候重建日志文件呢?
上面是不是有一个方法忘记分析了?
journalRebuildRequired
private boolean journalRebuildRequired() {
final int redundantOpCompactThreshold = 2000;
return redundantOpCount >= redundantOpCompactThreshold
&& redundantOpCount >= lruEntries.size();
}
redundantOpCount 会在每次向日志文件写入一条记录时 +1,所以当日志文件的行数达到一定值时就会重建日志文件。
临时文件,备份文件的作用
在 rebuildJournal() 方法中,你是否会有以下疑问?
-
为什么不是直接清空日志文件,然后重写写入记录?
答:这样在正常情况下可行的。但是如果出现异常情况就不能保证日志文件的有效性了。比如,直接清空日志文件后开始写入,但是这时APP结束运行了,那就会导致一些保存的图像在日志文件中没有记录了,这样本来能从磁盘缓存中获得的图像就获得不了了。
-
备份文件中存在的意义。
答:
rebuildJournal()中,如果临时文件还没有重名为日志文件就退出程序了,在下次打开APP调用open()时,备份文件就起作用了:发现有备份文件却没有日志文件,那就把备份文件改名为日志文件,也就是用重建前的日志文件。
内部类Entry的 readable 和 currentEditor 能否等价使用?
这个问题源自我阅读时的一个疑问,currentEditor!=null 就能表示缓存项不可读,那何必要用 readable 属性呢?
答:currentEditor!=null 时,缓存项一定是不可读的。但是 currentEditor 为 null 时,不一定是可读的,因为存在写入失败的情况。completeEdit() 方法是无论编辑是否成功都应该调用的,而这个方法会将 currentEditor 置为null。所以不能用 currentEditor 代替 readable。
序号的意义
前面说到,edit()的一个重载:
private synchronized Editor edit(String key, long expectedSequenceNumber)
参数 expectedSequenceNumber 理解为“预期的序号”。 再具体看下代码:
private synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException {
checkNotClosed();
Entry entry = lruEntries.get(key);
//代码a
if (expectedSequenceNumber != ANY_SEQUENCE_NUMBER && (entry == null
|| entry.sequenceNumber != expectedSequenceNumber)) {
return null; // Value is stale.
}
...
}
我们把情况分为“存”和“修改”
存:
前面说过,“存”操作只会调用edit(key),进而调用edit(key, ANY_SEQUENCE_NUMBER)。ANY_SEQUENCE_NUMBER 为常量,-1。所以在上面的 代码a 处的if里的语句肯定不会执行。
修改:
前面说过,“修改”操作调用内部类 Value 的 edit() 方法。如下:
public final class Value {
private final String key;
private final long sequenceNumber;
private Value(String key, long sequenceNumber, File[] files, long[] lengths) {
this.key = key;
this.sequenceNumber = sequenceNumber;
this.files = files;
this.lengths = lengths;
}
public Editor edit() throws IOException {
return DiskLruCache.this.edit(key, sequenceNumber);
}
...
}
结合源码中的其他地方,Value 的 sequenceNumber 取决于构造函数传入的值,也就是取决于调用get()方法时 entry 的 sequenceNumber。
那么 entry 的 sequenceNumber 有没有可能改变呢?
在 completeEdit() 方法中,如果编辑成功,sequenceNumber会加1。如下:
private long nextSequenceNumber = 0;
private synchronized void completeEdit(Editor editor, boolean success) throws IOException {
...
if (entry.readable | success) {
entry.readable = true;
journalWriter.append(CLEAN);
journalWriter.append(' ');
journalWriter.append(entry.key);
journalWriter.append(entry.getLengths());
journalWriter.append('\n');
if (success) {
//写入成功,序号+1
entry.sequenceNumber = nextSequenceNumber++;
}
}
...
}
其中,nextSequenceNumber是全局成员变量。 源码中有一段源于它的注释:
To differentiate between old and current snapshots, each entry is given a sequence number each time an edit is committed. A snapshot is stale if its sequence number is not equal to its entry's sequence number.
翻译+结合上面的分析就是:
为了区分旧的和现在的快照,每一个缓存项被赋予一个序号,每次编辑被提交后, 这个序号都会+1。如果一个快照的序号和缓存项里的序号不同,就认为这个快照是过期的。
下面是一些个人理解/总结:
缓存项在日志文件中的变化:
- “存”操作时,会向日志文件写入一条 DIRTY 记录。
- 写入成功后,写入一条 CLEAN 记录。
- “取”操作时,写入一条 READ 记录。
- 调用
remove(),写入一条 REMOVE 记录。
所以一个缓存项在日志文件中的正常流程如下: DIRTY --> CLEAN --> (READ) --> REMOVE
Glide 的 DiskLruCache 相比 Jake Wharton 的 DiskLruCache 作了什么改进?
答:查看两者的源码就发现一下差别。在进行“存”操作的时候,Jake Wharton 的版本是调用 newOutputStream(...) 获得一个 FileOutputStream 对象实例。而 Glide 的版本则是调用getFile(...) 获得一个File对象实例。个人理解这样做的好处是调用者可以根据自己的需求改变输出流的类型,而不仅仅是FileOutputStream。