1、前序
废话就不多了,博主默认大家都已经知道什么是LRU算法了,且都知道了JDK中是有一个LinkedHashMap容器,可以稍加继承改造下就会很容易的实现一个LRU机制的缓存容器;
本篇的重点其实不在JDK自带的LinkedHashMap容器上进行扩展,而是重点讲它实现LRU算法的思路(这个功能很隐蔽,一般不看源码不跟代码,根本就不知道它除了节点的插入和访问有序外,还可以实现满容后,再put元素,移除头节点的功能,注意是移除头节点,不是网上大多数人想当然的写的是移除尾节点!!!!,一看就是照搬过来,自己没有亲自跟源码的,简直就是误导人!),然后借鉴它的思路,我们通过手写代码的方式,来实现一个我们自己的LRUCache。
2、LinkedHashMap
2.1 构造函数
总过5个构造器,我们常用的就是无参默认构造器和带容量参数的构造器,即图中黄色圈框的从上到下数,2和3,最不常用的就是5,没说错吧。
接下来,我们重点看下第5个构造器:
public LinkedHashMap(int initialCapacity,float loadFactor,boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}
重点看下第三个参数,accessOrder,如果你知道这个参数的意思,也调过这个参数设置为true时(默认false,按插入的顺序访问,也就是你put进去数据是什么顺序,你最终访问的就是什么顺序,不会因为你访问了某个数据而导致数据的顺序发生了改变,这个有点不太直白,晦涩难懂,没关系,往下看,相信你自己,你会恍然大悟的!)对LinkedHashMap添加数据的影响时,可以跳过下面的内容,如果没有用过这个参数的话,那就跟着我一起认识下,这个参数还是很关键的,我把它比作是LinkedHashMap开启LRU算法的开关,不相信是吧,来,往下看。
2.2 看下面一段代码,请说出输出结果
public static void main(String[] args) {
Map<String,Integer> link = new LinkedHashMap<>(6);
link.put("a",1);
link.put("b",2);
link.put("c",3);
link.put("d",4);
System.out.println(link);
link.get("b");
System.out.println(link);
}
A. 两个都是 {a=1, b=2, c=3, d=4}
B. 第一个是{a=1, b=2, c=3, d=4},第二个是{a=1, c=3, d=4, b=2}
答案选:A
2.3 再看下面一段代码,请说出输出结果
public static void main(String[] args) {
/**第三个参数 accessOrder,访问顺序,如果true的话, 基于访问顺序来,否则,基于插入顺序*/
Map<String,Integer> link = new LinkedHashMap<>(6,0.75f,true);
link.put("a",1);
link.put("b",2);
link.put("c",3);
link.put("d",4);
System.out.println(link);
link.get("b");
System.out.println(link);
}
A. 两个都是 {a=1, b=2, c=3, d=4}
B. 第一个是{a=1, b=2, c=3, d=4},第二个是{a=1, c=3, d=4, b=2}
答案选:B
2.4 分析一下accessOrder这个参数会在哪里使用
上面两种方式虽然运行一下代码就知道答案了,但是,but!如果你仅停留在IDEA给你输出的答案, 自己不去想为什么这样的话,显然是说过不去的,作为一个程序猿,一个敢于说我精通Java的程序猿(吹一下还是允许的),是不允许这种一探究竟,"手撕源码"的机会从手中划走的。
来,定位下,在哪使用的,看下面:
圈出来的,如果大家看不懂是怎么实现的话,我来补个图说明下:
除了访问key的时候会牵扯到节点的尾移法,当然put、merage等只要是节点的操作动作都会涉及到节点的尾移法。
其他略
2.5 分析完accessOrder参数的影响后,思考下
LRU算法的理念就是将数据缓存容器中最近最少使用的元素给移除掉,想想我们刚才分析了LinkedHashMap的accessOrder参数的作用和对当前链表的节点的顺序影响后,简直细思极恐啊,LinkedHashMap这不就是把最近有在访问和使用到的节点移动到链表的尾部吗,反过来,那些待在链表头的节点不就是LRU(Least Recently Used)节点吗?如果LinkedHashMap提供当容器的容量(capacity)满了后,再put节点会触发remove头节点的方法,那不就是彻彻底底实现了一个基于LRU机制的且有序的容器了吗?
有这个方法吗?还真有,很隐秘,不看源码的话,你都不知道还有这个方法!!!
先不要管这个返回值是true还是false,我们先来看一下,它是在哪被调用的
2.6 分析removeEldestEntry(Map.Entry<K,V> eldest)方法
很明显, 当evict=true,头结点不为空,且removeEldestEntry头节点成功时(注意移除的是头节点(first),移不移除头结点,该方法的返回值参与了决策),会触发removeNode方法,这个方法想都不要想,就是干掉头结点,头结点是什么,上面说过了,就是LRU节点!!!
我们再来看下,这个afterNodeInsertion(boolean evict)会在哪里调用,
当你在LinkedHashMap中搜索它是在哪调用时,你会发现,只有方法的实现,没有方法的调用的入口处。
既然LinkedHashMap中没有,我们就去他的父类HashMap中找
我们追踪到这里,赫然发现下面这段注释,大意就是这些方法是为了LinkedHashMap专门定义的,为了其进行相关动作的操作时回调用的
Callbacks to allow LinkedHashMap post-actions
我们直奔主题,找到afterNodeInserting方法的调用处(共有5处)
其实到这里,我们已经知道如何基于LinkedHashMap来实现一个我们自己的LRU缓存了,如果你还没想明白要怎么实现的话,那上面两点(accessOrder参数和removeEldestEntry方法)我算是白讲那么多了,废话不多说,比葫芦画瓢,我们先看一下其他我们常用的第三方包里面有没有实现LRU算法的缓存容器:
随便整开一个,还是spring模块中带的瞧一瞧
3、基于JDK现有LinkedHashMap实现LRU缓存
我们模仿一个还不行吗,来,上代码
精简版:
public class LRUCache extends LinkedHashMap<String,Integer>{
private int maxSize;
public LRUCache(int maxSize){
super(maxSize,0.75f,true);
this.maxSize = maxSize;
}
@Override
public Integer get(Object key) {
return super.getOrDefault(key,-1);
}
@Override
protected boolean removeEldestEntry(Map.Entry<String, Integer> eldest) {
return size() > maxSize;
}
}
注释+测试版:
package com.appleyk.leetcode.LRU算法实现;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* <p>基于JDK现有的LinkedHashMap类,写一个LRU(最近最少使用)机制的缓存容器</p>
*
* @author appleyk
* @version V.0.1.1
* @blob https://blog.csdn.net/appleyk
* @date created on 13:03 2020/10/13
*/
public class LRUCache extends LinkedHashMap<String,Integer>{
private int maxSize;
public LRUCache(int maxSize){
super(maxSize,0.75f,true);
// 注意,这个赋值一定不要落下,否则你会发现put的时候,一直put空(因为,插入的节点总是会移除,卖个关子)
this.maxSize = maxSize;
}
@Override
public Integer get(Object key) {
// 当取不到key对应的节点时,返回默认值-1
return super.getOrDefault(key,-1);
}
/**
* 这个方法在执行put操作时,会触发链表中的头结点的删除
* 调用链:
* 1、HashMap#put(k,v)
* 2、HashMap#putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict)
* 3、HashMap#afterNodeInsertion(evict);
* 4、HashMap中为LinkedHashMap预留了三个方法
* 备注:下面的注释很明确了,就是专门预留给LinkedHashMap进行回调的
* // Callbacks to allow LinkedHashMap post-actions
* void afterNodeAccess(Node<K,V> p) { // 在节点操作之后调用,方法作用:move node to last}
* void afterNodeInsertion(boolean evict) { // 在节点插入之后调用,方法作用:possibly remove eldest}
* void afterNodeRemoval(Node<K,V> p) { // 在节点移除之后调用,方法作用:消除当前节点的链接}
* 其中afterNodeInsertion(evict)在LinkedHashMap中被实现,实现方式如下:
* LinkedHashMap#void afterNodeInsertion(boolean evict) {// possibly remove eldest}
* 注释很明确,就是可能会移除最近最少使用的节点(Entry)
* 为什么说是可能呢,因为不确定是否要移除,是有条件的,接着往下看
* LinkedHashMap#
* if (evict && (first = head) != null && removeEldestEntry(first)) {
* K key = first.key;
* removeNode(hash(key), key, null, false, true);
* }
* evict已经肯定是true(传过来就是),其次如果链表中头结点不为空的话,则
* 如果LinkedHashMap#removeEldestEntry这个方法返回的是true的话,那就removeNode掉当前的头节点
* 所以,我们要想基于LinkedHashMap实现一个LRU算法的话,就要复写该方法,当缓存容量满的时候要清除
* 那些最近使用最少的entry了,对于LinkedHashMap来说,是移除头结点c
* 这里有个细节需要注意,那就是一定要设置accessOrder = true,否则get的时候,无法将访问的当前节点放到链表的尾部
*/
@Override
protected boolean removeEldestEntry(Map.Entry<String, Integer> eldest) {
return size() > maxSize;
}
public static void main(String[] args) {
/**第三个参数 accessOrder,访问顺序,如果true的话, 基于访问顺序来,否则,基于插入顺序*/
Map<String,Integer> link = new LinkedHashMap<>(6,0.75f,true);
link.put("a",1);
link.put("b",2);
link.put("c",3);
link.put("d",4);
System.out.println(link);
link.get("b");
System.out.println(link);
System.out.println("=================== 基于LinkedHashMap,实现LRU缓存 =====================");
LRUCache cache = new LRUCache(2);
cache.put("1",1); // 缓存节点1
cache.put("2",2); // 缓存节点2
System.out.println("不访问节点1时,链表的输出:"+cache);// {1,2}
cache.get("1");// 访问1,则会将1节点重置(重新安放)到链表的尾部
System.out.println("访问节点1时,链表的输出:"+cache);// 再打印的时候,此时{2,1}
// 缓存节点3,因为cache的容量最大就是2,超过这个值后,removeEldestEntry的值为true
// 就要触发其中afterNodeInsertion方法中的removeNode方法了,该方法将移除头结点
// 因为,当accessOrder = true时,是按照访问顺序来的,也就是访问节点时,就会触发将节点重置到链表尾部的操作
// 所以,当cache的容量满时,再put节点时,会触发两个操作,一个就是将当前节点插入到链表尾部,一个就是将头节点删除
cache.put("3",3);
System.out.println(cache.get("3") == -1 ? "找不到了":"找到了,value = "+cache.get("3"));
System.out.println(cache.get("2") == -1 ? "找不到了":"找到了,value = "+cache.get("3"));
System.out.println(cache.get("1") == -1 ? "找不到了":"找到了,value = "+cache.get("1"));
}
}
运行结果
{a=1, b=2, c=3, d=4}
{a=1, c=3, d=4, b=2}
=================== 基于LinkedHashMap,实现LRU缓存 =====================
不访问节点1时,链表的输出:{1=1, 2=2}
访问节点1时,链表的输出:{2=2, 1=1}
找到了,value = 3
找不到了
找到了,value = 1
4、基于LinkedHashMap衍生出我们自实现的LRUCache
理解了LinkedHashMap是如何实现LRU算法的话,手写一个LRU缓存容器就变得不是那么难了,思路:
(1)数据结构使用,HashMap+双链表
(至于为什么,因为链表的访问速度不快,需要一个个遍历,而HashMap是基于key进行hash值的,定位数据的效率要比链表快,所以借助于HashMap,可以快读命中缓存,双链表用起来很嗨皮,操作简单,只要解决了节点快速定位的问题,剩下的不管是节点新增、删除还是移动,性能是很ok的,手写的代码也不多。总之,使用HashMap+双链表的数据结构,就是一种空间换时间的概念。当然,你可以尝试用其他的数据结构组合。)
(2)get数据的时候,将当前访问的数据节点移动到链表的头部
(3)put数据的时候,如果节点不存在,直接新建一个插入到链表的头部,同时判断下当前缓存中的元素Size是否比当前的容量capacity要大,如果大了,就是缓存满了,这时候就需要进行LRU节点的移除操作了,移除谁呢,当然是尾节点啊
(这个和LinkedHashMap的LRU实现正好相反,它是将最近常访问的节点放到了链表的尾部,而移除LRU节点时,移除的是头部节点,如果你想反着来,也是可以的,只要实现LRU算法就行,不在乎正反)
(4)put数据的时候,如果节点存在,就替换该节点的val,同时将该节点移动到链表的头部
(这个和get同理)
不废话了,直接上代码
引导版(核心功能,没实现,提供思路,可以自己先不看完整版,来一遍,效果更佳):
import java.util.HashMap;
import java.util.Map;
public class LRUCache<K, V> {
/**默认容量*/
private static final int DEFAULT_CAPACITY = 16;
/**缓存容量,既缓存最大可承受的存储对象的个数*/
private int capacity;
/**缓存元素的大小*/
private int size = 0;
/**缓存元素被修改的次数*/
private int modCount = 0;
/**构建一个Map集合,主要用来基于key快速定位Node(缓存数据),时间查找复杂度O(1)*/
private Map<K,Node> cache = new HashMap<>(capacity);
/**定义头、尾节点,此处两个变量只是标识(指向),并不存储数据*/
private Node head,tail;
/**双向链表,新增、修改、删除的效率高,配合Map使用,空间换时间*/
class Node {
K key;
V val;
/**前驱结点*/
Node prev;
/**后驱节点*/
Node next;
public Node() {}
public Node(K key, V val) {
this.key = key;
this.val = val;
}
@Override
public String toString() {
return ""
}
}
public LRUCache(){
this(DEFAULT_CAPACITY);
}
public LRUCache(int capacity){
this.capacity = capacity;
head = new Node();
tail = new Node();
// 构建双向链表,头后驱节点指向尾,尾前驱节点指向头
head.next = tail;
tail.prev = head;
}
/**
* 基于key取缓存数据
* @param key 缓存key
* @return V 返回缓存数据
*/
public V get(K key){
// 1、先从map缓存中查找key对应的node
// 2、如果存在的话,干一件事情,把当前正在被使用的节点移动到头部
moveToHead(node);
return node.val;
}
/***
* 缓存数据
* @param key 键
* @param val 值
*/
public void put(K key,V val){
// 1.首先要先从cache中取key对应的Node
// 2.如果等于空的话,走创建,并put进cache
if(node == null){
// 2.1 创建新的节点
node = new Node(key,val);
// 2.2 放进去
cache.put(key,node);
// 2.3 把最近添加的(新增)node,添加到头部
addToHead(node);
// 2.4 别忘了size+1
size++;
// 2.5 判断当前size是否超过最大capacity
if(size > capacity){
// 2.5.1 将尾部的节点移除掉
Node tail = removeTail();
// 2.5.2 链表移除后,Map中也移除下
cache.remove(tail.key);
// 2.5.3 别忘了,缓存数据的个数减1
size--;
}
}else{
// 3.如果node存在,替换下当前的值
node.val = val;
// 4.将当前node移动到头部
moveToHead(node);
}
}
public int size(){
return cache.size();
}
/**
* 从cache里遍历数据(不保证顺序)
* @return String {a=1,b=2,....}
*/
@Override
public String toString() {
return "";
}
/**
* 从当前双向链表中遍历节点
* @return String {a=1,b=2,....}
*/
public String list() {
return "";
}
/**
* 在头部添加一个node
* @param node 缓存节点(数据)
*/
public void addToHead(Node node){
// 1、保存下头的后驱节点(即可以称作带有数据的链表中的第一个(first)节点)
// 2、将头的后驱节点重定向到当前节点
// 3、设置当前节点的前驱节点为头结点
// 4、设置当前节点的后驱节点为之前头节点的后驱节点
// 5、设置first的前驱节点为当前节点(这一点一定不要忽略了!!!,否则会抛NPE)
}
/**
* 将node移动到头部
* @param node 缓存节点(数据)
*/
public void moveToHead(Node node){
// 1、先重置当前节点的前后驱节点
removeNode(node);
// 2、在将当前节点添加到头部
addToHead(node);
}
/**
* 对当前node进行移动(移动的话,需要断开前驱和后驱的指向)
* @param node 缓存节点(数据)
*/
public void removeNode(Node node){
// 1、当前节点的前驱节点的后驱节点指向其后驱节点
// 2、当前节点的后驱节点的前驱节点指向其前驱节点
}
/**
* 移动最近最久未使用的节点,即尾节点!!!
* @return Node 返回(被移除)的尾节点
*/
public Node removeTail(){
// 1、直接找到尾节点
Node last = tail.prev;
// 2、移动该节点
removeNode(last);
System.out.println("** 节点:"+last+"被移除! **");
return last;
}
}
最终完整版:
package com.appleyk.leetcode.手写LRU缓存;
import java.util.HashMap;
import java.util.Map;
/**
* <p>实现方式哈希Map+双向链表</p>
*
* @author appleyk
* @version V.0.1.1
* @blob https://blog.csdn.net/appleyk
* @date created on 11:05 下午 2020/10/13
*/
public class LRUCache<K, V> {
/**默认容量*/
private static final int DEFAULT_CAPACITY = 16;
/**缓存容量,既缓存最大可承受的存储对象的个数*/
private int capacity;
/**缓存元素的大小*/
private int size = 0;
/**缓存元素被修改的次数*/
private int modCount = 0;
/**构建一个Map集合,主要用来基于key快速定位Node(缓存数据),时间查找复杂度O(1)*/
private Map<K,Node> cache = new HashMap<>(capacity);
/**定义头、尾节点,此处两个变量只是标识(指向),并不存储数据*/
private Node head,tail;
/**双向链表,新增、修改、删除的效率高,配合Map使用,空间换时间*/
class Node {
K key;
V val;
/**前驱结点*/
Node prev;
/**后驱节点*/
Node next;
public Node() {}
public Node(K key, V val) {
this.key = key;
this.val = val;
}
@Override
public String toString() {
return "Node{" +
"key=" + key +
", val=" + val +
'}';
}
}
public LRUCache(){
this(DEFAULT_CAPACITY);
}
public LRUCache(int capacity){
this.capacity = capacity;
head = new Node();
tail = new Node();
// 构建双向链表,头后驱节点指向尾,尾前驱节点指向头
head.next = tail;
tail.prev = head;
}
/**
* 基于key取缓存数据
* @param key 缓存key
* @return V 返回缓存数据
*/
public V get(K key){
// 1、先从map缓存中查找key对应的node
Node node = cache.get(key);
if(node == null){
return null;
}
// 2、如果存在的话,干一件事情,把当前正在被使用的节点移动到头部
moveToHead(node);
return node.val;
}
/***
* 缓存数据
* @param key 键
* @param val 值
*/
public void put(K key,V val){
// 1.首先要先从cache中取key对应的Node
Node node = cache.get(key);
// 2.如果等于空的话,走创建,并put进cache
if(node == null){
// 2.1 创建新的节点
node = new Node(key,val);
// 2.2 放进去
cache.put(key,node);
// 2.3 把最近添加的(新增)node,添加到头部
addToHead(node);
// 2.4 别忘了size+1
size++;
// 2.5 判断当前size是否超过最大capacity
if(size > capacity){
// 2.5.1 将尾部的节点移除掉
Node tail = removeTail();
// 2.5.2 链表移除后,Map中也移除下
cache.remove(tail.key);
// 2.5.3 别忘了,缓存数据的个数减1
size--;
}
}else{
// 3.如果node存在,替换下当前的值
node.val = val;
// 4.将当前node移动到头部
moveToHead(node);
}
}
public int size(){
return cache.size();
}
/**
* 从cache里遍历数据(不保证顺序)
* @return String {a=1,b=2,....}
*/
@Override
public String toString() {
// StringBuilder非线程安全,不考虑并发,使用这种方式对string操作效率快
StringBuilder sb = new StringBuilder();
sb.append("{");
for (Map.Entry<K, Node> nodeEntry : cache.entrySet()) {
Node node = nodeEntry.getValue();
sb.append(nodeEntry.getKey())
.append("=")
.append(node.val)
.append(",");
}
return sb.toString().substring(0,sb.lastIndexOf(","))+"}";
}
/**
* 从当前双向链表中遍历节点
* @return String {a=1,b=2,....}
*/
public String list() {
StringBuilder sb = new StringBuilder();
sb.append("{");
Node n = head.next ;
// n必须是实际存储数据的节点,因此判断需要排除tail节点
while(n!=null && n!=tail){
sb.append(n.key)
.append("=")
.append(n.val)
.append(",");
n=n.next;
}
return sb.toString().substring(0,sb.lastIndexOf(","))+"}";
}
/**
* 在头部添加一个node
* @param node 缓存节点(数据)
*/
public void addToHead(Node node){
// 1、保存下头的后驱节点(即可以称作带有数据的链表中的第一个(first)节点)
Node first = head.next;
// 2、将头的后驱节点重定向到当前节点
head.next = node;
// 3、设置当前节点的前驱节点为头结点
node.prev = head;
// 4、设置当前节点的后驱节点为之前头节点的后驱节点
node.next = first;
// 5、设置first的前驱节点为当前节点(这一点一定不要忽略了!!!,否则会抛NPE)
first.prev = node;
}
/**
* 将node移动到头部
* @param node 缓存节点(数据)
*/
public void moveToHead(Node node){
// 1、先重置当前节点的前后驱节点
removeNode(node);
// 2、在将当前节点添加到头部
addToHead(node);
}
/**
* 对当前node进行移动(移动的话,需要断开前驱和后驱的指向)
* @param node 缓存节点(数据)
*/
public void removeNode(Node node){
// 1、当前节点的前驱节点的后驱节点指向其后驱节点
node.prev.next = node.next;
// 2、当前节点的后驱节点的前驱节点指向其前驱节点
node.next.prev = node.prev;
}
/**
* 移动最近最久未使用的节点,即尾节点!!!
* @return Node 返回(被移除)的尾节点
*/
public Node removeTail(){
// 1、直接找到尾节点
Node last = tail.prev;
// 2、移动该节点
removeNode(last);
System.out.println("** 节点:"+last+"被移除! **");
return last;
}
public static void main(String[] args) {
LRUCache<String,Integer> lruCache = new LRUCache<>(5);
lruCache.put("a",1);
lruCache.put("b",2);
lruCache.put("c",3);
lruCache.put("d",4);
lruCache.put("e",5);
lruCache.get("a");//命中a,给a"续命",将a放到链表的头部
lruCache.put("f",6);//默认缓存容量是5,再添加一个数据,就需要触发LRU机制移除尾节点(b)了
System.out.println("遍历Map :"+lruCache);
System.out.println("遍历双链表:"+lruCache.list());
}
}
最终执行效果:
** 节点:Node{key=b, val=2}被移除! **
遍历Map :{a=1,c=3,d=4,e=5,f=6}
遍历双链表:{f=6,a=1,e=5,d=4,c=3}