缓存淘汰算法:LRU(Least recently used,最近最少使用)算法是调度场景下( 内存调度、缓存淘汰等)常用到的算法,其原理是根据数据的最近访问时间来安排数据淘汰的顺序。其实常用的LRU算法是LRU-K算法体系下LRU-1特例算法。
LRU算法的核心归根结底还是在链表基础上改装而来。其运行逻辑如下:
1.新数据插入链表首部(最新被使用的数据,优先级最高,位于链首)
2.每当存在LRU缓存队列中的其他元素被命中,则将该元素提高链首;
3.当链表满时,即意味着容量不足,淘汰链表尾部的元素,腾出空间。
性能分析
当整个系统存在热点(二八原则),LRU效率很好,可以充分利用缓存优势,缓存中数据命中率始终较高,但当系统存在周期性的批量操作(周期步长正好超过了链表长度)则会导致LRU缓存体系命中率很低,几乎需要一直更新缓存内容,偶发性密集操作也会导致缓存池体系污染严重。这也是因为K取1,导致数据惯性较低,很容易被偶发操作引入的新数据从缓冲池挤掉。
LRU可以认为是LRU-1算法,但是可能存在“缓存污染”的问题,故而为了应对这种冲击,出现了LRU-K,其中K代表最近使用过K次,从而增强体系对偶发性操作的适应性。LRU-K除了需要维护一个缓冲队列,还要维护一个历史访问次数记录队列。即LRU-K是由: 可快速检索定位的历史列表(如hash-table) + LRU缓冲队列
1.数据历史访问计数: 当数据第一次被访问,加入到访问历史列表中,历史记录列表可能是使用hash-table或者红黑树管理的数据结构;
2.升级进入LRU队列:如果数据在历史访问列表中的访问总次数达到K次,则将数据索引项从历史记录中删除,将数据迁移到缓存队列中,置为链首
3.周期性检查:如果过了一段时间,历史访问列表中的某项数据依旧没有达到K次,则删除该数据项在历史列表中的记录(限期淘汰原则)
4.LRU末尾淘汰: 需要从缓存队列中腾出空位时,则淘汰链表尾部的数据。
性能分析
LRU-K相比LRU具有对于偶发性密集操作的对抗性,有效地降低“缓存污染”问题。但是LRU-K需要维护的数据记录较多(历史访问记录+缓存队列),显然历史访问记录列表存在时间复杂度和空间复杂度不可均得的情况,一般是采用hash-table这类数据结构实现历史访问列表,以空间换取时间。故而空间消耗更大,并且由于存在诸如周期性检查这类操作,导致时间复杂度也较LRU更复杂。
实际应用中来说还是LRU-2是综合效果更优的选择,LRU-3可以识缓存命中率更高,但是适应性较差,K越大一旦面对使用场景切换过渡效果很差。
2Q算法类似于LRU-2算法,但是和LRU-2算法中的访问历史记录队列改成了FIFO队列,即2Q算法位两个缓存队列: FIFO历史次数记录队列+LRU队列。这一改进主要是针对LRU-K算法在历史访问列表的性能短板。
2Q算法是针对LRU-2进行针对性的改善,对于其他LRU-K算法并不通用,2Q的原理是:数据第一次被访问时,数据时被缓存在FIFO队列中,当数据第二次被访问到时,则将数据从FIFO队列中迁移到LRU队列中。
1.新访问的数据被插入到FIFO队列中;
2.如果数据在FIFO队列中一直没有被再次访问,则最终按照FIFO规则淘汰(淘汰周期由FIFO队列的长度决定,符合缓冲淘汰的使用习惯);
3.如果数据在FIFO队列中被第二次访问到,则将该数据迁移到LRU队列链首
4.如果数据在LRU队列中被再次访问到,则将该数据迁移到LRU队列链首
5.LRU队列容量不足时,则淘汰末尾的数据
性能分析
2Q算法和LRU算法类似,但唯一不同的是LRU-2算法的历史队列中存放的是数据索引,并非真正的数据块,故而2Q算法比LRU-2算法少一次从磁盘读取数据块到buffer的迁移操作(即LRU-2是数据项满足要求时,才正式将该数据块加入缓存,而历史队列中保存的是数据索引项,而2Q算法在历史记录队列中便是保存着数据块内容,一旦要从历史队列迁移到缓存LRU队列中,无需再次读取磁盘)
在2Q算法基础上衍生出更复杂的Multi Queue(MQ)。MQ算法的核心思想是访问次数越多的数据的优先级越高,其存储的缓存buffer所处的级别就应该越高,越不应该被偶发的密集操作给从缓存体系中挤出去。
1 新插入的数据放入优先级最低的Q0;
2 每个队列都是按照LRU管理数据
3 当数据的访问次数达到一定次数,需要提升优先级,则将数据加入到高一级的队列头部;
4 位了防止高优先级的数据永远不被淘汰,当数据在指定的时间内没有被访问时,则需要降低优先级,将数据从高优先级队列中降级到低一级的LRU队列首部;
5 需要淘汰数据时,从最低一级队列按照LRU淘汰,每个队列淘汰数据时,将数据从缓存中删除,但是将数据索引项加入到Q-history队列头部。
6 如果数据在Q-history中被重新访问,则重新计算其优先级,移到目标队列的头部
7 Q-history也是按照LRU原则淘汰数据的索引项
性能分析
MQ是尽可能地降低“缓存污染”问题,但是MQ需要维护多个队列,且还需要记录每个数据的访问时间,时间复杂度明显要比LRU高,并且存在着定期更新不同缓冲块的优先级的扫描任务,需要定时扫描所有队列,这一额外任务决定了MQ的管理代价比LRU要高,虽然MQ队列看起来要多,但是缓存容量是有上限的,故而和LRU-1队列容量一样。
摘自org_apache_tomcat项目中关于LRU的实现部分代码
public class LRUCache
{
//org.apache.tomcat.util.collections.LRUCache
private int cacheSize; //LRU队列的最大容量
private Hashtable nodes;//除了LRU链表管理各buffer块,为了加速找到个buffer的,将各buffer_head指针用哈希表来管理
private int currentSize;//当前LRU队列中存在元素个数
private CacheNode first;//LRU链首
private CacheNode last; //LRU链尾
class CacheNode
{
CacheNode prev; //buffer的前部指针
CacheNode next; //buffer的后部指针
Object value; //数据块中存储的数据
Object key; //hash-table中的hash-key
CacheNode() {}
}
public LRUCache(int i)
{
currentSize = 0;
cacheSize = i;
nodes = new Hashtable(i); //缓存容器
}
public Object get(Object key)
{
CacheNode node = (CacheNode) nodes.get(key);
if (node != null)
{
moveToHead(node);
return node.value;
}
else
return null;
}
//添加新的缓存buffer
public void put(Object key, Object value)
{
CacheNode node = (CacheNode)nodes.get(key);//按照提供的hash-key先去hash-table查找,看是否需要覆盖
if (node == null) //如果hash-table中映射出来的索引值位空,则意味着该元素是第一次加入buffer
{
if (currentSize >= cacheSize)//意味着LRU队列已经满了,需要删除LRU末尾元素
{
if (last != null) //将最少使用的删除
nodes.remove(last.key);//将hash-table中的想一个元素索引值给删除掉
removeLast();
}
else
currentSize++;
node = new CacheNode();
}
node.value = value;
node.key = key;
moveToHead(node);//将该元素提到LRU队列的链首
nodes.put(key, node); //将该元素加入到hash-table中
}
public Object remove(Object key)
{
CacheNode node = (CacheNode) nodes.get(key); //为了快速定位到目标buffer块,先去hash-table获取索引值
if (node != null)
{
if (node.prev != null)
node.prev.next = node.next;
if (node.next != null)
node.next.prev = node.prev;
if (last == node)
last = node.prev;
if (first == node)
first = node.next;
}
return node;
}
public void clear()
{
first = null;
last = null;
}
private void removeLast()
{
if (last != null)
{
if (last.prev != null)
last.prev.next = null;
else
first = null;
last = last.prev;
}
}
private void moveToHead(CacheNode node)
{
if (node == null)
return;
if (node.prev != null)
node.prev.next = node.next;
if (node.next != null )
node.next.prev = node.prev;
if (last == node)
last = node.prev;
if (first != null)
{
node.next = first;
first.prev = node;
}
first = node;
node.prev = null;
if (last = null)
last = first;
}
}