Bootstrap

LRU算法的今生前世&LruCache在Android中的实现

操作系统中的起源

缓存文件置换机制

现代语言的很多特性都可以在操作系统中找到最初的原型,LRU我们最早也可以在操作系统中找到当初的设计。

“高速缓存是计算机科学中唯一重要的思想”        -Bill Joy

我们知道,无论是内存还是硬盘,又或者是我们在各自应用中用到的cache,由于大小固定,因而总会面临空间不足,而需要进行缓存置换(or替换),而替换的原则被我们称为缓存文件置换机制。

而今天聊得主题就是:最近最少未使用算法(LRU),即最久没有访问的内容作为替换对象。

页面置换算法

操作系统中,我们可以利用覆盖或交换来扩充内存。当操作系统的内存采用基本分页存储管理,即操作系统采用分页系统基础时,操作系统可以进行请求调页功能和页面置换功能,从而实现虚拟存储器的功能。选择调出页面的算法就成为页面置换算法。LRU就是其中一种。

最近最久未使用/LRU页面置换算法,选择最近最长时间未被访问过的页面予以淘汰,它认为过去一段时间内未访问过的页面,在最近的将来可能也不会被访问。该算法为每个页面设置一个访问字段,来记录页面来自上次被访问以来所经历的时间,淘汰页面时选择现有页面中值最大的予以淘汰。

访问页面70120304230321201701
物理块1777224440111
物理块200000033300
物理块31133222227
缺页否✔️✔️✔️✔️✔️✔️✔️✔️✔️✔️✔️✔️

如上表所示,进程第一次对页面2进行访问时,将最近最久未被访问的页面7置换出去。随后访问页面3时,将最近最久未使用的页面1换出。

LruCache算法

前文介绍的LRU作为一种缓存淘汰策略,应用在cache上即LruCache 算法。

LruCache 算法通过 LinkedHashMap 来实现。LinkedHashMap继承于HashMap,它使用一个双向链表来存储 Map 中的 Entry 顺序关系,对 get、put、remove 等操作,LinkedHashMap 除了要做 HashMap 做的事情,还做些调整 Entry 顺序链表的工作。

LruCache 中将 LinkedHashMap 的顺序设置为 LRU 顺序来实现 LRU 缓存,每次调用 get(也就是从内存缓存中取图片),则将该对象移到链表的尾端。 调用 put 插入新的对象也是存储在链表尾端,这样当内存缓存达到设定的最大值时,将链表头部的对象(近期最少用到的)移除。

Android中的LruCache

LruCache是Android 3.1(Api12)时引入,兼容低版本实际会使用v4包进行使用。LruCache的引入让系统在缓存不足时移除队列末尾的值,方便GC。LruCache用于内存缓存,在避免程序发生OOM和提高执行效率有着良好表现。

看一下androidx.collection包中的LruCache

public class LruCache<K, V> {
    private final LinkedHashMap<K, V> map;

    private int size;           //当前缓存的大小
    private int maxSize;        //最大可缓存的大小

    private int putCount;
    private int createCount;
    private int evictionCount;
    private int hitCount;      //命中缓存的次数
    private int missCount;     //丢失缓存的次数

 
    public LruCache(int maxSize) {
        if (maxSize <= 0) {
            throw new IllegalArgumentException("maxSize <= 0");
        }
        this.maxSize = maxSize;
        this.map = new LinkedHashMap<K, V>(0, 0.75f, true);
    }
  
  // ...
  
  }
    

LruCache的构造函数中传入的参数maxSize为可缓存的最大容量,maxSize代表了LruCache内部的LinkedHashMap可存储的最大键值对数量。

 public final V put(@NonNull K key, @NonNull V value) {
        if (key == null || value == null) {
            throw new NullPointerException("key == null || value == null");
        }

        V previous;
        synchronized (this) {
            putCount++;
            size += safeSizeOf(key, value);
            previous = map.put(key, value);
            if (previous != null) {
                size -= safeSizeOf(key, previous);
            }
        }

        if (previous != null) {
            entryRemoved(false, key, previous, value);
        }

        trimToSize(maxSize);
        return previous;
    }

LruCacheput()方法首先判断了LruCache的键或值不能为null。

在插入元素前会调用一次safeSizeOf()safeSizeOf()方法调用sizeOf()方法,sizeOf()默认返回1,一般会对它进行重写:比如LruCache存储的value是一个File,那么sizeOf返回的就应该是当前对应该key的File的大小(所有的File大小不能超过maxSize)。因为当前缓存增加了,相应的size也要完成自增长,且将对应的key-value插入到链表中( 这一步:previous = map.put(key, value))。

if (previous != null) {entryRemoved(false, key, previous, value);}这一步进行了二次检查,如果该key已经存在链表中,此时新的value覆盖后,size要减去之前的value所占用的大小。

为了保证多线程场景下size的准确性,用 synchronized (this)确保以上操作都是同步的。

如果是覆盖了旧的value,LruCache还对外提供了一个空方法entryRemoved。该方法会在一个值被remove或者put时被调用, 默认实现什么都不做。

trimToSize()方法保证了缓存不溢出:

public 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!");
                }

                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);
        }
    }

trimToSize()方法会删除最旧的一条键值对,直到缓存<=最大容量。具体来说:每插入一次元素就会被调用该方法,方法是一个无限循环,当当前缓存大小不大于最大容量就结束循环。否则取出LinkedHashMap的entrySet的头部,也就是最早被插入且最近未被访问过的键值对并删除,更新size。重复此步骤直到缓存<=最大容量。LRU缓存的实现利用了访问顺序的LinkedHashMap的特性完成。

取值的get()方法:

 public final V get(@NonNull K key) {
        if (key == null) {
            throw new NullPointerException("key == null");
        }

        V mapValue;
        synchronized (this) {
            mapValue = map.get(key);
            if (mapValue != null) {
                hitCount++;
                return mapValue;
            }
            missCount++;
        }

        V createdValue = create(key);
        if (createdValue == null) {
            return null;
        }

        synchronized (this) {
            createCount++;
            mapValue = map.put(key, createdValue);

            if (mapValue != null) {
                // There was a conflict so undo that last put
                map.put(key, mapValue);
            } else {
                size += safeSizeOf(key, createdValue);
            }
        }

        if (mapValue != null) {
            entryRemoved(false, key, createdValue, mapValue);
            return mapValue;
        } else {
            trimToSize(maxSize);
            return createdValue;
        }
    }

get()方法key值不能为null,如果map中存在与key相对应的value,则返回该value,并且缓存命中数+1。不存在,则缓存丢失数+1;如果不存在的话,会调用 V createdValue = create(key)去尝试根据该key创建一个value。create()方法默认返回null,需要自己实现。

结尾

LruCache作为一个基础的缓存策略应用在很多方面,安卓从Android3.1引入,很多的开源框架比如Glide图片缓存中,也会用到LruCache。

参考

https://zh.wikipedia.org/w/index.php

https://developer.android.com/reference/android/util/LruCache

;