高级篇 04. 多级缓存 - JVM 进程缓存之 Caffeine 初识

4 阅读5分钟

📚 高级篇 04. 多级缓存 - JVM 进程缓存之 Caffeine 初识

一、 核心认知:为什么需要 JVM 进程缓存?

在引入 Caffeine 之前,我们先理清一个架构常识:有了 Redis,为什么还要搞本地缓存?

  1. 突破网络 I/O 的物理极限:

    Tomcat 去读 Redis,需要经过网卡序列化、网络传输、Redis 处理、网络传回、反序列化。哪怕在同一个内网,这个过程通常也需要 1~5 毫秒 (ms)

    而读取 JVM 进程内的缓存,直接在内存地址中寻址,没有任何网络开销,速度是 纳秒级 (ns) !两者相差成千上万倍。

  2. 终结 Redis 热点 Key 灾难:

    如果某个明星宣布结婚,相关微博的访问量瞬间暴涨。如果全靠 Redis 扛,存放该微博数据的单一 Redis 节点会被瞬间打瘫。如果把这条数据缓存在每个 Tomcat 实例的 JVM 内存里,就能完美实现流量的“去中心化分流”。

🌟 什么是 Caffeine?

Caffeine 是一个基于 Java 8 的高性能本地缓存库。它极度压榨了 Java 内存的读取性能,并且提供了一种近乎完美的缓存淘汰算法(W-TinyLFU),号称是目前 Java 领域命中率最高、性能最强的本地缓存之王。


二、 核心机制:缓存驱逐策略 (Eviction)

由于 JVM 堆内存(Heap)极其宝贵(通常就几个 G),我们绝对不能把海量的商品数据无限量地塞进 Caffeine 里,否则一定会引发恐怖的 OOM(内存溢出) 导致系统崩溃。

因此,Caffeine 提供了三种核心的“缓存驱逐(淘汰)策略”,告诉框架什么时候该把老数据扔掉:

1. 基于容量驱逐 (maximumSize)

这是最简单粗暴、也是最常用的兜底策略。

  • 规则: 指定缓存里最多能放多少个对象。当数量快达到上限时,Caffeine 会根据内部算法,把最不常用的数据踢出去。
  • 场景: “我只给本地缓存留了 10000 个位置,多出来的就给我滚。”

2. 基于时间驱逐 (expireAfterWrite / expireAfterAccess)

缓存的数据不能永远有效,否则后台改了商品价格,前端看到的永远是老价格。

  • expireAfterWrite (写后过期): 数据被存入缓存后,经过指定时间就失效。(强烈推荐! 性能极高,时间一到绝对过期)。
  • expireAfterAccess (读写后过期): 数据被**访问(读或写)**后,经过指定时间失效。如果一个热点数据一直被读,它就永远不过期。(适合那些“只要有人看就一直留着”的场景,但维护成本稍高)。

3. 基于引用驱逐 (Reference)

  • 规则: 利用 Java 的软引用 (Soft) 和弱引用 (Weak) 机制,把缓存对象的存活权交给 JVM 的垃圾回收器 (GC)。当 JVM 内存不足时,自动回收这些缓存。
  • 缺点: 极度不可控!线上排查问题简直是噩梦,企业级开发中强烈不推荐使用!

三、 极速上手:Caffeine API 核心实操

让我们用原生 Java 代码来感受一下 Caffeine 的优雅。

第 1 步:引入依赖

XML

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

第 2 步:基础用法演示 (面试常考手撕)

Java

public class CaffeineDemo {
    public static void main(String[] args) {
        
        // 1. 构建 Caffeine 缓存对象 (配置驱逐策略)
        Cache<String, String> cache = Caffeine.newBuilder()
                .maximumSize(10_000) // 最大容量 1万个
                .expireAfterWrite(Duration.ofSeconds(60)) // 写入后 60 秒过期
                .build();

        // 2. 存入数据
        cache.put("user:1001", "张三");

        // 3. 读取数据 (如果不存,返回 null)
        String user = cache.getIfPresent("user:1001"); 
        System.out.println(user); // 输出: 张三

        // 🌟 4. 【企业级王炸用法】:优雅的 get 方法
        // 逻辑:去缓存里找 "user:1002"。如果有,直接返回;
        // 如果没有,就自动执行后面的 Lambda 表达式 (去数据库查),并把查到的结果自动存入缓存,然后返回!
        String user2 = cache.get("user:1002", key -> {
            System.out.println("缓存未命中,开始查询数据库...");
            return "李四(从数据库查出)"; // 模拟数据库查询结果
        });
        
        System.out.println(user2); 
    }
}

💡 亮点解析: 第 4 步的 cache.get(key, function) 方法,完美解决了一个痛点:以往我们写缓存逻辑,总是要写一堆 if (cacheData == null) { 查库; 存缓存; } return cacheData; 的臃肿代码。Caffeine 一行代码全部搞定,极其优雅!


四、 大厂高频拷问:Caffeine 为什么那么强?(W-TinyLFU)

在秋招大厂面试中,如果你在简历里写了“使用 Caffeine 优化多级缓存”,面试官一定会问: “Caffeine 凭什么取代 Guava Cache?它的核心优势是什么?”

💡 满分防身话术:

“Caffeine 最核心的优势在于它的超高命中率,这得益于它独创的 W-TinyLFU 缓存淘汰算法。

传统的淘汰算法有两个痛点:

LRU(最近最少使用): 很容易被偶发的“全量扫描”或者“突发冷门流量”把真正的热点数据洗掉(缓存污染)。

LFU(最不经常使用): 能够抵御偶发流量,但它需要维护所有数据的访问频率,极度消耗内存;而且如果一个数据曾经是热点,后来过气了(比如旧新闻),它因为历史频率极高,很难被淘汰出局(历史包袱)。

Caffeine 的 W-TinyLFU 完美融合了这两者的优点:

  1. 它使用了一种类似布隆过滤器的 Count-Min Sketch 数据结构来记录访问频率,把频率统计的内存占用压缩到了极致(Tiny)。
  2. 它引入了基于时间的衰减机制,解决了 LFU 的历史包袱问题,让老热点数据能够顺利退休。
  3. 它引入了一个前端的 Window Cache(W),专门用来接纳新进入的数据,确保那些“刚刚爆发的新热点”在还没积攒够访问频率之前,不会被立刻踢出缓存。

凭借这个算法,Caffeine 在有限的内存下,做到了业界天花板级别的命中率。”


学习总结

这一节,你成功解锁了 Java 性能最霸道的进程内缓存框架——Caffeine。

它不依赖网络,极致压榨了 CPU 和内存的性能,是我们多级缓存架构中最靠近应用层、速度最快的一道防线。