Guava Cache

实际使用中有两种方式都能启动缓存的效果:

  • Supplier
  • Cache

Supplier

这个单词英文的意思是:提供者、供应者,这个到底供应啥呢?当然是数据。事实上可以将Supplier看成是简单的轻量级缓存

@Test
public void test36() {
    Supplier<Integer> supplier = new Supplier<Integer>() {
        @Override
        public Integer get() {
            System.out.println("called...");
            return 4;
        }
    };
    Supplier<Integer> supplier1 = Suppliers.memoizeWithExpiration(supplier, 3, TimeUnit.SECONDS);
    RateLimiter limiter = RateLimiter.create(1);
    for (; ; ) {
        limiter.acquire();//call per second
        System.out.println(supplier1.get());
    }
}

called...
4
4
4
called...
4
4
4
4
called...

Suppliers.memoizeWithExpiration(supplier, 3, TimeUnit.SECONDS); 将一个已有的supplier封装成带缓存的形式,并指定缓存时间为3s。

工作中已经多次应用过Supplier缓存数据,如颜值PK中将颜值PK的全部配置信息(loadAll)通过Supplier缓存,在商户通中将所有模块信息(loadAll)通过这种方式缓存等。

外网:http://git.oschina.net/22221cjp/mylearnnote/blob/master/guava-demo/src/main/java/com/charles/guava/ProductModuleServiceImpl.java?dir=0&filepath=guava-demo%2Fsrc%2Fmain%2Fjava%2Fcom%2Fcharles%2Fguava%2FProductModuleServiceImpl.java&oid=3432e98b360cad55f01c11b9641d3509fb0ce04b&sha=182f295f3fd52837bd7ea8b0a2e76d388a2ea7f5

Cache

复杂一点的缓存方案就是Guava cache了,Guava cache是一个应用内缓存。

一个缓存需要考虑的问题:

  • 缓存读取失败如何加载数据
  • 加载策略(同步还是异步)
  • 缓存过期问题
  • 统计缓存命中情况
  • 缓存数据失效时设置监听
  • 缓存满时替换策略(LRU、FIFO)
  • ......

Guava简单的示例:

@Test
public void test6() {
    CacheLoader<String, String> loader = new CacheLoader<String, String>() {
        @Override
        public String load(String key) throws Exception {
            System.out.println("call..");
            return key.toUpperCase();
        }
    };
    LoadingCache<String, String> cache = CacheBuilder.newBuilder().build(loader);
    System.out.println(cache.size());
    System.out.println(cache.getUnchecked("aaa"));
    System.out.println(cache.size());
    System.out.println(cache.getUnchecked("aaa"));
    try {
        System.out.println(cache.get("cjp"));
    } catch (ExecutionException e) {
        // TODO Auto-generated catch block
        e.printStackTrace();
    }
}

该示例中,仅仅将一个类型是字符串作为key,value为它的大写形式。运行的结果如下:

0
call..
AAA
1
AAA
call..
CJP

Guava的缓存是一个LoadingCache实例,通过CacheBuilder创建该实例,并传入一个CacheLoader,CacheLoader实例注明了在缓存读取失败时如何加载数据,开始时,缓存中没有任何数据,size为0,当取aaa的时候,触发了缓存加载数据,输出call...,虽然缓存的size变成了1。然后再取aaa时,因为缓存中已经有了该key对应的value,就没有触发加载。

需要注意一下getUnchecked方法和get方法的不同,前者不对可能的异常做检查,调用代码不需要显式的捕捉异常,而后者调用代码需要显式的捕获异常。

这是一个非常简单的示例,可以看到使用guava实现一个缓存非常简单,如果将创建CacheLoader实例和build LoadingCache的两行代码合并,使用仅一行代码就可以实现一个缓存,并且Guava的缓存是线程安全的,可以放心的在多线程的环境中使用。

复杂一点的例子

@Test
public void test14() throws Exception {
    //缓存同步删除
    LoadingCache<String, String> cache = CacheBuilder.newBuilder().expireAfterWrite(5, TimeUnit.SECONDS).maximumSize(3).recordStats().removalListener(new RemovalListener<String, String>() {
        @Override
        public void onRemoval(RemovalNotification<String, String> notification) {
            System.out.println("remove key[" + notification.getKey() + "],value[" + notification.getValue() + "],remove reason[" + notification.getCause() + "]");
            System.out.println("remove thread name " + Thread.currentThread().getName());
        }
    }).build(new CacheLoader<String, String>() {
        @Override
        public String load(String key) throws Exception {
            System.out.println("key[" + key + "] to upper case");
            return key.toUpperCase();
        }
    });
    System.out.println(cache.getUnchecked("a"));
    System.out.println(cache.getUnchecked("b"));
    System.out.println("thread name " + Thread.currentThread().getName());
    cache.invalidate("b");//删除key为b的值
    System.out.println(cache.getUnchecked("a"));
    Thread.sleep(5000);
    System.out.println(cache.getUnchecked("c"));
    System.out.println(cache.getUnchecked("a"));
    System.out.println(cache.stats().toString());
    System.out.println("end");
}

结果如下:

key[a] to upper case
A
key[b] to upper case
B
thread name main
remove key[b],value[B],remove reason[EXPLICIT]
remove thread name main A
remove key[a],value[A],remove reason[EXPIRED]
remove thread name main key[c] to upper case
C
key[a] to upper case
A
CacheStats{hitCount=1, missCount=4, loadSuccessCount=4, loadExceptionCount=0, totalLoadTime=3460000, evictionCount=1}
end

创建了一个稍复杂的LoadingCache实例。各方法意义如下:

expireAfterWrite:写入缓存后的过期时间
maximumSize:缓存的最多存放元素个数
recordStats:对缓存命中情况进行统计
removalListener:设置缓存数据失效时监听器

Guava中很多地方都是这种fluent的方式,看上去是不是很酷!

在删除的监听器中打印线程的名字是为了显示该监听器是同步的还是异步的。可以看到删除监听是同步的,因为和主线程的名字是一样的,其实可以理解,因为我们并没有指定额外的线程池。删除监听器中可以看到删除的key、value、cause。主线程sleep 5s后,缓存中key为a的元素就过期了,可以看到监听器被调用,最后通过cache.stats()取得缓存命中的情况统计。可以看到命中1次,miss了4次(load了4次),事实上的确如此。

可以通过RemovalListeners.asynchronous方法就可以创建一个异步的listener对象。如下方式创建LoadingCache:

LoadingCache<String, String> cache = CacheBuilder.newBuilder().expireAfterWrite(5, TimeUnit.SECONDS).maximumSize(3).recordStats().removalListener(RemovalListeners.asynchronous(new RemovalListener<String, String>() {
    //删除缓存监听器异步删除
    @Override
    public void onRemoval(RemovalNotification<String, String> notification) {
        System.out.println("remove key[" + notification.getKey() + "],value[" + notification.getValue() + "],remove reason[" + notification.getCause() + "]");
        System.out.println("remove thread name " + Thread.currentThread().getName());
    }
}, Executors.newCachedThreadPool())).build(new CacheLoader<String, String>() {
    @Override
    public String load(String key) throws Exception {
        System.out.println("key[" + key + "] to upper case");
        return key.toUpperCase();
    }
});

可以看到RemovalListeners.asynchronous方法接受两个参数,第一个参数是RemovalListener对象,第二个参数接收一个线程池,这样就可以异步的设置删除监听器了。如果运行可以看到主线程的线程名和监听器中的线程名是不同的。

上面创建缓存的方式是通过expireAfterWrite指定元素的过期时间,达到重新加载的。也就是说当过期后,这个元素就不存在了,再获取的时候就要通过load重新加载,当加载的时候,获取value的主线程必须同步的等缓存加载完获得数据后才能继续执行。这在一定程度上限制了访问速度。

如果数据量不大的情况下,就不必使用过期时间这种方式,而使用刷新,使用refreshAfterWrite指定刷新的时间间隔。看如下代码:

/**
 * 测试refresh
 */
@Test
public void test16() throws InterruptedException {
    LoadingCache<String, String> cache = CacheBuilder.newBuilder().recordStats().refreshAfterWrite(3, TimeUnit.SECONDS).build(new CacheLoader<String, String>() {
        @Override
        public String load(String key) throws Exception {
            System.out.println("load key[" + key + "]");
            return key.toUpperCase();
        }
        @Override
        public ListenableFuture<String> reload(String key, String oldValue) throws Exception {
            System.out.println("reload key[" + key + "],oldValue[" + oldValue + "]");
            return super.reload(key, oldValue);
        }
    });
    System.out.println(cache.getUnchecked("a"));
    System.out.println(cache.getUnchecked("b"));
    cache.refresh("a");
    Thread.sleep(3000);
    System.out.println(cache.getUnchecked("a"));
    System.out.println(cache.getUnchecked("c"));
}

这是一个非常简单的refresh示例,如果使用refreshAfterWrite,需要实现CacheLoader的reload方法,如果不实现,他有一个默认的实现,就是本示例展示的代码,直接调用load方法。代码的运行结果如下:

load key[a]
A
load key[b] B
reload key[a],oldValue[A]
load key[a] reload key[a],oldValue[A]
load key[a] A
load key[c] C

本例中刷新的时间设置为3s,再第一次显式的调用cache.refresh("a")的时候,可以看到reload方法被调用了。但是reload直接走默认的实现,调用了load方法,所以接着就输出了load key[a]当主线程sleep 3s后,再取a的值时因为超过刷新间隔,又会调用reload方法。可以想象这里的reload肯定是以同步的方式进行的,因为我们并没有指定额外的线程池用来执行reload方法,也就是说当到达刷新时间间隔后,取value的主线程还是要等refresh结束,才能拿到数据后执行,这和刚才的expireAfterWrite方式差不多。如你所想,Guava肯定提供了异步刷新的方式,没错!看代码:

/**
 * 缓存失效时异步重现加载,缓存调用者永远不用阻塞等
 *
 * @throws InterruptedException
 */
@Test
public void test37() throws InterruptedException {
    LoadingCache<String, String> cache = CacheBuilder.newBuilder().recordStats().refreshAfterWrite(3, TimeUnit.SECONDS).build(new CacheLoader<String, String>() {
        @Override
        public String load(String key) throws Exception {
            System.out.println("load key[" + key + "]");
            return key.toUpperCase();
        }
        @Override
        public ListenableFuture<String> reload(final String key, String oldValue) throws Exception {
            ListenableFutureTask<String> task = ListenableFutureTask.create(new Callable<String>() {
                @Override
                public String call() throws Exception {
                    System.out.println("reload key[" + key + "] synchronize at thread[" + Thread.currentThread().getName() + "],this will take 1 second...");
                    Thread.sleep(1000);
                    System.out.println("reload end...");
                    return key.toUpperCase();
                }
            });
            Executors.newCachedThreadPool().execute(task);
            System.out.println("reload key[" + key + "],oldValue[" + oldValue + "]");
            return task;
        }
    });
    //注意:如果重来没有被get过,在缓存中完全没有,第一次调用会执行load,然后加入到cache中,只有被加入到其中的
    //到达失效时间后,再被加载的时候才会触发reload
    System.out.println(cache.getUnchecked("a"));
    System.out.println(cache.getUnchecked("b"));
    cache.refresh("a");
    Thread.sleep(3000);
    //这里的取a 不会触发reload,因为上面refresh需要耗1s才能结束,而主线程这里只需要等3s
    //所以这里的a还有1s的存活时间
    System.out.println(cache.getUnchecked("a"));
    //但是这里的b 就必须reload了,但是reload的过程需要注意下:先调用load方法,然后发现失效了,但是还会返回之前
    //缓存中的值,同时会加载reload,因为是异步reload,主线程这里不用等reload结束,继续向下运行获取c的值
    System.out.println(cache.getUnchecked("b"));
    System.out.println(cache.getUnchecked("c"));
    //这里再暂停5s是为了看清楚上面reload b的结束
    Thread.sleep(5000);
}

本示例依然设置refresh时间为3s。重点是reload方法,先打印出reload执行所在的线程名,为了能清楚的看到主线程不需要等refresh完,这里sleep了1s。其他代码跟之前的差不多,运行结果如下:

load key[a]
A
load key[b] B
reload key[a],oldValue[A]
reload key[a] synchronize at thread[pool-1-thread-1],this will take 1 second...
reload end...
A
reload key[b],oldValue[B]
B
load key[c] C
reload key[b] synchronize at thread[pool-2-thread-1],this will take 1 second...
reload end...

当执行cache.refresh("a")代码的时候,调用了reload方法,可以看到reload所在线程名是线程池中的。这句代码紧接着主线程sleep了3s,然后又去取a的值,按理说这时候a应该到达了刷新的时间间隔了,但是因为之前的reload方法执行就需要1s,所以对于a来说,还有1s的刷新时间剩余,所以这时取a的值,并不会触发reload。而紧接着取b的值就不同了,因为b没有被refresh过,这时候取b的值达到了刷新的时间间隔,所以会触发reload b。但是因为是异步的刷新,主线程根本不用等刷新完,所以立即输出了原来旧的值B,并立即输出了load c的结果,然后才看到 reload b的过程在继续进行,直到结束。

异步刷新,主线程永远不用等缓存的加载!现在在工作中所有使用Guava cahe的地方全部采用这种方式。

注意如下几点:

  • refreshAfterWrite和expireAfterWrite的区别

    • refreshAfterWrite只不过在刷新时间间隔到的时候,调用reload方法获取对于的key对于的value后替换当前内存中的key值。原来内存中的key对于的value是一直存在的。
    • expireAfterWrite方式当达到过期时间后,内存中的对应的key-value就被删除了(应该是被动删除方式,其实还在内存中,获取key的瞬间被删除)。只能通过load方法重新加载key对于的value。
  • refresh方式并不是达到时间间隔后就立即刷新,而是在get数据的时候,发现超过刷新时间间隔了才会刷新,是被动的方式。

  • 只有缓存中存在的key,在到达刷新时间时,才会通过reload刷新,如果缓存中没有对应key的value,第一次永远是调用load加载数据。

Guava Cache批量操作

使用cache.getUnchecked("a")这种方式只能一次从缓存中获取一个值,当没命中时调用load方法也是针对单个key的。Guava也提供了批量从缓存中获取key的方法,当没命中的情况下同样可以批量加载。

看如下代码:

@Test
public void test38() throws ExecutionException {
    LoadingCache<Integer, String> cache = CacheBuilder.newBuilder().refreshAfterWrite(3, TimeUnit.SECONDS).build(new CacheLoader<Integer, String>() {
        private Map<Integer, String> batchLoad(Iterable<? extends Integer> keys) {
            Map<Integer, String> map = Maps.newHashMap();
            for (Integer k : keys) {
                map.put(k, "hello" + k);
            }
            return map;
        }

        @Override
        public String load(Integer key) throws Exception {
            System.out.println("call load");
            return "hello" + key;
        }

        @Override
        public Map<Integer, String> loadAll(Iterable<? extends Integer> keys) throws Exception {
            System.out.println("call loadAll: " + keys);
            return batchLoad(keys);
        }

        @Override
        public ListenableFuture<String> reload(Integer key, String oldValue) throws Exception {
            ListenableFutureTask<String> listenableFutureTask = ListenableFutureTask.create(new Callable<String>() {
                @Override
                public String call() throws Exception {
                    return "hello" + key;
                }
            });
            Executors.newCachedThreadPool().execute(listenableFutureTask);
            return listenableFutureTask;
        }
    });
    System.out.println(cache.getUnchecked(1));
    ImmutableMap<Integer, String> all = cache.getAll(Lists.newArrayList(1, 2, 3, 4, 5));
    System.out.println(all);
    System.out.println(cache.get(2));
}

运行的结果:

call load
hello1
call loadAll: [2, 3, 4, 5]
{1=hello1, 2=hello2, 3=hello3, 4=hello4, 5=hello5}
hello2

通过cache.getAll批量的从缓存中获取值,没命中的key会批量的调用loadAll方法,可以看到参数中2、3、4、5没有命中,所以调用loadAll的参数是2,3,4,5。

需要注意:

  • loadAll返回的map的key必须包括所有传入的参数的key,且对应的value不能为null。否则会抛异常。
  • load和reload方法一个key返回的值同样不能为null

批量使用的例子:

外网:http://git.oschina.net/22221cjp/mylearnnote/blob/master/guava-demo/src/main/java/com/charles/guava/BeautyDealTagCache.java?dir=0&filepath=guava-demo%2Fsrc%2Fmain%2Fjava%2Fcom%2Fcharles%2Fguava%2FBeautyDealTagCache.java&oid=7a35d4e834a6e6d462ee31dea5c9c825bee677df&sha=182f295f3fd52837bd7ea8b0a2e76d388a2ea7f5

guava cache在工作中已经被多次使用了。因为是本地缓存,在分布式环境下,使用时需要注意一下情况:

  • 读操作: 可以直接根据key从guava中取,根本不用担心取不到的情况。
  • 写操作: 直接写入数据库,不用同步的写缓存,分布式环境下也能正常工作,不会出现数据一致性问题。
  • 更新: 一台机器的更新不能实时的反应到集群中其他机器上,会出现数据不一致问题。
  • 删除: 类似更新。

所以guava cache并不太适合更新频繁且对数据实时性要求较高的场景。我一般用来缓存一些配置信息,结合spy清理缓存,能够做到配置信息修改,能够立即起到效果。颜值PK中的配置信息就是这么应用的,因为活动的不同状态,运营经常改规则、头图等信息,就使用了Guava cache结合spy让配置信息及时生效。

外网:http://git.oschina.net/22221cjp/mylearnnote/blob/master/guava-demo/src/main/java/com/charles/guava/TechnicianPKConfigCache.java?dir=0&filepath=guava-demo%2Fsrc%2Fmain%2Fjava%2Fcom%2Fcharles%2Fguava%2FTechnicianPKConfigCache.java&oid=abc95170a6b462ad108042a5ba2441078eb5ea69&sha=182f295f3fd52837bd7ea8b0a2e76d388a2ea7f5

results matching ""

    No results matching ""