Java-187 Guava Cache 并发参数与 refreshAfterWrite 实战:LoadingCache 动态加载与自定义 LRU 全解析

TL;DR

  • 场景:在高并发 Java 服务中使用 Guava Cache 做本地缓存,同时需要控制刷新时延与内存占用。
  • 结论:合理设置 concurrencyLevel + refreshAfterWrite,并理解 LoadingCache 的单 key 加锁语义,可在保证线程安全的前提下拿到接近 ConcurrentHashMap 的并发性能。
  • 产出:给出并发参数选型思路、refresh 阻塞行为理解框架,以及对比自定义 LinkedHashMap LRU 的实现边界,方便在项目中直接落地配置。

版本矩阵

组件 / 能力 版本 / 范围 已验证 说明
JDK 8 1.8.x 典型存量项目环境,Guava Cache 并发与 refreshAfterWrite 行为一致
JDK 11 11.x 服务器端主流 LTS,示例代码与语义无差异
JDK 17 17.x 新项目常用 LTS,适用于文中所有配置与示例
Guava Cache 核心 API 23.x--32.x+ CacheBuilder、LoadingCache、concurrencyLevel、refreshAfterWrite 语义稳定
并发分段实现(Segment/Striped) 与 JDK ConcurrentHashMap 思路对齐 通过 segmentFor(hash) 进行分段定位,减少锁竞争
自定义 LRU(LinkedHashMap) JDK 标准库 基于 removeEldestEntry 的访问顺序 LRU,适合单线程或外层自行加锁场景

Guava Cache

并发设置

Guava Cache 通过设置 concurrencyLevel 参数来优化并发性能,使得缓存能够高效地支持多线程环境下的并发读写操作。以下是关于该机制的详细说明:

  1. 并发级别参数详解:

    • concurrencyLevel 指定了缓存内部使用的分段锁数量(默认值为4)
    • 每个分段独立管理一部分缓存条目,不同分段可以并发操作
    • 合理设置该值可以显著减少线程竞争(建议设置为预估并发线程数的1.5倍)
  2. 底层实现原理:

    • 采用分段锁(Striped Lock)技术实现
    • 将整个缓存划分为多个Segment(数量等于concurrencyLevel)
    • 每个Segment维护自己的哈希表和读写锁
    • 通过key的hashcode确定所属Segment
  3. 性能优化建议:

    • 低并发场景(<4线程):使用默认值即可
    • 中等并发(4-16线程):建议设置为8-16
    • 高并发场景(>16线程):需要根据实际压力测试调整
    • 设置过高会导致内存浪费,设置过低会造成锁竞争
  4. 实际应用示例:

java 复制代码
Cache<String, Object> cache = CacheBuilder.newBuilder()
    .concurrencyLevel(8)  // 设置为8个分段
    .maximumSize(1000)
    .build();
  1. 注意事项:
    • 该参数只在缓存构建时生效,创建后不可修改
    • 与maximumSize配合使用时,每个Segment会平均分配容量限制
    • 在极高并发场景下,可考虑结合refreshAfterWrite使用

这种设计使得Guava Cache在保持线程安全的同时,能够获得接近并发哈希表的性能表现,特别适合作为高性能应用中的本地缓存解决方案。

java 复制代码
LoadingCache<String, Object> cache = CacheBuilder.newBuilder()
                .maximumSize(3)
                // 根据CPU情况进行并发
                .concurrencyLevel(Runtime.getRuntime().availableProcessors())
                .build(new CacheLoader<String, Object>() {
                    @Override
                    public String load(String key) throws Exception {
                        return "get: " + key;
                    }
                });

concurrencyLevel = Segment 数组的长度,同 ConcurrentHashMap 类似 Guava Cache 的并发也是通过分离锁实现的:

java 复制代码
@CanIgnoreReturnValue // TODO(b/27479612): consider removing this
V get(K key, CacheLoader<? super K, V> loader) throws ExecutionException {
    int hash = hash(checkNotNull(key));
    return segmentFor(hash).get(key, hash, loader);
}

LoadingCache 采用了类似 ConcurrentHashMap 的方式,将映射表分多个 Segment,Segment 之间可以并发访问,这样可以大大的提高并发效率,使得并发冲突的可能性降低了。

更新锁定

Guava Cache 提供了一个 refreshAfterWrite 定时刷新数据的配置项,这个特性主要用于保证缓存数据的时效性。当配置了 refreshAfterWrite 后,如果缓存项在指定时间内没有被更新或覆盖,则会在下一次获取该值的时候触发刷新机制。

刷新过程的具体实现如下:

  1. 后台会启动一个异步线程去回源(如数据库、远程接口等)获取最新数据
  2. 在刷新期间,所有对该缓存项的请求会被阻塞(block),默认阻塞时间为 1 分钟
  3. 刷新过程中只有一个请求会实际执行回源操作,避免了并发回源导致的系统压力
  4. 如果在阻塞时间内成功获取到新值,则返回新值并更新缓存
  5. 如果超过阻塞时间仍未获取到新值,则会返回旧值,保证系统不会因为刷新失败而不可用

典型应用场景包括:

  • 配置信息缓存(如系统参数、开关配置等)
  • 商品信息缓存(如价格、库存等)
  • 热点数据缓存(如排行榜数据)

示例配置代码:

java 复制代码
CacheBuilder.newBuilder()
    .refreshAfterWrite(5, TimeUnit.MINUTES)  // 5分钟未更新则触发刷新
    .build(new CacheLoader<String, Object>() {
        @Override
        public Object load(String key) throws Exception {
            return fetchDataFromDB(key);  // 数据加载逻辑
        }
    });

注意事项:

  1. 与 expireAfterWrite 不同,refreshAfterWrite 不会自动移除过期数据
  2. 建议设置合理的 refresh 时间,避免过于频繁的回源操作
  3. 阻塞时间可以通过重载 CacheLoader 的 reload 方法来自定义
java 复制代码
LoadingCache<String, Object> cache = CacheBuilder.newBuilder()
                .maximumSize(3)
                // 根据 CPU 数量并发
                .concurrencyLevel(Runtime.getRuntime().availableProcessors())
                // 3秒内阻塞的话会返回旧的数据
                .refreshAfterWrite(3, TimeUnit.SECONDS)
                .build(new CacheLoader<String, Object>() {
                    @Override
                    public String load(String key) throws Exception {
                        return "get: " + key;
                    }
                });

动态加载

动态加载行为是缓存系统中常见的机制,它通常发生在以下两种场景:

  1. 首次获取数据时缓存中不存在该数据
  2. 缓存中的数据已经过期(基于时间或大小的过期策略)

Guava Cache 采用了一种优雅的回调模式来实现动态加载。具体实现机制如下:

  1. 回调模式设计

    • 用户需要预先定义数据加载方式(Loader)
    • 当缓存需要加载新数据时,会自动回调这个预定义的加载方式
    • 这种设计遵循了"好莱坞原则"(不要调用我们,我们会调用你)
  2. 线程安全处理

    • 当多个线程同时请求同一个缺失的key时
    • Guava Cache 会确保只有一个线程执行加载操作
    • 其他线程会等待加载完成并共享结果
  3. 代码实现示例

java 复制代码
// 获取对应哈希段的Segment对象
Segment<K, V> segment = segmentFor(hash);
// 调用get方法,传入key、hash值和Loader
V value = segment.get(key, hash, loader);

其中关键组件说明:

  • loader:用户自定义的数据加载逻辑,需要实现CacheLoader接口
  • segmentFor(hash):Guava Cache的分段锁实现,用于提高并发性能
  • get()方法内部会先检查缓存,若不存在则调用loader加载数据

典型应用场景:

  1. 数据库查询缓存
  2. 耗时计算结果的缓存
  3. 远程服务调用结果的缓存

这种设计既保证了线程安全,又提供了良好的扩展性,让使用者可以专注于业务逻辑的实现。

自定义LRU

java 复制代码
package icu.wzk;

import java.util.LinkedHashMap;
import java.util.Map;


public class LinkedHashMapCache<K, V> {

    private int limit;

    private LRUCache<K, V> internalCache;

    public LinkedHashMapCache(int limit) {
        this.limit = limit;
        this.internalCache = new LRUCache<>(limit);
    }

    public V get(K key) {
        return internalCache.get(key);
    }

    public void put(K key, V value) {
        this.internalCache.put(key, value);
    }

    public static void main(String[] args) {
        LinkedHashMapCache<String, String> cache = new LinkedHashMapCache<>(3);
        // 放入三个数据
        cache.put("1", "1");
        cache.put("2", "2");
        cache.put("3", "3");

        // 第四个数据
        cache.put("4", "4");
        for (Object o : cache.internalCache.values()) {
            System.out.println(o);
        }
    }
}


class LRUCache<K,V> extends LinkedHashMap<K, V> {
    private final int limit;

    public LRUCache(int limit) {
        super(limit, 0.75f, true);
        this.limit = limit;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        // 移除老的数据
        return size() > limit;
    }
}

我们尝试运行,结果如下所示:

错误速查

症状 根因 定位 修复
并发线程数上来后,缓存命中变差、Full GC 频繁 concurrencyLevel 设置过大,Segment 数量过多导致元数据与锁开销放大 检查 CacheBuilder.newBuilder() 中并发参数与实际 QPS、线程数 将 concurrencyLevel 控制在预估并发线程数附近,避免盲目按 CPU×N 放大
以为配置了 refreshAfterWrite 后,过期数据会自动清除 混淆 refreshAfterWrite(刷新)与 expireAfterWrite(过期剔除) 通过日志或监控看到 key 长时间存在但有后台回源请求 需要同时根据业务配置 expireAfterWrite 或主动 invalidate
高峰期 read 被卡住,线程堆栈停在 CacheLoader.load 附近 load / 回源逻辑过慢,且 refreshAfterWrite 单 key 刷新会阻塞其他请求 打线程 dump,观察大量线程阻塞在同一 key 的加载路径 优化回源逻辑、增加超时与降级,必要时拆 key 或引入多级缓存
刷新期间期待"后台异步不阻塞",实际请求却被挂起 误解 refresh 语义:单 key 刷新期间,其他请求默认等待结果 结合文档与代码调试发现 refresh 期间返回时间抖动 根据业务接受度决定:改用 expire+懒加载,或对热点 key 单独设计缓存
多线程场景下自定义 LinkedHashMapCache 偶现数据错乱或 NPE LinkedHashMap 非线程安全,外层未加锁就直接在并发环境下复用 在压测或线上日志中发现 size 与实际访问不一致、偶发异常 若需要并发,外层用 Collections.synchronizedMap 或 ReentrantLock 包裹,或直接改用 Guava Cache
maximumSize=3 等非常小,QPS 略高即频繁触发淘汰,命中率极差 LRU 容量过小,未结合实际 key 数和访问分布评估 cache size 监控中 eviction 数量远高于命中数 根据热 key 数量与访问模式重新估算 maximumSize,适当上调缓存容量
使用 Runtime.getRuntime().availableProcessors() 直接套到并发 CPU 核心数≠真实并发访问线程数,导致过高或过低的锁分段配置 对比线程池大小、Tomcat 连接数与 CPU 核心数,发现不匹配 以"高并发线程数"为主维度评估 concurrencyLevel,而不是机械等于 CPU 数

其他系列

🚀 AI篇持续更新中(长期更新)

AI炼丹日志-29 - 字节跳动 DeerFlow 深度研究框斜体样式架 私有部署 测试上手 架构研究 ,持续打造实用AI工具指南!
AI研究-132 Java 生态前沿 2025:Spring、Quarkus、GraalVM、CRaC 与云原生落地
🔗 AI模块直达链接

💻 Java篇持续更新中(长期更新)

Java-180 Java 接入 FastDFS:自编译客户端与 Maven/Spring Boot 实战

MyBatis 已完结,Spring 已完结,Nginx已完结,Tomcat已完结,分布式服务已完结,Dubbo已完结,MySQL已完结,MongoDB已完结,Neo4j已完结,FastDFS 已完结,OSS正在更新... 深入浅出助你打牢基础!
🔗 Java模块直达链接

📊 大数据板块已完成多项干货更新(300篇):

包括 Hadoop、Hive、Kafka、Flink、ClickHouse、Elasticsearch 等二十余项核心组件,覆盖离线+实时数仓全栈!
大数据-278 Spark MLib - 基础介绍 机器学习算法 梯度提升树 GBDT案例 详解
🔗 大数据模块直达链接

相关推荐
期待のcode几秒前
Thymeleaf模板引擎
java·html·springboot
白宇横流学长4 分钟前
基于SpringBoot实现的电子发票管理系统
java·spring boot·后端
白宇横流学长7 分钟前
基于SpringBoot实现的智慧就业管理系统
java·spring boot·后端
weixin_4624462312 分钟前
EasyExcel 动态修改模板 Sheet 名称:自定义 SheetWriteHandler 拦截器
java·开发语言·easyexcel
赵庆明老师17 分钟前
NET 使用SmtpClient 发送邮件
java·服务器·前端
苏小瀚19 分钟前
[Java EE] HTML·CSS·JavaScript基础
java·java-ee
绝世唐门三哥29 分钟前
使用Intersection Observer js实现超出视口固定底部按钮
开发语言·前端·javascript
李拾叁的摸鱼日常30 分钟前
Spring 框架中 RequestContextHolder 深度解析
java·架构
Ayu阿予38 分钟前
C++从源文件到可执行文件的过程
开发语言·c++
C++业余爱好者39 分钟前
JVM优化入门指南:JVM垃圾收集器(GC)介绍
java·开发语言·jvm