深入理解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源码的主要内容,如果未能解决你的问题,请参考以下文章

JDK源码系列 ------ 深入理解SPI机制

Android DiskLruCache 源代码解析 硬盘缓存的绝佳方案

深入理解NIO

深入理解JDK中的Reference原理和源码实现

Java 集合深入理解 :stack源码分析,及如何利用vector实现栈

.NET Core 3.0之深入源码理解Configuration