Glide原始碼分析(一)——DiskLruCache磁碟快取的實現
Glide磁碟的實現主要是通過DiskLruCache來實現的。DiskLruCache並非針對Glide編寫的,而是一個通用的磁碟快取實現,雖然並非Google官方的程式碼,但是已經在很多應用中得到了引入使用。
journal日誌
DiskLruCache通過日誌來輔助保證磁碟快取的有效性。在應用程式執行階段,可以通過記憶體資料來保證快取的有效性,但是一旦應用程式退出或者被意外殺死,下次再啟動的時候就需要通過journal日誌來重新構建磁碟快取資料記錄,保證上次的磁碟快取是有效和可用的。
journal日誌的基本資料
為了理解journal日誌是如何起作用的,首先需要理解journal日誌的結構。以下是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
其中第一行固定為libcore.io.DiskLruCache;第二行是DiskLruCache的版本,目前固定為1;第三行表示所屬應用的版本號;第四行valueCount表示一個快取key可能對應多少個快取檔案,它決定了後面一個CLEAN狀態記錄最多可以size大小資料;第五行是空行。此後記錄的就是DiskLruCache針對磁碟快取的操作記錄了。其中幾個狀態表示如下:
- CLEAN 表示快取處於一個穩定狀態,即當前沒有對該快取資料進行寫操作,在該狀態下,對快取檔案的讀寫都是安全的。
- DIRTY 表示當前該key對應的快取檔案正在被修改,該狀態下對快取檔案的讀寫都是不安全的,需要阻塞到對檔案的修改完成,使該key對應的狀態轉變成CLEAN為止。
- REMOVE 表示該key對應的快取檔案被刪除了,在快取整理的過程中可能會出現多條這樣的記錄。
- READ 表示一個對key對應的快取檔案進行讀取的操作記錄。
每個操作記錄狀態後面都有一個字串,表示快取的key,其中CLEAN狀態在後面還會有一個或者多個數字,這些數字表示對應快取檔案的大小。之所以允許一個key對應多個檔案,主要是考慮到滿足類似於一張圖片可能存在多個大小和解析度不同的快取的功能。
Entry實現
一個Entry對應一條快取,DiskLruCache通過Entry對相應的快取進行操作。其主要的成員為:
private final class Entry {
private final String key;
/**
* Lengths of this entry's files.
* 之所以可能會存在多個length,是因為一個Key也可能存在多個大小不同的快取檔案,例如尺寸或者解析度不同的圖片
*/
private final long[] lengths;
/** True if this entry has ever been published */
private boolean readable;
/** The ongoing edit or null if this entry is not being edited. */
private Editor currentEditor; // 正在操作entry的editor
/** The sequence number of the most recently committed edit to this entry. */
private long sequenceNumber;
...
}
其中key對應快取的key,lengths表示key對應的若干個快取檔案的大小,readable表示該快取是否能夠被讀取,只有在快取狀態為CLEAN的時候才為true;currentEditor是Entry用來修改快取檔案的類;sequenceNumber是一個唯一的序號,每當對應的快取檔案被改變後,該序號就改變,在通過snapshot獲取快取映像的時候,snapshot內部的sequenceNumber和當時刻的Entry的sequenceNumber相同,如果在此後Entry對應的快取檔案被改變,那麼通過snapshot獲取快取的時候就能發現二者的sequenceNumber不相同,因此snapshot就失效了,這樣避免通過snapshot獲取到舊的快取資料資訊。
Editor實現
Editor是用來對快取檔案進行修改的操作的封裝,他和Entry一一對應,一個entry如果其中的currentEditor不為空,表示這個Entry對應的快取檔案正在被修改,即該Entry處於DIRTY狀態。總體來說,Editor的功能單一實現簡單。唯一需要說明一下的是在使用它對檔案進行操作後,需要呼叫它的commit方法,以使它對快取檔案的更改記錄更新到記憶體lruEntries和日誌當中,否則對應的快取將會始終處於DIRTY狀態而無法被再次修改和讀取。
Snapshot實現
Snapshot就是一個快取記錄的映像,它代表在某一時刻快取的狀態,它和某一時刻的Entry一一對應,前面也提到過,一旦Snapshot對應的Entry被修改了,那麼雖然該Snapshot雖然還和這個Entry有對應關係,但是因為快取檔案的內容已經發生了改變,所以該Snapshot處於失效狀態,不能被使用。
Snapshot是封裝給外部使用的,它封裝了介面專門用來對快取檔案進行讀寫,方便外部通過它直接訪問快取檔案而不管具體的快取處理細節。
lruEntries記憶體記錄和日誌
lruEntries是一個LinkedHashMap,DiskLruCache用它來實現LRU的功能。它是快取在記憶體中的記錄,能夠反映當前磁碟中的快取檔案的狀態,通過讀取這個資料結構,能夠快速對磁碟中是否有對應key的快取檔案做出判斷。
每當DiskLruCache在初始化的時候,就會根據journal日誌來初始化其中的lruEntries,其相關程式碼如下:
/**
* Opens the cache in {@code directory}, creating a cache if none exists
* there.
*
* @param directory a writable directory
* @param appVersion
* @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 java.io.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");
}
// 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();
cache.journalWriter = new BufferedWriter(new FileWriter(cache.journalFile, true),
IO_BUFFER_SIZE);
return cache;
} catch (IOException journalIsCorrupt) {
// System.logW("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;
}
/**
* 讀取日誌檔案中的內容,根據日誌檔案中的記錄來初始化對應entry的狀態並構建lruEntries
* @throws IOException
*/
private void readJournal() throws IOException {
InputStream in = new BufferedInputStream(new FileInputStream(journalFile), IO_BUFFER_SIZE);
try {
String magic = readAsciiLine(in);
String version = readAsciiLine(in);
String appVersionString = readAsciiLine(in);
String valueCountString = readAsciiLine(in);
String blank = readAsciiLine(in);
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 + "]");
}
while (true) {
try {
readJournalLine(readAsciiLine(in));
} catch (EOFException endOfJournal) {
break;
}
}
} finally {
closeQuietly(in);
}
}
/**
* 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) { // currenEditor為null,表示當前為clean狀態
for (int t = 0; t < valueCount; t++) {
size += entry.lengths[t]; // 累計大小
}
} else {
// 在初始化階段才呼叫該函式,如果此時日誌檔案中記錄該項為dirty,那麼就直接放棄這條快取,刪除其相關的檔案以及在lruEntries中的記錄
entry.currentEditor = null;
for (int t = 0; t < valueCount; t++) {
deleteIfExists(entry.getCleanFile(t));
deleteIfExists(entry.getDirtyFile(t));
}
i.remove();
}
}
}
/**
* 讀取每行日誌,初始化日誌對應的entry狀態
* @param line
* @throws IOException
*/
private void readJournalLine(String line) throws IOException {
String[] parts = line.split(" ");
if (parts.length < 2) {
throw new IOException("unexpected journal line: " + line);
}
String key = parts[1];
if (parts[0].equals(REMOVE) && parts.length == 2) {
lruEntries.remove(key);
return;
}
Entry entry = lruEntries.get(key);
if (entry == null) {
entry = new Entry(key);
lruEntries.put(key, entry);
}
if (parts[0].equals(CLEAN) && parts.length == 2 + valueCount) {
entry.readable = true;
entry.currentEditor = null;
entry.setLengths(copyOfRange(parts, 2, parts.length));
} else if (parts[0].equals(DIRTY) && parts.length == 2) {
entry.currentEditor = new Editor(entry);
} else if (parts[0].equals(READ) && parts.length == 2) {
// this work was already done by calling lruEntries.get()
} else {
throw new IOException("unexpected journal line: " + line);
}
}
在open函式中通過readJournal和processJournal兩個函式來完成從journal日誌到lruEntries的構建。其中readJournal和readJournalLine分別通過解析日誌的每一行內容,通過簡單的容錯邏輯來初步構建lruEntries的內容,包括快取key和其狀態和快取檔案大小等。其中解析是從第一行到最後一行,因此一個key可能在lruEntries中被操作多次,特別是對快取狀態的改變。在readJournal完成後,lruEntries基本上就是上次引用結束後的DiskLruCache的快取狀態,之後再呼叫processJournal對異常狀態進行處理後就得到了一個有效且能反映當前磁碟快取狀態的lruEntries記錄了。
此後,在對快取進行修改的時候會同步修改journal日誌和lruEntries,以使二者保證狀態一致,但對於讀操作,僅僅只需要在日誌中記錄一條記錄即可。
在快取大小達到上線的時候,DiskLruCache會將長時間不使用的快取清理掉,同時如果日誌的條數也達到了上線,就會利用lruEntries來重新構建日誌,這正好是和初始化的時候的一個逆過程,相關程式碼如下:
private final Callable<Void> cleanupCallable = new Callable<Void>() { // 日誌清理任務
@Override public Void call() throws Exception {
synchronized (DiskLruCache.this) {
if (journalWriter == null) {
return null; // closed
}
trimToSize();
if (journalRebuildRequired()) {
rebuildJournal();
redundantOpCount = 0;
}
}
return null;
}
};
/**
* 使快取大小保證在最大限制之內。
* 由於lruEntries是LinkedHashMap,能保證是實現lru特性的刪除操作
* @throws IOException
*/
private void trimToSize() throws IOException {
while (size > maxSize) {
// Map.Entry<String, Entry> toEvict = lruEntries.eldest();
final Map.Entry<String, Entry> toEvict = lruEntries.entrySet().iterator().next();
remove(toEvict.getKey());
}
}
/**
* Creates a new journal that omits redundant information. This replaces the
* current journal if it exists.
* 根據內部資料結構lruEntries來構造新的日誌檔案,然後替換原來的日誌檔案。
* 在初始化階段,一般構建的日誌檔案中沒有dirty記錄,如果是日誌清理執行緒呼叫的,那麼存在dirty的記錄
* 注意是同步方法
*/
private synchronized void rebuildJournal() throws IOException {
if (journalWriter != null) {
journalWriter.close();
}
Writer writer = new BufferedWriter(new FileWriter(journalFileTmp), IO_BUFFER_SIZE); // 先寫到tmp檔案中,注意該方法是同步方法
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) {
writer.write(DIRTY + ' ' + entry.key + '\n');
} else {
writer.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n');
}
}
writer.close();
journalFileTmp.renameTo(journalFile); // 重新命名成journal檔案
journalWriter = new BufferedWriter(new FileWriter(journalFile, true), IO_BUFFER_SIZE);
}
DiskLruCache快取讀寫流程
DiskLruCache的讀寫操作實際上分別是通過Snapshot和Editor來完成的,二者封裝了檔案IO的操作,對外暴露簡單的介面,使用很方便。但是在多執行緒的環境下,為了保證快取檔案、日誌檔案和lruEntries在同一時刻只能被同一執行緒修改,在很多細節的地方都做了同步處理。例如在獲取快取映像,構造輸入輸出流等操作上要麼使用了同步方法,要麼使用同步塊。
DiskLruCache的寫操作主要由Editor完成,其中構建Editor的方法為:
/**
* 注意需要寫快取和日誌檔案,因此是同步方法
* @param key
* @param expectedSequenceNumber
* @return
* @throws IOException
*/
private synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException {
checkNotClosed();
validateKey(key);
Entry entry = lruEntries.get(key);
if (expectedSequenceNumber != ANY_SEQUENCE_NUMBER
&& (entry == null || entry.sequenceNumber != expectedSequenceNumber)) {
// entry為空,表示該快取已經被刪除,
// sequenceNumber和snapshot不匹配,說明entry對應的內容已經改變,不能通過該snapshot來獲取快取內容
return null; // snapshot is stale
}
if (entry == null) { // expectedSequenceNumber == ANY_SEQUENCE_NUMBER
entry = new Entry(key);
lruEntries.put(key, entry);
} else if (entry.currentEditor != null) { // 已經有editor正在進行修改
return null; // another edit is in progress
}
Editor editor = new Editor(entry);
entry.currentEditor = editor;
// flush the journal before creating files to prevent file leaks
journalWriter.write(DIRTY + ' ' + key + '\n'); // 表示正在進行修改
journalWriter.flush();
return editor;
}
由於在獲取Editor的時候需要寫入一個READ狀態到journal中,因此該方法是同步方法。Editor中對檔案的讀寫方法如下:
/**
* Returns an unbuffered input stream to read the last committed value,
* or null if no value has been committed.
*/
public InputStream newInputStream(int index) throws IOException {
synchronized (DiskLruCache.this) {
if (entry.currentEditor != this) {
throw new IllegalStateException();
}
if (!entry.readable) {
return null;
}
return new FileInputStream(entry.getCleanFile(index));
}
}
/**
* 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;
}
/**
* 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();
}
return new FaultHidingOutputStream(new FileOutputStream(entry.getDirtyFile(index))); // 注意是寫到dirty檔案中的
}
}
可以看到在構建輸入輸出流的時候都做了同步處理,同時注意,通過Editor操作的都是臨時檔案,在通過IO對檔案進行了操作後,需要在呼叫commit方法來確認操作,該操作會呼叫DiskLruCache的completeEdit方法,程式碼如下:
/**
* 在editor修改了快取後呼叫,用來同步快取內容和更新日誌及相關狀態
* 要寫快取和日誌,同步方法
* @param editor
* @param success
* @throws IOException
*/
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 (!entry.getDirtyFile(i).exists()) { // 因為editor在edit的時候是將快取內容寫到dirty檔案中的,因此這裡要求dirty檔案一定要存在
editor.abort();
throw new IllegalStateException("edit didn't create file " + i);
}
}
}
for (int i = 0; i < valueCount; i++) {
File dirty = entry.getDirtyFile(i);
if (success) {
if (dirty.exists()) {
File clean = entry.getCleanFile(i);
dirty.renameTo(clean); // 寫入成功,將dirty檔案程式設計clean 檔案
long oldLength = entry.lengths[i];
long newLength = clean.length();
entry.lengths[i] = newLength;
size = size - oldLength + newLength; // 更新快取大小
}
} else {
deleteIfExists(dirty); // dirty檔案的使命完成
}
}
redundantOpCount++; // 增加日誌一條
entry.currentEditor = null; // entry clean了
if (entry.readable | success) {
entry.readable = true;
journalWriter.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n');
if (success) {
entry.sequenceNumber = nextSequenceNumber++; // 更新sequenceNumber,這樣之前的舊的snapshot就不會讀錯資料
}
} else {
lruEntries.remove(entry.key);
journalWriter.write(REMOVE + ' ' + entry.key + '\n');
}
if (size > maxSize || journalRebuildRequired()) {
executorService.submit(cleanupCallable);
}
}
同樣,該方法也是一個同步方法,在通過容錯判斷後,將臨時檔案重新命名為快取檔案,然後將日誌寫入到journal中,並且更新entry的sequenceNumber,表示該快取已經被改變。
snapshot由於只涉及到檔案的讀取,並不會修改日誌和記憶體資訊,因此完全不需要進行同步,邏輯也很簡單,不再進行分析。