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

TL;DR

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

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

版本矩阵

组件 / 能力版本 / 范围已验证说明
JDK 81.8.x典型存量项目环境,Guava Cache 并发与 refreshAfterWrite 行为一致
JDK 1111.x服务器端主流 LTS,示例代码与语义无差异
JDK 1717.x新项目常用 LTS,适用于文中所有配置与示例
Guava Cache 核心 API23.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. 实际应用示例:

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

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

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 的并发也是通过分离锁实现的:

@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. 如果超过阻塞时间仍未获取到新值,则会返回旧值,保证系统不会因为刷新失败而不可用

典型应用场景包括:

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

示例配置代码:

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 方法来自定义
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. 代码实现示例

// 获取对应哈希段的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

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;
            }
            }

我们尝试运行,结果如下所示:
自定义实现LRU Cache

错误速查

症状根因定位修复
并发线程数上来后,缓存命中变差、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 偶现数据错乱或 NPELinkedHashMap 非线程安全,外层未加锁就直接在并发环境下复用在压测或线上日志中发现 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案例 详解
大数据模块直达链接

posted @ 2026-01-16 19:04  yangykaifa  阅读(1)  评论(0)    收藏  举报