Redis内存淘汰策略中会使用到 LFU 算法,以下是简单说明及Java代码实现。
一、前言
LFU(The Least Frequently Used)即最不经常使用算法。其原理是如果一个数据最近被访问次数不多,那么将来它被使用的可能也更低,因此在内存达到一定阈值时,将最不经常使用的数据淘汰的一种策略算法。
二、图解
TODO 图解说明后期有空再补充
三、LFU 算法 Java 实现
3.1 定义节点类
节点Node类中应包含 key(键)、value(值)、cnt(使用频次)和prev(前置节点)、next(后置节点)指针;
import lombok.Getter;
import lombok.Setter;
/**
* 节点Node
*/
@Getter
@Setter
public class Node {
/**
* 节点键key
*/
private int key;
/**
* 节点值value
*/
private int value;
/**
* 使用频次
*/
private int cnt;
/**
* 前置节点
*/
private Node prev;
/**
* 后置节点
*/
private Node next;
public Node() {
}
public Node(int key, int value) {
this.key = key;
this.value = value;
this.cnt = 1;
}
}
注意:代码中 @Getter、@Setter 使用的是 lombok 插件,如果没有安装直接删除这两个注解和对应 import 导入逻辑,再手工给所有属性添加 Getter/Setter 方法即可,后续代码也是不做另外说明;
3.2 双向链表实现
双向链表中定义 dummyHead(虚拟头节点)、dummyTail(虚拟尾节点)两个虚拟头尾节点,避免后续处理中针对头尾节点的特殊处理;再用 length 属性维护链表长度
即
真实头节点:dummyHead.next
真实尾节点:dummyTail.prev
双向链表中定义 add、delete、deleteHead 方法,实现对链表的新增、删除及删除头节点功能(因为我们新增插入是在链表尾部,所以如需淘汰从链表头开始);
import lombok.Getter;
import lombok.Setter;
/**
* 双向链表
* LFU相同使用频次实现使用双向链表存储,头尾节点(head、tail)使用两个默认的虚拟节点,这样避免使用时针对头尾节点的特殊处理
*/
@Getter
@Setter
public class DoubleLinkedList {
/**
* 虚拟头节点,实际头节点应为dummyHead.next
*/
private Node dummyHead;
/**
* 虚拟尾节点,实际尾节点应为dummyTail.prev
*/
private Node dummyTail;
/**
* 链表长度
*/
private int length;
public DoubleLinkedList() {
//默认虚拟头、尾节点初始化
this.dummyHead = new Node();
this.dummyTail = new Node();
this.dummyHead.setNext(this.dummyTail);
this.dummyTail.setPrev(this.dummyHead);
this.length = 0;
}
/**
* 添加节点
* 每次添加节点在链表末尾
* @param node
*/
public void add(Node node) {
node.setNext(this.dummyTail);
node.setPrev(this.dummyTail.getPrev());
this.dummyTail.getPrev().setNext(node);
this.dummyTail.setPrev(node);
this.length++;
}
/**
* 删除节点
* @param node
*/
public void delete(Node node) {
node.getPrev().setNext(node.getNext());
node.getNext().setPrev(node.getPrev());
node.setPrev(null);
node.setNext(null);
this.length--;
}
/**
* 删除头节点
*/
public void deleteHead() {
Node head = this.dummyHead.getNext();
if (head == null) {
return;
}
this.delete(this.dummyHead.getNext());
}
}
3.3 LFUCache 类实现
我们使用双向链表 +双哈希表的方式实现LFU。LFUCache 中定义哈希表一 nodeMap(节点Map,key为节点key,值为节点Node)、哈希表二 cntMap(使用频次Map,key为使用频次,值为相同频次节点组成的双向链表,时间最新在链尾)、capacity(容量大小)、length(当前元素数量)属性,且应包含 get、put、getMinCnt(获取最少使用频次) 方法,具体实现见下面代码
import java.util.*;
import java.util.stream.Collectors;
/**
* LFU算法Java实现,指定容量达到时淘汰使用频次最低数据,同频次则按照LRU删除最早数据
*/
public class LFUCache {
/**
* 节点Map
*/
private Map<Integer, Node> nodeMap;
/**
* 使用频次Map,key为使用频次,value为频次相同的双向链表,最新时间放在链表尾部,淘汰从头节点开始
*/
private Map<Integer, DoubleLinkedList> cntMap;
/**
* 容量大小,超过时应淘汰最久未使用数据
*/
private int capacity;
/**
* 当前元素数量
*/
private int length;
/**
* 元素不存在默认为-1
*/
private static final int NOT_FOUND = -1;
private static final Integer ONE = 1;
public LFUCache(int capacity) {
this.capacity = capacity;
this.length = 0;
nodeMap = new HashMap<>();
cntMap = new HashMap<>();
}
/**
* get获取单个节点值,未找到则返回-1
* @param key
* @return
*/
public int get(int key) {
if (this.capacity == 0) {
//容量capacity设置为0直接返回-1
return NOT_FOUND;
}
if (!nodeMap.containsKey(key)) {
//根据key在哈希表中未找到即不存在返回-1
return NOT_FOUND;
}
//节点存在
Node node = nodeMap.get(key);
//根据使用频次cnt获取对应的双向链表,从链表中删除当前元素
int cnt = node.getCnt();
cntMap.get(cnt).delete(node);
//使用频次cnt+1,查询cntMap如链表不存在则新建,将节点插入到链表中(默认插入到链表尾部)
cnt++;
DoubleLinkedList linkedList = cntMap.containsKey(cnt) ? cntMap.get(cnt) : new DoubleLinkedList();
//更新节点使用频次
node.setCnt(cnt);
linkedList.add(node);
cntMap.put(cnt, linkedList);
return node.getValue();
}
/**
* put新增或更新节点
* 注意:新增节点时如达到设定容量上限,则应淘汰使用频次最少数据,如频次相同按LFU算法方式处理
* @param key
* @param value
*/
public void put(int key, int value) {
if (this.capacity == 0) {
//容量capacity设置为0即认为无法进行put操作,直接返回即可
return ;
}
//键值对存在,即应更新维护键值对新值,且使用频次cnt+1
if (nodeMap.containsKey(key)) {
Node node = nodeMap.get(key);
//根据使用频次cnt获取对应的双向链表,从链表中删除当前元素
int cnt = node.getCnt();
cntMap.get(cnt).delete(node);
//使用频次cnt+1,查询cntMap如链表不存在则新建,将节点插入到链表中(默认插入到链表尾部)
cnt++;
DoubleLinkedList linkedList = cntMap.containsKey(cnt) ? cntMap.get(cnt) : new DoubleLinkedList();
//更新节点使用频次、节点值
node.setCnt(cnt);
node.setValue(value);
linkedList.add(node);
cntMap.put(cnt, linkedList);
return ;
}
//当容量达到上限需删除使用频次最少数据中的第一个节点
if (this.length == this.capacity) {
//最少使用频次
int minCnt = LFUCache.getMinCnt(cntMap);
DoubleLinkedList linkedList = cntMap.get(minCnt);
//哈希表映射中删除对应映射
nodeMap.remove(linkedList.getDummyHead().getNext().getKey());
//链表中移除头结点元素
linkedList.deleteHead();
if (linkedList.getLength() == 0 && minCnt > 1) {
//该使用频次链表无节点元素,正常应cntMap中删除该频次节点。但考虑到后续插入新元素频次固定为1,所以此逻辑加一个minCnt大于1才删除的逻辑
cntMap.remove(minCnt);
}
}
//元素未达到上限前插入动作,则length长度应递增1
if (this.length < this.capacity) {
this.length++;
}
//哈希表新增映射
Node node = new Node(key, value);
nodeMap.put(key, node);
//新增元素固定频次为1
DoubleLinkedList linkedList = cntMap.containsKey(ONE) ? cntMap.get(ONE) : new DoubleLinkedList();
linkedList.add(node);
cntMap.put(ONE, linkedList);
}
@Override
public String toString() {
if (this.length == 0) {
return "LFUCache[]";
}
//从头节点开始遍历按顺序打印
Set<Integer> set = cntMap.keySet();
List<Integer> list = set.stream().collect(Collectors.toList());
Collections.sort(list);
StringBuffer sb = new StringBuffer("LFUCache[");
for (int i = 0; i < list.size(); i++) {
Integer k = list.get(i);
DoubleLinkedList linkedList = cntMap.get(k);
Node node = linkedList.getDummyHead().getNext();
while (node != linkedList.getDummyTail()) {
if (i == list.size() - 1 && node.getNext() == linkedList.getDummyTail()) {
//最后一个元素
sb.append("{\"key\": ").append(node.getKey()).append(", \"value\": ").append(node.getValue())
.append(", \"cnt\": ").append(node.getCnt()).append("}");
} else {
sb.append("{\"key\": ").append(node.getKey()).append(", \"value\": ").append(node.getValue())
.append(", \"cnt\": ").append(node.getCnt()).append("}, ");
}
node = node.getNext();
}
}
sb.append("]");
return sb.toString();
}
/**
* 获取最少使用频次
* @param cntMap 使用频次Map,key为使用频次,value为频次相同的双向链表,时间升序排列
* @return 最少使用频次
*/
private static int getMinCnt(Map<Integer, DoubleLinkedList> cntMap) {
if (cntMap == null || cntMap.size() == 0) {
throw new IllegalArgumentException("illegal param, cntMap can not be empty.");
}
Set<Integer> set = cntMap.keySet();
List<Integer> list = set.stream().collect(Collectors.toList());
Collections.sort(list);
for (int i = 0; i < list.size(); i++) {
Integer minCnt = list.get(i);
DoubleLinkedList linkedList = cntMap.get(minCnt);
if (linkedList == null || linkedList.getLength() == 0) {
continue;
}
//使用频次已升序排列,第一个链表长度大于0的即当前最小频次
return minCnt;
}
//未找到最少使用频次minCnt应抛异常,正常业务场景下cntMap不为空时应必须存在最少使用频次minCnt
throw new RuntimeException("minCnt is not exist.");
}
}
3.4 测试验证
测试代码如下:
public static void main(String[] args) {
LFUCache LFUCache = new LFUCache(2);
LFUCache.put(1, 11);
LFUCache.put(2, 22);
System.out.println(LFUCache);
System.out.println("获取节点1,值为" + LFUCache.get(1));
System.out.println(LFUCache);
System.out.println("更新节点2,新值为250");
LFUCache.put(2, 250);
System.out.println(LFUCache);
System.out.println("达到最大容量,插入新值");
LFUCache.put(6, 66);
System.out.println(LFUCache);
}
打印输出如下:
LFUCache[{"key": 1, "value": 11, "cnt": 1}, {"key": 2, "value": 22, "cnt": 1}]
获取节点1,值为11
LFUCache[{"key": 2, "value": 22, "cnt": 1}, {"key": 1, "value": 11, "cnt": 2}]
更新节点2,新值为250
LFUCache[{"key": 1, "value": 11, "cnt": 2}, {"key": 2, "value": 250, "cnt": 2}]
达到最大容量,插入新值
LFUCache[{"key": 6, "value": 66, "cnt": 1}, {"key": 2, "value": 250, "cnt": 2}]