淘汰策略终极对决!手写 LFU、CLOCK、FIFO,碾压面试官!

引言

Redis 的缓存淘汰策略是其高效内存管理的核心,LFU(Least Frequently Used,最少使用频次)、CLOCK 和 FIFO(First-In-First-Out)提供了多样化的淘汰方式,适用于不同场景。在面试中,“如何实现 LFU 或 CLOCK 算法?” 是高频考题,考察你对数据结构和算法的深度理解。继前四篇实现基础缓存、键过期、AOF 持久化和 LRU 后,本篇带你手写 LFU、CLOCK 和 FIFO 淘汰策略,打造MultiEvictionCache,让你在面试中一招制胜!

目标:

  • 实现 LFU、CLOCK 和 FIFO 缓存淘汰策略,支持灵活切换。

  • 优化性能,保持 O(1) 或近似 O(1) 复杂度。

  • 兼容 TTL 和 AOF 持久化,提供可运行代码和测试用例。

  • 提供详细步骤、图表和面试要点,助你深入学习。

适合人群:

  • 想深入理解 Redis 淘汰策略的开发者。

  • 准备大厂面试、需手写复杂算法的程序员。

  • 对缓存优化感兴趣的初学者和进阶者。

前置工作:

  • 环境:Java 8+,Maven/Gradle。

  • 依赖:前四篇的 SimpleCache、ExpiryCache、AofCache、LruCache 类(本篇继承)。

  • 代码仓库:https://github.com/yourname/redis-handwritten.git。

  • 建议:运行前四篇代码后,跟着本篇敲代码,体验多种淘汰策略的魅力!

Redis 淘汰策略场景
在这里插入图片描述

为什么需要多种淘汰策略?

Redis 的内存有限,缓存满时需要通过淘汰策略移除数据。不同策略适用于不同场景:

  • LFU(最少使用频次):基于访问频率淘汰,优先移除访问次数最少的键,适合长期热点数据场景。

  • CLOCK:近似 LRU,使用参考位和循环指针,效率高且内存开销低,适合高并发。

  • FIFO(先进先出):按插入顺序淘汰,简单但命中率较低,适合临时数据。

本篇将实现 MultiEvictionCache,支持 LFUCLOCKFIFO 策略,兼容 TTL 和 AOF,为后续哈希表优化(第6篇)铺路。

实现步骤(详细展开)

以下是实现 MultiEvictionCache 的五个详细步骤,
确保用户清晰理解每个环节,易于学习和实践。

步骤 1:设计统一数据结构

目标:

  • 为 LFU、CLOCK 和 FIFO 设计统一的数据结构,支持多种淘汰策略,兼容 TTL 和 AOF。

详细说明:

  • Node 类:扩展前篇的 Node,增加字段支持多种策略:

      key, value, expiry(TTL,继承自 ExpiryCache)。
    
  • accessCount(LFU 的频率计数)。

  • referenceBit(CLOCK 的引用位,0 或 1)。

  • insertionOrder(FIFO 的插入序号)。

  • prev, next(双向链表指针,复用 LRU 结构)。

  • HashMap:存储键到节点的映射,O(1) 查找。

  • 双向链表:维护节点顺序(LFU 用频率排序,CLOCK/FIFO 用插入顺序)。

  • 策略标志:添加 evictionStrategy 枚举(LFU, CLOCK, FIFO),运行时切换策略。

  • 哨兵节点:头尾哨兵简化链表操作。

  • 兼容性:保留 TTL(过期检查)和 AOF(持久化)功能。

要求:

  • 定义 enum EvictionStrategy { LFU, CLOCK, FIFO }。

  • Node 包含所有字段,灵活支持三种策略。

  • 初始化时指定策略,默认 LFU。

为何如此设计:

  • 统一数据结构减少代码冗余,支持动态切换策略。

  • 字段复用(如 accessCount)兼顾 LFU 和缓存污染优化。

  • 哨兵节点和 HashMap 确保 O(1) 或近似 O(1) 操作。

MultiEvictionCache 数据结构

在这里插入图片描述

步骤 2:实现 LFU 淘汰策略

目标:
实现 LFU 算法,基于访问频率淘汰最少使用的键。

详细说明:

LFU 逻辑:

  • 每次 get 或 put 增加节点 accessCount。

  • 缓存满时,移除 accessCount 最低的节点(若频率相同,移除最久未访问的)。

数据结构:

  • 使用 HashMap 快速定位节点。

  • 使用双向链表维护频率排序(低频节点靠近尾部)。

  • 为优化查找最低频率,维护一个 TreeMap<Integer, List>(频率到节点列表的映射)。

操作流程:

  • get:查找节点,增加 accessCount,更新频率映射,移到高频位置。

  • put:插入或更新节点,更新频率,缓存满时移除最低频率节点。

TTL 兼容 检查过期时间,过期键优先移除。

要求:

  • get 和 put 操作尽量接近 O(1)(TreeMap 可能引入 O(log n))。

  • 维护频率排序,确保最低频率节点易于查找。

  • 异常处理空输入、无效 TTL。

为何如此设计:

  • LFU 适合热点数据场景,频率计数提高命中率。
  • TreeMap 优化最低频率查找,平衡复杂度和效率。
  • 兼容 TTL 确保与前篇无缝衔接。

LFU 操作流程
在这里插入图片描述

步骤 3:实现 CLOCK 淘汰策略

目标:
实现 CLOCK 算法,近似 LRU,降低内存开销。
详细说明:
CLOCK 逻辑:

  • 每个节点有 referenceBit(0 或 1),表示最近是否被访问。
  • 使用循环指针遍历链表,缓存满时:
  • 若节点 referenceBit = 1,置为 0,指针后移。
    若节点 referenceBit = 0,移除该节点。
  • get 或 put 将节点 referenceBit 置为 1。

数据结构:

  • 复用双向链表,循环指针(clockHand)指向当前检查节点。

  • HashMap 存储键到节点映射。

操作流程:

  • get:查找节点,置 referenceBit = 1,返回值。
  • put:插入或更新节点,置 referenceBit = 1,缓存满时运行 CLOCK 扫描。
  • TTL 兼容:过期键优先移除。

要求:

  • CLOCK 扫描接近 O(1) 均摊复杂度。

  • 维护 clockHand 指针,确保循环遍历。

  • 兼容 AOF 和 TTL 逻辑。

为何如此设计:

  • CLOCK 是 Redis 近似 LRU 的实现,内存开销低(仅需 1 位)。
  • 循环指针简化实现,适合高并发场景。
  • 复用链表结构减少代码改动。

时钟驱逐机制图解
在这里插入图片描述

步骤 4:实现 FIFO 淘汰策略

目标:

  • 实现 FIFO 算法,按插入顺序淘汰最早的键。

详细说明:

  1. FIFO 逻辑:
  • 按插入顺序维护节点,缓存满时移除最早插入的键(链表尾部)。

  • 每个节点记录 insertionOrder(全局递增计数)。

  1. 数据结构:
  • 复用双向链表,按插入顺序排列(最早插入的在尾部)。

  • HashMap 存储键到节点映射。

  1. 操作流程:
  • get:查找节点,返回值(不影响顺序)。

  • put:插入新节点到头部,缓存满时移除尾部节点。

  • TTL 兼容:过期键优先移除。

要求:

  • FIFO 操作保持 O(1) 复杂度。

  • 维护插入顺序,尾部节点即最早插入。

  • 兼容 AOF 和 TTL。

为何如此设计:

  • FIFO 实现简单,适合临时数据场景。

  • 复用链表结构,代码改动最小。

  • 插入顺序记录确保淘汰准确。

FIFO 操作流程
在这里插入图片描述

步骤 5:添加测试用例

目标:
通过 JUnit 测试验证 LFU、CLOCK 和 FIFO 功能,覆盖淘汰、TTL 和 AOF。

详细说明:
测试场景:

  • LFU:验证低频键淘汰,高频键保留。
  • CLOCK:验证参考位更新和循环扫描。
  • FIFO:验证按插入顺序淘汰。
  • TTL 兼容:过期键自动移除。
  • AOF恢复 :模拟重启,验证数据和策略状态。
  • 异常处理:测试空输入、无效 TTL。

实现细节:

  • 使用 @Before 和 @After 清理 AOF 文件和资源。

  • 测试每种策略的独立实例(strategy=LFU/CLOCK/FIFO)。

  • 通过 Thread.sleep 模拟 TTL 过期。

为何如此设计:

  • 全面测试确保代码可靠性,增强用户信心。
  • 模拟真实场景(如过期、重启)帮助理解策略差异。
  • 详细注释便于学习和调试。

核心代码实现

以下是 MultiEvictionCache 的完整实现,存放在 MultiEvictionCache.java,继承自 AofCache

前置代码SimpleCacheExpiryCacheAofCacheLruCache

为节省篇幅,假设前四篇的类已存在:

  • SimpleCache:固定大小缓存。
  • ExpiryCache:TTL、惰性删除、定期删除。
  • AofCache:AOF 持久化。
  • LruCache:LRU 淘汰。
    主代码:MultiEvictionCache
import java.io.*;
import java.util.*;
import java.util.concurrent.*;

public class MultiEvictionCache extends AofCache {
    public enum EvictionStrategy { LFU, CLOCK, FIFO }

    private class Node {
        String key, value;
        long expiry;
        int accessCount; // LFU
        int referenceBit; // CLOCK
        long insertionOrder; // FIFO
        Node prev, next;
        Node(String key, String value, long expiry) {
            this.key = key;
            this.value = value;
            this.expiry = expiry;
            this.accessCount = 1;
            this.referenceBit = 1;
            this.insertionOrder = insertionCounter++;
        }
    }

    private Map<String, Node> cache;
    private Node head, tail;
    private int capacity;
    private EvictionStrategy strategy;
    private TreeMap<Integer, List<Node>> freqMap; // LFU 频率映射
    private Node clockHand; // CLOCK 指针
    private long insertionCounter; // FIFO 插入序号

    public MultiEvictionCache(int capacity, EvictionStrategy strategy) {
        super(capacity);
        this.capacity = capacity;
        this.strategy = strategy;
        this.cache = new HashMap<>();
        this.freqMap = new TreeMap<>();
        this.head = new Node(null, null, 0);
        this.tail = new Node(null, null, 0);
        head.next = tail;
        tail.prev = head;
        this.clockHand = head;
        this.insertionCounter = 0;
        loadAof();
    }

    @Override
    public String get(String key) {
        if (key == null) throw new IllegalArgumentException("Key cannot be null");
        Node node = cache.get(key);
        if (node == null || System.currentTimeMillis() > node.expiry) {
            if (node != null) {
                removeNode(node);
                cache.remove(key);
                if (strategy == EvictionStrategy.LFU) updateFreqMap(node, -node.accessCount);
            }
            return null;
        }
        updateNodeAccess(node);
        return node.value;
    }

    @Override
    public void put(String key, String value, long ttlMillis) {
        if (key == null || value == null) throw new IllegalArgumentException("Key or value cannot be null");
        if (ttlMillis <= 0) throw new IllegalArgumentException("TTL must be positive");

        Node node = cache.get(key);
        if (node != null) {
            node.value = value;
            node.expiry = System.currentTimeMillis() + ttlMillis;
            updateNodeAccess(node);
        } else {
            if (cache.size() >= capacity) {
                removeEvictedNode();
            }
            node = new Node(key, value, System.currentTimeMillis() + ttlMillis);
            cache.put(key, node);
            addToHead(node);
            if (strategy == EvictionStrategy.LFU) {
                freqMap.computeIfAbsent(1, k -> new ArrayList<>()).add(node);
            }
        }
        appendToAof("PUT", key, value, ttlMillis);
    }

    private void updateNodeAccess(Node node) {
        if (strategy == EvictionStrategy.LFU) {
            updateFreqMap(node, -node.accessCount);
            node.accessCount++;
            updateFreqMap(node, node.accessCount);
            moveToHead(node);
        } else if (strategy == EvictionStrategy.CLOCK) {
            node.referenceBit = 1;
        } else if (strategy == EvictionStrategy.FIFO) {
            // FIFO 不更新顺序
        }
    }

    private void removeEvictedNode() {
        if (strategy == EvictionStrategy.LFU) {
            Map.Entry<Integer, List<Node>> entry = freqMap.firstEntry();
            if (entry != null) {
                List<Node> nodes = entry.getValue();
                Node node = nodes.remove(nodes.size() - 1);
                if (nodes.isEmpty()) freqMap.remove(entry.getKey());
                removeNode(node);
                cache.remove(node.key);
            }
        } else if (strategy == EvictionStrategy.CLOCK) {
            while (true) {
                clockHand = clockHand.next == tail ? head.next : clockHand.next;
                if (clockHand == head) continue;
                if (System.currentTimeMillis() > clockHand.expiry) {
                    Node toRemove = clockHand;
                    clockHand = clockHand.prev;
                    removeNode(toRemove);
                    cache.remove(toRemove.key);
                    return;
                }
                if (clockHand.referenceBit == 0) {
                    removeNode(clockHand);
                    cache.remove(clockHand.key);
                    return;
                }
                clockHand.referenceBit = 0;
            }
        } else if (strategy == EvictionStrategy.FIFO) {
            Node toRemove = tail.prev;
            if (toRemove != head) {
                removeNode(toRemove);
                cache.remove(toRemove.key);
            }
        }
    }

    private void addToHead(Node node) {
        node.next = head.next;
        node.prev = head;
        head.next.prev = node;
        head.next = node;
    }

    private void removeNode(Node node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    private void moveToHead(Node node) {
        removeNode(node);
        addToHead(node);
    }

    private void updateFreqMap(Node node, int count) {
        if (strategy != EvictionStrategy.LFU) return;
        freqMap.computeIfAbsent(count, k -> new ArrayList<>()).remove(node);
        if (freqMap.get(count) != null && freqMap.get(count).isEmpty()) {
            freqMap.remove(count);
        }
    }
}

测试代码
以下是 MultiEvictionCacheTest.java,验证 LFUCLOCKFIFO 功能。

import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import static org.junit.Assert.*;

import java.io.File;

public class MultiEvictionCacheTest {
    private MultiEvictionCache cache;
    private File aofFile = new File("appendonly.aof");

    @Before
    public void setUp() {
        if (aofFile.exists()) aofFile.delete();
    }

    @After
    public void tearDown() {
        if (cache != null) cache.shutdown();
        if (aofFile.exists()) aofFile.delete();
    }

    @Test
    public void testLfuEviction() throws InterruptedException {
        cache = new MultiEvictionCache(3, MultiEvictionCache.EvictionStrategy.LFU);
        cache.put("key1", "value1", 5000);
        cache.put("key2", "value2", 5000);
        cache.put("key3", "value3", 5000);
        for (int i = 0; i < 5; i++) cache.get("key1"); // key1 高频
        cache.put("key4", "value4", 5000); // 移除低频 key2
        assertNull(cache.get("key2"));
        assertEquals("value1", cache.get("key1"));
    }

    @Test
    public void testClockEviction() {
        cache = new MultiEvictionCache(3, MultiEvictionCache.EvictionStrategy.CLOCK);
        cache.put("key1", "value1", 5000);
        cache.put("key2", "value2", 5000);
        cache.put("key3", "value3", 5000);
        cache.get("key1"); // referenceBit = 1
        cache.put("key4", "value4", 5000); // 移除 referenceBit = 0 的键
        assertTrue(cache.size() <= 3);
    }

    @Test
    public void testFifoEviction() {
        cache = new MultiEvictionCache(3, MultiEvictionCache.EvictionStrategy.FIFO);
        cache.put("key1", "value1", 5000);
        cache.put("key2", "value2", 5000);
        cache.put("key3", "value3", 5000);
        cache.put("key4", "value4", 5000); // 移除 key1(最早插入)
        assertNull(cache.get("key1"));
        assertEquals("value4", cache.get("key4"));
    }

    @Test
    public void testTtlAndAof() throws InterruptedException {
        cache = new MultiEvictionCache(3, MultiEvictionCache.EvictionStrategy.LFU);
        cache.put("key1", "value1", 1000);
        Thread.sleep(1500);
        assertNull(cache.get("key1")); // 过期
        cache.put("key2", "value2", 5000);
        cache = new MultiEvictionCache(3, MultiEvictionCache.EvictionStrategy.LFU);
        assertEquals("value2", cache.get("key2")); // AOF 恢复
    }
}

运行方式:

  • 确保 Maven 项目包含 JUnit 依赖(同前四篇)。

  • 将 SimpleCache.java, ExpiryCache.java, AofCache.java, LruCache.java, MultiEvictionCache.java 和 MultiEvictionCacheTest.java 放入项目。

  • 运行测试:mvn test 或通过 IDE 执行。

测试输出(示例):

	Cache full, removed key: key2

面试要点

问:如何实现一个高效的 HashMap?
  • 答:使用数组 + 链表,哈希函数映射键到桶,链表处理冲突。动态扩容保持负载因子。本实现用位运算优化哈希,容量为 2 的幂。
问:HashMap 的冲突处理如何优化?
  • 答:链表适合低冲突场景,高冲突可转为红黑树(如 Java 8)。本实现用链表,简单且高效。
问:动态扩容的性能影响?
  • 答:一次性 rehash 可能导致延迟,可通过渐进式 rehash 分摊开销(第7篇实现)。
问:Redis 哈希表与 Java HashMap 的区别?
  • 答:Redis 使用自定义哈希表,支持渐进式 rehash和内存优化,适合高并发。本实现是简化版,接近 Java HashMap。

下一步预告

恭喜你掌握 Redis 的哈希表核心!下一篇文章,我们将实现 渐进式 rehash,让哈希表扩容更高效!敬请期待!
完整代码:https://github.com/codeInbpm/redis-handwritten

**运行代码了吗?有问题?欢迎在评论区留言!**
觉得本篇炸裂吗?一键三连,下一期更精彩!
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

wáng bēn

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值