文章目录
参考:
Android开发中,为了减少用户的流量使用和使APP体验更流畅,我们通常会使用缓存技术。通常来说,缓存分两级。
- 第一级,是内存缓存,它的好处是,读写非常快,缺点则是,过量地使用会使APP整体变得十分卡顿,因为运行的内存不足了,甚至引起OOM。
- 第二级,是文件缓存(File,SQLite等),文件缓存的读写效率要低于内存缓存。但是空间更加的充足。
一级缓存由于空间很有限,我们通常会为它设置一个size,当超过这个size时,缓存会将不常用的内容清掉。
Android中提供了一个方便的容器,用来处理这个缓存问题——LruCache
1. 简介
LRU 是 Least Recently Used 最近最少使用算法。
曾经,在各大缓存图片的框架没流行的时候。有一种很常用的内存缓存技术:SoftReference 和 WeakReference(软引用和弱引用)。但是走到了 Android 2.3(Level 9)时代,垃圾回收机制更倾向于回收 SoftReference 或 WeakReference 的对象。后来,又来到了 Android3.0,图片缓存在内容中,因为不知道要在是什么时候释放内存,没有策略,没用一种可以预见的场合去将其释放。这就造成了内存溢出。
2. 使用方法
- (必填)你需要提供一个缓存容量作为构造参数。
- (必填) 覆写 sizeOf 方法 ,自定义设计一条数据放进来的容量计算,如果不覆写就无法预知数据的容量,不能保证缓存容量限定在最大容量以内。
- (选填) 覆写 entryRemoved 方法 ,你可以知道最少使用的缓存被清除时的数据( evicted, key, oldValue, newVaule )。
- (记住)LruCache是线程安全的,在内部的 get、put、remove 包括 trimToSize 都是安全的(因为都上锁了)。
使用如下:
private static final float ONE_MIB = 1024 * 1024;
// 7MB
private static final int CACHE_SIZE = (int) (7 * ONE_MIB);
private LruCache<String, Bitmap> bitmapCache;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
this.bitmapCache = new LruCache<String, Bitmap>(CACHE_SIZE) {
protected int sizeOf(String key, Bitmap value) {
return value.getByteCount();
}
@Override
protected void entryRemoved(boolean evicted, String key, Bitmap oldValue, Bitmap newValue) {
}
};
}
3. 源码分析
LruCache 原理概要解析
LruCache 就是 利用 LinkedHashMap 的一个特性( accessOrder=true 基于访问顺序 )再加上对 LinkedHashMap 的数据操作上锁实现的缓存策略。
LruCache 的数据缓存是内存中的。
-
首先设置了内部 LinkedHashMap 构造参数 accessOrder=true, 实现了数据排序按照访问顺序。
-
然后在每次 LruCache.get(K key) 方法里都会调用 LinkedHashMap.get(Object key)。
-
如上述设置了 accessOrder=true 后,每次 LinkedHashMap.get(Object key) 都会进行 LinkedHashMap.afterNodeAccess(Node<K,V> e)。
-
LinkedHashMap 是双向循环链表,然后每次 LruCache.get -> LinkedHashMap.get 的数据就被放到最末尾了。
-
在 put 和 trimToSize 的方法执行下,如果发生数据量移除,会优先移除掉最前面的数据(因为最新访问的数据在尾部)。
主要的成员变量
//主要的存储成员变量
private final LinkedHashMap<K, V> map;
/**
* 缓存大小的单位。不规定元素的数量。
*/
// 已经存储的数据大小
private int size;
// 最大存储大小
private int maxSize;
// 调用put的次数
private int putCount;
// 调用create的次数
private int createCount;
// 收回的次数 (如果出现)
private int evictionCount;
// 命中的次数(取出数据的成功次数)
private int hitCount;
// 丢失的次数(取出数据的丢失次数)
private int missCount;
LruCache 的唯一构造方法
/**
* LruCache的构造方法:需要传入最大缓存个数
*/
public LruCache(int maxSize) {
if (maxSize <= 0) {
throw new IllegalArgumentException("maxSize <= 0");
}
this.maxSize = maxSize;
/*
* 初始化LinkedHashMap
* 第一个参数:initialCapacity,初始大小
* 第二个参数:loadFactor,负载因子=0.75f
* 第三个参数:accessOrder=true,基于访问顺序;accessOrder=false,基于插入顺序
*/
this.map = new LinkedHashMap<K, V>(0, 0.75f, true);
}
-
第一个参数 initialCapacity 用于初始化该 LinkedHashMap 的大小。
-
第二个参数 loadFactor,这个其实的 HashMap 里的构造参数,涉及到扩容问题,比如 HashMap 的最大容量是100,那么这里设置0.75f的话,到75容量的时候就会扩容。
-
第三个参数 accessOrder=true ,这样的话 LinkedHashMap 数据排序就会基于数据的访问顺序,从而实现了 LruCache 核心工作原理。
LruCache.get(K key)
/**
* 根据key查询缓存,如果存在于缓存或者被create方法创建了。
* 如果值返回了,那么它将被移动到双向循环链表的的尾部。
* 如果如果没有缓存的值,则返回null。
*/
public final V get(K key) {
if (key == null) {
throw new NullPointerException("key == null");
}
V mapValue;
synchronized (this) {
// LinkHashMap 如果设置按照访问顺序的话,这里每次get都会重整数据顺序
mapValue = map.get(key);
// 计算 命中次数
if (mapValue != null) {
hitCount++;
return mapValue;
}
// 计算 丢失次数
missCount++;
}
/*
* 官方解释:
* 尝试创建一个值,这可能需要很长时间,并且Map可能在create()返回的值时有所不同。如果在create()执行的时
* 候,一个冲突的值被添加到Map,我们在Map中删除这个值,释放被创造的值。
*/
V createdValue = create(key);
if (createdValue == null) {
return null;
}
/***************************
* 不覆写create方法走不到下面 *
***************************/
/*
* 正常情况走不到这里
* 走到这里的话 说明 实现了自定义的 create(K key) 逻辑
* 因为默认的 create(K key) 逻辑为null
*/
synchronized (this) {
// 记录 create 的次数
createCount++;
// 将自定义create创建的值,放入LinkedHashMap中,如果key已经存在,会返回 之前相同key 的值
mapValue = map.put(key, createdValue);
// 如果之前存在相同key的value,即有冲突。
if (mapValue != null) {
/*
* 有冲突
* 所以 撤销 刚才的 操作
* 将 之前相同key 的值 重新放回去
*/
map.put(key, mapValue);
} else {
// 拿到键值对,计算出在容量中的相对长度,然后加上
size += safeSizeOf(key, createdValue);
}
}
// 如果上面 判断出了 将要放入的值发生冲突
if (mapValue != null) {
/*
* 刚才create的值被删除了,原来的 之前相同key 的值被重新添加回去了
* 告诉 自定义 的 entryRemoved 方法
*/
entryRemoved(false, key, createdValue, mapValue);
return mapValue;
} else {
// 上面 进行了 size += 操作 所以这里要重整长度
trimToSize(maxSize);
return createdValue;
}
}
LinkedHashMap.get(Object key)
public V get(Object key) {
Node<K,V> e;
// 调用 HashMap get 方法
if ((e = getNode(hash(key), key)) == null)
return null;
// 如果设置了 LRU 策略
if (accessOrder)
// 这个方法把当前 key 移动到队尾
afterNodeAccess(e);
return e.value;
}
从上述源码中,可以看到,通过 afterNodeAccess 方法把当前访问节点移动到了队尾,其实不仅仅是 get 方法,执行 getOrDefault、compute、computeIfAbsent、computeIfPresent、merge 方法时,也会这么做,通过不断的把经常访问的节点移动到队尾,那么靠近队头的节点,自然就是很少被访问的元素了。
afterNodeAccess方法如下:
//把当前访问节点移动到了队尾
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMapEntry<K,V> last;
if (accessOrder && (last = tail) != e) {
LinkedHashMapEntry<K,V> p =
(LinkedHashMapEntry<K,V>)e, b = p.before, a = p.after;
p.after = null;
if (b == null)
head = a;
else
b.after = a;
if (a != null)
a.before = b;
else
last = b;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
tail = p;
++modCount;
}
}
LinkedHashMap 是双向循环链表,然后此次 LruCache.get -> LinkedHashMap.get 的数据就被放到最末尾了。
以上就是 LruCache 核心工作原理。
双向循环链表图如下所示:
接下来介绍 LruCache 的容量溢出策略。
LruCache.put(K key, V value)
/**
* 给对应key缓存value,该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) {
// 记录 put 的次数
putCount++;
// 拿到键值对,计算出在容量中的相对长度,然后加上
size += safeSizeOf(key, value);
/*
* 放入 key value
* 如果 之前存在key 则返回 之前key 的value
* 记录在 previous
*/
previous = map.put(key, value);
// 如果存在冲突
if (previous != null) {
// 计算出 冲突键值 在容量中的相对长度,然后减去
size -= safeSizeOf(key, previous);
}
}
// 如果上面发生冲突
if (previous != null) {
/*
* previous值被剔除了,此次添加的 value 已经作为key的 新值
* 告诉 自定义 的 entryRemoved 方法
*/
entryRemoved(false, key, previous, value);
}
trimToSize(maxSize);
return previous;
}
记住几点:
- put 开始的时候确实是把值放入 LinkedHashMap 了,不管超不超过你设定的缓存容量。
- 然后根据 safeSizeOf 方法计算 此次添加数据的容量是多少,并且加到 size 里 。
- 说到 safeSizeOf 就要讲到 sizeOf(K key, V value) 会计算出此次添加数据的大小 。
- 直到 put 要结束时,进行了 trimToSize 才判断 size 是否 大于 maxSize 然后进行最近很少访问数据的移除。
LruCache.trimToSize(int maxSize)
public void trimToSize(int maxSize) {
/*
* 这是一个死循环,
* 1.只有 扩容 的情况下能立即跳出
* 2.非扩容的情况下,map的数据会一个一个删除,直到map里没有值了,就会跳出
*/
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!");
}
// 如果是 扩容 或者 map为空了,就会中断,因为扩容不会涉及到丢弃数据的情况
if (size <= maxSize || map.isEmpty()) {
break;
}
Map.Entry<K, V> toEvict = map.entrySet().iterator().next();
key = toEvict.getKey();
value = toEvict.getValue();
map.remove(key);
// 拿到键值对,计算出在容量中的相对长度,然后减去。
size -= safeSizeOf(key, value);
// 添加一次收回次数
evictionCount++;
}
/*
* 将最后一次删除的最少访问数据回调出去
*/
entryRemoved(true, key, value, null);
}
}
简单描述:会判断之前 size 是否大于 maxSize 。是的话,直接跳出后什么也不做。不是的话,证明已经溢出容量了。由于最近经常访问的数据在最末尾。拿到一个存放 key 的 Set,然后一直一直从头开始删除,删一个判断是否溢出,直到没有溢出。
覆写 entryRemoved 的作用
/**
* 1.当被回收或者删掉时调用。该方法当value被回收释放存储空间时被remove调用
* 或者替换条目值时put调用,默认实现什么都没做。
* 2.该方法没用同步调用,如果其他线程访问缓存时,该方法也会执行。
* 3.evicted=true:如果该条目被删除空间 (表示 进行了trimToSize or remove) evicted=false:put冲突后 或 get里成功create后
* 导致
* 4.newValue!=null,那么则被put()或get()调用。
*/
protected void entryRemoved(boolean evicted, K key, V oldValue, V newValue) {
}
entryRemoved被LruCache调用的场景:
- (put) put 发生 key 冲突时被调用,evicted=false,key=此次 put 的 key,oldValue=被覆盖的冲突 value,newValue=此次 put 的 value。
- (trimToSize) trimToSize 的时候,只会被调用一次,就是最后一次被删除的最少访问数据带回来。evicted=true,key=最后一次被删除的 key,oldValue=最后一次被删除的 value,newValue=null(此次没有冲突,只是 remove)。
- (remove) remove的时候,存在对应 key,并且被成功删除后被调用。evicted=false,key=此次 put的 key,oldValue=此次删除的 value,newValue=null(此次没有冲突,只是 remove)。
- (get后半段,查询丢失后处理情景,不过建议忽略) get 的时候,正常的话不实现自定义 create 的话,代码上看 get 方法只会走一半,如果你实现了自定义的 create(K key) 方法,并且在 你 create 后的值放入 LruCache 中发生 key 冲突时被调用,evicted=false,key=此次 get 的 key,oldValue=被你自定义 create(key)后的 value,newValue=原本存在 map 里的 key-value。
4. 总结
LruCache重要的几点:
-
LruCache 是通过 LinkedHashMap 构造方法的第三个参数的 accessOrder=true 实现了 LinkedHashMap 的数据排序基于访问顺序 (最近访问的数据会在链表尾部),在容量溢出的时候,将链表头部的数据移除。从而,实现了 LRU 数据缓存机制。
-
LruCache 在内部的get、put、remove包括 trimToSize 都是安全的(因为都上锁了)。
-
LruCache 自身并没有释放内存,将 LinkedHashMap 的数据移除了,如果数据还在别的地方被引用了,还是有泄漏问题,还需要手动释放内存。
-
覆写 entryRemoved 方法能知道 LruCache 数据移除是是否发生了冲突,也可以去手动释放资源。
-
maxSize 和 sizeOf(K key, V value) 方法的覆写息息相关,必须相同单位。( 比如 maxSize 是7MB,自定义的 sizeOf 计算每个数据大小的时候必须能算出与MB之间有联系的单位 )