深入理解DiskLruCache源码
Posted 冬天的毛毛雨
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深入理解DiskLruCache源码相关的知识,希望对你有一定的参考价值。
作者:岩浆李的游鱼leo2
前言
我们在用第三方框架的时候,比如glide,okhttp等,使用起来已经轻而易举,因为我们只管用。忽视了其用到的缓存技术。让我们一起来理解下缓存的本质。内存缓存一般现在用的是LruCache缓存,磁盘缓存是DiskLruCache。它们用的都是LRU算法(最近最少使用)。网络缓存当然就是网络请求,数据放在后端数据库了。
LRU算法:Least Recently Used 即为近期最少使用。在缓存数据的时候,如果数据不存在缓存中,则放入缓存中,如存在缓存中,会将缓冲重新放入头部位置表示最近使用了,底部位置则为近期最少使用,在一些配置下,如果达到了缓存的容量下,那么要缓存新的数据,则需要从底部最近最少使用的开始删除缓存。
看到这里,其实LRU也有他的缺点。比如遇到这样的场景,一般电商都会有秒杀日,那么一旦出现秒杀日,那么在秒杀日当天可能会因为缓存容量的问题,把真正潜在经常访问的缓存给删除。怎么优化的,其实也有办法,LRU+2Q。有点偏题了,感兴趣的同学可以自行了解。
接下来跟着我节奏,我们将DiskLruCache各个击破。当然你需要先下载一份DiskLruCache源码。
点这里-源码地址;摩拜JakeWharton大神
一、DiskLruCache的创建
DiskLruCache mDiskLruCache = null;
try
File cacheDir = getDiskCacheDir(this, "bitmap");
if (!cacheDir.exists())
cacheDir.mkdirs();
mDiskLruCache = DiskLruCache.open(cacheDir, getVersionCode(this), 1, 10 * 1024 * 1024);
catch (IOException e)
e.printStackTrace();
做完以上呢我们打开手机的缓存文件,因为android手机太杂,老一点带sd卡的你可以从这个路径里去找/sdcard/Android/data/项目包名/cache/bitmap/。内置储存卡的,可以通过点击手机内部存储–> Android/data/项目包名/cache/bitmap/ 在这个文件夹可以看到一个 journal文件。先介绍完open后,会介绍journal文件的
可以看到DiskLruCahe是通过open创建的,其中包括4个参数,我们来看下源码。源码就直接给了注解,我会直接翻译成中文
/**
* 参数1:directory 缓存路径位置,如果缓存文件不存在就新建
* 参数2:appVersion 当前app的版本号,也就是versionCode
* 参数3:valueCount 一个缓存key的缓存个数。一般传1,表示1个key对应1个缓存
* 参数4: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");
//new出当前文件,也就是对成员变量赋值,如directory,appVersion,valueCount,journalFile等
DiskLruCache cache = new DiskLruCache(directory, appVersion, valueCount, maxSize);
//那为什么我们调用的方法是open呢? 看下面的判断的这个判断,大致意思是:
//如果我们传入的参数,和原始缓存文件一致,那么返回原始缓存文件,并在上面拼接
//如果缓存文件journal存在的话
if (cache.journalFile.exists())
try
//就是去读原始缓存文件里的参数,和现在赋值的参数是否一致,不一致的话抛出异常
//同时将日志信息读取到我们的缓存链表里LinkedHashMap<String, Entry> lruEntries 这里会放到【1.1、readJournal()】详细讲解
cache.readJournal();
//这里会将上次读取还处于头部文件为 DIRTY 即还未写入缓存的脏数据清掉。会放到【1.2、processJournal()】详细讲解
cache.processJournal();
//看这个append为true,内部调用的是new FileOutputStream(file, append),输出流在原始文件上拼接
//如果这个参数为false的话,会把原始文件内容清掉,重新写。断点下载就是这个原理。有兴趣的同学可自了解
cache.journalWriter = new BufferedWriter(new FileWriter(cache.journalFile, true),
IO_BUFFER_SIZE);
return cache;
catch (IOException journalIsCorrupt)
//抛出异常的话,那么把原始缓存文件删除,在下面新建缓存文件
cache.delete();
//如果走到这一步,说明上面没有把原始缓存return出去。且还走了catch里把原始缓存文件删除了,那么按新的参数,生成缓存文件
directory.mkdirs();
cache = new DiskLruCache(directory, appVersion, valueCount, maxSize);
//重建缓存文件,会放到【1.3、rebuildJournal();】详细讲解
cache.rebuildJournal();
return cache;
创建缓存代码也贴上来,方便读者阅览。
// 获取缓存路径,这里已经做好了判断是否有外部存储
public File getDiskCacheDir(Context context, String uniqueName)
String cachePath;
if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())
|| !Environment.isExternalStorageRemovable())
cachePath = context.getExternalCacheDir().getPath();
else
cachePath = context.getCacheDir().getPath();
return new File(cachePath + File.separator + uniqueName);
//获取app当前的版本号versionCode
public int getVersionCode(Context context)
PackageManager packageManager = context.getPackageManager();
PackageInfo packageInfo;
int versionCode = 1;
try
packageInfo = packageManager.getPackageInfo(context.getPackageName(), 0);
versionCode = packageInfo.versionCode;
catch (PackageManager.NameNotFoundException e)
e.printStackTrace();
return versionCode;
1.1、readJournal()
private void readJournal() throws IOException
//把journal文件通过输入流读入
InputStream in = new BufferedInputStream(new FileInputStream(journalFile), IO_BUFFER_SIZE);
try
//这几句就是读出原始缓存文件journal的参数和现在通过open传入的参数进行对比,如果参数不一致那么会抛出异常
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
//把缓存的日志信息,读入到我们的缓存链表LinkedHashMap<String, Entry> lruEntries
//这里还有一个操作,把头部REMOVE的日志清掉,属于冗余日志
readJournalLine(readAsciiLine(in));
catch (EOFException endOfJournal)
break;
finally
closeQuietly(in);
再来看下readJournalLine()的源码
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];
//这里就是日志信息里的已经REMOVE的缓存,从缓存列表里移除
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);
//读出头部为CLEAN,表示已经缓存成功的数据的一些信息。并复赋值到缓存列表里
//缓存列表里存的Entry对象,其句是DiskLruCache定义的内部类,用于存储缓存信息用的。
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);
这个时候你可能会想了,即使不断的操作缓存,是不是journal日志文件会越来越大呢?引用一下郭神的原话
如果我不停频繁操作的话,就会不断地向journal文件中写入数据,那这样journal文件岂不是会越来越大?这倒不必担心,DiskLruCache中使用了一个redundantOpCount变量来记录用户操作的次数,每执行一次写入、读取或移除缓存的操作,这个变量值都会加1,当变量值达到2000的时候就会触发重构journal的事件,这时会自动把journal中一些多余的、不必要的记录全部清除掉,保证journal文件的大小始终保持在一个合理的范围内。
然后我仔细阅读了源码发现,其实在每次操作get(),completeEdit(),remove都调用了一句这样的代码
if (journalRebuildRequired())
executorService.submit(cleanupCallable);
那么看journalRebuildRequired(),字面意思很明显,jornal文件是否需要被重建
//一看源码和郭神所说一致
private boolean journalRebuildRequired()
final int REDUNDANT_OP_COMPACT_THRESHOLD = 2000;
return redundantOpCount >= REDUNDANT_OP_COMPACT_THRESHOLD
&& redundantOpCount >= lruEntries.size();
不难想到cleanupCallable里发生了什么,这里除了缓存超过最大缓存,清理缓存外,也判断了journal文件是否需要被重建。将redundantOpCount置为0
if (journalRebuildRequired())
rebuildJournal();
redundantOpCount = 0;
1.2、processJournal()
processJournal()是在执行完readJournal()后执行的。此时缓存文件信息已全部读入到了缓存列表里LinkedHashMap<String, Entry> lruEntries。
processJournal()其实就是把上次还未缓存成功,停留在头部信息为DIRTY的缓存数据,即为脏数据给清除掉。(数据缓存成功即有2行缓存日志,头部信息分别为DIRTY,CLEAN;移除成功,头部信息也有2行分别为DIRTY,REMOVE;会在讲解journal说清楚,现在跟着“感觉”走就行了)上源码
private void processJournal() throws IOException
//这个时候还做了个操作把journalFileTmp也就是journal的临时文件删除了,
//因为这个时候没走rebuildJournal()重建journal文件的方法,所以journalFileTmp已经没有存在的必要了,删除文件
deleteIfExists(journalFileTmp);
for (Iterator<Entry> i = lruEntries.values().iterator(); i.hasNext(); )
Entry entry = i.next();
//已经被缓存,那么缓存size++
if (entry.currentEditor == null)
for (int t = 0; t < valueCount; t++)
size += entry.lengths[t];
else
//没有被缓存,那么清除掉这条脏数据
entry.currentEditor = null;
for (int t = 0; t < valueCount; t++)
deleteIfExists(entry.getCleanFile(t));
deleteIfExists(entry.getDirtyFile(t));
i.remove();
1.3、rebuildJournal()
这是在open()方法内,参数不一致,最终会走到journal文件重建的方法
private synchronized void rebuildJournal() throws IOException
if (journalWriter != null)
journalWriter.close();
//这块就是journal的头信息,你也可以直接把他认为是参数信息
Writer writer = new BufferedWriter(new FileWriter(journalFileTmp), IO_BUFFER_SIZE);
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里,最后改名为journalFile里
//其实这个时候我也在想,这个临时文件就是个多余的存在。纵观了代码,也就在这起了点作用。journalFile文件在前面
//被delete掉了,难道不能在这里从新new下,取代这个journalFileTmp吗?我觉得是可以的,可能大神想把初始化工作都
//放在open()里吧
journalFileTmp.renameTo(journalFile);
journalWriter = new BufferedWriter(new FileWriter(journalFile, true), IO_BUFFER_SIZE);
二、journal文件
本来想把这个放在最后说,起着画龙点睛的左右。但是前面提了这么多,如果有看到这的朋友,其实再这点这龙的眼睛是最合适的。承上启下,贯穿全文。
通过上面的创建,我们打开手机,可以看到journal文件生成了。
因为我是新建的,里面还没存数据,所以日志信息是不全的。加上缓存信息,是第三步的内容。所以暂且用源码的地址讲解,大神也注释的非常清楚了。这里我们只要知道操作缓存的话,这些操作信息都是会被记录到journal文件里的。
/*
* libcore.io.DiskLruCache
* 1
* 1
* 1
*
* 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 DiskLruCache的版本号,这个值是恒为1的
- 第三行: 1 app的版本号,也就是对应着我们从open里传入的值
- 第四行:1 即valueCount,如果是1那么我们的key和缓存是一对一的关系
- 第五行:空行,前五行被成为journal文件的头
DIRTY头部 : 目前这是一条脏数据,正准备缓存,但还未缓存成功。后面跟着的是key值,因为需要一一对应,且不允许带符号,这个key是通过图片url地址进行MD5加密得来
CLEAN头部 : 该key值对应的缓存数据,已成功缓存。key值后面对应的数字是缓存的大小,源码里跟了2个,说明他valueCount设置成了2,一个key对应多个缓存
REMOVE头部 : 该key值对应的缓存数据,被清除了
READ头部 : 该key值对应的缓存数据,被使用了
可以看到每一行DIRTY的key,后面都应该有一行对应的CLEAN或者REMOVE的记录,否则这条数据就是“脏”的,会被自动删除掉。我们在第1小结里,已经讲到过了。journal大概介绍就讲完了。如果你对下方知识点不管感兴趣,可以跳过
这个时候可能会有一个疑问,为什么key还能一对多呢?这里引入一个知识点,结合这个知识点,可能你就理解了。没错哈希碰撞。
什么是 哈希碰撞 ?
我们要存数据,首先要把存储的key值转换成系统认识的标记。举个列子,字母‘a’和数子97的ASCII码都是 97,转换成二进制是:01100001。所以这样就造成了哈希碰撞,即key计算出来的hashcode是一样的值。
那么解决了hash碰撞的问题,岂不是同一key可以对应多个值?有很多解决方式,这里我们说下拉链法:即如果出现哈希碰撞的key,对应的不是一个value,而是一个单项链表的地址。先看下其数据结构:
static class Entry<K,V> implements Map.Entry<K,V>
final K key;
V value;
Entry<K,V> next;
int hash;...
单项列表就是通过next,next,有碰撞就存下去。当我们通过key去取value的时候,这个key对应的是哈希碰撞的话,就会取到一个单列表,然后通过遍历(因为数据结构里有具体的key值,key值对应上),得到最终的value。因为是遍历的,所以这里并不适合存太长,size满8的话会转换成红黑树,提高查询效率。
三、写入缓存
3.1、DiskLruCache.Editor
写入缓存是通过DiskLruCache.Editor 这类,后面跟的是写入缓存的key,这个key呢我们只要保证不带什么特殊符号且唯一性即可,这里用郭神的方案,我们使用MD5加密
try
String imageUrl = "https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/54a4b40807034b3090708c935689345f~tplv-k3u1fbpfcp-zoom-crop-mark:1304:1304:1304:734.awebp?";
String key = hashKeyForDisk(imageUrl);
DiskLruCache.Editor editor = mDiskLruCache.edit(key);
catch (IOException e)
e.printStackTrace();
public String hashKeyForDisk(String key)
String cacheKey;
try
final MessageDigest mDigest = MessageDigest.getInstance("MD5");
mDigest.update(key.getBytes());
cacheKey = bytesToHexString(mDigest.digest());
catch (NoSuchAlgorithmException e)
cacheKey = String.valueOf(key.hashCode());
return cacheKey;
private String bytesToHexString(byte[] bytes)
StringBuilder sb = new StringBuilder();
for (int i = 0; i < bytes.length; i++)
String hex = 以上是关于深入理解DiskLruCache源码的主要内容,如果未能解决你的问题,请参考以下文章
Android DiskLruCache 源代码解析 硬盘缓存的绝佳方案