LruCache是我们经常使用的缓存机制,也叫“最近最少使用的”缓存策略。
其本质原理是通过历史访问记录来倒序淘汰数据,它认为刚刚访问的数据,将来被访问的可能性较大,因此将该类数据维护到相对安全的区域,防止被淘汰。此时如果超过设定的内存瓶颈,将优先淘汰最老的数据。
举个例子
- 比如你在玩一款游戏,游戏里有一个仓库,有20个位置可以存放装备。
- 1-20件装备都可以正常存放,并根据时间顺序进行了排序,最晚存放的装备排在最后。
- 当你使用其中任意一件装备的时候,这件装备的排序就变成了最后。
- 有一天你又获得了一件装备,但是你的仓库已经满了,只能扔一件才能存储刚刚获得的,扔的顺序是从头开始扔。新装备存储后,排序为最后。依次类推。
接下来我们通过正常的使用来分析源码的逻辑,以下所有源码均来自API 28
- 通过LruCache提供的每一个方法进行一对一分析,首先我们看一段正常使用LruCache的代码
private void lruTest() {
LruCache<Integer, Integer> lruCache = new LruCache<>(5);
lruCache.put(1, 1);
lruCache.put(2, 2);
lruCache.put(3, 3);
lruCache.put(4, 4);
lruCache.put(5, 5);
for (Map.Entry<Integer, Integer> entry : lruCache.snapshot().entrySet()) {
Log.e("LRU", entry.getKey() + ":" + entry.getValue());
}
Log.e("LRU", "超出设定存储容量后");
lruCache.put(6, 6);
for (Map.Entry<Integer, Integer> entry : lruCache.snapshot().entrySet()) {
Log.e("LRU", entry.getKey() + ":" + entry.getValue());
}
}
---日志输出---
2019-11-18 18:21:03.624 24293-24293/com.we.we E/LRU: 1:1
2019-11-18 18:21:03.624 24293-24293/com.we.we E/LRU: 2:2
2019-11-18 18:21:03.624 24293-24293/com.we.we E/LRU: 3:3
2019-11-18 18:21:03.624 24293-24293/com.we.we E/LRU: 4:4
2019-11-18 18:21:03.624 24293-24293/com.we.we E/LRU: 5:5
2019-11-18 18:21:03.624 24293-24293/com.we.we E/LRU: 超出设定存储容量后
2019-11-18 18:21:03.624 24293-24293/com.we.we E/LRU: 2:2
2019-11-18 18:21:03.624 24293-24293/com.we.we E/LRU: 3:3
2019-11-18 18:21:03.624 24293-24293/com.we.we E/LRU: 4:4
2019-11-18 18:21:03.625 24293-24293/com.we.we E/LRU: 5:5
2019-11-18 18:21:03.625 24293-24293/com.we.we E/LRU: 6:6
- 以上的例子的流程比较简单,初始化一个容量为5的LruCache。
- 调用put方法添加数据,最后通过snapshot()方法获取Map然后遍历打印。
- 但是超出设定容量后再进行put数据,会将最先put的数据顶替掉,我们记住这个特性往下看。
LruCache构造函数
- 如果有特殊需求可以通过重写sizeOf()方法设定每条缓存数据的大小
public LruCache(int maxSize) {
if (maxSize <= 0) {
throw new IllegalArgumentException("maxSize <= 0");
}
this.maxSize = maxSize;
//初始化一个负载因子为0.75,启动访问顺序排序的LinkedHashMap对象。至于LinkedHashMap是什么东西,我们后续分析。
this.map = new LinkedHashMap<K, V>(0, 0.75f, true);
}
put()方法源码解析
/**
* 对于 key,缓存其相应的 value,key-value 条目放置于队尾
*
* @return 返回先前 key 对应的 value 值
*/
public final V put(K key, V value) {
//如果键值有一个为空,则抛空指针异常。
if (key == null || value == null) {
throw new NullPointerException("key == null || value == null");
}
V previous;
synchronized (this) {
putCount++;
//先给 size +1, safeSizeOf()方法返回值默认是1
size += safeSizeOf(key, value);
//map.put()方法返被替换的值,如果没有被替换,则返回null。(只有当前key在put之前就已经有数据,这种情况存储才会被替换)
previous = map.put(key, value);
if (previous != null) {
//如果是被顶替的数据,那size需要-1,因为没有新增数据。
size -= safeSizeOf(key, previous);
}
}
if (previous != null) {
//当多线程中一个线程调用到了create方法,另一个线程调用到了put,此时一个key可能生成了两个值,因此需要通过重写这个方法进行处理。该方法默认为空方法。
entryRemoved(false, key, previous, value);
}
//判断是否超出设定容量,如果超出,则循环删除最早数据,直到容量<=设定值。
trimToSize(maxSize);
return previous;
}
put()方法小结
- 首先会进行K/V的null判断,如果为null则抛出异常。
- 加锁并自增元素条目计数器,通过LinkedHashMap.put方法将K/V添加到LinkedHashMap对象中,如果put方法返回值不为null,则说明原来该key下有数据,因此只是更新key对应的数据并没有新增数据,所以此时size需 -1。
- 如果previous!=null,说明当前的put操作替换了之前的一个值,如果重写了entryRemoved()方法,则会回调到该方法中进行处理,如果没重写entryRemoved(),该方法默认不做任何处理。
- 调用trimToSize(maxSize)进行容量检测和处理。
trimToSize()方法源码解析,以及bug发现
/**
*删除最旧的元素,直到剩余数据总数 <= maxSize。
*/
private void trimToSize(int maxSize) {
while (true) {
K key;
V value;
synchronized (this) {
if (size < 0 || (map.isEmpty() && size != 0)) {
throw new IllegalStateException(getClass().getName()
+ ".sizeOf() is reporting inconsistent results!");
}
//如果当前元素数<=设定的最大值,则直接break退出循环。
if (size <= maxSize) {
break;
}
// BEGIN LAYOUTLIB CHANGE
// get the last item in the linked list.
// This is not efficient, the goal here is to minimize the changes
// compared to the platform version.
Map.Entry<K, V> toEvict = null;
for (Map.Entry<K, V> entry : map.entrySet()) {
//获取最少使用的数据元素,此处的写法是源码的一个bug,后续我会详细说明。
toEvict = entry;
}
// END LAYOUTLIB CHANGE
if (toEvict == null) {
break;
}
key = toEvict.getKey();
value = toEvict.getValue();
//移除元素
map.remove(key);
//计数长度size -1;
size -= safeSizeOf(key, value);
//缓存移除的次数 +1
evictionCount++;
}
entryRemoved(true, key, value, null);
}
}
trimToSize()方法小结
- 该方法主要用于容量检测,和容量超出后的元素移除操作。
- 首先开启循序,并加锁,判断当前数据量 <= 设定的阀值maxSize则break跳出循环。
- 获取当前LinkedHashMap对象中的最老数据(最不常用的数据),但是源码中通过for循环去循环赋值,实际上循环完获取到的是最新的数据。
3.1. 举例说明,可通过以下例子和日志输出判断for循环后的最终赋值。
LruCache<Integer, Integer> lruCache = new LruCache<>(5);
lruCache.put(1, 1);
lruCache.put(2, 2);
lruCache.put(3, 3);
lruCache.put(4, 4);
lruCache.put(5, 5);
for (Map.Entry<Integer, Integer> entry : lruCache.snapshot().entrySet()) {
Log.e("LRU", entry.getKey() + ":" + entry.getValue());
}
2019-11-19 16:22:54.299 31747-31747/com.we.we E/LRU: 1:1
2019-11-19 16:22:54.299 31747-31747/com.we.we E/LRU: 2:2
2019-11-19 16:22:54.300 31747-31747/com.we.we E/LRU: 3:3
2019-11-19 16:22:54.300 31747-31747/com.we.we E/LRU: 4:4
2019-11-19 16:22:54.300 31747-31747/com.we.we E/LRU: 5:5
3.2. 通过以上例子的日志打印,可以看到最后打印的也就是最后循环的数据为5,而5是最新插入的数据,那这个逻辑删除的不是最新插入的数据了么?那对LRU而言不是有问题了么?我又翻看了以前SDK版本的源码,接下来我们对比下API 27和API 28中该段逻辑的差异。
API 27
Map.Entry<K, V> toEvict = map.eldest();
//...
//eldest是被标记隐藏的. 实现如下:
public Entry<K, V> eldest() {
LinkedEntry<K, V> eldest = header.nxt;
return eldest != header ? eldest : null;
}
API 28
Map.Entry<K, V> toEvict = null;
for (Map.Entry<K, V> entry : map.entrySet()) {
toEvict = entry;
}
3.3. 通过对比因此这段逻辑是源码中的一个bug,但是我们使用过程中并不影响正常的功能,这是为什么?其实是Framework实现和SDK源码不一致, 而SDK中是一份带bug版本的LruCache.
- 移除数据并size -1,缓存移除的次数 +1
get()方法源码解析
/**
* 指定 key 对应的 value 值存在时返回,否则通过 create 方法创建相应的 key-value 对。
* 如果对应的 value 值被返回,那么这个 key-value 对将被移到队尾。
* 当返回 null 时,表明没有对应的 value 值并且也无法被创建
*/
public final V get(K key) {
if (key == null) {
throw new NullPointerException("key == null");
}
V mapValue;
synchronized (this) {
//通过LinkedHashMap对象获取当前key对应的value
mapValue = map.get(key);
if (mapValue != null) {
//缓存命中的次数+1
hitCount++;
//并return返回结束
return mapValue;
}
//缓存未命中的次数+1
missCount++;
}
/*
* 尝试创建一个value值,但是可能消耗很长时间,当create方法返回时,哈希表有几率发生变化
* 如果再哈希表中添加了一个有冲突的值,那么将保留原有的值,替换刚刚生成的value
*/
//create()是需要重写的方法,如果不重写默认返回null
V creaweValue = create(key);
if (creaweValue == null) {
return null;
}
synchronized (this) {
//创建 key 对应的 value 的次数+1
createCount++;
//map.put方法返回的是该key下原来的值,如果该key下原来没有值则返回null
mapValue = map.put(key, creaweValue);
if (mapValue != null) {
//mapValue不为null说明该key原来有对应的值,因此需要使用原来值替代刚刚生成的value。
map.put(key, mapValue);
} else {
//如果原来key没有对应的值,说明新增了一对键值对,因此需要size+1
size += safeSizeOf(key, creaweValue);
}
}
if (mapValue != null) {
entryRemoved(false, key, creaweValue, mapValue);
return mapValue;
} else {
trimToSize(maxSize);
return creaweValue;
}
}
get()方法小结
- get()放中除了正常的判null以为,进行了加锁并通过LinkedHashMap.get(key)获取该key下的value。
- 如果LinkedHashMap.get(key)没有获取到value,则通过create()方法创建对应的value值,但create()默认返回null,需要重写进行创建处理。
- 如果重写了create()方法并且生成了value,则加锁通过LinkedHashMap.put()进行插入,并根据put方法的返回值判断该key之前是否有对应的value,如果有则重新将原有的值put替换刚刚生成的值。
注
- 以上逻辑中create(key)方法其实是未加锁的,如果该方法是有使用的,可能会出现多线程调用同一个key,而生成多个value的情况。或者当一个线程调用put而另一个线程调用了create方法为其创建值时这种情况下,在后续逻辑中会调用entryRemoved()方法进行处理。而entryRemoved()方法也需要重写实现。可根据entryRemoved()方法的参数值判断当前调用是由 put 或 remove 引起的还是为了腾空间由trimToSize()引起的。
@param evicwe true 表明条目正在被删除以腾出空间,false 表明删除是由 put 或 remove 引起的(并非是为了腾出空间)
@param newValue key 的新值。如果非 null,则此删除是由 put 引起的。否则它是由 remove引起的
remove()源码解析
/**
* 删除key对应的value
*
* 返回key对应的value值
*/
public final V remove(K key) {
if (key == null) {
throw new NullPointerException("key == null");
}
V previous;
synchronized (this) {
previous = map.remove(key);
if (previous != null) {
//数据删除后需要将数据计数器size -1
size -= safeSizeOf(key, previous);
}
}
if (previous != null) {
entryRemoved(false, key, previous, null);
}
return previous;
}
remove()方法小结
- 1.判空,并加锁调用LinkedHashMap.remove()方法移除该key对应的value,并通过返回值判断size -1;
- 2.调用entryRemoved()方法
总结,通篇下来发现LruCache有以下几个特点。
- 其实LruCache类中方法数量不多,大部分都依赖于LinkedHashMap的方法,而LruCache只是在维护一个内部LinkedHashMap对象。
- LruCache使用并不是线程安全的,因此提供了entryRemoved()方法用来重写解决异常情况。
- LruCache的大部分方法中都进行了K/V判空,因此 LruCache 不支持空K和空V。
由于篇幅原因本文只针对LruCache源码进行分析,在下一篇 懂LruCache?你必须先懂LinkedHashMap,顺带给LruCache提个建议对LinkedHashMap进行解析,欢迎浏览