前言 LRU 是 LeastRecentlyUsed 的简写,字面意思则是 最近最少使用。
通常用于缓存的淘汰策略实现,由于缓存的内存非常宝贵,所以需要根据某种规则来剔除数据保证内存不被撑满。
如常用的 Redis 就有以下几种策略:
实现一 之前也有接触过一道面试题,大概需求是:
-
实现一个 LRU 缓存,当缓存数据达到 N 之后需要淘汰掉最近最少使用的数据。
-
N 小时之内没有被访问的数据也需要淘汰掉。
以下是我的实现:
public
class
LRUAbstractMap
extends
java
.
util
.
AbstractMap
{
private
final
static
Logger
LOGGER
=
LoggerFactory
.
getLogger
(
LRUAbstractMap
.
class
);
/**
* 检查是否超期线程
*/
private
ExecutorService
checkTimePool
;
/**
* map 最大size
*/
private
final
static
int
MAX_SIZE
=
1024
;
private
final
static
ArrayBlockingQueue
<
Node
>
QUEUE
=
new
ArrayBlockingQueue
<>(
MAX_SIZE
)
;
/**
* 默认大小
*/
private
final
static
int
DEFAULT_ARRAY_SIZE
=
1024
;
/**
* 数组长度
*/
private
int
arraySize
;
/**
* 数组
*/
private
Object
[]
arrays
;
/**
* 判断是否停止 flag
*/
private
volatile
boolean
flag
=
true
;
/**
* 超时时间
*/
private
final
static
Long
EXPIRE_TIME
=
60
*
60
*
1000L
;
/**
* 整个 Map 的大小
*/
private
volatile
AtomicInteger
size
;
public
LRUAbstractMap
()
{
arraySize
=
DEFAULT_ARRAY_SIZE
;
arrays
=
new
Object
[
arraySize
]
;
//开启一个线程检查最先放入队列的值是否超期
executeCheckTime
();
}
/**
* 开启一个线程检查最先放入队列的值是否超期 设置为守护线程
*/
private
void
executeCheckTime
()
{
ThreadFactory
namedThreadFactory
=
new
ThreadFactoryBuilder
()
.
setNameFormat
(
"check-thread-%d"
)
.
setDaemon
(
true
)
.
build
();
checkTimePool
=
new
ThreadPoolExecutor
(
1
,
1
,
0L
,
TimeUnit
.
MILLISECONDS
,
new
ArrayBlockingQueue
<>(
1
),
namedThreadFactory
,
new
ThreadPoolExecutor
.
AbortPolicy
());
checkTimePool
.
execute
(
new
CheckTimeThread
())
;
}
@Override
public
Set
<
Entry
>
entrySet
()
{
return
super
.
keySet
();
}
@Override
public
Object
put
(
Object
key
,
Object
value
)
{
int
hash
=
hash
(
key
);
int
index
=
hash
%
arraySize
;
Node
currentNode
=
(
Node
)
arrays
[
index
]
;
if
(
currentNode
==
null
){
arrays
[
index
]
=
new
Node
(
null
,
null
,
key
,
value
);
//写入队列
QUEUE
.
offer
((
Node
)
arrays
[
index
])
;
sizeUp
();
}
else
{
Node
cNode
=
currentNode
;
Node
nNode
=
cNode
;
//存在就覆盖
if
(
nNode
.
key
==
key
){
cNode
.
val
=
value
;
}
while
(
nNode
.
next
!=
null
){
//key 存在 就覆盖 简单判断
if
(
nNode
.
key
==
key
){
nNode
.
val
=
value
;
break
;
}
else
{
//不存在就新增链表
sizeUp
();
Node
node
=
new
Node
(
nNode
,
null
,
key
,
value
)
;
//写入队列
QUEUE
.
offer
(
currentNode
)
;
cNode
.
next
=
node
;
}
nNode
=
nNode
.
next
;
}
}
return
null
;
}
@Override
public
Object
get
(
Object
key
)
{
int
hash
=
hash
(
key
)
;
int
index
=
hash
%
arraySize
;
Node
currentNode
=
(
Node
)
arrays
[
index
]
;
if
(
currentNode
==
null
){
return
null
;
}
if
(
currentNode
.
next
==
null
){
//更新时间
currentNode
.
setUpdateTime
(
System
.
currentTimeMillis
());
//没有冲突
return
currentNode
;
}
Node
nNode
=
currentNode
;
while
(
nNode
.
next
!=
null
){
if
(
nNode
.
key
==
key
){
//更新时间
currentNode
.
setUpdateTime
(
System
.
currentTimeMillis
());
return
nNode
;
}
nNode
=
nNode
.
next
;
}
return
super
.
get
(
key
);
}
@Override
public
Object
remove
(
Object
key
)
{
int
hash
=
hash
(
key
)
;
int
index
=
hash
%
arraySize
;
Node
currentNode
=
(
Node
)
arrays
[
index
]
;
if
(
currentNode
==
null
){
return
null
;
}
if
(
currentNode
.
key
==
key
){
sizeDown
();
arrays
[
index
]
=
null
;
//移除队列
QUEUE
.
poll
();
return
currentNode
;
}
Node
nNode
=
currentNode
;
while
(
nNode
.
next
!=
null
){
if
(
nNode
.
key
==
key
){
sizeDown
();
//在链表中找到了 把上一个节点的 next 指向当前节点的下一个节点
nNode
.
pre
.
next
=
nNode
.
next
;
nNode
=
null
;
//移除队列
QUEUE
.
poll
();
return
nNode
;
}
nNode
=
nNode
.
next
;
}
return
super
.
remove
(
key
);
}
/**
* 增加size
*/
private
void
sizeUp
(){
//在put值时候认为里边已经有数据了
flag
=
true
;
if
(
size
==
null
){
size
=
new
AtomicInteger
()
;
}
int
size
=
this
.
size
.
incrementAndGet
();
if
(
size
>=
MAX_SIZE
)
{
//找到队列头的数据
Node
node
=
QUEUE
.
poll
()
;
if
(
node
==
null
){
throw
new
RuntimeException
(
"data error"
)
;
}
//移除该 key
Object
key
=
node
.
key
;
remove
(
key
)
;
lruCallback
()
;
}
}
/**
* 数量减小
*/
private
void
sizeDown
(){
if
(
QUEUE
.
size
()
==
0
){
flag
=
false
;
}
this
.
size
.
decrementAndGet
()
;
}
@Override
public
int
size
()
{
return
size
.
get
()
;
}
/**
* 链表
*/
private
class
Node
{
private
Node
next
;
private
Node
pre
;
private
Object
key
;
private
Object
val
;
private
Long
updateTime
;
public
Node
(
Node
pre
,
Node
next
,
Object
key
,
Object
val
)
{
this
.
pre
=
pre
;
this
.
next
=
next
;
this
.
key
=
key
;
this
.
val
=
val
;
this
.
updateTime
=
System
.
currentTimeMillis
()
;
}
public
void
setUpdateTime
(
Long
updateTime
)
{
this
.
updateTime
=
updateTime
;
}
public
Long
getUpdateTime
()
{
return
updateTime
;
}
@Override
public
String
toString
()
{
return
"Node{"
+
"key="
+
key
+
", val="
+
val
+
'}'
;
}
}
/**
* copy HashMap 的 hash 实现
* @param key
* @return
*/
public
int
hash
(
Object
key
)
{
int
h
;
return
(
key
==
null
)
?
0
:
(
h
=
key
.
hashCode
())
^
(
h
>>>
16
);
}
private
void
lruCallback
(){
LOGGER
.
debug
(
"lruCallback"
);
}
private
class
CheckTimeThread
implements
Runnable
{
@Override
public
void
run
()
{
while
(
flag
){
try
{
Node
node
=
QUEUE
.
poll
();
if
(
node
==
null
){
continue
;
}
Long
updateTime
=
node
.
getUpdateTime
()
;
if
((
updateTime
-
System
.
currentTimeMillis
())
>=
EXPIRE_TIME
){
remove
(
node
.
key
)
;
}
}
catch
(
Exception
e
)
{
LOGGER
.
error
(
"InterruptedException"
);
}
}
}
}
}
感兴趣的朋友可以直接从:
https://github.com/crossoverJie/Java-Interview/blob/master/src/main/java/com/crossoverjie/actual/LRUAbstractMap.java
下载代码本地运行。
代码看着比较多,其实实现的思路还是比较简单:
-
采用了与 HashMap 一样的保存数据方式,只是自己手动实现了一个简易版。
-
内部采用了一个队列来保存每次写入的数据。
-
写入的时候判断缓存是否大于了阈值 N,如果满足则根据队列的 FIFO 特性将队列头的数据删除。因为队列头的数据肯定是最先放进去的。
-
再开启了一个守护线程用于判断最先放进去的数据是否超期(因为就算超期也是最先放进去的数据最有可能满足超期条件。)
-
设置为守护线程可以更好的表明其目的(最坏的情况下,如果是一个用户线程最终有可能导致程序不能正常退出,因为该线程一直在运行,守护线程则不会有这个情况。)
以上代码大体功能满足了,但是有一个致命问题。
就是最近最少使用没有满足,删除的数据都是最先放入的数据。
不过其中的 putget 流程算是一个简易的 HashMap 实现,可以对 HashMap 加深一些理解。
实现二 因此如何来实现一个完整的 LRU 缓存呢,这次不考虑过期时间的问题。
其实从上一个实现也能想到一些思路:
-
要记录最近最少使用,那至少需要一个有序的集合来保证写入的顺序。
-
在使用了数据之后能够更新它的顺序。
基于以上两点很容易想到一个常用的数据结构:链表。
-
每次写入数据时将数据放入链表头结点。
-
使用数据时候将数据移动到头结点。
-
缓存数量超过阈值时移除链表尾部数据。
因此有了以下实现:
public
class
LRUMap
<
K
,
V
>
{
private
final
Map
<
K
,
V
>
cacheMap
=
new
HashMap
<>();
/**
* 最大缓存大小
*/
private
int
cacheSize
;
/**
* 节点大小
*/
private
int
nodeCount
;
/**
* 头结点
*/
private
Node
<
K
,
V
>
header
;
/**
* 尾结点
*/
private
Node
<
K
,
V
>
tailer
;
public
LRUMap
(
int
cacheSize
)
{
this
.
cacheSize
=
cacheSize
;
//头结点的下一个结点为空
header
=
new
Node
<>();
header
.
next
=
null
;
//尾结点的上一个结点为空
tailer
=
new
Node
<>();
tailer
.
tail
=
null
;
//双向链表 头结点的上结点指向尾结点
header
.
tail
=
tailer
;
//尾结点的下结点指向头结点
tailer
.
next
=
header
;
}
public
void
put
(
K key
,
V value
)
{
cacheMap
.
put
(
key
,
value
);
//双向链表中添加结点
addNode
(
key
,
value
);
}
public
V get
(
K key
){
Node
<
K
,
V
>
node
=
getNode
(
key
);
//移动到头结点
moveToHead
(
node
)
;
return
cacheMap
.
get
(
key
);
}
private
void
moveToHead
(
Node
<
K
,
V
>
node
){
//如果是最后的一个节点
if
(
node
.
tail
==
null
){
node
.
next
.
tail
=
null
;
tailer
=
node
.
next
;
nodeCount
--
;
}
//如果是本来就是头节点 不作处理
if
(
node
.
next
==
null
){
return
;
}
//如果处于中间节点
if
(
node
.
tail
!=
null
&&
node
.
next
!=
null
){
//它的上一节点指向它的下一节点 也就删除当前节点
node
.
tail
.
next
=
node
.
next
;
nodeCount
--
;
}
//最后在头部增加当前节点
//注意这里需要重新 new 一个对象,不然原本的node 还有着下面的引用,会造成内存溢出。
node
=
new
Node
<>(
node
.
getKey
(),
node
.
getValue
())
;
addHead
(
node
)
;
}
/**
* 链表查询 效率较低
* @param key
* @return
*/
private
Node
<
K
,
V
>
getNode
(
K key
){
Node
<
K
,
V
>
node
=
tailer
;
while
(
node
!=
null
){
if
(
node
.
getKey
().
equals
(
key
)){
return
node
;
}
node
=
node
.
next
;
}
return
null
;
}
/**
* 写入头结点
* @param key
* @param value
*/
private
void
addNode
(
K key
,
V value
)
{
Node
<
K
,
V
>
node
=
new
Node
<>(
key
,
value
);
//容量满了删除最后一个
if
(
cacheSize
==
nodeCount
)
{
//删除尾结点
delTail
();
}
//写入头结点
addHead
(
node
);
}
/**
* 添加头结点
*
* @param node
*/
private
void
addHead
(
Node
<
K
,
V
>
node
)
{
//写入头结点
header
.
next
=
node
;
node
.
tail
=
header
;
header
=
node
;
nodeCount
++;
//如果写入的数据大于2个 就将初始化的头尾结点删除
if
(
nodeCount
==
2
)
{
tailer
.
next
.
next
.
tail
=
null
;
tailer
=
tailer
.
next
.
next
;
}
}
private
void
delTail
()
{
//把尾结点从缓存中删除
cacheMap
.
remove
(
tailer
.
getKey
());
//删除尾结点
tailer
.
next
.
tail
=
null
;
tailer
=
tailer
.
next
;
nodeCount
--;
}
private
class
Node
<
K
,
V
>
{
private
K key
;
private
V value
;
Node
<
K
,
V
>
tail
;
Node
<
K
,
V
>
next
;
public
Node
(
K key
,
V value
)
{
this
.
key
=
key
;
this
.
value
=
value
;
}
public
Node
()
{
}
public
K getKey
()
{
return
key
;
}
public
void
setKey
(
K key
)
{
this
.
key
=
key
;
}
public
V getValue
()
{
return
value
;
}
public
void
setValue
(
V value
)
{
this
.
value
=
value
;
}
}
@Override
public
String
toString
()
{
StringBuilder
sb
=
new
StringBuilder
()
;
Node
<
K
,
V
>
node
=
tailer
;
while
(
node
!=
null
){
sb
.
append
(
node
.
getKey
()).
append
(
":"
)
.
append
(
node
.
getValue
())
.
append
(
"-->"
)
;
node
=
node
.
next
;
}
return
sb
.
toString
();
}
}
源码: https://github.com/crossoverJie/Java-Interview/blob/master/src/main/java/com/crossoverjie/actual/LRUMap.java
实际效果,写入时:
@Test
public
void put ()
throws
Exception
{
LRUMap < String , Integer
lruMap
new
LRUMap ( 3 )
;
lruMap
. put ( "1" , 1 )
;
lruMap
. put ( "2" , 2 )
;
lruMap
. put ( "3" , 3 )
;
System . out . println ( lruMap . toString ());
lruMap
. put ( "4" , 4 )
;
System . out . println ( lruMap . toString ());
lruMap
. put ( "5" , 5 )
;
System . out . println ( lruMap . toString ());
}
//输出:
1 : 1 --> 2 : 2 --> 3 : 3 -->
2 : 2 --> 3 : 3 --> 4 : 4 -->
3 : 3 --> 4 : 4 --> 5 : 5 -->
使用时:
@Test
public
void
get
()
throws
Exception
{
LRUMap
<
String
,
Integer
>
lruMap
=
new
LRUMap
(
3
)
;
lruMap
.
put
(
"1"
,
1
)
;
lruMap
.
put
(
"2"
,
2
)
;
lruMap
.
put
(
"3"
,
3
)
;
System
.
out
.
println
(
lruMap
.
toString
());
System
.
out
.
println
(
"=============="
);
Integer
integer
=
lruMap
.
get
(
"1"
);
System
.
out
.
println
(
integer
);
System
.
out
.
println
(
"=============="
);
System
.
out
.
println
(
lruMap
.
toString
());
}
//输出
1
:
1
-->
2
:
2
-->
3
:
3
-->
==============
1
==============
2
:
2
-->
3
:
3
-->
1
:
1
-->
实现思路和上文提到的一致,说下重点:
-
数据是直接利用 HashMap 来存放的。
-
内部使用了一个双向链表来存放数据,所以有一个头结点 header,以及尾结点 tailer。
-
每次写入头结点,删除尾结点时都是依赖于 header tailer,如果看着比较懵建议自己实现一个链表熟悉下,或结合下文的对象关系图一起理解。
-
使用数据移动到链表头时,第一步是需要在双向链表中找到该节点。这里就体现出链表的问题了。查找效率很低,最差需要 O(N)。之后依赖于当前节点进行移动。
-
在写入头结点时有判断链表大小等于 2 时需要删除初始化的头尾结点。这是因为初始化时候生成了两个双向节点,没有数据只是为了形成一个数据结构。当真实数据进来之后需要删除以方便后续的操作(这点可以继续优化)。
-
以上的所有操作都是线程不安全的,需要使用者自行控制。
下面是对象关系图:
初始化时
写入数据时
LRUMap<String,Integer> lruMap =new LRUMap(3) ;
lruMap.put("1",1);
lruMap.put("2",2);
![](https://s4.51cto.com/images/blog/202012/09/dce4837fdd1125d69cd7993bf9747f03.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_30,g_se,x_10,y_10,shadow_20,type_ZmFuZ3poZW5naGVpdGk=)
lruMap.put("3",3);
![](https://s4.51cto.com/images/blog/202012/09/0f066baa50fa73fd2408cafc0cf4122f.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_30,g_se,x_10,y_10,shadow_20,type_ZmFuZ3poZW5naGVpdGk=)
lruMap.put("4",4) ;
![](https://s4.51cto.com/images/blog/202012/09/13643103ea4e17d9fbda7dc4b7c716f3.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_30,g_se,x_10,y_10,shadow_20,type_ZmFuZ3poZW5naGVpdGk=)
**获取数据时**
数据和上文一样:
Integer integer = lruMap.get("2");
![](https://s4.51cto.com/images/blog/202012/09/91256764fcbab3a64efd560c877007f5.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_30,g_se,x_10,y_10,shadow_20,type_ZmFuZ3poZW5naGVpdGk=)
通过以上几张图应该是很好理解数据是如何存放的了。
**实现三**
其实如果对 Java 的集合比较熟悉的话,会发现上文的结构和 LinkedHashMap 非常类似。
对此不太熟悉的朋友可以先了解下 LinkedHashMap 底层分析 。
所以我们完全可以借助于它来实现:
public
class
LRULinkedMap < K , V
{
/**
* 最大缓存大小
*/
private
int cacheSize ;
private
LinkedHashMap < K , V
cacheMap ;
public
LRULinkedMap ( int cacheSize )
{
this . cacheSize
cacheSize ;
cacheMap
=
new
LinkedHashMap ( 16 , 0.75F , true ){
@Override
protected
boolean removeEldestEntry ( Map . Entry eldest )
{
if
( cacheSize +
1
== cacheMap . size ()){
return
true
;
} else
{
return
false
;
}
}
};
}
public
void put ( K key , V value ){
cacheMap
. put ( key , value )
;
}
public V get ( K key ){
return cacheMap . get ( key )
;
}
public
Collection < Map . Entry < K , V
getAll ()
{
return
new
ArrayList < Map . Entry < K , V
( cacheMap . entrySet ());
}
}
源码: https://github.com/crossoverJie/Java-Interview/blob/master/src/main/java/com/crossoverjie/actual/LRULinkedMap.java
这次就比较简洁了,也就几行代码(具体的逻辑 LinkedHashMap 已经帮我们实现好了)
实际效果:
@Test
public
void put ()
throws
Exception
{
LRULinkedMap < String , Integer
map
new
LRULinkedMap ( 3 )
;
map
. put ( "1" , 1 );
map
. put ( "2" , 2 );
map
. put ( "3" , 3 );
for
( Map . Entry < String ,
Integer
e : map . getAll ()){
System . out . print ( e . getKey ()
" : "
e . getValue ()
"\t" );
}
System . out . println ( "" );
map
. put ( "4" , 4 );
for
( Map . Entry < String ,
Integer
e : map . getAll ()){
System . out . print ( e . getKey ()
" : "
e . getValue ()
"\t" );
}
}
//输出
1:1 2:2 3:3
2:2 33 4:4
使用时:
@Test
public
void get ()
throws
Exception
{
LRULinkedMap < String , Integer
map
new
LRULinkedMap ( 4 )
;
map
. put ( "1" , 1 );
map
. put ( "2" , 2 );
map
. put ( "3" , 3 );
map
. put ( "4" , 4 );
for
( Map . Entry < String ,
Integer
e : map . getAll ()){
System . out . print ( e . getKey ()
" : "
e . getValue ()
"\t" );
}
System . out . println ( "" );
map
. get ( "1" )
;
for
( Map . Entry < String ,
Integer
e : map . getAll ()){
System . out . print ( e . getKey ()
" : "
e . getValue ()
"\t" );
}
}
}
//输出
1:1 2:2 3:3 4:4
2:2 33 4:4 1:1
LinkedHashMap 内部也有维护一个双向队列,在初始化时也会给定一个缓存大小的阈值。初始化时自定义是否需要删除最近不常使用的数据,如果是则会按照实现二中的方式管理数据。
其实主要代码就是重写了 LinkedHashMap 的 removeEldestEntry 方法:
protected
boolean removeEldestEntry ( Map . Entry < K , V
eldest )
{
return
false ;
}
它默认是返回 false,也就是不会管有没有超过阈值。
所以我们自定义大于了阈值时返回 true,这样 LinkedHashMap 就会帮我们删除最近最少使用的数据。
**总结**
以上就是对 LRU 缓存的实现,了解了这些至少在平时使用时可以知其所以然。
当然业界使用较多的还有 guava 的实现,并且它还支持多种过期策略。
**号外**
最近在总结一些 Java 相关的知识点,感兴趣的朋友可以一起维护。
地址: https://github.com/crossoverJie/Java-Interview