Java开发利器Guava Cache之使用篇

前言

提到缓存,可能第一时间想到的就是Redis、Memcache等,这些都属于是分布式缓存,而在某些场景下我们可能并不需要分布式缓存,毕竟需要多引入维护一个中间件,那么在数据量小,且访问频繁,或者说一些不会变的静态配置数据我们都可以考虑放置到本地缓存中,那么我们平时是怎么做的呢?相信大家在写或者在读有关本地缓存代码时,都会看到如下实现方式:

private static final Map<K,V> LOCAL_CACHE = new ConcurrentHashMap<>();

的确这种方式简单有效,但是带来的弊端就是过于简单,功能也就过于缺乏,而且如果使用不大,将带来可怕的内存溢出,比如谈起缓存,那不得不提缓存淘汰策略缓存过期策略等,但是不要着急,强大的Guava工具库已经为我们提供了简单有效的Guava Cache。

值得注意的是,请不要被强大的Guava Cache迷惑双眼,如果你的缓存场景用不到这些缓存的特性,那么ConcurrentHashMap或许是你最好的选择

Guava Cache

官方地址:https://github.com/google/guava/wiki/CachesExplained

Guava Cache能力一览

入门使用

key对应的缓存值计算方式

缓存无非可能就是缓存那些耗时很长的计算(除了CPU型任务,I/O型也算)出来值,只有第一次从缓存中访问指定key时,才会进行真正的计算,那么Guava Cache就提供三种缓存计算方式,你也可以理解为缓存加载方式,它们分别是CacheLoader、Callable、直接插入

CacheLoader

CacheLoader方式,简单点说就是计算方式作用于所有key,也就是说通过CacheLoader方法创建的Cache,不管你访问哪个key,它的计算方式都是同一个,来看示例:

@Test
public void guavaCacheTest001(){
    LoadingCache<String, String> loadingCache = CacheBuilder.newBuilder().maximumSize(2)
        .build(new CacheLoader<String, String>() {
            @Override
            public String load(String key) throws Exception {
                System.out.println(key+"真正计算了!");
                return "cache-"+key;
            }
        });

    System.out.println(loadingCache.getUnchecked("key1"));
    System.out.println(loadingCache.getUnchecked("key1"));

    System.out.println(loadingCache.getUnchecked("key2"));
    System.out.println(loadingCache.getUnchecked("key2"));
}

对应输出:

key1真正计算了!
cache-key1
cache-key1
key2真正计算了!
cache-key2
cache-key2

在这个例子中,我们通过给CacheBuilder的build方法传入一个CacheLoad的匿名类,该CacheLoad的load方法逻辑为当获取某个缓存key时,如果该key缓存中不存在,则将计算其缓存值的计算方式。从输出我们可以看到,只有缓存第一次访问时才真正执行了值的计算行为,并且每个缓存key的计算方式都一样。

Callable

当对CacheLoader有了认识之后,你可能会想:如果我针对不同的缓存key的计算缓存值方式并不一样,那该怎么办啊!,别急,Callable为你保驾护航:

@Test
public void testCallable() throws ExecutionException {
    Cache<Object, Object> cache = CacheBuilder.newBuilder().build();
    Object cacheKey1 = cache.get("key1", () -> {
        System.out.println("key1真正计算了");
        return "key1计算方式1";
    });
    System.out.println(cacheKey1);

    cacheKey1 = cache.get("key1",()->{
        System.out.println("key1真正计算了");
        return "key1计算方式1";
    });
    System.out.println(cacheKey1);

    Object cacheKey2 = cache.get("key2", () -> {
        System.out.println("key1真正计算了");
        return "key1计算方式2";
    });
    System.out.println(cacheKey2);

    cacheKey2 = cache.get("key2",()->{
        System.out.println("key1真正计算了");
        return "key1计算方式2";
    });
    System.out.println(cacheKey2);
}

输出:
key1真正计算了
key1计算方式1
key1计算方式1
key1真正计算了
key1计算方式2
key1计算方式2

从例子中可以看到,在调用get的时候,可以传入一个Callable来为此缓存key提供专门的缓存值计算方式。

直接插入

这种方式计算缓存值的逻辑不再由Guava Cache管理,而是调用方可以调用put(key,value) 直接将要缓存的值插入。

@Test
public void testDirectInsert() throws ExecutionException {
    Cache<Object, Object> cache = CacheBuilder.newBuilder().build();
    cache.put("key1","cache-key1");
    System.out.println(cache.get("key1",()->"callable cache-key1"));
}

输出:
cache-key1

缓存淘汰机制

有一个残酷的事实就是,往往我们没有那么大的内存去支撑我们的缓存,所以我们必须有效的利用起来我们这昂贵的内存,即针对那些不常用的缓存及时剔除,那么Guava Cache为我们提供了三种缓存剔除机制:基于大小剔除、基于缓存时间剔除、基于引用剔除。

基于大小剔除

这里并不是指占用缓存大小,而是指缓存条目的数量,当缓存key的数量达到指定数量时,将按照LRU针对缓存key进行剔除。

@Test
public void testSizeBasedEviction(){
    LoadingCache<String, String> loadingCache = CacheBuilder.newBuilder().maximumSize(3)
        .build(new CacheLoader<String, String>() {
            @Override
            public String load(String key) throws Exception {
                System.out.println(key+"真正计算了");
                return "cached-" + key;
            }
        });

    System.out.println("第一次访问");
    loadingCache.getUnchecked("key1");
    loadingCache.getUnchecked("key2");
    loadingCache.getUnchecked("key3");

    System.out.println("第二次访问");
    loadingCache.getUnchecked("key1");
    loadingCache.getUnchecked("key2");
    loadingCache.getUnchecked("key3");

    System.out.println("开始剔除");
    loadingCache.getUnchecked("key4");

    System.out.println("第三次访问");
    loadingCache.getUnchecked("key3");
    loadingCache.getUnchecked("key2");
    loadingCache.getUnchecked("key1");
}

输出:
第一次访问
key1真正计算了
key2真正计算了
key3真正计算了
第二次访问
开始剔除
key4真正计算了
第三次访问
key1真正计算了

在上面这个例子中,设置了最大缓存条目为3,然后依次添加了三个缓存项,并且依次进行了访问,可以看到当第一次访问时,由于缓存中都没值,因此进行了计算,第二次访问时,由于缓存中都有值所以直接从缓存读取,到了开始剔除阶段时,此时尝试获取之前没访问过的key4,而由于最大缓存条目为3,所以此时需要从缓存中剔除掉一个值,那么剔除谁呢?遵循LRU算法,key1是最近最不常不使用的,所以剔除的就是key1了,从我们第三次访问输出的结果就可以验证。

注意:如果maximumSize传入0,则所有key都将不进行缓存!

除了maximumSize指定缓存key最大数量,也可以通过maximumWeight指定最大权重,就是说,每个缓存的key都需要返回一个权重,如果所有缓存的key的权重之和大于了我们指定的最大权重,那么将执行LRU淘汰策略:

@Test
public void testWeightBasedEviction(){
    LoadingCache<String, String> loadingCache = CacheBuilder.newBuilder().maximumWeight(6).weigher((key,value)->{
        if (key.equals("key1")){
            return 1;
        }
        if (key.equals("key2")){
            return 2;
        }
        if (key.equals("key3")){
            return 3;
        }

        if (key.equals("key4")){
            return 1;
        }
        return 0;
    })
        .build(new CacheLoader<String, String>() {
            @Override
            public String load(String key) throws Exception {
                System.out.println(key+"真正计算了");
                return "cached-" + key;
            }
        });

    System.out.println("第一次访问");
    loadingCache.getUnchecked("key1");
    loadingCache.getUnchecked("key2");
    loadingCache.getUnchecked("key3");

    System.out.println("第二次访问");
    loadingCache.getUnchecked("key1");
    loadingCache.getUnchecked("key2");
    loadingCache.getUnchecked("key3");

    System.out.println("开始剔除");
    loadingCache.getUnchecked("key4");
    loadingCache.getUnchecked("key3");
    loadingCache.getUnchecked("key2");
    loadingCache.getUnchecked("key1");
}

输出:
第一次访问
key1真正计算了
key2真正计算了
key3真正计算了
第二次访问
开始剔除
key4真正计算了
key1真正计算了

这个就不多解释了吧,自己根据输出想想...

基于时间剔除

Guava Cache针对CacheBuilder提供了两个方法:expireAfterAccess(long, TimeUnit)expireAfterWrite(long, TimeUnit)

  • expireAfterAccess
    顾名思义,当某个缓存key自最后一次访问(读取或者写入)超过指定时间后,那么这个缓存key将失效。
@Test
public void testExpiredAfterAccess() throws InterruptedException {
    LoadingCache<String, String> loadingCache = CacheBuilder.newBuilder().expireAfterAccess(3,TimeUnit.SECONDS)
        .build(new CacheLoader<String, String>() {
            @Override
            public String load(String key) throws Exception {
                System.out.println(key+"真正计算了");
                return "cached-" + key;
            }
        });

    System.out.println("第一次访问(写入)");
    loadingCache.getUnchecked("key1");

    System.out.println("第二次访问");
    loadingCache.getUnchecked("key1");

    TimeUnit.SECONDS.sleep(3);
    System.out.println("过3秒后访问");
    loadingCache.getUnchecked("key1");
}

输出:
第一次访问(写入)
key1真正计算了
第二次访问
过3秒后访问
key1真正计算了

这个例子中,我们设置了缓存自最近一次访问(或写入)超过3秒后,将失效,通过输出也可以看到确实 如此。

  • expireAfterWrite
    顾名思义,当缓存key自最近一次写入(注意,这就是和expireAfterAccess的区别,expireAfterWrite强调写,不关心读)超过一定时间则过期剔除:
@Test
public void testExpiredAfterWrite() throws InterruptedException {
    LoadingCache<String, String> loadingCache = CacheBuilder.newBuilder().expireAfterWrite(3,TimeUnit.SECONDS)
        .build(new CacheLoader<String, String>() {
            @Override
            public String load(String key) throws Exception {
                System.out.println(key+"真正计算了");
                return "cached-" + key;
            }
        });
    for (int i = 0; i < 4; i++) {
        System.out.println(new Date());
        loadingCache.getUnchecked("key1"); //首次执行的时候,为写入
        TimeUnit.SECONDS.sleep(1);
    }
}

输出:
Sat Oct 02 20:06:47 CST 2021
key1真正计算了
Sat Oct 02 20:06:48 CST 2021
Sat Oct 02 20:06:49 CST 2021
Sat Oct 02 20:06:50 CST 2021
key1真正计算了

同样,这里根据程序和输出应该可以理解啦!

基于引用剔除

Java有四大引用,强、软、弱、虚、如果对这几个引用不是很了解的可以先去看看我这篇文章:😺Java四种引用类型:强、软、弱、虚

Guava Cache提供了基于引用的剔除策略,看到这里,你是否想起来了ThreadLocal如何防止内存泄露呢?,如果不知道没关系,继续看我上面贴的引用文章。Guava Cache提供了三种基于引用剔除的策略:

  • CacheBuilder.weakKeys()
    当我们使用了weakKeys() 后,Guava cache将以弱引用 的方式去存储缓存key,那么根据弱引用的定义:当发生垃圾回收时,不管当前系统资源是否充足,弱引用都会被回收,直接上例子:
@Test
public void testWeakKeys() throws InterruptedException {
    LoadingCache<MyKey, String> loadingCache = CacheBuilder.newBuilder().weakKeys()
        .build(new CacheLoader<MyKey, String>() {
            @Override
            public String load(MyKey key) throws Exception {
                System.out.println(key.getKey()+"真正计算了");
                return "cached-" + key.getKey();
            }
        });

    MyKey key = new MyKey("key1");
    System.out.println("第一次访问");
    loadingCache.getUnchecked(key);
    System.out.println(loadingCache.asMap());

    System.out.println("第二次访问");
    loadingCache.getUnchecked(key);
    System.out.println(loadingCache.asMap());

    System.out.println("key失去强引用GC后访问");
    key = null;
    System.gc();
    TimeUnit.SECONDS.sleep(3);
    System.out.println(loadingCache.asMap());

}

@Data
private static class MyKey{
    String key;

    public MyKey(String key) {
        this.key = key;
    }
}
  • CacheBuilder.weakValues()
    有了CacheBuilder.weakKeys()的基础,CacheBuilder.weakValues()的作用想必照猫画虎应该也知道了吧?换汤不换药,这次针对的是缓存值!

  • CacheBuilder.softValues()
    有了CacheBuilder.weakValues()的基础,CacheBuilder.softValues()的作用相比照猫画虎应该也知道了吧?对,你真棒,就是之前的弱引用换为了软引用,软引用相比弱引用,被回收的条件就苛刻点:当发生垃圾回收时,只有当系统资源不足时,才会回收!

主动剔除

上面讲了被动剔除策略,那么除了被动,我们也可以主动调用方法去清除缓存。

  • Cache.invalidate(key)
  • Cache.invalidateAll(keys)
  • Cache.invalidateAll()

缓存失效监听器

有时候我们希望当缓存失效被剔除的时候,可以做一些善后事情,此时,我们就可以通过CacheBuilder.removalListener(RemovalListener) 来指定一个缓存失效监听器,当缓存失效时,将回调我们的监听器:

@Test
public void testRemovalListener() throws InterruptedException {
    LoadingCache<String, String> loadingCache = CacheBuilder.newBuilder().removalListener(notification -> {
        System.out.println(String
            .format("缓存 %s 因为 %s 失效了,它的value是%s", notification.getKey(), notification.getCause(),
                notification.getValue()));
    }).expireAfterAccess(3, TimeUnit.SECONDS).build(new CacheLoader<String, String>() {
        @Override
        public String load(String key) throws Exception {
            System.out.println(key + "真正计算了");
            return "cached-" + key;
        }
    });

    System.out.println("第一次访问(写入)");
    loadingCache.getUnchecked("key1");

    System.out.println("第二次访问");
    loadingCache.getUnchecked("key1");
    TimeUnit.SECONDS.sleep(3);

    System.out.println("3秒后");
    loadingCache.getUnchecked("key1");
}

输出:
第一次访问(写入)
key1真正计算了
第二次访问
3秒后
缓存 key1 因为 EXPIRED 失效了,它的value是cached-key1
key1真正计算了

Guava Cache什么进行清理动作?

这个其实在上节实验缓存剔除监听器的时候我就发现一个问题:如果缓存失效后,我不再进行任何操作,那么这个缓存监听器就得不到调用!,从这里就可以看出,Guava cache并不是自己主动去清理那些失效缓存的,而是当我们对缓存进行了操作时,才会进行检查清理以及其他动作。那么为什么呢?想想啊,如果要主动清除,那肯定要有一个一直运行的后台线程去执行清理,多了个线程出来,那么意味着不再是单线程程序了,涉及多线程就要考虑加锁资源保护了,这无疑会消耗我们资源,影响性能,而主动清除又不是必须的,等你操作了再清除,一点也不晚!

当然Guava cache也提供给我们主动清理的方法:Cache.cleanUp(),那么有了这个方法之后,是否主动清理的操作就交给了我们,由我们自己去权衡。

缓存刷新

CacheBuilder中提供了refreshAfterWrite 用来指定缓存key写入多久后重新进行计算并缓存:

@Test
public void testRefresh() throws InterruptedException {
    LoadingCache<String, String> loadingCache = CacheBuilder.newBuilder().refreshAfterWrite(1,TimeUnit.SECONDS)
        .build(new CacheLoader<String, String>() {
            @Override
            public String load(String key) throws Exception {
                System.out.println(key + "真正计算了");
                return "cached-" + key;
            }
        });
    for (int i = 0; i < 3; i++) {
        loadingCache.getUnchecked("key1");
        TimeUnit.SECONDS.sleep(2);
    }
}

输出
key1真正计算了
key1真正计算了
key1真正计算了

在这个例子中,我们指定缓存key写入后,超过1秒就会刷新,然后我们每隔2秒访问一次缓存key,可以看到每次都得到了重新计算!

小结

本文通过大量代码案例详细介绍了Guava Cache的使用,当然你以为会止步于此吗?由于篇幅的原因,本文为使用篇,接下来将推出原理篇,我们的目的是从这些大佬的源码设计中吸取精华,所谓知己知彼~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,056评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,842评论 2 378
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,938评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,296评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,292评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,413评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,824评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,493评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,686评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,502评论 2 318
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,553评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,281评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,820评论 3 305
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,873评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,109评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,699评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,257评论 2 341

推荐阅读更多精彩内容

  • 缓存分为本地缓存和远端缓存。常见的远端缓存有Redis,MongoDB;本地缓存一般使用map的方式保存在本地内存...
    hehehehe阅读 327评论 0 0
  • Guava Cache以下的特性: automatic loading of entries into the c...
    小锄禾阅读 8,612评论 2 11
  • 概述 缓存是日常开发中经常应用到的一种技术手段,合理的利用缓存可以极大的改善应用程序的性能。Guava官方对Cac...
    小陈阿飞阅读 1,762评论 0 0
  • 16宿命:用概率思维提高你的胜算 以前的我是风险厌恶者,不喜欢去冒险,但是人生放弃了冒险,也就放弃了无数的可能。 ...
    yichen大刀阅读 6,029评论 0 4
  • 公元:2019年11月28日19时42分农历:二零一九年 十一月 初三日 戌时干支:己亥乙亥己巳甲戌当月节气:立冬...
    石放阅读 6,868评论 0 2