Guava Cache:缓存的回收、刷新和统计

适用性

        缓存在很多场景下都是相当有用的。例如,计算或检索一个值的代价很高,并且对同样的输入需要不止一次获取值的时候,就应当考虑使用缓存。
        Guava Cache与ConcurrentMap很相似,但也不完全一样。最基本的区别是ConcurrentMap会一直保存所有添加的元素,直到显式地移除。相对地,Guava Cache为了限制内存占用,通常都设定为自动回收元素。在某些场景下,尽管LoadingCache 不回收元素,它也是很有用的,因为它会自动加载缓存。

    通常来说,Guava Cache适用于:

  • 你愿意消耗一些内存空间来提升速度。
  • 你预料到某些键会被查询一次以上。
  • 缓存中存放的数据总量不会超出内存容量。(Guava Cache是单个应用运行时的本地缓存。它不把数据存放到文件或外部服务器。如果这不符合你的需求,请尝试Memcached这类工具)
  • 如果你的场景符合上述的每一条,Guava Cache就适合你。

注:如果你不需要Cache中的特性,使用ConcurrentHashMap有更好的内存效率——但Cache的大多数特性都很难基于旧有的ConcurrentMap复制,甚至根本不可能做到。


Guava Cache创建方式:

  • CacheLoader
  // cacheLoader创建方式
        LoadingCache<String, String> cache = CacheBuilder.newBuilder()
                .maximumSize(100) //最大缓存数目
                .expireAfterAccess(1, TimeUnit.SECONDS) //缓存1秒后过期
                .build(new CacheLoader<String, String>() {
                    @Override
                    public String load(String key) throws Exception {
                        return key;

                    }
                });
        cache.put("j", "JAVA");
        cache.put("c", "C++");
        cache.put("s", "SCALA");
        cache.put("g", "GO");
        try {
            System.out.println(cache.get("j"));
            TimeUnit.SECONDS.sleep(2);
            System.out.println(cache.get("s")); //输出s
        } catch (ExecutionException | InterruptedException e) {
            e.printStackTrace();
        }
  • Callable callback
 // Callable创建方式
        Cache<String, String> stringCache = CacheBuilder.newBuilder()
                .maximumSize(100)
                .expireAfterAccess(1, TimeUnit.SECONDS)
                .build();
        try {


            String fly = stringCache.get("FLY", new Callable<String>() {
                @Override
                public String call() throws Exception {
                    return "Hi,AlexFly";
                }
            });

            System.out.println(fly);


            // lambda调用
            String result = stringCache.get("java", () -> "hello java");
            System.out.println(result);

        } catch (ExecutionException e) {
            e.printStackTrace();
        }

Guava Cache缓存回收:

  • 基于容量和定时的回收
LoadingCache<String, Object> caches = CacheBuilder.newBuilder()
                .maximumSize(100)
                .expireAfterWrite(10, TimeUnit.MINUTES)
                .build(new CacheLoader<String, Object>() {
                    @Override
                    public Object load(String key) throws Exception {
                        return generateValueByKey(key);
                    }
                });
try {
    System.out.println(caches.get("key-zorro"));
} catch (ExecutionException e) {
    e.printStackTrace();
}
  1. 如代码所示,新建了名为caches的一个缓存对象,maximumSize定义了缓存的容量大小,当缓存数量即将到达容量上线时,则会进行缓存回收,回收最近没有使用或总体上很少使用的缓存项。需要注意的是在接近这个容量上限时就会发生,所以在定义这个值的时候需要视情况适量地增大一点。 
  2. 另外通过expireAfterWrite这个方法定义了缓存的过期时间,写入十分钟之后过期。 
    CacheBuilder提供两种定时回收的方法:
    
    expireAfterAccess(long, TimeUnit):缓存项在给定时间内没有被读/写访问,则回收。
    请注意这种缓存的回收顺序和基于大小回收一样。
    
    expireAfterWrite(long, TimeUnit):缓存项在给定时间内没有被写访问(创建或覆盖),则回收。
    如果认为缓存数据总是在固定时候后变得陈旧不可用,这种回收方式是可取的。
  3. 在build方法里,传入了一个CacheLoader对象,重写了其中的load方法当获取的缓存值不存在或已过期时,则会调用此load方法,进行缓存值的计算。 
  4. 这就是最简单也是我们平常最常用的一种使用方法。定义了缓存大小、过期时间及缓存值生成方法。
  5. 如果用其他的缓存方式,如redis,我们知道上面这种“如果有缓存则返回;否则运算、缓存、然后返回”的缓存模式是有很大弊端的。当高并发条件下同时进行get操作,而此时缓存值已过期时,会导致大量线程都调用生成缓存值的方法,比如从数据库读取。这时候就容易造成数据库雪崩。这也就是我们常说的“缓存穿透”。 
  6. Guava cache则对此种情况有一定控制。当大量线程用相同的key获取缓存值时,只会有一个线程进入load方法,而其他线程则等待,直到缓存值被生成。这样也就避免了缓存穿透的危险。

Guava Cache定时刷新:

        如上的使用方法,虽然不会有缓存穿透的情况,但是每当某个缓存值过期时,老是会导致大量的请求线程被阻塞。而Guava则提供了另一种缓存策略,缓存值定时刷新更新线程调用load方法更新该缓存,其他请求线程返回该缓存的旧值。这样对于某个key的缓存来说,只会有一个线程被阻塞,用来生成缓存值,而其他的线程都返回旧的缓存值,不会被阻塞。 
这里就需要用到Guava cache的 refreshAfterWrite 方法。

        如下所示:

LoadingCache<String, Object> caches = CacheBuilder.newBuilder()
                .maximumSize(100)
                .refreshAfterWrite(10, TimeUnit.MINUTES)
                .build(new CacheLoader<String, Object>() {
                    @Override
                    public Object load(String key) throws Exception {
                        return generateValueByKey(key);
                    }
                });
try {
    System.out.println(caches.get("key-zorro"));
} catch (ExecutionException e) {
    e.printStackTrace();
}

        如代码所示,每隔十分钟缓存值则会被刷新。

        此外需要注意一个点,这里的定时并不是真正意义上的定时。Guava cache的刷新需要依靠用户请求线程,让该线程去进行load方法的调用,所以如果一直没有用户尝试获取该缓存值,则该缓存也并不会刷新。


Guava Cache异步刷新:

        如上的使用方法,解决了同一个key的缓存过期时会让多个线程阻塞的问题,只会让用来执行刷新缓存操作的一个用户线程会被阻塞。由此可以想到另一个问题,当缓存的key很多时,高并发条件下大量线程同时获取不同key对应的缓存,此时依然会造成大量线程阻塞,并且给数据库带来很大压力。这个问题的解决办法就是将刷新缓存值的任务交给后台线程,所有的用户请求线程均返回旧的缓存值,这样就不会有用户线程被阻塞了。 
详细做法如下:

ListeningExecutorService backgroundRefreshPools = 
                MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(20));
        LoadingCache<String, Object> caches = CacheBuilder.newBuilder()
                .maximumSize(100)
                .refreshAfterWrite(10, TimeUnit.MINUTES)
                .build(new CacheLoader<String, Object>() {
                    @Override
                    public Object load(String key) throws Exception {
                        return generateValueByKey(key);
                    }

                    @Override
                    public ListenableFuture<Object> reload(String key,
                            Object oldValue) throws Exception {
                        return backgroundRefreshPools.submit(new Callable<Object>() {

                            @Override
                            public Object call() throws Exception {
                                return generateValueByKey(key);
                            }
                        });
                    }
                });
try {
    System.out.println(caches.get("key-zorro"));
} catch (ExecutionException e) {
    e.printStackTrace();
}

        在上面的代码中,我们新建了一个线程池,用来执行缓存刷新任务。并且重写了CacheLoader的reload方法,在该方法中建立缓存刷新的任务并提交到线程池。 

        注意此时缓存的刷新依然需要靠用户线程来驱动,只不过和上面不同之处在于该用户线程触发刷新操作之后,会立马返回旧的缓存值。

TIPS

  • 可以看到防缓存穿透和防用户线程阻塞都是依靠返回旧值来完成的。所以如果没有旧值,同样会全部阻塞,因此应视情况尽量在系统启动时将缓存内容加载到内存中。

  • 在刷新缓存时,如果generateValueByKey方法出现异常或者返回了null,此时旧值不会更新。

  • 题外话:在使用内存缓存时,切记拿到缓存值之后不要在业务代码中对缓存直接做修改,因为此时拿到的对象引用是指向缓存真正的内容的。如果需要直接在该对象上进行修改,则在获取到缓存值后拷贝一份副本,然后传递该副本,进行修改操作。(我曾经就犯过这个低级错误 - -!)

Guava Cache统计:


  • CacheBuilder.recordStats()用来开启Guava Cache的统计功能。统计打开后,Cache.stats()方法会返回CacheStats对象以提供
  • 如下统计信息:
hitRate():缓存命中率;
averageLoadPenalty():加载新值的平均时间,单位为纳秒;
evictionCount():缓存项被回收的总数,不包括显式清除。
此外,还有其他很多统计信息。这些统计信息对于调整缓存设置是至关重要的,在性能要求高的应用中我们建议密切关注这些数据。

Guava Cache:asMap视图


  • asMap视图提供了缓存的ConcurrentMap形式,但asMap视图与缓存的交互需要注意:
  1. cache.asMap()包含当前所有加载到缓存的项。因此相应地,cache.asMap().keySet()包含当前所有已加载键;
  2. asMap().get(key)实质上等同于cache.getIfPresent(key),而且不会引起缓存项的加载。这和Map的语义约定一致。
  3. 所有读写操作都会重置相关缓存项的访问时间,包括Cache.asMap().get(Object)方法和Cache.asMap().put(K, V)方法,但不包括Cache.asMap().containsKey(Object)方法,也不包括在Cache.asMap()的集合视图上的操作。比如,遍历Cache.asMap().entrySet()不会重置缓存项的读取时间。

参考来源:http://ifeve.com/google-guava-cachesexplained/

参考来源:https://blog.csdn.net/u012859681/article/details/75220605#commentBox

猜你喜欢

转载自blog.csdn.net/fly910905/article/details/80976161