关于作者
郭孝星,程序员,吉他手,主要从事Android平台基础架构方面的工作,欢迎交流技术方面的问题,可以去我的Github提issue或者发邮件至guoxiaoxingse@163.com与我交流。
文章目录
- 一 Lru算法
- 二 LruCache原理分析
- 2.1 写入缓存
- 2.2 读取缓存
- 2.3 删除缓存
- 三 DiskLruCache原理分析
- 3.1 写入缓存
- 3.2 读取缓存
- 3.3 删除缓存
更多Android开源框架源码分析文章请参见Android open framework analysis。
一 Lru算法
在分析LruCache与DiskLruCache之前,我们先来简单的了解下LRU算法的核心原理。
LRU算法可以用一句话来描述,如下所示:
LRU是Least Recently Used的缩写,最近最久未使用算法,从它的名字就可以看出,它的核心原则是如果一个数据在最近一段时间没有使用到,那么它在将来被
访问到的可能性也很小,则这类数据项会被优先淘汰掉。
LRU算法流程图如下所示:
了解了算法原理,我们来思考一下如果是我们来做,应该如何实现这个算法。从上图可以看出,双向链表是一个好主意。
假设我们从表尾访问数据,在表头删除数据,当访问的数据项在链表中存在时,则将该数据项移动到表尾,否则在表尾新建一个数据项。当链表容量超过一定阈值,则移除表头的数据。
好,以上便是整个Lru算法的原理,我们接着来分析LruCache与DiskLruCache的实现。
二 LruCache原理分析
理解了Lru算法的原理,我们接着从LruCache的使用入手,逐步分析LruCache的源码实现。
在分析LruCache的源码实现之前,我们先来看看LruCache的简单使用,如下所示:
1 | 复制代码int maxMemorySize = (int) (Runtime.getRuntime().totalMemory() / 1024); |
注:getBitmapSize()用来计算图片占内存的大小,具体方法参见附录。
可以发现,在使用LruCache的过程中,需要我们关注的主要有三个方法:
- sizeOf():覆写此方法实现自己的一套定义计算entry大小的规则。
- V create(K key):如果key对象缓存被移除了,则调用次方法重建缓存。
- entryRemoved(boolean evicted, K key, V oldValue, V newValue) :当key对应的缓存被删除时回调该方法。
我们来看看这三个方法的默认实现,如下所示:
1 | 复制代码public class LruCache<K, V> { |
可以发现entryRemoved()方法为空实现,create()方法也默认返回null。sizeOf()方法默认返回1,也就是以entry的数量来计算entry的大小,这通常不符合我们的需求,所以我们一般会覆写此方法。
我们前面提到,要实现Lru算法,可以利用双向链表。
假设我们从表尾访问数据,在表头删除数据,当访问的数据项在链表中存在时,则将该数据项移动到表尾,否则在表尾新建一个数据项。当链表容量超过一定阈值,则移除表头的数据。
LruCache使用的是LinkedHashMap,为什么会选择LinkedHashMap呢?🤔
这跟LinkedHashMap的特性有关,LinkedHashMap的构造函数里有个布尔参数accessOrder,当它为true时,LinkedHashMap会以访问顺序为序排列元素,否则以插入顺序为序排序元素。
1 | 复制代码public class LruCache<K, V> { |
我们来写个小例子验证一下。
1 | 复制代码Map<Integer, Integer> map = new LinkedHashMap<>(5, 0.75F, true); |
程序输入Log:
注:在LinkedHashMap中最近被方位的元素会被移动到表尾,LruCache也是从从表尾访问数据,在表头删除数据,
可以发现,最后访问的数据就会被移动最尾端,这是符合我们的预期的。所有在LruCache的构造方法中构造了一个这样的LinkedHashMap。
1 | 复制代码public LruCache(int maxSize) { |
我们再来看看LruCache是如何进行缓存的写入、获取和删除的。
2.1 写入缓存
写入缓存是通过LruCache的put()方法实现的,如下所示:
1 | 复制代码public class LruCache<K, V> { |
整个插入元素的方法put()实现逻辑是很简单的,如下所示:
- 插入元素,并相应增加当前缓存的容量。
- 调用trimToSize()开启一个死循环,不断的从表头删除元素,直到当前缓存的容量小于最大容量为止。
2.2 读取缓存
读取缓存是通过LruCache的get()方法实现的,如下所示:
1 | 复制代码public class LruCache<K, V> { |
获取元素的逻辑如下所示:
- 调用LinkedHashMap的get()方法,注意如果该元素存在,且accessOrder为true,这个方法会将该元素移动到表尾.
- 当获取不到和key对应的元素时,尝试调用create()方法创建建元素,以下就是创建的过程,和put()方法流程相同。
2.3 删除缓存
删除缓存是通过LruCache的remove()方法实现的,如下所示:
1 | 复制代码public class LruCache<K, V> { |
删除元素的逻辑就比较简单了,调用对应LinkedHashMap的remove()方法删除对应元素。
三 DiskLruCache原理分析
在分析DiskLruCache的实现原理之前,我们先来写个简单的小例子,从例子出发去分析DiskLruCache的实现原理。
1 | 复制代码File directory = getCacheDir(); |
这个就是DiskLruCache的大致使用流程,我们来看看这个入口方法的实现,如下所示:
1 | 复制代码public final class DiskLruCache implements Closeable { |
先来说一下这个入口方法的四个参数的含义:
- File directory:缓存目录。
- int appVersion:应用版本号。
- int valueCount:一个key对应的缓存文件的数目,如果我们传入的参数大于1,那么缓存文件后缀就是.0,.1等。
- long maxSize:缓存容量上限。
DiskLruCache的构造方法并没有做别的事情,只是简单的将对应成员变量进行初始化,open()方法主要围绕着journal文件的创建与读写而展开的,如下所示:
- readJournal():读取journal文件,主要是读取文件头里的信息进行检验,然后调用readJournalLine()逐行去读取,根据读取的内容,执行相应的缓存
添加、移除等操作。 - rebuildJournal():重建journal文件,重建journal文件主要是写入文件头(上面提到的journal文件都有的前面五行的内容)。
- rocessJournal():计算当前缓存容量的大小。
我们接着来分析什么是journal文件,以及它的创建与读写流程。
3.1 journal文件的创建
在前面分析的open()方法中,主要围绕着journal文件的创建和读写来展开的,那么journal文件是什么呢?🤔
我们如果去打开缓存目录,就会发现除了缓存文件,还会发现一个journal文件,journal文件用来记录缓存的操作记录的,如下所示:
1 | 复制代码libcore.io.DiskLruCache |
注:这里的缓存目录是应用的缓存目录/data/data/pckagename/cache,未root的手机可以通过以下命令进入到该目录中或者将该目录整体拷贝出来:
1 | 复制代码 |
我们来分析下这个文件的内容:
- 第一行:libcore.io.DiskLruCache,固定字符串。
- 第二行:1,DiskLruCache源码版本号。
- 第三行:1,App的版本号,通过open()方法传入进去的。
- 第四行:1,每个key对应几个文件,一般为1.
- 第五行:空行
- 第六行及后续行:缓存操作记录。
第六行及后续行表示缓存操作记录,关于操作记录,我们需要了解以下三点:
- DIRTY 表示一个entry正在被写入。写入分两种情况,如果成功会紧接着写入一行CLEAN的记录;如果失败,会增加一行REMOVE记录。注意单独只有DIRTY状态的记录是非法的。
- 当手动调用remove(key)方法的时候也会写入一条REMOVE记录。
- READ就是说明有一次读取的记录。
- CLEAN的后面还记录了文件的长度,注意可能会一个key对应多个文件,那么就会有多个数字。
这几种操作对应到DiskLruCache源码中,如下所示:
1 | 复制代码private static final String CLEAN = "CLEAN"; |
那么构建一个新的journal文件呢?上面我们也说过这是调用rebuildJournal()方法来完成的。
rebuildJournal()
1 | 复制代码public final class DiskLruCache implements Closeable { |
你可以发现,构建一个新的journal文件过程就是写入文件头的过程,文件头内容包含前面我们说的appVersion、valueCount、空行等五行内容。
我们再来看看如何读取journal文件里的内容。
readJournal()
1 | 复制代码public final class DiskLruCache implements Closeable { |
新来说一下这个lruEntries是什么,如下所示:
1 | 复制代码private final LinkedHashMap<String, Entry> lruEntries = |
就跟上面的LruCache一样,它也是一个以访问顺序为序的LinkedHashMap,可以用它来实现Lru算法。
该方法的逻辑就是根据记录中不同的操作类型进行相应的处理,如下所示:
- 如果该条记录以REMOVE为开头,则执行删除操作。
- 如果该key不存在,则新建Entry并加入lruEntries。
- 如果该条记录以CLEAN为开头,则初始化entry,并设置entry.readable为true、设置entry.currentEditor为null,初始化entry长度。
- 如果该条记录以DIRTY为开头。则设置currentEditor对象。
- 如果该条记录以READ为开头,则什么也不做。
说了这么多,readJournalLine()方法主要是通过读取journal文件的每一行,然后封装成entry对象,放到了LinkedHashMap集合中。并且根据每一行不同的开头,设置entry的值。也就是说通过读取这
个文件,我们把所有的在本地缓存的文件的key都保存到了集合中,这样我们用的时候就可以通过集合来操作了。
processJournal()
1 | 复制代码public final class DiskLruCache implements Closeable { |
这里提到了一个非常缓存记录,那么什么是非法缓存记录呢?🤔
DIRTY 表示一个entry正在被写入。写入分两种情况,如果成功会紧接着写入一行CLEAN的记录;如果失败,会增加一行REMOVE记录。注意单独只有DIRTY状态的记录是非法的。
该方法主要用来计算当前的缓存总容量,并删除非法缓存记录以及该记录对应的文件。
理解了journal文件的创建以及读写流程,我们来看看硬盘缓存的写入、读取和删除的过程。
3.2 写入缓存
DiskLruCache缓存的写入是通过edit()方法来完成的,如下所示:
1 | 复制代码public final class DiskLruCache implements Closeable { |
这个方法构建了一个Editor对象,它主要做了两件事情:
- 从集合中找到对应的实例(如果没有创建一个放到集合中),然后创建一个editor,将editor和entry关联起来。
- 向journal中写入一行操作数据(DITTY 空格 和key拼接的文字),表示这个key当前正处于编辑状态。
我们在前面的DiskLruCache的使用例子中,调用了Editor的newOutputStream()方法创建了一个OutputStream来写入缓存文件。如下所示:
1 | 复制代码public final class DiskLruCache implements Closeable { |
这个方法的形参index就是我们开始在open()方法里传入的valueCount,这个valueCount表示了一个key对应几个value,也就是说一个key对应几个缓存文件。那么现在传入的这个index就表示
要缓存的文件时对应的第几个value。
有了输出流,我们在接着调用Editor的commit()方法就可以完成缓存文件的写入了,如下所示:
1 | 复制代码public final class DiskLruCache implements Closeable { |
可以看到该方法调用DiskLruCache的completeEdit()方法来完成缓存写入,如下所示:
1 | 复制代码public final class DiskLruCache implements Closeable { |
这个方法一共做了以下几件事情:
- 如果输出流写入数据成功,就把写入的临时文件重命名为正式的缓存文件
- 重新设置当前总缓存的大小
- 向journal文件写入一行CLEAN开头的字符(包括key和文件的大小,文件大小可能存在多个 使用空格分开的)
- 如果输出流写入失败,就删除掉写入的临时文件,并且把集合中的缓存也删除
- 向journal文件写入一行REMOVE开头的字符
- 重新比较当前缓存和最大缓存的大小,如果超过最大缓存或者journal文件的操作大于2000条,就把集合中的缓存删除一部分,直到小于最大缓存,重新建立新的journal文件
到这里,缓存的插入流程就完成了。
3.3 读取缓存
读取缓存是由DiskLruCache的get()方法来完成的,如下所示:
1 | 复制代码public final class DiskLruCache implements Closeable { |
读取操作主要完成了以下几件事情:
- 获取对应的entry。
- 打开所有缓存文件的输入流,等待被读取。
- 向journal写入一行READ开头的记录,表示执行了一次读取操作。
- 如果缓存总大小已经超过了设定的最大缓存大小或者操作次数超过了2000次,就开一个线程将集合中的数据删除到小于最大缓存大小为止并重新写journal文件。
- 返回一个缓存文件快照,包含缓存文件大小,输入流等信息。
该方法最终返回一个缓存文件快照,包含缓存文件大小,输入流等信息。利用这个快照我们就可以读取缓存文件了。
3.4 删除缓存
删除缓存是由DiskLruCache的remove()方法来完成的,如下所示:
1 | 复制代码public final class DiskLruCache implements Closeable { |
删除操作主要做了以下几件事情:
- 获取对应的entry。
- 删除对应的缓存文件,并将缓存大小置为0.
- 向journal文件添加一行REMOVE开头的记录,表示执行了一次删除操作。
- 如果缓存总大小已经超过了设定的最大缓存大小或者操作次数超过了2000次,就开一个线程将集合中的数据删除到小于最大缓存大小为止并重新写journal文件。
好,到这里LrcCache和DiskLruCache的实现原理都讲完了,这两个类在主流的图片框架Fresco、Glide和网络框架Okhttp等都有着广泛的应用,后续的文章后继续分析LrcCache和DiskLruCache
在这些框架里的应用。
附录
图片占用内存大小的计算
Android里面缓存应用最多的场景就是图片缓存了,谁让图片在内存里是个大胖子呢,在做缓存的时候我们经常会去计算图片展内存的大小。
那么如何去获取一张图片占用内存的大小呢?🤔
1 | 复制代码private int getBitmapSize(Bitmap bitmap) { |
那么这三个方法处了版本上的差异,具体有什么区别呢?
getRowBytes()返回的是每行的像素值,乘以高度就是总的像素数,也就是占用内存的大小。 getAllocationByteCount()与getByteCount()的返回值一般情况下都是相等的。只是在图片
复用的时候,getAllocationByteCount()返回的是复用图像所占内存的大小,getByteCount()返回的是新解码图片占用内存的大小。
我们来写一个小例子验证一下,如下所示:
1 | 复制代码BitmapFactory.Options options = new BitmapFactory.Options(); |
运行的log如下所示:
可以发现reuseBitmap的getAllocationByteCount()和getByteCount()返回不一样,getAllocationByteCount()返回的是复用bitmap占用内存的大小,
getByteCount()返回的是新的reuseOptions实际解码占用的内存大小。
注意在复用图片的时候,options.inMutable必须设置为true,否则无法进行复用,如下所示:
本文转载自: 掘金