LRUCache原理及HashMap LinkedHashMap内部实现原理

时间:2021-08-29 19:11:23

LRUCache概述

在开发android应用时,加载图片我们都会加上二级缓存,一级是内存缓存,二级是硬盘缓存。在内存缓存中我们一般采用SDK提供给我们的LRUCahce。
LRU(least recently uses最近最少使用)是一种页面置换算法,最近最少使用的被替换掉。LRU算法是以时间轴为依据进行替换,而不是使用频率为依据替换。
常用的页面置换算法还有:
LFU(least frequently used 最不经常使用)是以单位时间内使用频率为依据进行替换,使用频率低的被替换掉。
FIFO(first in fist out 先进先出)按顺序进行替换。

LRUCache实现原理

先看一下LruCache的构造方法

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内部维护了一个LinkedHashMap,LinkedHashMap继承自HashMap,所以我们先来研究下HashMap的实现原理。

HashMap的实现原理

还是先看下构造方法

public HashMap(int capacity) {
if (capacity < 0) {
throw new IllegalArgumentException("Capacity: " + capacity);
}

if (capacity == 0) {
@SuppressWarnings("unchecked")
HashMapEntry<K, V>[] tab = (HashMapEntry<K, V>[]) EMPTY_TABLE;
table = tab;
threshold = -1; // Forces first put() to replace EMPTY_TABLE
return;
}

if (capacity < MINIMUM_CAPACITY) {
capacity = MINIMUM_CAPACITY;
} else if (capacity > MAXIMUM_CAPACITY) {
capacity = MAXIMUM_CAPACITY;
} else {
capacity = Collections.roundUpToPowerOfTwo(capacity);
}
makeTable(capacity);
}

其中 Collections.roundUpToPowerOfTwo(capacity); 方法是得到一个不小于所给数的2^n的数,就是说假如传入参数10,那么返回不小于10的2的n次方数就是16。具体的可以看这里
看下makeTable 方法

private HashMapEntry<K, V>[] makeTable(int newCapacity) {
@SuppressWarnings("unchecked") HashMapEntry<K, V>[] newTable
= (HashMapEntry<K, V>[]) new HashMapEntry[newCapacity];
table = newTable;
threshold = (newCapacity >> 1) + (newCapacity >> 2); // 3/4 capacity
return newTable;
}

可以看到,HashMap的构造方法中生成了一个大小为2^n的HashMapEntry的数组。我们看下HashMapEntry 的结构

static class HashMapEntry<K, V> implements Entry<K, V> {
final K key;
V value;
final int hash;
HashMapEntry<K, V> next;

其中有map中的key、value还有根据key得到的hash值和它的后继引用next。其中key value 都是Object
既然是基于Map的,那么我们看下HashMapput方法和get方法:

@Override public V put(K key, V value) {
if (key == null) {
//如果key为null调用该方法处理,需要注意的是HashMap允许key和value为null
return putValueForNullKey(value);
}
//根据key得到hash值
int hash = Collections.secondaryHash(key);
HashMapEntry<K, V>[] tab = table;
//根据hash值和内部维护数组的长度得到索引
int index = hash & (tab.length - 1);
//如果index处的e不为null,通过循环不断遍历e的下一个元素
for (HashMapEntry<K, V> e = tab[index]; e != null; e = e.next) {
// 找到指定 key 与需要放入的 key 相等(hash 值相同
// 并且key的值相等)说明map中已经存在该数据,那么执行替换
if (e.hash == hash && key.equals(e.key)) {
//该方法是空实现,注意LinkedHashMap中会用到
preModify(e);
V oldValue = e.value;
e.value = value;
return oldValue;
}
}

// No entry for (non-null) key is present; create one
//index处的Entry为null,说明此处还没有数据Entry
modCount++;
if (size++ > threshold) {
//大于数组的长度,扩容 大小x2
tab = doubleCapacity();
index = hash & (tab.length - 1);
}
addNewEntry(key, value, hash, index);
return null;
}

需要说明的是这句

int index = hash & (tab.length - 1);

首先HashMap 采用一种所谓的Hash 算法来决定每个元素的存储位置。 Java中Object提供一个hashCode()方法,也就是说每个对象都有hashCode()方法得到一个唯一的hash值
上面得到index的方法非常巧妙,它总是通过 h &(table.length -1) 来得到该对象的保存位置——而 HashMap 底层数组的长度总是 2 的 n 次方,当 length 总是 2 的倍数时,h & (length-1) 将是一个非常巧妙的设计:假设 h=5,length=16, 那么 h & length - 1 将得到 5;如果 h=6,length=16, 那么 h & length - 1 将得到 6 ……如果 h=15,length=16, 那么 h & length - 1 将得到 15;但是当 h=16 时 , length=16 时,那么 h & length - 1 将得到 0 了;当 h=17 时 , length=16 时,那么 h & length - 1 将得到 1 了……这样保证计算得到的索引值总是位于 table 数组的索引之内。
也就是说该方法返回的值index总是在0–table.length之间循环。也就是说虽然key的hash值不同,但是他们在HashMap中的存储位置是一样的。你可能有点疑惑,我们马上就会找到答案。
那么put方法会把key-value放入HashMap,首先根据keyhash值得到在HashMap中的存储位置,如果该位置上有数据,并且key的值相等,那么直接执行修改value的操作。如果不想的会执行addNewEntry(key, value, hash, index);方法。这个方法是干什么的呢,我们来看下

void addNewEntry(K key, V value, int hash, int index) {
table[index] = new HashMapEntry<K, V>(key, value, hash, table[index]);
}

代码很简单,生成一个新的Entry,并且把key、value、hash相应赋值,然后最重要的是把HashMapindex位置的Entry的引用付给了新Entrynext变量。也就是说在这里形成了一个Entry链,并且新的Entry位于Entry链的头部,然后把index的位置上的值更改成了新生成的Entry
HashMapget方法跟put方法的逻辑基本是一致的,感兴趣的读者可以自己查阅。
那么分析到这里我们也就理解了HashMap的基本结构,用一张图表示
LRUCache原理及HashMap LinkedHashMap内部实现原理
可以看到HashMap中维护了一个数组,并且先用数组进行定位查找,然后用链表维护数组中同一位置上的Entry。所以说HashMap效率高不是没有道理的。

LinkedHashMap的实现原理

同样的先看下构造方法:

    public LinkedHashMap(
int initialCapacity, float loadFactor, boolean accessOrder) {
super(initialCapacity, loadFactor);
header = new LinkedEntry<K, V>();
this.accessOrder = accessOrder;
}

在LruCache中是这样使用的:
this.map = new LinkedHashMap<K, V>(0, 0.75f, true);
注意accessOrder的值为true,
首先调用父类也就是HashMap的构造方法生成一个数组,然后new了一个LinkedEntry做为header,看下LinkedEntry结构

    static class LinkedEntry<K, V> extends HashMapEntry<K, V> {
LinkedEntry<K, V> nxt;
LinkedEntry<K, V> prv;

/** Create the header entry */
LinkedEntry() {
super(null, null, 0, null);
nxt = prv = this;
}
/** Create a normal entry */
LinkedEntry(K key, V value, int hash, HashMapEntry<K, V> next,
LinkedEntry<K, V> nxt, LinkedEntry<K, V> prv) {
super(key, value, hash, next);
this.nxt = nxt;
this.prv = prv;
}
}

可以看到它继承自HashMap中的HashMapEntry,那么其中的数据就有: key、value、hash、next、nxt、prv。
可以看到LinkedHashMapHashMap的基础上又增加了双向链表。也就是说LinkedHashMap的结构是数组+单向链表+双向链表。
那么这个双向链表是做什么用的呢。我们继续分析。
还记得HashMap中的preModify方法吗,LinkedHashMap实现了该方法用于put时替换index位置已经存在并且key值相等的节点:

    @Override void preModify(HashMapEntry<K, V> e) {
if (accessOrder) {
// accessOrder为true
makeTail((LinkedEntry<K, V>) e);
}
}
    private void makeTail(LinkedEntry<K, V> e) {
// Unlink e
e.prv.nxt = e.nxt;
e.nxt.prv = e.prv;

// Relink e as tail
LinkedEntry<K, V> header = this.header;
LinkedEntry<K, V> oldTail = header.prv;
e.nxt = header;
e.prv = oldTail;
oldTail.nxt = header.prv = e;
modCount++;
}

可以看到首先把e从双向链表中移除,然后放到链表的最尾端。可以看到其中的链表不仅是双向的而且是循环的。联系到HashMap中的put中的的操作,这一步只是修改value值并且更改在双向循环链表中的位置。
再看下LinkedHashMap中的addNewEntry方法:

    @Override void addNewEntry(K key, V value, int hash, int index) {
LinkedEntry<K, V> header = this.header;

// Remove eldest entry if instructed to do so.
LinkedEntry<K, V> eldest = header.nxt;
if (eldest != header && removeEldestEntry(eldest)) {
remove(eldest.key);
}

// Create new entry, link it on to list, and put it into table
LinkedEntry<K, V> oldTail = header.prv;
LinkedEntry<K, V> newTail = new LinkedEntry<K,V>(
key, value, hash, table[index], header, oldTail);
table[index] = oldTail.nxt = header.prv = newTail;
}

做的事情是生成一个新的LinkedEntry,把它的引用放到双向循环列表的尾部,并且把它的引用赋值到数组中相应位置上的值。
再看下get方法

    @Override public V get(Object key) {
/*
* This method is overridden to eliminate the need for a polymorphic
* invocation in superclass at the expense of code duplication.
*/
if (key == null) {
HashMapEntry<K, V> e = entryForNullKey;
if (e == null)
return null;
if (accessOrder)
makeTail((LinkedEntry<K, V>) e);
return e.value;
}

int hash = Collections.secondaryHash(key);
HashMapEntry<K, V>[] tab = table;
for (HashMapEntry<K, V> e = tab[hash & (tab.length - 1)];
e != null; e = e.next) {
K eKey = e.key;
if (eKey == key || (e.hash == hash && key.equals(eKey))) {
if (accessOrder)
makeTail((LinkedEntry<K, V>) e);
return e.value;
}
}
return null;
}

逻辑跟HashMap基本一样,根据index在数组中定位,然后在单向列表中查找。也就是不管我们在LinkedHashMap中执行get方法或者put方法,最后操作的那个entry都是插到双向链表的尾部。那么头部的nxt的那个entry也就是最久未使用的entry依据这个特性我们就可以实现LRU实现缓存。
依据看一张图
LRUCache原理及HashMap LinkedHashMap内部实现原理
然而分析道这里实际上并没有用到双向循环列表,那么这个双向循环列表到底是干什么用的呢?
我们看下LruCache中的put方法

    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 += 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;
}
    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方法中根据LinkedHashMapentrySet得到iterator,然后执行next方法,最后执行的是LinkedHashMap中的这些代码

 LinkedEntry<K, V> next = header.nxt;
LinkedEntry<K, V> lastReturned = null;
int expectedModCount = modCount;

public final boolean hasNext() {
return next != header;
}

final LinkedEntry<K, V> nextEntry() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
LinkedEntry<K, V> e = next;
if (e == header)
throw new NoSuchElementException();
next = e.nxt;
return lastReturned = e;
}

可以看到返回的是header.nxt,也就是双向循环链表中的第一个entry,那么也就是最久未使用的那个entry

总结

到此LruCache的基本原理我们都分析清楚了,并且梳理了HashMap和LinkedHashMap的内部实现原理,虽然我们在开发项目的时候对基本数据结构的内部原理不太关注,但是我们也应该学习其内部原理。毕竟基础才是最重要的。说到HashMap又想到了androidSDK中的SparseArray可以替代key为Integer类型的HashMap,感性却的可以看这篇文章