JVM缓存就是将数据缓存到JVM内存中,这种缓存是最快的一种缓存方式。如果缓存的数据不多,并且服务是单机的话,那么就可以考虑使用JVM缓存而非使用第三方的redismemcache等。

JVM缓存我们可以选择将需要缓存的数据存储到一个Map中,然后启动一个线程去管理这个Map中的数据。Guava Cache做的就是这样的一件事。当然它会把这件事做好的同时拓展更多的接口,使开发者使用时更加方便。

安装依赖

<dependency>
  <groupId>com.google.guava</groupId>
  <artifactId>guava</artifactId>
  <version>30.1-jre</version>
</dependency>

构建缓存

普通缓存

Guava Cache提供了Builder模式构建缓存对象。

@Test
public void cacheTest() throws InterruptedException {
  Cache<String, String> cache = CacheBuilder.newBuilder()
    //设置并发级别为8,并发级别是指可以同时写缓存的线程数
    .concurrencyLevel(8)
    //设置缓存容器的初始容量为10
    .initialCapacity(10)
    //设置缓存最大容量为100,超过100之后就会按照LRU最近最少使用算法来移除缓存项
    .maximumSize(100)
    // 是否需要统计缓存情况,该操作消耗一定的性能,生产环境应该去除
    .recordStats()
    // 设置写缓存后n秒钟过期
    .expireAfterWrite(60, TimeUnit.SECONDS)
    // 设置n秒未访问就过期
    // .expireAfterAccess(60, TimeUnit.SECONDS)
    // 写入数据后n秒过期,只阻塞当前数据加载线程,其他线程返回旧值
    // .refreshAfterWrite(60, TimeUnit.SECONDS)
    // 缓存移除监听器
    .removalListener(notification -> System.out.printf("%s : %s 被移除了", notification.getKey(), notification.getValue()))
    .build();

  // 存缓存
  cache.put("test", "test");
  // 输出缓存的统计信息
  System.out.println(cache.stats().toString());
  // 获取缓存
  String test = cache.getIfPresent("test");
  // 再次查看缓存的状态(缓存状态只有在get操作后才会更新)
  System.out.println(cache.stats().toString());
  Assert.assertEquals("test", test);
  // 等待60秒,让缓存过期
  TimeUnit.SECONDS.sleep(60);
  // 还可以使用invalidate方法手动移除缓存
  // cache.invalidate("test");
  test = cache.getIfPresent("test");
  System.out.println(test);
  // 查看缓存的状态
  System.out.println(cache.stats().toString());
  Assert.assertNull(test);
}

自动加载缓存

LoadingCache

LoadingCacheCache的一个子接口,该接口提供了一个load方法,通过这个方法可以提供一个加载缓存的方式,比如从数据库获取数据等。

public class DemoCacheLoader extends CacheLoader<String, Object> {

    @Override
    public Object load(@Nonnull String key) throws Exception {
        // 从数据库加载数据,这里模拟从数据库加载数据
        return this.loadData().get(key);
    }

    private Map<String, Object> loadData() {
        Map<String, Object> map = new LinkedHashMap<>();
        List<String> list = new LinkedList<>();
        list.add("111");
        list.add("222");
        list.add("333");
        list.add("444");
        list.add("555");
        list.add("666");
        list.add("777");
        map.put("num", list);
        map.put("test", "test");
        map.put("name", "Bennett");
        map.put("age", 24);
        map.put("sex", "man");
        return map;
    }
}
@Test
public void cacheLoaderTest() throws ExecutionException, InterruptedException {
  // 默认监听器是一个同步操作,可以使用下面的代码将其装饰成异步操作
  RemovalListener<Object, Object> listener = RemovalListeners.asynchronous(notification -> {
    System.out.printf("%s : %s 被移除了\n", notification.getKey(), notification.getValue());
  }, Executors.newSingleThreadExecutor());
  LoadingCache<String, Object> cacheLoader = CacheBuilder.newBuilder()
    // 设置并发级别为8,并发级别是指可以同时写缓存的线程数
    .concurrencyLevel(8)
    // 设置缓存容器的初始容量为10
    .initialCapacity(10)
    // 设置缓存最大容量为100,超过100之后就会按照LRU最近虽少使用算法来移除缓存项
    .maximumSize(100)
    // 是否需要统计缓存情况,该操作消耗一定的性能,生产环境应该去除
    .recordStats()
    // 设置写缓存后n秒钟过期
    .expireAfterWrite(60, TimeUnit.SECONDS)
    // 设置n秒未访问就过期
    // .expireAfterAccess(60, TimeUnit.SECONDS)
    // 写入数据后n秒过期,只阻塞当前数据加载线程,其他线程返回旧值
    // .refreshAfterWrite(60, TimeUnit.SECONDS)
    // 设置移除监听器
    .removalListener(listener)
    // build方法中可以指定CacheLoader,在缓存不存在时通过CacheLoader的实现自动加载缓存
    .build(new DemoCacheLoader());

  String test = (String) cacheLoader.get("test");
  System.out.println(test);
  System.out.println(cacheLoader.stats().toString());
  TimeUnit.SECONDS.sleep(60);
  // 又通过load方法加载了一遍,所以会觉得好像没过期一样
  test = (String) cacheLoader.get("test");
  System.out.println(cacheLoader.stats().toString());
  System.out.println(test);
}

使用缓存默认值

@Test
public void defaultCacheTest() throws ExecutionException, InterruptedException {
  Cache<String, Object> cache = CacheBuilder.newBuilder()
    //设置并发级别为8,并发级别是指可以同时写缓存的线程数
    .concurrencyLevel(8)
    //设置缓存容器的初始容量为10
    .initialCapacity(10)
    //设置缓存最大容量为100,超过100之后就会按照LRU最近虽少使用算法来移除缓存项
    .maximumSize(100)
    // 是否需要统计缓存情况,该操作消耗一定的性能,生产环境应该去除
    .recordStats()
    // 设置写缓存后n秒钟过期
    .expireAfterWrite(60, TimeUnit.SECONDS)
    .removalListener(notification -> System.out.printf("%s : %s 被移除了", notification.getKey(), notification.getValue()))
    .build();

  String val = (String) cache.get("key1", () -> {
    System.out.println(Thread.currentThread().getName());
    return "default_value";
  });
  System.out.println(val);
}

Cache的更多方法

@Test
public void moreCacheTest() throws ExecutionException, InterruptedException {
  Cache<String, Object> cache = CacheBuilder.newBuilder()
    //设置并发级别为8,并发级别是指可以同时写缓存的线程数
    .concurrencyLevel(8)
    //设置缓存容器的初始容量为10
    .initialCapacity(10)
    //设置缓存最大容量为100,超过100之后就会按照LRU最近虽少使用算法来移除缓存项
    .maximumSize(100)
    // 是否需要统计缓存情况,该操作消耗一定的性能,生产环境应该去除
    .recordStats()
    // 设置写缓存后n秒钟过期
    .expireAfterWrite(60, TimeUnit.SECONDS)
    // 设置key,value的弱引用/软引用
    .weakKeys()
    // .weakValues()
    .softValues()
    .removalListener(notification -> System.out.printf("%s : %s 被移除了", notification.getKey(), notification.getValue()))
    .build();

  String test = new String("test");
  cache.put("test", test);
  test = new String("11111");
  System.gc();
  // 弱引用则输出null,软引用值会依旧存在(软引用只有jvm内存不足时才会将其回收)
  System.out.println(cache.getIfPresent("test"));
  
  // 手动移除缓存
  cache.invalidate("key");
  String[] keys = new String[]{"111", "222"};
  // 移除部分/全部缓存
  cache.invalidateAll(Arrays.asList(keys));
  cache.invalidateAll();
}

缓存的移除时机

redis中有key的懒惰移除,即不使用get操作就不会执行移除操作。而Guava Cache中也是类似的,缓存在未执行get操作时并不会去将缓存移除,可以通过下面的一个demo来观察效果。

@Test
    public void cacheRemoveTest() throws InterruptedException {
        Cache<String, Object> cache = CacheBuilder.newBuilder()
                //设置并发级别为8,并发级别是指可以同时写缓存的线程数
                .concurrencyLevel(8)
                //设置缓存容器的初始容量为10
                .initialCapacity(10)
                //设置缓存最大容量为100,超过100之后就会按照LRU最近虽少使用算法来移除缓存项
                .maximumSize(100)
                // 是否需要统计缓存情况,该操作消耗一定的性能,生产环境应该去除
                .recordStats()
                // 设置写缓存后n秒钟过期
                .expireAfterWrite(5, TimeUnit.SECONDS)
                .removalListener(notification -> System.out.printf("%s : %s 被移除了", notification.getKey(), notification.getValue()))
                .build();

        cache.put("testkey", "testval");

        new Thread(()->{
            while (true) {
                System.out.println(LocalTime.now() + ", size: " + cache.size());
                // 每隔一秒获取一次缓存大小
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException ignored) {

                }
            }
        }).start();

        // 睡眠2分钟(缓存过期时间为5秒,为了保证确实应该过期,设置等待时长为2分钟,如果测试时觉得时间太长可以调短一点)
        try {
            TimeUnit.MINUTES.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("==============分隔符==============");
        cache.put("hhh", "aaaa");
        System.out.println(cache.size());

        // 睡眠2分钟(保证第二个key也过期)
        try {
            TimeUnit.MINUTES.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("------------------分隔符--------------");
        System.out.println(cache.size());

        System.out.println("*****************分隔符***************");
        System.out.println("hhh:" + cache.getIfPresent("hhh") + ", cache size: " + cache.size());
    }

下面是输出的部分数据,重复的无意义数据没有全部放出来。

22:57:29.142, size: 1
22:57:30.148, size: 1
22:57:31.150, size: 1
22:57:32.151, size: 1
...
==============分隔符==============
2
22:59:29.487, size: 2
22:59:30.489, size: 2
23:01:27.819, size: 2
23:01:28.823, size: 2
...
------------------分隔符--------------
2
*****************分隔符***************
hhh:null, cache size: 1

从上面输出的信息来看,确实是如果不进行get操作就不会移除缓存,从放入第一个key后缓存大小为1,然后在2分钟后(此时第一个key已过期),放入第二个key,此时缓存大小是2。这说明2分钟后缓存依旧未移除,4分钟后第二个key的缓存也过期后,再次查看缓存大小时,缓存大小依旧2,直至get操作第二个key后,缓存大小才变为1。这说明确实是只有get操作后才会进行缓存的移除,与redis的懒惰移除是一样的,Guava Cache并未另起线程去检测缓存是否已过期,并在过期后立马将其移除。

参考文章

作者:rickiyang

出处:Guava cache使用总结

作者:poype

出处:Guava Cache用法介绍

作者:零壹技术栈

出处:理解Java的强引用、软引用、弱引用和虚引用

如果文章中有错误的,欢迎在评论区留言指正,谢谢!