Picasso解析(2)-LruCache缓存分析

0.前言

上一次 Picasso解析(1)-一张图片是如何加载出来的中,我已经将整个Picasso如何解析一张网络图片的过程梳理了一遍。如果仅仅只是这样梳理一遍,那就只是为了看代码而看代码了,真正的我们还是应该学习到这些高质量代码中优美的设计与精湛的技巧。从这一篇开始,我就会逐一将我在当中学习到的地方分享出来。首先就从LruCache开始分析。

1.最近最少使用算法

LRU是Least Recently Used 最近最少使用算法,相信大学时候学过操作系统这门课的同学一定不会陌生。作为一种在内存当中存取数据的策略,LRU算法的本质是希望提高内存数据使用的命中率。核心思想就是:如果数据最近被访问过,那么将来被访问的几率也会更高。
具体一点举个例子,比如说我们有一个大小为3的内存块,现在我们有4,1,4,2,1,5,7,2八组数据要进入我们的内存块,那么我们来看一下内存块中的变化情况,在这里我们规定右边的数据是最近使用的数据
(1)4:4
(2)1:4,1
(3)4:1,4
(4)2:1,4,2
(5)1:4,2,1
(6)5:2,1,5
(7)7:1,5,7
(8)2:5,7,2
在这里我们可以很清晰地看出来,在LRU算法中,最近使用过的数据总是在队列的前面,总是淘汰处于队列中最末尾的数据。

2.自己动手实现LRUCache

在这里我用一个很简单很暴力的写法来演示一下LRU算法的原理:

public class LRUDemo {
    public static void main(String[] args) {
        LRU lru = new LRU(3);
        int[] arr = {4, 1, 4, 2, 1, 5, 7, 2};
        for (int i = 0; i < arr.length; i++) {
            lru.set(arr[i]);
            lru.traversal();
            System.out.println();
        }
    }
}

class LRU {
    private Queue mLRUList;
    private int mMaxSize;
    private int mCurrentSize;

    public LRU(int maxSize) {
        mLRUList = new LinkedList<>();
        mMaxSize = maxSize;
        mCurrentSize = 0;
    }

    public void set(Integer data) {
        isExist(data);

        if (mCurrentSize >= mMaxSize) {
            mLRUList.poll();
        }

        mCurrentSize++;
        mLRUList.add(data);
    }

    public void isExist(int data) {
        if(mLRUList.remove(data)) {
            mCurrentSize--;
        }
    }

    public void traversal() {
        for (Integer i : mLRUList) {
            System.out.print(i + " ");
        }
    }
}

运行结果:
Picasso解析(2)-LruCache缓存分析_第1张图片

在这里,我采用了LinkedList来模拟一个大小为3的队列,用了一种很暴力的方法,只要发现队列中存在重复的元素,就直接删除掉,然后将新的数据添加到队列的头部,这样就可以保证队列从头部到尾部依次是最近使用过的数据了。这里只是为了演示LRU的原理,这种写法当然是不提倡的。

3.Picasso中LRUCache的实现原理

前面主要针对LRU算法的一些原理进行了解释,接下来才是本文的核心部分,关于Picasso源码中的LRUCache实现原理的分析。

其实在android.util包中也有为我们提供LRUCache算法的实现,但是Picasso的作者还是自己实现了一个。我对比着看过了这两种LRUCache代码的实现,核心的实现思路都是一样。所以这里的分析以Picasso中的LRUCache为准。

(1).构造方法

  public LruCache(@NonNull Context context) {
    this(Utils.calculateMemoryCacheSize(context));
  }

  public LruCache(int maxSize) {
    if (maxSize <= 0) {
      throw new IllegalArgumentException("Max size must be positive.");
    }
    this.maxSize = maxSize;
    this.map = new LinkedHashMap(0, 0.75f, true);
  }

第一种构造方法是通过Utils方法里提供的计算内存方法来指定大小,其中在Picasso中需要的内存是当前应用内存的七分之一,也就是15%左右,具体为什么是七分之一我并不知道原因,个人认为应该是JakeWharton大神经过反复实践得出的最合理分配,所以我们也就默认这么大了。在第二个构造方法中,我们可以看到该方法初始化了一个LinkedMap成员变量用来存储图片。而这个LinkedMap的构造方法则与我们平常所使用的不太一样。我特意翻看了一下文档:
Picasso解析(2)-LruCache缓存分析_第2张图片

第一个参数指定的是初始化容器大小。第二个参数是加载因子,这里传入的是0.75f。而第三个参数则是指定排序方法,如果为true,则是按照最常访问来进行排序,如果为false,则是按照插入时间来进行排序,在这里指定为true也很符合LRU算法的理念。所以我们重点来关注一下第二个参数,顺着构造方法进去阅读一下源码吧:

    public LinkedHashMap(int initialCapacity,
                         float loadFactor,
                         boolean accessOrder) {
        super(initialCapacity, loadFactor);
        this.accessOrder = accessOrder;
    }


public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY) {
            initialCapacity = MAXIMUM_CAPACITY;
        } else if (initialCapacity < DEFAULT_INITIAL_CAPACITY) {
            initialCapacity = DEFAULT_INITIAL_CAPACITY;
        }

        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        // Android-Note: We always use the default load factor of 0.75f.

        // This might appear wrong but it's just awkward design. We always call
        // inflateTable() when table == EMPTY_TABLE. That method will take "threshold"
        // to mean "capacity" and then replace it with the real threshold (i.e, multiplied with
        // the load factor).
        threshold = initialCapacity;
        init();
    }

我们看到loadFactor参数居然除了简单判断了一下以外完全没有用到,google的程序员们非常善意的给我们留下了note,大概意思就是说我们总是使用默认值0.75。但还是令我感到一脸懵逼,于是我就到JAVA的官方文档里去看,发现了如下一段解释:

As a general rule, the default load factor (.75) offers a good tradeoff between time and space costs. Higher values decrease the space overhead but increase the lookup cost (reflected in most of the operations of the HashMap class, including get and put). The expected number of entries in the map and its load factor should be taken into account when setting its initial capacity, so as to minimize the number of rehash operations. If the initial capacity is greater than the maximum number of entries divided by the load factor, no rehash operations will ever occur.

大概的意思是说,0.75这个值是权衡时间与空间利弊以后的一个最佳值,如果高于这个值,会节省一些空间,但会影响时间效率。我们再来看看Android源码中HashMap的一段注解:

    // Android-Note: We always use a load factor of 0.75 and ignore any explicitly
    // selected values.
    final float loadFactor = DEFAULT_LOAD_FACTOR;

google的大神们直接认准了0.75这个值,就算你传参传了个其他值也没用,所以难怪我们在看源码的时候没有看到任何的赋值操作,而只是做了一个简单的判断而已。当然,至于这个值为什么要这样设定,时间与空间上是如何平衡的,不在本文的讨论范围之内,等以后分析JAVA集合框架的时候在仔细研究。

(2).set方法

@Override
    public void set(@NonNull String key, @NonNull Bitmap bitmap) {
        if (key == null || bitmap == null) {
            throw new NullPointerException("key == null || bitmap == null");
        }

        int addedSize = Utils.getBitmapBytes(bitmap);
        if (addedSize > maxSize) {
            return;
        }

        synchronized (this) {
            putCount++;
            size += addedSize;
            Bitmap previous = map.put(key, bitmap);
            if (previous != null) {
                size -= Utils.getBitmapBytes(previous);
            }
        }

        trimToSize(maxSize);
    }

我们来看看核心的set方法。首先做了一些简单的判断,然后开始计算Bitmap的大小,如果说图片的大小直接就比我们整个缓存的大小都大的话,那就直接return掉了,如果不是的话,就进入一段同步代码块(保证多线程加载图片情况下的线程安全)。在这段同步代码块里,首先会将bitmap put进LinkedHashmap中,当Map中存在同样key的value时,put方法就会返回给旧的值,同时也会根据添加和替换的结果来计算当前内存中的size。同步代码块的代码执行完毕后会做一个trimToSize操作:


private void trimToSize(int maxSize) {
        while (true) {
            String key;
            Bitmap 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 toEvict = map.entrySet().iterator()
                        .next();
                key = toEvict.getKey();
                value = toEvict.getValue();
                map.remove(key);
                size -= Utils.getBitmapBytes(value);
                evictionCount++;
            }
        }
    }

一上来我们就发现这是一个无限循环,该方法主要就是根据当前的size大小来进行一个判断。如果当前的size没有超过maxSize的话,循环就会中指,如果size大于maxSize的话,就会删除掉最久没有使用的图片,并一直到size最终小于maxSize时终止。那么,在这里为什么我们通map.entrySet().iterator().next()方法就能拿到最久没有使用的对象呢?这就涉及到之前构造方法里传参的第三个参数accessOrder,当它为true的时候,就是按照访问的顺序来进行排序,所以我们才能顺利地拿到这个需要删除的对象,其中具体的原理也是等到以后分析JAVA集合框架的时候再说吧。

4.总结

至此LRUCache缓存类的原理就分析完毕了,核心的思想便是一个最近最少使用算法。我们可以看到JakeWharton大神巧妙的利用到了LinkedHashmap中的一些高级特性来实现了一个LRU算法,为我们以后设计诸如此类算法策略时提供了一些新的思路。

你可能感兴趣的:(Picasso源码分)