Java本地缓存技术——Guava、Caffeine

本地缓存介绍

提到缓存我们首先会想到Redis,Redis是一种分布式缓存,应用进程与缓存进程运行在不同主机上,进程之间通过RPC或HTTP的方式通信。这种缓存方式可以实现服务解耦,但数据要经过网络传输,不可避免地会带来性能损耗,对实时性要求更高、缓存数据更少的场景下可以考虑本地缓存。

Redis是独立进程,就算部署到本地也会因为跨进程开销,导致性能不如本地缓存

适用场景

  • 静态配置/元数据类信息:如系统配置、规则参数、黑白名单等;
  • 变更不频繁,数据量小的信息:如地区编码、字典表等数据;
  • 计算结果缓存:需要密集计算的结果暂存到本地缓存,避免重复计算;
  • 临时数据:临时状态(如验证码、临时 token,非跨端共享),数据仅在当前请求有效,无需持久化。

本地缓存实现方法

  • 原生JDK实现:HashMap(线程安全可使用ConcurrentHashMap)。可实现最简单的数据保存功能,但无过期数据清理、淘汰策略、监控等功能,只能用于极简单的项目场景。
  • Guava Cache:Google开源的工具类库,可实现容量限制、过期策略,LRU淘汰方案,性能略逊于Caffeine,多在旧项目中使用。
  • Caffeine:Java领域性能最好的本地缓存库之一,基于LRU算法的改进版本(W-TinyLFU)实现。

Guava基本使用

Guava cache

引入依赖

xml 复制代码
<dependency>
   <groupId>com.google.guava</groupId>
   <artifactId>guava</artifactId>
   <version>31.0.1-jre</version>
</dependency>

使用Cache缓存的基本操作代码如下:

java 复制代码
public void HandTest() throws ExecutionException {
Cache<String, String> cache = CacheBuilder.newBuilder()
        .expireAfterWrite(3, TimeUnit.MINUTES)  // 写入后 3 分钟过期
        .concurrencyLevel(5)    // 最大并发写入线程数
        .initialCapacity(100)   // 初始容量
        .maximumSize(1000)  // 最大容量
        .build();
// 缓存写入数据
cache.put("key", "value");

String value1=cache.get("key",new Callable<String>() {
    // 回调函数,当缓存中不存在key时的操作,先加载到缓存再返回
    @Override
    public String call(){
        return "Null";
    }
});

String value2=cache.get("key1",new Callable<String>() {
    public String call(){
        return "Null";
    }
});
// 获取记录
String value3=cache.getIfPresent("key");

System.out.println("获取到value1:"+value1);
System.out.println("获取到value2:"+value2);

// 记录失效
cache.invalidate("key");
// 清除过期记录
cache.cleanUp();
}

LoadingCache

上述过程需要在每次get中指定默认逻辑,在此基础上Guava封装了LoadingCache类,可在build方法内指定通用处理逻辑,同时支持异步刷新,使用实例如下:

java 复制代码
LoadingCache loadingCache=CacheBuilder.newBuilder()
   .refreshAfterWrite(3, TimeUnit.MINUTES) // 写入后刷新,调用reload逻辑
   .build(new CacheLoader<String, String>() {
       // 未命中时同步加载缓存数据,同步阻塞进程,多个线程同时查找时只会被一个线程加载,防止击穿
       @Override
       public String load(String s) throws Exception {
           System.out.println("缓存未命中,正在查库");
           return queryfromdb(s);
       }
       // 刷新方案
       @Override
       public ListenableFuture<String> reload(String key, String oldValue) throws Exception {
           // 查询更新记录
           return Futures.immediateFuture(queryfromdb(key));
       }

       public String queryfromdb(String s){
           return s;
       }
   });

首先介绍Guava的内置逻辑load,该加载方法会阻塞进程,但自带缓存击穿保护机制,多个线程同时查找相同的记录key时只会触发一次load,避免缓存击穿。

工程优化

上述过程实现了缓存未命中时的记录刷新方案,但从工程角度来说仍然不足,主要是两方面:

  1. 健壮性。如果下游服务宕机或返回错误数据,Guava缓存可能会保存错误值
  2. 效率。现有记录刷新采用同步阻塞方案,缓存刷新不影响用户请求响应,可另开线程异步执行。

优化后的代码如下:

java 复制代码
// 线程池包装成执行器
ListeningExecutorService listeningExecutorService = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(4));
LoadingCache<String, String> loadingCache=CacheBuilder.newBuilder()
        .refreshAfterWrite(3, TimeUnit.MINUTES) // 写入指定时间后刷新
        .build(new CacheLoader<String, String>() {
            // 未命中时同步加载缓存数据,同步阻塞进程,多个线程同时查找时只会被一个线程加载,防止击穿
            @Override
            public String load(String s) throws Exception {
                try {
                    return getConfig(s);
                } catch (Exception e) {
                    log.error("load config failed, key={}", s, e);
                    return ""; // 兜底
                }
            }
            // 异步刷新方案
            @Override
            public ListenableFuture<String> reload(String key, String oldValue) throws Exception {
                // 异步更新数据
                return listeningExecutorService.submit(()->{
                    try {
                        return getConfig(key);
                    }
                    catch (Exception e) {
                        log.error("reload config failed, key={}", key, e);
                        // 出错了使用旧值
                        return oldValue;
                    }
                });
            }

            public String getConfig(String s){
                String data=getConfigData(s);
                if(s==null||s.isEmpty()){
                    // 数据出错时返回空数据
                    return "";
                }
                return data;
            }
        });

首次加载是同步阻塞,异步更新是通过 reload 和线程池实现,更新数据时若读取配置抛出异常,会使用旧值兜底,从效率和健壮性两个层面保证了缓存可用。

该方案通过refreshAfterWrite设置刷新策略 ,重写reload刷新方法,保证缓存不为空,在刷新方法中使用线程池,提高缓存系统执行效率。

Caffeine基本使用

Caffeine简介

Caffeine是本地缓存Guava的继任者,使用更先进的缓存淘汰算法(W-TinyLFU),优化了内部数据结构,在命中率、吞吐量和内存效率都优于Guava,目前是Spring Cache的默认底层实现,新建项目的本地缓存模块可优先选择Caffeine。

W-TinyLFU缓存淘汰算法

  • 传统算法局限性:LRU(最近最久未使用)可能淘汰高频但短期未访问的条目;LFU(访问频率最低)需要为每个条目维护精确的访问计数器,内存开销大。
  • 设计思想:用极小的内存开销近似统计访问频率,对突发访问和长期高频访问均保持高命中率,避免传统 LFU 的计数器膨胀问题。
  • 核心组件:窗口缓存(Window Cache)缓存最新访问的条目, 主缓存(Main Cache)缓存高频访问的条目,TinyLFU 频率统计通过多个哈希函数近似统计访问频率。
  • 淘汰流程:新访问条目进入窗口缓存,窗口缓存满时与主缓存中试用区的条目竞争,试用区中高频访问的条目会晋升到保护区。
  • 优势:窗口缓存吸收突发流量,主缓存保留长期高频条目,频率统计比LRU更精准,计数器仅需4位,内存开销小,适应数据访问模式的变化,无锁或细粒度锁设计,减少线程竞争。

有关算法详细介绍可见:W-TinyLFU 淘汰算法详解

maven中引入依赖:

xml 复制代码
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

基本 Cache

使用方法与Guava基本类似,示例如下:

java 复制代码
@Test
void testCache(){
    Cache<String,String> cache= Caffeine.newBuilder()
            .maximumSize(1000) // 缓存最大条目数量
            .expireAfterAccess(5, TimeUnit.MINUTES) // 过期时间五分钟
            .build();

    cache.put("username","kevin");
    // 获取数据,不存在则返回null
    String username=cache.getIfPresent("username");
    // 获取数据,不存在则调用方法自动加载
    String username1=cache.get("username1",key->{
        return "get_kevin";
    });
    System.out.println(username);
    System.out.println(username1);
}

LoadingCache

这种方式是统一了缓存不存在的调用逻辑,将自动加载方法写在LoadingCache缓存类内部,使用示例如下:

java 复制代码
LoadingCache<String, String> loadingCache = Caffeine.newBuilder()
        .maximumSize(10_000) //_分割线,不影响实际数字 
        .expireAfterAccess(10, TimeUnit.MINUTES) // 访问后10分钟过期
        .build(new CacheLoader<String, String>() {
            @Override
            public String load(String key) throws Exception {
                // 统一的加载逻辑
                return key+"_get_kevin";
            }
        });
loadingCache.put("username","kevin");
System.out.println(loadingCache.get("username"));
System.out.println(loadingCache.get("username1"));

异步操作

在Guava章节中,我们设置了线程池将缓存更新为异步,caffeine对异步操作提供了原生支持,对应Cache和LoadingCache分别为AsyncCache和AsyncLoadingCache,使用实例如下:

java 复制代码
AsyncLoadingCache<String,String> asyncLoadingCache=Caffeine.newBuilder()
		.refreshAfterWrite(30, TimeUnit.SECONDS) //写入30s后刷新
       	.buildAsync(new CacheLoader<String, String>() {
           public String load(String key) throws Exception {
               return key+"_Async_get_kevin";
           }
       });
// 异步获取缓存数据值,包装成CompletableFuture类
CompletableFuture<String> valueFuture = CompletableFuture.supplyAsync(() -> {
   // 模拟耗时操作
   try {
       Thread.sleep(100);
   } catch (InterruptedException e) {
       e.printStackTrace();
   }
   return "kevin"; // 异步获取到的值
});
// 存入缓存
asyncLoadingCache.put("username",valueFuture);

// 获取异步结果容器
CompletableFuture<String> future=asyncLoadingCache.get("username");
// 成功获取结果的回调
future.thenAccept(username -> {
   System.out.println("异步获取到用户名:"+username);
});
// 异常处理回调
future.exceptionally(e -> {
   System.out.println("加载缓存失败"+e.getMessage());
   return "guest"; // 异常兜底返回默认值
});

// 等待 put 操作和 get 对应的 future 都完成(阻塞主线程)
CompletableFuture.allOf(valueFuture, future).join(); 

// 读取端可视业务需要选择同步获取方法
try {
    // 添加 1 秒超时时间,避免无限阻塞
    String us = future.get(1, TimeUnit.SECONDS); 
    System.out.println("同步获取用户名:" + us);
} catch (ExecutionException | InterruptedException e) {
    // 异步任务执行异常或线程被中断
    log.error("获取缓存值失败", e);
    throw new RuntimeException(e);
} catch (TimeoutException e) {
    // 超时异常单独捕获,做针对性兜底
    log.warn("获取缓存值超时(1秒)", e);
    // 业务兜底:返回默认值,而非直接抛异常
    String defaultUs = "guest";
    System.out.println("同步获取用户名(兜底):" + defaultUs);
}

完全异步读写速度极快,如果不加最后的阻塞语句,主线程立即退出,而异步任务还未完成。

开发者可以直接调用这些方法,而无需关注底层安全细节,这就是caffeine框架的价值所在。

缓存统计

Caffeine有缓存统计功能,包括缓存命中、加载等情况,帮助开发者优化缓存设计,使用示例如下:

java 复制代码
Cache<String, User> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .recordStats() // 开启统计
    .build();
// 使用一段时间后获取统计信息
CacheStats stats = cache.stats();
System.out.println("命中率:" + stats.hitRate());
System.out.println("平均加载时间(纳秒):" + stats.averageLoadPenalty());
System.out.println("加载失败率:" + stats.loadFailureRate());

/*
 * hitCount :命中的次数
 * missCount:未命中次数
 * requestCount:请求次数
 * hitRate:命中率
 * missRate:丢失率
 * loadSuccessCount:成功加载新值的次数
 * loadExceptionCount:失败加载新值的次数
 * totalLoadCount:总条数
 * loadExceptionRate:失败加载新值的比率
 * totalLoadTime:全部加载时间
 * evictionCount:丢失的条数
 */

总结

本地缓存对比分布式缓存:分布式缓存有额外的网络通信开销,对于性能要求更高、数据量少的场景可以有效考虑本地缓存。

本地缓存介绍:Java生态中Caffeine是首选方案,基于谷歌Guava的改进,现在是完全的上位替代,使用更新的淘汰算法,更小的内存开销,提供同步和异步两种不同的读写方式,让开发者更关注业务本身,无需考虑底层细节。

参考文献:
Java中的四种本地缓存
Java本地缓存技术选型
本地缓存之王Caffeine
本地缓存王者Caffeine

相关推荐
weixin_515069662 小时前
1.guava-retrying 重试框架
java·框架·guava·java常用api
lkbhua莱克瓦242 小时前
反射4-反射获取成员变量
java·开发语言·servlet·反射
lifewange2 小时前
Linux 日志查看命令速查表
java·linux·运维
风景的人生2 小时前
一台电脑上可以同时运行多个JVM(Java虚拟机)实例
java·开发语言·jvm
阿蒙Amon2 小时前
C#每日面试题-进程和线程的区别
java·面试·c#
一 乐2 小时前
养老院信息|基于springboot + vue养老院信息管理系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端
hopsky3 小时前
mvn install 需要 手动清除 pom.lastUpdated
java·maven·mavbne
5980354153 小时前
【java工具类】小数、整数转中文小写
android·java·开发语言
cike_y3 小时前
Mybatis之作用域(Scope)和生命周期-解决属性名和字段名不一致的问题&ResultMap结果集映射
java·开发语言·数据库·tomcat·mybatis