Android LruCache 缓存机制实现原理

通过使用 LruCache, 查看 LinkedHashMap 源码, 分析 LRU 算法的具体实现细节.

先来一张分析图

LRU 算法描述

当序列达到设置的内存上限时, 丢弃序列中最近最少使用的元素.

LruCache

Android SDK 提供的使用了(Least Recently Used)最近最少使用算法的缓存类.

编写一个 LruCache, 用于缓存 Integer.

public class IntegerCache extends LruCache<String, Integer> {
    public IntegerCache(int maxSize) {
        super(maxSize);
    }

    @Override
    protected int sizeOf(String key, Integer value) {
        return Integer.SIZE;
    }
}
// 最大容量为 4 个 Integer
IntegerCache ca = new IntegerCache(4 * Integer.SIZE)
ca.put("1", 1);
ca.put("2", 2);
ca.put("3", 3);
ca.put("4", 4);
ca.get("4");
ca.put("5", 5);
ca.put("4", 4);
ca.put("6", 6);

缓存中内容:

{1=1}                // put 1
{1=1, 2=2}           // put 2
{1=1, 2=2, 3=3}      // put 3
{1=1, 2=2, 3=3, 4=4} // put 4
---
{1=1, 2=2, 3=3, 4=4} // get 4
{2=2, 3=3, 4=4, 5=5} // put 5
{2=2, 3=3, 5=5, 4=4} // put 4
{3=3, 5=5, 4=4, 6=6} // put 6

可见, 每次的 getput 操作, 都会造成序列中的重排序, 最近使用的元素在末尾, 最近最少使用的元素在头部, 当容量超过限制时会移出最近最少使用的元素.

LruCache 的构造

public class LruCache<K, V> {
    // 构造时就初始化的一个 LinkedHashMap
    private final LinkedHashMap<K, V> map;

    private int size;          /* 记录当前缓存占用的内存大小 */
    private int maxSize;       /* 最多能缓存的内存大小 */

    private int putCount;      /* 记录 put 调用的次数 */
    private int createCount;   /* 记录 create 调用的次数 */
    private int evictionCount; /* 记录被丢弃的对象个数 */
    private int hitCount;      /* 记录调用 get 时,缓存命中的次数 */
    private int missCount;     /* 记录调用 get 时,缓存未命中的次数 */

    public LruCache(int maxSize) {
        if (maxSize <= 0) {
            throw new IllegalArgumentException("maxSize <= 0");
        }
        this.maxSize = maxSize;
        // 初始容量为0, 扩容系数为 0.75, 排序模式: true 表示按访问排序, false 表示按插入排序, SDK 实现里固定为 ture
        this.map = new LinkedHashMap<K, V>(0, 0.75f, true);
    }

LruCache 插入元素

public final V put(K key, V value) {
    V previous;
    synchronized (this) {
        putCount++;
        // 内存占用记录增加
        size += safeSizeOf(key, value);
        // 存入新的值, 并获取 key 对应的旧值
        previous = map.put(key, value);
        if (previous != null) {
            // 如果旧值存在, 就减去对应内存
            size -= safeSizeOf(key, previous);
        }
    }

    // 如果 size > maxSize, 就执行丢弃元素, 裁剪内存操作
    trimToSize(maxSize);
    return previous;
}

LurCache 获取缓存

public final V get(K key) {
    V mapValue;
    synchronized (this) {
        // 从缓存中获取 key 对应的 value, 如果存在就直接返回
        mapValue = map.get(key);
        if (mapValue != null) {
            hitCount++;
            return mapValue;
        }
        missCount++;
    }

    // 如果缓存中没有, 就尝试创建一个对应对象, 该方法由子类实现, 可以返回 null
    V createdValue = create(key);
    if (createdValue == null) {
        return null;
    }

    // 如果子类 create 返回了非 null 对象, 就把这个对象返回, 并插入到缓存中
    synchronized (this) {
        createCount++;
        mapValue = map.put(key, createdValue);
        // 上面 get 时得到了 null 才会走到这, 怎么在插入时旧值又跑出来了 ?
        if (mapValue != null) {
            // 这里应该是避免多线程访问时, 在 get 获取为 null 之后, 其他线程插入了对应的值, 所以这里把其他线程插入的值还原回去
            map.put(key, mapValue);
        } else {
            // 如果没有其他插入, 就把新创建的内存占用记账
            size += safeSizeOf(key, createdValue);
        }
    }
    ...
}

以上就是 LruCache 里主要的方法了, 看完也没发现与 LRU 算法有关的东西, 那 LRU 的具体实现肯定就在 LinkedHashMap 里了.

LinkedHashMap 的实现

  • 内部数据结构: 双向链表
// LinkedHashMap 的节点数据结构, 继承自 HashMap.Node
static class LinkedHashMapEntry<K,V> extends HashMap.Node<K,V> {
    LinkedHashMapEntry<K,V> before, after;
    LinkedHashMapEntry(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }
}

构造

public LinkedHashMap(int initialCapacity,
                     float loadFactor,
                     boolean accessOrder) {
    super(initialCapacity, loadFactor);
    // accessOrder 决定内部的排序顺序
    this.accessOrder = accessOrder;
}

获取操作

public V get(Object key) {
    Node<K,V> e;
    // 调用父类 HashMap 的方法
    if ((e = getNode(hash(key), key)) == null)
        return null;
    // 如果按访问顺序排序为 ture, 则进行重排序
    if (accessOrder)
        // 将 e 移动到最后
        afterNodeAccess(e);
    return e.value;
}

可以看到, 重点就是 afterNodeAccess 这个方法.

访问 node 之后的排序操作

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, /* p 指向当前节点 e */
        b = p.before,   /* b 指向前一个节点 */
        a = p.after;    /* a 指向后一个节点 */
        p.after = null; /* 当前节点 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;
    }
}

Case 1: 访问元素的前后存在元素

初始状态 移动指向 最终结果
image
image
image

Case 1.2: 访问元素的前后存在多个元素

初始状态 移动指向 最终结果
image
image
image

Case 2: 访问元素的后面存在元素, 前面不存在

初始状态 移动指向 最终结果
image
image
image

Case 3: 访问元素的前面存在元素, 后面不存在

这种 case 不会做排序操作, 因为元素已经位于链表尾部了.


在访问元素之后, 通过 afterNodeAccess 排序之后, 被访问的元素就移动到了链表的尾部.

插入操作

LinkedHashMap 的 put 操作是直接调用父类 HashMap 的, HashMap 的 put 操作之后, 被插入的元素将会位于链表的尾部, 然后会调用 afterNodeInsertion, 该方法在 LinkedHashMap 中的实现:

void afterNodeInsertion(boolean evict) { // possibly remove eldest
    LinkedHashMapEntry<K,V> first;
    // 如果 removeEldestEntry 为 true, 则移出头部的元素
    // LinkedHashMap 中 removeEldestEntry 默认返回 false
    if (evict && (first = head) != null && removeEldestEntry(first)) {
        K key = first.key;
        removeNode(hash(key), key, null, false, true);
    }
}

由于 LinkedHashMapremoveEldestEntry 默认返回 false, 所以 LinkedHashMap 的插入操作, 默认不会移出元素, 移出元素的操作实际在 LruCache 中的 trimToSize 实现.

在获取和插入之后, LinkedHashMap 中的元素排列就会是: 最近最多使用的位于尾部, 最近最少使用的位于头部.

LruCache 的 trimToSize

trimToSize 目的在于当缓存大于设置的最大内存时, 会移出最近最少使用到的元素(在 LinkedHashMap 中就是头部的元素):

androidxref 上的源码实现:

public void trimToSize(int maxSize) {
    while (true) {
        K key;
        V value;
        synchronized (this) {

            if (size <= maxSize) {
                break;
            }

            // 该方法会返回 LinkedHashMap 的头节点
            Map.Entry<K, V> toEvict = map.eldest();
            if (toEvict == null) {
                break;
            }

            key = toEvict.getKey();
            value = toEvict.getValue();
            // 移出这个节点
            map.remove(key);
            size -= safeSizeOf(key, value);
            evictionCount++;
        }

        entryRemoved(true, key, value, null);
    }
}

总结

  • Android 提供的 LruCache 基于 LinkedHashMap 实现, 利用 LinkedHashMap 会在每次访问元素之后, 将元素移动到序列末尾的特点, 保证了最近最多使用的元素位于尾部, 最近最少使用的元素位于头部.
    当缓存占用达到设置的上限时, LruCache 就会移出 LinkedHashMap 中的头节点.

  • LinkedHashMap 扩展 HashMap, 实现了一套双向链表机制, 保证了在元素的移动上和元素的查找上的时间复杂度都为 O(1).

😁 原文链接

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容