前言

Github:https://github.com/HealerJean

博客:http://blog.healerjean.com

一、本地缓存

1、JVM 缓存

` JVM 缓存,也可以认为是堆缓存。其实就是创建一些全局变量,如 Map、List` 之类的容器用于存放数据

缺点:

①、只能显式的写入,清除数据。

②、不能按照一定的规则淘汰数据,如 LRU,LFU,FIFO 等。

③、没有清除数据时的回调通知。

④、其他一些定制功能等。

2、EhcacheGuava Cache

由于 JVM 缓存的缺点,出现了一些专门用作 JVM 缓存的开源工具出现了,如: Guava Cache。它具有上文 JVM 缓存不具有的功能,如自动清除数据、多种清除算法、清除回调等。

缺点因为有了这些功能,所以必然会多出许多东西需要额外维护,自然也就增加了系统的消耗

3、分布式缓存

上面的两种缓存其实都是堆内缓存,只能在单个节点中使用,这样在分布式场景下就招架不住了。于是也有了一些缓存中间件,如 RedisMemcached,在分布式环境下可以共享内存。

二、Guava Cache

在高并发场景下我们很多场景都会使用缓存来抗高并发。通常的做法都是使用 redis 等分布式缓存+本地二级缓存方案来解决。即首先读取分布式缓存,如果分布式缓存挂了,则降级读取作为二级缓存的本地缓存。在实现本地缓存的时候,我们通常也不会闭门造车,而是使用 Google 大神提供的 Guava Cache

1、创建缓存

1)concurrencyLevel 缓存的并发级别

并发级别是指可以同时写缓存的线程数

Guava提供了设置并发级别的 api,使得缓存支持并发的写入和读取。同 ConcurrentHashMap 类似 Guava cache 的并发也是通过分离锁实现。在一般情况下,将并发级别设置为服务器 cpu 核心数是一个比较不错的选择。

cache = CacheBuilder.newBuilder()
                .concurrencyLevel(8)

2)缓存的初始容量设置

我们在构建缓存时可以为缓存设置一个合理大小初始容量,由于Guava的缓存使用了分离锁的机制,扩容的代价非常昂贵。所以合理的初始容量能够减少缓存容器的扩容次数。

cache = CacheBuilder.newBuilder()
  								   .initialCapacity(10)

2、驱逐策略

1)基于容量

Guava Cache 支持通过 .maximumSize(long) 设置缓存中最多可以存储的条目数量。当缓存条目数达到或接近该值时,会自动触发清除机制,移除“最近最少使用”(LRU)的条目以腾出空间。

  • 适用场景:缓存项大小相对均匀(例如每个 key-value 大小差不多)。
  • 清除策略:内部使用近似 LRU 算法(不是严格 LRU,但足够高效)。
  • 注意:一旦调用 put() 导致超出容量,Guava 会在插入前自动清理一条旧记录。
cache = CacheBuilder.newBuilder()
                .maximumSize(100)

2)基于权重

当缓存中每个条目的“大小”差异较大时(例如有些 value 是大对象,有些是小字符串),使用 maximumSize 就不够精细。这时可以使用权重机制

  • weigher():定义每个缓存项的“权重”,通常根据 value 的大小(如字节数、字段数等)计算。
  • maximumWeight():设置总权重上限。
  • 清除机制依然基于近似 LRU。
Cache<String, byte[]> cache = CacheBuilder.newBuilder()
    .maximumWeight(10000)
    .weigher((Weigher<String, byte[]>) (key, value) -> value.length)
    .build();

3)过期策略

Guava 提供两种过期机制:

特性 expireAfterAccess expireAfterWrite
计时起点 最后一次 访问或写入 时间 最后一次 写入 时间
过期策略 最后一次读或写(getput)开始计时,超过 duration 即视为过期。 最后一次写入(putreload)开始计时,超过 duration 即视为过期。

1)删除机制

  • 惰性删除Caffeine 采用惰性删除策略,即在尝试访问缓存项时检查其是否过期。如果缓存项已过期,则会在访问时被删除(或更准确地说,是从缓存中移除)。这意味着过期数据不会立即被删除,而是会在下一次访问时进行检查和删除。

  • 写操作触发:在某些情况下,写操作(如put invalidate )也可能会触发对过期数据的清理。但是,这主要取决于Caffeine 的内部实现和当前的缓存状态

  • 后台维护Caffeine 还包含一个后台维护线程,该线程会定期扫描缓存并删除过期的缓存项。然而,这个后台线程的频率和具体行为是可配置的,并且通常不是实时进行的。

a、expireAfterAccess:访问后过期

最后一次读或写操作开始计时,若长时间未访问则过期。

  • 场景:适合“热点数据常驻,冷数据淘汰”的场景,如页面缓存。
  • 注意;Guava 的过期是惰性删除,只有在访问时才会检查并清理过期条目。后台也有定时清理线程,但不保证实时。
.expireAfterWrite(10, TimeUnit.MINUTES)

b、expireAfterWrite:写入后过期

最后一次写入(putreload)开始计时,时间到即过期。

  • 场景:适合数据“写入后固定时间失效”的场景,如缓存用户登录信息。
cache = CacheBuilder.newBuilder()
  .expireAfterWrite(5, TimeUnit.SECONDS)
  .build(new CacheLoader<Integer, Optional<CacheDTO>>() {
    @Override
    public Optional<CacheDTO> load(Integer id) throws Exception {
      log.info("线程名:{}, 加载数据开始", Thread.currentThread().getName());
      TimeUnit.SECONDS.sleep(8);
      Random random = new Random();

      CacheDTO cacheDTO = new CacheDTO()
        .setId(random.nextInt())
        .setName("HealerJean");
      log.info("线程名:{}, 加载数据结束", Thread.currentThread().getName());
      return Optional.ofNullable(cacheDTO);
    }
  });

3、刷新-refreshAfterWrite

  • 说明:当某个缓存项自写入后到达指定时间,下一次读取该 key 时,Guava 会尝试刷新这个 key 的值。
  • 关键点
    • 条件:缓存有值才生效,缓存无值都是 load
    • 惰性:刷新的触发是惰性的——只有在访问时才会检查是否需要刷新
  • 优点:
    • 刷新期间,旧值仍可读取:即使正在刷新,其他线程依然可以读到旧值,不会出现缓存击穿或长时间等待。
    • 避免雪崩:不像 expireAfterWrite 那样过期后必须阻塞加载,refreshAfterWrite 更平滑。

1)阻塞行为

刷新过程默认是 同步阻塞 的,除非你重写 reload() 方法实现异步加载。

  • 默认行为:同步刷新(会阻塞)
  • 推荐做法:重写 reload() 实现异步刷新,返回旧值
情况 哪个线程被阻塞? 其他线程能否读取旧值?
只重写 load() 触发刷新的读线程 被阻塞 可以
重写 reload() 无阻塞,立即返回旧值 可以
.build(new CacheLoader<Key, Graph>() {
    public Graph load(Key key) {
        return getGraphFromDatabase(key);
    }

    // 异步刷新:读线程不阻塞,返回旧值
    public ListenableFuture<Graph> reload(Key key, Graph oldValue) {
        return executorService.submit(() -> {
            try {
                return getGraphFromDatabase(key);
            } catch (Exception e) {
                // 加载失败,返回旧值
                return oldValue;
            }
        });
    }
});

2)加载 vs 刷新

场景 行为 是否阻塞
缓存为空(首次访问) 调用 load() 加载 阻塞所有读线程
缓存有值,且未到刷新时间 直接返回值 不阻塞
缓存有值,且可刷新(未重写 reload 调用 load()阻塞当前读线程 阻塞当前线程
缓存有值,且可刷新(重写 reload 为异步) 异步调用 reload()立即返回旧值 不阻塞

3)总结

要点 说明
刷新触发 访问时检查是否需要刷新(惰性)
默认刷新 同步阻塞(如果只实现 load
推荐方式 重写 reload() 实现异步刷新
高可用 刷新期间旧值仍可读,避免穿透
适用场景 数据需定期更新,且不能中断服务

3)问题

a、对比 expireAfterWrite

特性 refreshAfterWrite expireAfterWrite
是否自动更新 是(访问时触发) 否(过期后删除)
读取时是否阻塞 可配置(通过 reload 是(首次访问需加载)
旧值是否可用 刷新期间仍可用 过期后不可用,直到新值加载
适用场景 数据需要定期更新,但不能中断服务 数据严格过期,必须重新加载

b、缓存为空怎么处理

  • 缓存为空:所有读线程都会阻塞(实际只有一个线程执行 load,其他等待它结果)
  • 缓存有值 + 到刷新时间:
    • reload():触发刷新的线程阻塞,其他线程不阻塞
    • reload():不阻塞,返回旧值

4、其他

1)统计

是否需要统计缓存情况,该操作消耗一定的性能,生产环境应该去除

cache = CacheBuilder.newBuilder()
											.recordStats()
  
  
log.info("缓存统计信息:{}", cache.stats());

2)监听器 removalListener

设置缓存的移除通知

cache = CacheBuilder.newBuilder()
  .removalListener(notification -> {
    log.info("缓存移除通知:key:{}, value{}, case:{}", 
             notification.getKey(), 
             notification.getValue(), 
             notification.getCause());
  })

3)cache#get

Cacheget 方法有两个参数,第一个参数是要从 Cache 中获取记录的 key,第二个记录是一个Callable对象。

⬤ 当缓存中已经存在key对应的记录时,get方法直接返回key对应的记录。

⬤ 如果缓存中不包含key对应的记录,Guava 会启动一个线程执行 Callable 对象中的 call 方法,call 方法的返回值会作为 key 对应的值被存储到缓存中,并且被 get 方法返回。

CacheDTO cacheDTO = cache.get(1).orElse(null);
CacheDTO cacheDTO = cache.get(0, () -> {
		CacheDTO cacheDTO1 = new CacheDTO()
		.setId(0)
		.setName("NULL");
		return Optional.ofNullable(cacheDTO1);
}).orElse(null);

7、工具类

1)测试类

@Slf4j
public class GuavaCacheService {

  private static Executor executor = Executors.newFixedThreadPool(10);
  private static LoadingCache<Integer, Optional<CacheDTO>> cache = null;

  public static void initCache() {
    log.info("线程名:{}, LoadingCache初始化", Thread.currentThread().getName());

    cache = CacheBuilder.newBuilder()
      //设置并发级别为8,
      .concurrencyLevel(8)
      //设置缓存容器的初始容量为10
      .initialCapacity(10)
      //设置最大存储
      .maximumSize(100)
      //是否需要统计缓存情况,该操作消耗一定的性能,生产环境应该去除
      .recordStats()
      // 缓存清除策略
      //  expireAfterWrite 写缓存后多久过期
      //  expireAfterAccess 缓存项在给定时间内没有被读/写访问
      .expireAfterWrite(5, TimeUnit.SECONDS)
      // 定时刷新,只阻塞当前数据加载线程,其他线程返回旧值。
      .refreshAfterWrite(7, TimeUnit.SECONDS)

      //设置缓存的移除通知
      .removalListener(notification -> {
        log.info("缓存移除通知:key:{}, value{}, case:{}", 
                 notification.getKey(), 
                 notification.getValue(), 
                 notification.getCause());
      })

      .build(new CacheLoader<Integer, Optional<CacheDTO>>() {
        @Override
        public Optional<CacheDTO> load(Integer id) throws Exception {
          log.info("线程名:{}, load 开始", Thread.currentThread().getName());
          CacheDTO cacheDTO = new CacheDTO()
            .setId(1)
            .setName("HealerJean");
          log.info("线程名:{}, load 结束", Thread.currentThread().getName());
          return Optional.ofNullable(cacheDTO);

        }

        @Override
        public ListenableFuture<Optional<CacheDTO>> reload(Integer id, 
                                                           Optional<CacheDTO> oldValu) throws Exception {
          log.info("线程名:{}, reload 开始", Thread.currentThread().getName());
          ListenableFutureTask<Optional<CacheDTO>> futureTask = ListenableFutureTask.create(() -> {
            Random random = new Random();
            CacheDTO cacheDTO = new CacheDTO()
              .setId(random.nextInt())
              .setName("HealerJean");
            return Optional.ofNullable(cacheDTO);
          });
          executor.execute(futureTask);
          log.info("线程名:{}, reload 已执行", Thread.currentThread().getName());
          return futureTask;
        }

      });

    log.info("线程名:{}, LoadingCache 结束", Thread.currentThread().getName());
  }

  public static void main(String[] args) {

    initCache();

    //模拟线程并发
    new Thread(() -> {
      try {
        for (int i = 0; i < 10; i++) {
          CacheDTO cacheDTO = cache.get(1).orElse(null);
          log.info("main1 线程名:{}  时间:{} cache:{}", 
                   Thread.currentThread().getName(), 
                   LocalDateTime.now(), cacheDTO);
         					 TimeUnit.SECONDS.sleep(3);
        }
      } catch (Exception ignored) {
      }
    }).start();


    new Thread(() -> {
      try {
        for (int i = 0; i < 10; i++) {
          CacheDTO cacheDTO = cache.get(0, () -> {
            CacheDTO cacheDTO1 = new CacheDTO()
              .setId(0)
              .setName("NULL");
            return Optional.ofNullable(cacheDTO1);
          }).orElse(null);


          log.info("main2 线程名:{}  时间:{} cache:{}", 
                   Thread.currentThread().getName(), 
                   LocalDateTime.now(), 
                   cacheDTO);
          TimeUnit.SECONDS.sleep(5);
        }
      } catch (Exception ignored) {
      }
    }).start();


    log.info("缓存统计信息:{}", cache.stats());
  }

}

3)工具类

import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.cache.RemovalListener;
import lombok.extern.slf4j.Slf4j;

import java.util.Map;
import java.util.concurrent.TimeUnit;

@Slf4j
public class CacheManager {

    /**
     * 缓存项最大数量
     */
    private static final long GUAVA_CACHE_SIZE = 100000;
    /**
     * 缓存时间:天
     */
    private static final long GUAVA_CACHE_DAY = 10;
    /**
     * 缓存操作对象
     */
    private static LoadingCache<Long, String> GLOBAL_CACHE = null;

    static {
        try {
            GLOBAL_CACHE = loadCache(new CacheLoader<Long, String>() {
                @Override
                public String load(Long key) throws Exception {
                    // 处理缓存键不存在缓存值时的处理逻辑
                    LoadingCache<Long, String> cache = CacheBuilder.newBuilder()
                            //缓存池大小,在缓存项接近该大小时, Guava开始回收旧的缓存项
                            .maximumSize(GUAVA_CACHE_SIZE)
                            // 设置缓存在写入之后 设定时间 后失效
                            .expireAfterWrite(GUAVA_CACHE_DAY, TimeUnit.DAYS)
                            //移除监听器,缓存项被移除时会触发
                            .removalListener((RemovalListener<Long, String>) rn -> {
                                //逻辑操作
                            })
                            //开启Guava Cache的统计功能
                            .recordStats()
                            .build(new CacheLoader<Long, String>() {
                                @Override
                                public String load(Long aLong) throws Exception {
                                    // 处理缓存键不存在缓存值时的处理逻辑
                                    return "null";
                                }
                            });
                }
            });
        } catch (Exception e) {
            log.error("初始化Guava Cache出错", e);
        }
    }

    /**
     * 设置缓存值
     * 注: 若已有该key值,则会先移除(会触发removalListener移除监听器),再添加
     *
     * @param key
     * @param value
     */
    public static void put(Long key, String value) {
        try {
            GLOBAL_CACHE.put(key, value);
        } catch (Exception e) {
            log.error("设置缓存值出错", e);
        }
    }

    /**
     * 批量设置缓存值
     *
     * @param map
     */
    public static void putAll(Map<? extends Long, ? extends String> map) {
        try {
            GLOBAL_CACHE.putAll(map);
        } catch (Exception e) {
            log.error("批量设置缓存值出错", e);
        }
    }

    /**
     * 获取缓存值
     * 注:如果键不存在值,将调用CacheLoader的load方法加载新值到该键中
     *
     * @param key
     * @return
     */
    public static String get(Long key) {
        String token = "";
        try {
            token = GLOBAL_CACHE.get(key);
        } catch (Exception e) {
            log.error("获取缓存值出错", e);
        }
        return token;
    }

    /**
     * 移除缓存
     *
     * @param key
     */
    public static void remove(Long key) {
        try {
            GLOBAL_CACHE.invalidate(key);
        } catch (Exception e) {
            log.error("移除缓存出错", e);
        }
    }

    /**
     * 批量移除缓存
     *
     * @param keys
     */
    public static void removeAll(Iterable<Long> keys) {
        try {
            GLOBAL_CACHE.invalidateAll(keys);
        } catch (Exception e) {
            log.error("批量移除缓存出错", e);
        }
    }

    /**
     * 清空所有缓存
     */
    public static void removeAll() {
        try {
            GLOBAL_CACHE.invalidateAll();
        } catch (Exception e) {
            log.error("清空所有缓存出错", e);
        }
    }

    /**
     * 获取缓存项数量
     *
     * @return
     */
    public static long size() {
        long size = 0;
        try {
            size = GLOBAL_CACHE.size();
        } catch (Exception e) {
            log.error("获取缓存项数量出错", e);
        }
        return size;
    }
}

8、原理

1)如何做到高效读写

Guava Cache 借鉴了 ConcurrentHashMap 的实现原理 ( 基于1.7版本的实现,即没有使用红黑树),使用了桶+链表的方式来实现。其这部分实现代码逻辑集合都和ConcurrentHashMap一样。如下是新增缓存项的一段代码,是不是很ConcurrentHashMap很像呢。

Guava Cache 借鉴了 ConcurrentHashMap 的思想(甚至代码结构和实现都差不多),通过分段锁的方式解决了高并发读写的问题。

public V put(K key, V value) {
  Preconditions.checkNotNull(key);
  Preconditions.checkNotNull(value);
  int hash = this.hash(key);
  return this.segmentFor(hash).put(key, hash, value, false);
}

2)缓存项数量和容量大小限制实现

maximumWeight 和 这二者的实现都是通过权重来实现的

实现容量大小限制的时候,通过 maximumWeight 来置总容量大小,然后通过 weigher 函数来计算并告诉 Guava Cache每个缓存项的容量大小。这样 Guava Cache 就只需要将所有的缓存项目的权重值相加就能够知道其是否超过最大容量限制了。

在实现缓存项数据量大小限制的时候,虽然是通过maximumSize来指定的最大缓存项数据量。其实底层使用了和权重相同的代码逻辑实现,只是这里每个缓存项的权重为1。

3)LoadingCacheload()

a、expireAfterWrite 阻塞行为

结论:多个线程请求同一个已过期的 key 时,只有一个线程执行 load(),其他线程阻塞等待结果。

关键行为如下:

  1. 缓存未命中(过期或首次访问)
    • 当一个线程(比如 Thread-A)发现缓存中没有该 key 或已过期,它会触发 load() 方法
    • 此时,Guava Cache锁定该 key,确保同一 key 不会并发加载。
  2. 其他线程访问同一 key
    • 如果此时另一个线程(Thread-B)也尝试获取同一个 key(cache.get("key")),它不会立即返回旧值或 null
    • 相反,Thread-B 会被阻塞(block),直到 Thread-A 的 load() 方法执行完成并返回结果。
    • 一旦加载完成,所有等待的线程都会获得同一个新值
LoadingCache<String, String> cache = CacheBuilder.newBuilder()
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .build(new CacheLoader<String, String>() {
        @Override
        public String load(String key) throws Exception {
            // 模拟耗时操作,如查数据库、调远程接口
            Thread.sleep(5000); // 5秒
            return "value-for-" + key;
        }
    });

b、expireAfterWrite 不适用于耗时较长的加载

  • 高并发场景下,线程阻塞 = 请求堆积 = 响应变慢甚至超时
  • 例如:100 个并发请求同时访问一个刚过期的 key,第一个请求开始加载(耗时 5s),其余 99 个请求全部阻塞 5s。
  • 这可能导致:
    • Tomcat 线程池耗尽
    • 接口超时(如前端等待 5s)
    • 级联故障(下游服务被拖慢)

c、如何解决?—— 使用 refreshAfterWrite

Guava 提供了 refreshAfterWrite 来缓解这个问题:

行为:

  • key 过期后,第一次访问该 key 的线程会触发异步刷新(在后台线程池中执行 load())。
  • 但该线程会立即返回旧值(如果存在),不会阻塞!
  • 后续请求在刷新完成前,也都会返回旧值。
  • 刷新完成后,新值生效。

优点:避免了“缓存失效瞬间大量请求阻塞”的问题,提升了响应速度和系统稳定性。

注意:refreshAfterWrite 不会主动删除过期条目,只是标记“需要刷新”,所以缓存中可能长期存在旧值(直到刷新完成)。

LoadingCache<String, String> cache = CacheBuilder.newBuilder()
    .refreshAfterWrite(10, TimeUnit.MINUTES)  // 注意:不是 expireAfterWrite
    .build(new CacheLoader<String, String>() {
        @Override
        public String load(String key) {
            return fetchFromDB(key);
        }
    });

d、更优解:使用 Caffeine Cache

Caffeine 对这种场景做了更好的设计,原生支持异步刷新API 更清晰:

  • 所有读操作都不阻塞。
  • 刷新完全异步,不影响主线程响应。
  • 性能和体验优于 Guava
AsyncLoadingCache<String, String> cache = Caffeine.newBuilder()
    .refreshAfterWrite(10, TimeUnit.MINUTES)
    .buildAsync(key -> {
        // 在 ForkJoinPool.commonPool 中异步执行
        return fetchFromDB(key);
    });

// 获取时返回 CompletableFuture
CompletableFuture<String> future = cache.get("key");

b、expireAfterWriterefreshAfterWrite 同时配置的话。

refreshAfterWrite 通常与expireAfterWrite结合使用。

需要注意的是, refreshAfterWrite设置的时间要小于expireAfterWrite,因为在读取数据的时候首先通过 expireAfterWrite 来判断数据有没有失效。如果数据失效了,会同步更新数据;如果refreshAfterWrite 时间大于expireAfterWrite,那么刷新操作永远不会执行到,设置了refreshAfterWrite也没有任何意义。

expire 小于等于 refresh 时间,优先 expire失效,同时满足走 expire(无法 refresh)。

expire大于 refresh 时间,优先 refresh,同时满足走 expire

三、Caffeine Cache

1、Caffeine 使用

1)Cache:基础手动缓存

核心特点:

  • 不支持自动加载:必须手动检查是否存在,并决定是否加载。
  • 所有操作都是显式的:你需要自己调用 getIfPresentputget(配合 Callable)等方法。
  • 不会在访问时自动填充数据。

适用场景:

  • 需要完全控制缓存加载逻辑。
  • 数据加载不频繁或条件复杂。
  • 不希望有任何“隐式”行为。

注意:

  • CaffeineCache 不支持Guava 那样的 cache.get(key, func) 方法(即传入加载函数)。
  • 如果想实现自动加载,应使用 LoadingCache
@Test
public void test() {
    Cache<String, String> cache = Caffeine.newBuilder()
            .expireAfterWrite(10, TimeUnit.MINUTES)
            .maximumSize(10000)
            .build();
    String key = "KeyName";

    // 1、查找一个缓存元素, 没有查找到的时候返回null
    String value = cache.getIfPresent(key);

    // 3、添加或者更新一个缓存元素
    cache.put(key, value);

    // 4、移除一个缓存元素
    cache.invalidate(key);
}


/**
 * createExpensiveValue
 *
 * @param key key
 * @return String
 */
private String createExpensiveValue(String key) {
    return RandomUtils.nextInt() + key;
}

2)AsyncCache:基础异步缓存

核心特点:

  • 不预设加载逻辑:你需要在每次 get 时传入加载函数。
  • 操作返回 CompletableFuture<V>
  • 支持 get(key, func),即“如果不存在,则异步加载”。

行为说明:

  • 如果 key 存在:返回已有的 CompletableFuture
  • 如果 key 不存在:执行传入的函数,异步加载并放入缓存。
  • 多个线程请求同一个 key:只执行一次加载,共享 CompletableFuture(防击穿)。

适用场景:

  • 加载逻辑动态变化。
  • 需要灵活控制何时、如何加载。
  • 与响应式编程模型集成。
@Test
public void asyncCache() {
    AsyncCache<String, String> cache = Caffeine.newBuilder()
            .expireAfterWrite(10, TimeUnit.MINUTES)
            .maximumSize(10_000)
            .buildAsync();
    String key = "KeyName";

    // 1、查找一个缓存元素, 没有查找到的时候返回null
    CompletableFuture<String> graph = cache.getIfPresent(key);
    
    // 2、查找缓存元素,如果不存在,则异步生成,返回一个 CompletableFuture<String> 正在加载
    graph = cache.get(key, k -> createExpensiveValue(key));
  
    //3、添加或者更新一个缓存元素
    cache.put(key, graph);
  
    //4、移除一个缓存元素
    cache.synchronous().invalidate(key);
}

3)LoadingCache: 自动加载的同步缓存

核心特点:

  • 支持自动加载:在构建时提供一个加载函数(K -> V),访问不存在的 key 时自动调用。
  • 支持刷新机制
  • 同步阻塞:如果缓存未命中,调用线程会等待加载完成。
  • 返回类型为 V,使用方便。

适用场景:

  • 简单的“查缓存,无则加载”逻辑。
  • 加载速度较快,可接受短时间阻塞。
  • 需要与旧代码兼容(类似 Guava Cache 的行为)。
@Test
public void loadingCache() {
    LoadingCache<String, String> cache = Caffeine.newBuilder()
            .maximumSize(10000)
            .expireAfterWrite(10, TimeUnit.MINUTES)
            .build(this::createExpensiveValue);
    // 批量查找缓存,如果缓存不存在则生成缓存元素
    List<String> keys = Lists.newArrayList("key1", "key2");
    Map<String, String> valueMap = cache.getAll(keys);
    cache.asMap().get("key");
    String key = cache.get("key");
}

3)AsyncLoadingCache:自动加载的异步缓存

核心特点:

  • 支持自动异步加载:在构建时指定一个返回 CompletableFuture<V> 的加载函数。
  • 所有操作返回 CompletableFuture<V>
  • get(key) 会自动触发异步加载(如果未命中)。

适用场景:

  • 高并发系统,不能容忍阻塞。
  • 数据源响应慢(如远程 API、数据库)。
  • 希望实现“无感刷新”,提升用户体验。
@Test
public void asyncLoadingCache() {

    AsyncLoadingCache<String, String> cache = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(10, TimeUnit.MINUTES)
            // 你可以选择: 去异步的封装一段同步操作来生成缓存元素
            .buildAsync(this::createExpensiveValue);
            // 你也可以选择: 构建一个异步缓存元素操作并返回一个future
            // .buildAsync((key, executor) -> createExpensiveGraphAsync(key, executor));

    // 1、查找缓存元素,如果其不存在,将会异步进行生成
    String key = "KeyName";
    CompletableFuture<String> graph = cache.get(key);

    // 2、批量查找缓存元素,如果其不存在,将会异步进行生成
    List<String> keys = Lists.newArrayList("key1", "key2");
    CompletableFuture<Map<String, String>> graphs = cache.getAll(keys);
}

2、驱逐策略

1)基于容量

a、基于缓存内的元素个数进行驱逐

// 基于缓存内的元素个数进行驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .maximumSize(10_000)
    .build(key -> createExpensiveGraph(key));

b、基于缓存内元素权重进行驱逐

// 基于缓存内元素权重进行驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .maximumWeight(10_000)
    .weigher((Key key, Graph graph) -> graph.vertices().size())
    .build(key -> createExpensiveGraph(key));

2)基于时间

过期策略 说明 场景
expireAfterWrite 在写入缓存后的一段时间后过期,即从写入时开始计时,过期时间到达后缓存数据会被自动移除。 如果你需要在缓存项被写入一段时间后过期,则可以使用expireAfterWrite
expireAfterRead expireAfterRead 策略指的是缓存项在最后一次被读取之后的一段时间内过期 适用于那些一旦被读取后,其数据在短时间内不太可能再次被需要的情况。例如,某些临时数据或查询结果,它们在首次被读取后,其有效性可能随时间迅速降低。
expireAfterUpdate 在缓存项自最近一次更新之后经过一段时间后过期,即从最近一次更新开始计时,过期时间到达后缓存数据会被自动移除。 如果你需要在缓存项自最近一次更新之后过期,则可以使用expireAfterUpdate
expireAfterAccess expireAfterAccess策略则是指缓存项在最后一次被访问(无论是读取还是写入)之后的一段时间内过期。这里的“访问”是一个更广泛的概念,包括了读取和写入操作。因此,从缓存项最后一次被访问的那一刻开始计时,如果在设定的时间内该缓存项没有被再次访问,则它将被视为过期并被移除。 适用于那些需要基于访问频率来管理缓存项的情况。例如,某些热点数据在频繁访问时可能保持较高的有效性,但一旦访问频率降低,其有效性也可能随之降低。

3)基于引用

a、当 key 和 缓存元素 都不再存在其他强引用的时候驱逐

LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .weakKeys()
    .weakValues()
    .build(key -> createExpensiveGraph(key));

b、当进行 GC 的时候进行驱逐

// 当进行GC的时候进行驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .softValues()
    .build(key -> createExpensiveGraph(key));

3、缓存说明

1)刷新机制

a、行为

行为 机制
数据加载 第一次 get → 缓存未命中 → 自动加载(load)
数据刷新 后续 get → 检查是否到了 refresh 时间 → 是 → 异步刷新(reload),返回旧值
数据过期 get → 检查是否过期 → 是 → 同步加载新值(或自动加载)

b、术语

机制 说明
自动加载 get(key) 缓存未命中时,自动调用 load(key) 获取数据
自动刷新 配置 refreshAfterWrite,在 get 时若满足条件,异步刷新数据(旧值仍可用)
手动刷新 调用 cache.refresh(key) 或外部定时任务主动更新
过期失效 配置 expireAfterWrite,数据过期后下次 get 会阻塞加载

2)缓存比较

  • 如果你完全不想用自动加载 → 用 Cache + 手动 put/get
  • 如果你需要手动控制加载时机 → 用 AsyncCache

  • 如果你想要最简单的同步缓存 → 用 LoadingCache
  • 如果你追求高性能、非阻塞、自动刷新 → 用 AsyncLoadingCache
类型 是否自动加载 刷新机制    
Cache 手动 不支持    
AsyncCache 手动(但支持 get(key, func) 不支持    
LoadingCache 同步 支持 refreshAfterWrite    
AsyncLoadingCache 异步 支持 refreshAfterWrite    
特性/类型 Cache LoadingCache AsyncCache AsyncLoadingCache
自动加载 不支持 支持(同步) 不直接支持(需手动传入加载函数) 支持(异步)
操作返回类型 V (同步获取值) V (同步获取值) CompletableFuture<V> (异步获取值) CompletableFuture<V> (异步获取值)
刷新机制 不支持 支持 refreshAfterWrite() 不支持 支持 refreshAfterWrite()
线程安全
适用场景 对于不需要自动加载的数据,或者希望手动控制加载逻辑的情况 简单的缓存需求,适合需要在数据缺失时自动加载的情况 需要异步处理且对灵活性有较高要求的应用 高性能应用,尤其是需要非阻塞加载和高并发环境下的使用

4、统计

通过使用 Caffeine.recordStats() 方法可以打开数据收集功能。Cache.stats() 方法将会返回一个CacheStats 对象,其将会含有一些统计指标,比如:

Cache<Key, Graph> graphs = Caffeine.newBuilder()
    .maximumSize(10_000)
    .recordStats()
    .build();

| 方法 | 说明 | | ———————- | ——————– | | ` hitRate(): | 查询缓存的命中率 | | evictionCount(): | 被驱逐的缓存数量 | | averageLoadPenalty()` | 新值被载入的平均耗时 |

5、实战

需求 推荐方案
想要简单、自动加载,不怕轻微阻塞 LoadingCache + get(key)
使用 Cache 但不想返回 null cache.get(key, function)
高并发、低延迟,避免阻塞 AsyncLoadingCache + refreshAfterWrite
必须手动控制加载逻辑 getIfPresent + 手动加载 + put
允许返回默认值 返回空字符串、空集合等

四、比较 GuavaCaffeine

1、关键比较

对比维度 Caffeine Guava Cache
核心定位 专注高性能本地缓存,为高并发场景优化设计 Guava 工具库的一部分,缓存功能相对基础,侧重通用性
性能表现 高并发下优势显著,基于分段锁 + 无锁结构,读写延迟低、吞吐量高 低并发场景表现稳定,但高并发时锁竞争明显,性能下降快
缓存策略 支持 容量 / 时间 / 引用(弱引用、软引用) 回收,支持权重自定义容量控制 仅支持 容量 / 时间 回收,引用策略(如弱引用)支持有限,容量控制依赖 LRU 等
功能特性 提供 异步加载 / 刷新、丰富统计指标(命中率、加载耗时等)、接近实时的淘汰 仅支持 同步加载,统计指标少(仅基础命中率),淘汰策略触发相对滞后
内存管理 内存优化更激进,减少冗余对象,适合缓存大规模数据 内存占用相对较高,缓存数据量大时可能引发频繁 GC
生态适配 Spring Boot 2.0+ 默认集成,与现代框架(如 Micronaut)深度兼容 需手动集成,依赖 Guava 整体库(引入冗余工具类),老项目中使用广泛

2、方法:refreshAfterWrite

Guava CacheCaffeinerefreshAfterWrite 的触发机制上几乎完全一致

对比项 Guava Cache Caffeine
refreshAfterWrite 触发时机 访问时检查(懒触发) 访问时检查(懒触发)
默认刷新方式 同步(除非重写 reload 异步(天然非阻塞)
是否阻塞读取线程 阻塞(除非重写 reload 不阻塞(返回旧值)
API 易用性 较复杂(需处理 ListenableFuture 更简洁(基于 CompletableFuture
性能表现 高并发下可能成为瓶颈 更适合高并发、低延迟场景

ContactAuthor