目录
2、LinkedHashMap实现LRU原理(accessOrder = true)
FIFO的思想是实现一个先进先出的队列,LRU最近最久未使用。可以用双向链表linkedList来实现,同时为了兼顾查询节点时的效率,结合HashMap来实现。双向链表linkedList+HashMap的数据结构可以联想到LinkedHashMap,就不需要我们自己来实现了。LinkedHashMap存储数据是有序的,可以分为插入顺序(accessOrder = false)和访问顺序(accessOrder = true),默认为插入顺序,而且LinkedHashMap提供了删除最后一个节点的方法removeEldestEntry(Map.Entry eldest),正好可以用来实现FIFO(LinkedHashMap按插入顺序存储数据)和LRU算法(LinkedHashMap按访问顺序存储数据)。
1、HashMap原理
底层是Entry数组+链表(Entry节点的next指针)+红黑树。JDK8中,链表长度不小于8时,将链表转化为红黑树。默认无参构造函数会初始化大小为16,向集合中添加元素至集合大小的0.75倍时,会生成一个大小为原来2倍的新集合,然后重新计算元素的地址,将集合中元素插入到新集合,届时效率很低。线程不安全。(例如:put的时候导致的数据覆盖、集合扩展时(resize方法)会出现死循环)。
//HashMap的Entry结构:
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
}
2、LinkedHashMap实现LRU原理(accessOrder = true)
-
2.1 数据结构
HashMap的原理是内部维护了一个Entry数组,而LinkedHashMap在HashMap的基础上,增加了链表头节点和尾节点两个指针,增加了排序方式的标志,Entry节点增加了前后两个指针。因此LinkedHashMap的Entry节点有三个指针,一个是双向链表的前指针、一个是双向链表的后指针、一个是HashMap的hash地址重复时拉链法解决冲突的next的指针。
/*LinkedHashMap的Entry结构:*/
//双向链表头结点
transient LinkedHashMap.Entry<K,V> head;
//双向链表尾节点
transient LinkedHashMap.Entry<K,V> tail;
//是否基于访问顺序排序(默认为false即插入顺序排序)
final boolean accessOrder;
//Entry继承了HashMap的Entry,又增加了before, after两个指针
private static class Entry<K,V> extends HashMap.Entry<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, HashMap.Entry<K,V> next) {
super(hash, key, value, next);
}
}
-
2.2 put方法
如果put的是新key,则将Entry节点添加到Map中,并添加到双向链表的尾部,若initialCapacity数量已满,删除最近最久未使用的Entry节点即双向链表的头结点;若put的是已有的key,更新节点的value,并将节点删除并添加到尾部。
HashMap的put方法会生成一个节点,调用了newNode方法,而LinkedHashMap重写了此方法
/**
* 创建一个节点
* @param hash hash值
* @param key 键
* @param value 值
* @param e 下一个节点,这个是HashMap节点的属性
* @return
*/
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
//调用构造方法
LinkedHashMap.Entry<K,V> p =
new LinkedHashMap.Entry<K,V>(hash, key, value, e);
//维护链表
linkNodeLast(p);
return p;
}
/**
* 添加一个节点到末尾
* @param p 节点
*/
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
//保存尾部节点
LinkedHashMap.Entry<K,V> last = tail;
//更新尾部节点
tail = p;
//判断之前的尾部节点是否为空
if (last == null)
//之前的尾部节点为空,说明还没有数据,设置一下头节点
head = p;
else {
//说明之前已经有数据了,将新的节点作为尾部节点连接起来
p.before = last;
last.after = p;
}
}
HashMap当put一个已经存在的key时,会触发是否更新的操作,之后会调用afterNodeAccess方法,LinkedHashMap重写了此方法
/**
* accessOrder为true时,将操作的节点移到链表尾部
* @param e 节点
*/
void afterNodeAccess(Node<K,V> e) {
LinkedHashMap.Entry<K,V> last;
//accessOrder 这个参数是指在进行操作的时候,是否将操作的节点移动到链表的最后,默认false
//也就是说accessOrder为false的时候链表就是按照插入顺序维护的
//true的时候,会将最近使用的节点移动到链表最后
if (accessOrder && (last = tail) != e) {
//保存当前节点和其前置节点和后置节点
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<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
//说明该节点就是尾部节点,设置前置节点为后节点
//a == null 说明p就是尾部节点? 有点不清楚
last = b;
//统一更新尾部节点
if (last == null)
//说明只有这么一个节点
head = p;
else {
//将当前节点挂到链表末尾
p.before = last;
last.after = p;
}
//设置尾部节点
tail = p;
++modCount;
}
}
LinkedHashMap也重写了afterNodeInsertion方法
void afterNodeInsertion(boolean evict) {
LinkedHashMap.Entry<K,V> first;
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
需要注意:
removeEldestEntry方法是是否删除链表的头结点,默认为不删除,实现LRU需要覆盖此方法
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}
-
2.3 get方法
移动当前操作的节点到链表最后
public V get(Object key) {
// 调用genEntry得到Entry
Entry<K,V> e = (Entry<K,V>)getEntry(key);
if (e == null)
return null;
// 如果LinkedHashMap是访问顺序的,则get时,也需要重新排序
e.recordAccess(this);
return e.value;
}
-
2.4 remove方法
从Map和链表中删除
LinkedHashMap调用了HashMap的remove方法,重写了afterNodeRemoval方法
LinkedHashMap调用了HashMap的remove方法
重写了afterNodeRemoval方法
/**
* 删除链表中的节点
* @param e
*/
void afterNodeRemoval(Node<K,V> e) {
//获取当前节点的前置后置节点
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
//清空前置后置节点
p.before = p.after = null;
if (b == null)
//前置节点为空,说明为头节点,更新头节点为后置节点
head = a;
else
//前置节点不为空,设置前置节点的后置节点为删除节点的后置节点
b.after = a;
if (a == null)
//后置节点为空,说明为尾部节点,更新尾部节点为其前置节点
tail = b;
else
//后置节点不为空,更新后置节点的前置节点
a.before = b;
}
3、普通LRU代码实现
1、removeEldestEntry方法(是否删除元素)默认返回false,需要重写
2、通过LinkedHashMap构造函数中的参数accessOrder来指定数据存储的顺序(false为插入顺序,true为访问顺序)
//LinkedHashMap构造函数
public LinkedHashMap(int initialCapacity,
float loadFactor,
boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}
//LRU算法 (FIFO算法只需要将LinkedHashMap的第三个参数true改为false)
public class LRUCache {
private int capacity;
private LinkedHashMap<Integer, Integer> cache = new LinkedHashMap<Integer, Integer>(capacity,0.75f,true) {
@Override
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
return size() > capacity;
}
};
public LRUCache(int capacity) {
this.capacity = capacity;
}
public int get(int key) {
Integer res = cache.get(key);
return res;
}
public void put(int key, int value) {
cache.put(key, value);
}
}
4、实现一个线程安全并且可以设置过期时间的LRU缓存
-
4.1 解决安全问题
线程不安全主要是因为HashMap和LinkedHashMap都是线程不安全的,而且同时修改map和双向链表之间也会产生并发问题,所以仅仅使用线程安全的ConcurrentHashMap、ConcurrentLinkedQueue并不能解决问题,还要解决map和链表间的同步问题,最简单的方法就是在put或get时直接使用ReenTrantLock进行同步,如com.google.gson包提供的LruCache类,直接在方法上使用synchronized进行同步:
package com.google.gson;
import java.util.LinkedHashMap;
import java.util.Map.Entry;
final class LruCache<K, V> extends LinkedHashMap<K, V> implements Cache<K, V> {
private static final long serialVersionUID = 1L;
private final int maxCapacity;
public LruCache(int maxCapacity) {
super(maxCapacity, 0.7F, true);
this.maxCapacity = maxCapacity;
}
public synchronized void addElement(K key, V value) {
this.put(key, value);
}
public synchronized V getElement(K key) {
return this.get(key);
}
public synchronized V removeElement(K key) {
return this.remove(key);
}
protected boolean removeEldestEntry(Entry<K, V> entry) {
return this.size() > this.maxCapacity;
}
}
-
4.2 实现定期删除
使用ScheduledThreadPoolExecutor这种定时任务线程池来实现,ScheduledThreadPoolExecutor 使用的任务队列 DelayQueue 封装了一个 PriorityQueue,PriorityQueue 会对队列中的任务进行排序(堆排序),延迟时间最短的放在前面先被执行,如果执行所需时间相同则先提交的任务将被先执行。
具体实现就是增加一个方法,在增加缓存时(put方法中)提交延时任务。具体实现参考(https://zhuanlan.zhihu.com/p/135936339)
//过期后清除键值对
private void removeAfterExpireTime(K key, long expireTime) {
scheduledExecutorService.schedule(() -> {
//1、从map中删除
//2、从链表删除
}, expireTime, TimeUnit.MILLISECONDS);
}