DiskLruCache是一种使用有限数量的缓存空间来缓存文件的硬盘缓存,采用了最近最少使用策略在一定的空间大小下来缓存经常使用到的文件。在许多方面都有用到,比如移动开发中的图片在硬盘缓存时,经常用到的就是DiskLruCache.
journal文件
DiskLruCache中最重要的应该是一个叫journal的文件,这个文件记录了对缓存文件的所有操作,比如添加、删除、访问等一系列操作。文件中内容具体是这样的:
* 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
*
这些内容是什么意思呢?首先我们来看一个方法:
static final String JOURNAL_FILE = "journal";
static final String JOURNAL_FILE_TEMP = "journal.tmp";
static final String JOURNAL_FILE_BACKUP = "journal.bkp";
static final String MAGIC = "libcore.io.DiskLruCache";
static final String VERSION_1 = "1";
/**
* 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) {
journalWriter.close();
}
//创建一个journal.tmp文件
Writer writer = new BufferedWriter(
new OutputStreamWriter(new FileOutputStream(journalFileTmp), Util.US_ASCII));
try {
//向journal.tmp文件写入第一行
writer.write(MAGIC);
//换行
writer.write("\n");
//写入VERSION_1
writer.write(VERSION_1);
//换行
writer.write("\n");
//写入app的版本号
writer.write(Integer.toString(appVersion));
//换行
writer.write("\n");
//写入一个key 对应几个value
writer.write(Integer.toString(valueCount));
//换行
writer.write("\n");
//换行
writer.write("\n");
for (Entry entry : lruEntries.values()) {
if (entry.currentEditor != null) {
writer.write(DIRTY + ' ' + entry.key + '\n');
} else {
writer.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n');
}
}
} finally {
writer.close();
}
if (journalFile.exists()) {
//如果jorunal文件存在,就重命名为journal.bkp
renameTo(journalFile, journalFileBackup, true);
}
//将journal.tmp文件重命名为journal
renameTo(journalFileTmp, journalFile, false);
//删除journal.bkp文件
journalFileBackup.delete();
journalWriter = new BufferedWriter(
new OutputStreamWriter(new FileOutputStream(journalFile, true), Util.US_ASCII));
}
从上面的代码可以看出:
1、首先创建了一个journal.tmp的文件
2、然后向文件里面写入了一行"libcore.io.DiskLruCache",这个是个固定值
3、换行
4、写入了一个"1"//是一个常量
5、换行
6、写入了个appVersion(版本号)
7、换行
8、写入了一个valueCount,这个valueCount是在我们打开缓存的时候传入的,代码如下:
public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
这个valueCount 的作用是用来表示一个key对应了几个value,通常情况下,传入1表示一个key对应一个value.
9、换行
10、换行
执行完上面的10步后,接着是个for循环,写数据。从这个里可以看出每个DiskLruCache文件的前几行基本都是一样的,里面包含了几个常量和一个应用版本号、一个key对应的value的个数、一个空行。
for循环代码是从linkedHashMap中取出数据,写入到文件中,写的数据就是上面看到的以CLEAN、DIRTY等开头的那些内容。这些便是对缓存文件操作的记录。再下面在详细解读。for循环执行完毕后,最终将journal.tmp文件命名为了journal。这样一个journal文件便生成了。
打开缓存
要用这个缓存,首页我们要调用open方法打开这个缓存,我们也可以把这个方法当做是对cache的初始化操作。按照惯例,上代码:
/**
* Opens the cache in {@code directory}, creating a cache if none exists
* there.
*
* @param directory a writable directory
* @param valueCount the number of values per cache entry. Must be positive.
* @param maxSize the maximum number of bytes this cache should use to store
*@throws IOException if reading or writing the cache directory fails
*/
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();
}
}
// Create a new empty cache.
directory.mkdirs();
cache = new DiskLruCache(directory, appVersion, valueCount, maxSize);
cache.rebuildJournal();
return cache;
}
从代码中可以看出数据初始化大致可以分为以下几个步骤:
一、传入参数的合法性检测
二、判断journal文件是否存在、或者重命名
//如果备份文件存在
if (backupFile.exists()) {
File journalFile = new File(directory, JOURNAL_FILE);
// If journal file also exists just delete backup file.
//如果journal文件存在,就删除掉备份文件
if (journalFile.exists()) {
backupFile.delete();
} else {不存在,就把备份文件重命名为journal文件
//如果journal文件
renameTo(backupFile, journalFile, false);
}
}
三、如果journal文件存在,对文件内容处理
在对journal文件内容进行处理时,主要涉及到了两个重要的方法:1、readJournal() 2、processJournal().
先来看readJournal()方法:
private void readJournal() throws IOException {
StrictLineReader reader = new StrictLineReader(new FileInputStream(journalFile), Util.US_ASCII);
try {
//读取前5行数据,在文章开头介绍journal文件时,我们知道在journal的开头写入了5行固定的数据
String magic = reader.readLine();
String version = reader.readLine();
String appVersionString = reader.readLine();
String valueCountString = reader.readLine();
String blank = reader.readLine();
//验证前5行数据是否合法
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();
// If we ended on a truncated line, rebuild the journal before appending to it.
//如果不是正常结束的,就重新创建journal文件
if (reader.hasUnterminatedLine()) {
rebuildJournal();
} else {
journalWriter = new BufferedWriter(new OutputStreamWriter(
new FileOutputStream(journalFile, true), Util.US_ASCII));
}
} finally {
Util.closeQuietly(reader);
}
}
之后大部分操作就是在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) {
//截取从第一个' '位置到字符串最后的字段,截取的到字段为key
key = line.substring(keyBegin);
//如果第一个' '出现的位置正好和"REMOVE"的length一样,并且这个字符串也是以“REMOVE”开头的,就从集合中把这个key删掉,并return
if (firstSpace == REMOVE.length() && line.startsWith(REMOVE)) {
lruEntries.remove(key);
return;
}
} else {
//如果第二个' '存在,key为两个‘ ’之间的字段
key = line.substring(keyBegin, secondSpace);
}
Entry entry = lruEntries.get(key);
//entry不存在,创建一个新的放入集合
if (entry == null) {
entry = new Entry(key);
lruEntries.put(key, entry);
}
//如果第二个' '存在
//如果第一个' '出现的位置正好和"CLEAN"的length一样,并且这个字符串也是以“CLEAN”开头的
//CLEAN 3400330d1dfc7f3f7f4b8d4d803dfcf6 832 21054
if (secondSpace != -1 && firstSpace == CLEAN.length() && line.startsWith(CLEAN)) {
//截取第二个‘ ’之后的字段,并将截取后的字段用‘ ’分割成数组
//从上面可以看出,数组中其实是数字,其实就是文件的大小。因为可以通过valueCount来设置一个key对应的value的个数,所以文件大小也是有valueCount个
String[] parts = line.substring(secondSpace + 1).split(" ");
entry.readable = true;
entry.currentEditor = null;
entry.setLengths(parts);
//如果第一个' '出现的位置正好和"DIRTY"的length一样,并且这个字符串也是以“DIRTY”开头的
//DIRTY 335c4c6028171cfddfbaae1a9c313c52
} else if (secondSpace == -1 && firstSpace == DIRTY.length() && line.startsWith(DIRTY)) {
entry.currentEditor = new Editor(entry);
} else if (secondSpace == -1 && firstSpace == READ.length() && line.startsWith(READ)) {
// This work was already done by calling lruEntries.get().
} else {
throw new IOException("unexpected journal line: " + line);
}
}
readJournalLine()方法主要是通过读取journal文件的每一行,然后封装成entry对象,放到了LinkedHashMap集合中。并且根据每一行不同的开头,设置entry的值。也就是说通过读取这个文件,我们把所有的在本地缓存的文件的key都保存到了集合中,这样我们用的时候就可以通过集合来操作了。
接下来看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 {
//删除journal.tmp临时文件
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 {
//删除currentEditorb不为空的元素,并删除对应的文件
entry.currentEditor = null;
for (int t = 0; t < valueCount; t++) {
deleteIfExists(entry.getCleanFile(t));
deleteIfExists(entry.getDirtyFile(t));
}
i.remove();
}
}
}
这个方法主要是用来计算当前缓存的集合的总长度。因为diskLruCache缓存是有最大缓存限制的,所以要知道当前缓存文件的大小。到这里,对journal文件的处理就算结束了。
四、如果journal文件不存在,创建journal文件
如果journal文件不存在,就通过rebuildJournal()方法进行创建,这个在文章开头已经讲过。
写入数据
对于缓存,增删改查肯定是必须的,我们先来看添加数据。添加数据一般的操作是通过
通过调用edit()方法,获取到Editor。然后通过editor来保存数据,最后调用editor.commit()方法来完成的。那么我们首先来分析一下获取editor的方法吧。
/**
* Returns an editor for the entry named {@code key}, or null if another
* edit is in progress.
*/
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 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.
}
//为当前的实体的currentEditor赋值为当前的editor.也就是为当前的实体指定编辑器
Editor editor = new Editor(entry);
entry.currentEditor = editor;
// Flush the journal before creating files to prevent file leaks.
//然后向journal文件中写入一行有DITTY 空格 和key拼接的文字,正如我们在文章开头看到的示例
//写入这行的意思就是这个key的缓存正处于编辑状态
journalWriter.append(DIRTY);
journalWriter.append(' ');
journalWriter.append(key);
journalWriter.append('\n');
journalWriter.flush();
return editor;
}
上面这段代码比较简单,主要做了两件事:1、从集合中找到对应的实例(如果没有创建一个放到集合中),然后创建一个editor,将editor和entry关联起来。2、向journal中写入一行操作数据(DITTY 空格 和key拼接的文字),表示这个key当前正处于编辑状态。
接下来我们来看一下是editor是怎么存储数据的:
/**
* Returns a new unbuffered output stream to write the value at
* {@code index}. If the underlying output stream encounters errors
* when writing to the filesystem, this edit will be aborted when
* {@link #commit} is called. The returned output stream does not throw
* IOExceptions.
*/
public OutputStream newOutputStream(int index) throws IOException {
synchronized (DiskLruCache.this) {
if (entry.currentEditor != this) {
throw new IllegalStateException();
}
if (!entry.readable) {
written[index] = true;
}
return new FaultHidingOutputStream(new FileOutputStream(entry.getDirtyFile(index)));
}
}
editor对外提供了一个newOutputStream(int index)的方法,返回了一个输出流。有了这个输出流,那么我们就可以通过这个流把我们要缓存的文件写到硬盘中了。具体要把上面内容写到硬盘中editor就不管了,我们可以自由自在的发挥了。至于传入的index是什么呢?我们在文章的开头讲过,journal文件的前5行基本上是固定的,其中有一行写入的是一个valueCount,这个valueCount表示了一个key对应几个value,也就是说一个key对应几个缓存文件。那么现在传入的这个index就表示了要缓存的文件时对应的第几个value。现在我们已经通过outputStream把我们要做的缓存写入到本地文件中了,接下来就是要通过调用commit()方法告诉editor这次的工作结束了。
/**
* 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 {
//如果在通过输入流缓存文件时有错误
if (hasErrors) {
completeEdit(this, false);
//写文件出错,就把集合中的缓存移除掉
remove(entry.key); // the previous entry is stale
} else {
completeEdit(this, true);
}
}
hasErrors是什么呢,其实就是我们用之前的输出流写文件时,如果没有正常结束而是报了异常,就会把hasErrors的值设为TRUE,表示出错了。如果出错,就把集合中的缓存也移除掉,表示没有这个缓存。
下面让我们来看看通过completeEdit()方法做了什么吧。
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++) {
if (!editor.written[i]) {
editor.abort();
throw new IllegalStateException("Newly created entry didn't create value for index " + i);
}
if (!entry.getDirtyFile(i).exists()) {
editor.abort();
System.logW("DiskLruCache: Newly created entry doesn't have file for index " + i);
return;
}
}
}
for (int i = 0; i < valueCount; i++) {
//获取到对应的value的临时文件
File dirty = entry.getDirtyFile(i);
if (success) {
//如果临时文件存在,就重命名为正式缓存文件
if (dirty.exists()) {
File clean = entry.getCleanFile(i);
dirty.renameTo(clean);
long oldLength = entry.lengths[i];
long newLength = clean.length();
entry.lengths[i] = newLength;
//重新计算缓存的大小
size = size - oldLength + newLength;
}
} else {
//如果写入不成功,就删除掉临时文件
deleteIfExists(dirty);
}
}
//操作次数加一
redundantOpCount++;
//将实例的当前编辑器置空
entry.currentEditor = null;
if (entry.readable | success) {
//将实例设置为可读的
entry.readable = true;
//向journal文件写入一行CLEAN开头的字符(包括key和文件的大小,文件大小可能存在多个 使用空格分开的)
journalWriter.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n');
if (success) {
entry.sequenceNumber = nextSequenceNumber++;
}
} else {
//如果不成功,就从集合中删除掉这个缓存
lruEntries.remove(entry.key);
//向journal文件写入一行REMOVE开头的字符
journalWriter.write(REMOVE + ' ' + entry.key + '\n');
}
//如果缓存总大小已经超过了设定的最大缓存大小或者操作次数超过了2000次,
// 就开一个线程将集合中的数据删除到小于最大缓存大小为止并重新写journal文件
if (size > maxSize || journalRebuildRequired()) {
executorService.submit(cleanupCallable);
}
}
在这个方法里,一共做了这么几件事:
1、如果输出流写入数据成功,就把写入的临时文件重命名为正式的缓存文件
2、重新设置当前总缓存的大小
3、向journal文件写入一行CLEAN开头的字符(包括key和文件的大小,文件大小可能存在多个 使用空格分开的)
4、如果输出流写入失败,就删除掉写入的临时文件,并且把集合中的缓存也删除
5、向journal文件写入一行REMOVE开头的字符
6、重新比较当前缓存和最大缓存的大小,如果超过最大缓存或者journal文件的操作大于2000条,就把集合中的缓存删除一部分,直到小于最大缓存,重新建立新的journal文件
从这里可以看出,写缓存的操作主要就是利用editor中的输出流把要缓存的数据写入到文件中。在写文件之前会先向journal文件写入一行以DIRTY开头的数据,表示当前key为一条脏数据,不可以操作。当写成功后,会再向journal文件中写入一行clean开头的数据,表示这个key的写入已经成功了,可以操作,并且记录了该key对应缓存文件的大小,便于下次读取。如果写入缓存失败,会把集合中的数据删掉,并向向journal文件写入一行REMOVE开头的字符,表示这个缓存被删掉了。
查询数据
看了写入数据,接下来看读数据吧。读数据就比较简单了,写数据的时候是获取到输出流往文件里面写数据,读数据的时候自然就是获取到输入流,从文件里面读数据了。下面请看代码:
/**
* Returns a snapshot of the entry named {@code key}, or null if it doesn't
* exist is not currently readable. If a value is returned, it is moved to
* the head of the LRU queue.
*/
public synchronized Snapshot get(String key) throws IOException {
checkNotClosed();
validateKey(key);
Entry entry = lruEntries.get(key);
if (entry == null) {
return null;
}
if (!entry.readable) {
return null;
}
/*
* Open all streams eagerly to guarantee that we see a single published
* snapshot. If we opened streams lazily then the streams could come
* from different edits.
*/
InputStream[] ins = new InputStream[valueCount];
try {
for (int i = 0; i < valueCount; i++) {
ins[i] = new FileInputStream(entry.getCleanFile(i));
}
} catch (FileNotFoundException e) {
// a file must have been deleted manually!
return null;
}
redundantOpCount++;
journalWriter.append(READ + ' ' + key + '\n');
if (journalRebuildRequired()) {
executorService.submit(cleanupCallable);
}
return new Snapshot(key, entry.sequenceNumber, ins);
}
上面代码可以看出,在这期间可以看到通过journalWriter.append(READ + ' ' + key + '\n');向journalwe文件写入了一行READ开头的数据,表示这次执行了一次读取操作。通过key获取到了一个snapshot对象,并向这个对象里面传入了一个输入流数组和key。输入流数组之所以是数组,自然是由于我们之前讲到的一个key可以对应多个value,那么每个value对应一个输入流,所有就有了这个对应每一个缓存的输入流数组。再来看snapshot的代码中有这个几个方法:
/**
* Returns the unbuffered stream with the value for {@code index}.
*/
public InputStream getInputStream(int index) {
return ins[index];
}
/**
* Returns the string value for {@code index}.
*/
public String getString(int index) throws IOException {
return inputStreamToString(getInputStream(index));
}
@Override public void close() {
for (InputStream in : ins) {
IoUtils.closeQuietly(in);
}
}
看到这几个方法,我想大家就应该能够看出来,可以通过getInputStream(index)来获取到对应的输入流,有了输入流自然就能读取我们要找的数据了。读取完毕后可以调用close()方法,关闭掉所有的输入流。
删除数据
/**
* Drops the entry for {@code key} if it exists and can be removed. Entries
* actively being edited cannot be removed.
*
* @return true if an entry was removed.
*/
public synchronized boolean remove(String key) throws IOException {
checkNotClosed();
validateKey(key);
Entry entry = lruEntries.get(key);
if (entry == null || entry.currentEditor != null) {
return false;
}
for (int i = 0; i < valueCount; i++) {
//获取到这个key对应的所有的缓存文件
File file = entry.getCleanFile(i);
//删除
if (!file.delete()) {
throw new IOException("failed to delete " + file);
}
//重新设置当前缓存的大小
size -= entry.lengths[i];
entry.lengths[i] = 0;
}
redundantOpCount++;
//向journal文件写入一行REMOVE开头的文字
journalWriter.append(REMOVE + ' ' + key + '\n');
//从集合中删除对应的key
lruEntries.remove(key);
if (journalRebuildRequired()) {
executorService.submit(cleanupCallable);
}
return true;
}
删除操作也就挺简单了,一共做了以下几件事:
1、删除掉这个key对应的所有缓存文件
2、重新计算当前缓存的大小
3、从集合中掉对应的key
关闭
/**
* Closes this cache. Stored values will remain on the filesystem.
*/
public synchronized void close() throws IOException {
if (journalWriter == null) {
return; // already closed
}
for (Entry entry : new ArrayList<Entry>(lruEntries.values())) {
if (entry.currentEditor != null) {
entry.currentEditor.abort();
}
}
trimToSize();
//关闭掉journal文件的写入流
journalWriter.close();
//journalWriter置空
journalWriter = null;
}
当我们不在用缓存的时候,需要将缓存关闭。关闭的操作主要是将操作journal的journalWriter流关闭并置空。
总结
从以上分析可以看出,DiskLruCache的核心是journal文件,每次操作都会在journal文件中做一次记录。
写入缓存的时候会向journal文件写入一条以DIRTY开头的数据表示正在进行写操作,当写入完毕时,分两种情况:1、写入成功,会向journal文件写入一条以CLEAN开头的文件,其中包括该文件的大小。 2、写入失败,会向journal文件写入一条以REMOVE开头的文件,表示删除了该条缓存。也就是说每次写入缓存总是写入两条操作记录。
读取的时候,会向journal文件写入一条以READ开头的文件,表示进行了读操作
删除的时候,会向journal文件写入一条以REMOVE开头的文件,表示删除了该条缓存
通过journal就记录了所有对缓存的操作。并且按照从上到下的读取顺序记录了对所有缓存的操作频繁度和时间顺序。这样当退出程序再次进来调用缓存时,就可以读取这个文件来知道哪些缓存用的比较频繁了。然后把这些操作记录读取到集合中,操作的时候就可以直接从集合中去对应的数据了。