Guava Cache实战—从场景使用到原理分析

摘要:本文先介绍为什么使用 Guava Cache 缓存;然后讲解 Guava 缓存使用方法、清理方法及使用过程中踩过的坑;接着讲解其底层数据结构,分析其性能优异的原因;最后讲解在使用本地缓存时可以优化的方向。

为什么使用本地缓存

在多线程高并发场景中往往是离不开 cache 的,需要根据不同的应用场景来需要选择不同的 cache,比如分布式缓存如 Redis、memcached,还有本地(进程内)缓存如 ehcache、Guava Cache。缓存相比IO操作,速度快,效率高;Guava 相比 Redis 来说,应用和cache是在同一个进程内部,请求缓存非常迅速,没有过多的网络开销。redis 的好处是自身就是一个独立的应用,多个应用可以共享缓存。我们应该根据数据类型、业务场景来判断应该使用哪种类型的缓存,以达到减少计算量,提高响应速度的目的。 在这里插入图片描述

本地缓存适用场景(需都满足)

  • 愿意消耗一些内存空间来提升速度

  • 预料到某些键会被多次查询

  • 缓存中存放的数据总量不会超出内存容量

  • 要更快的响应,缓存不需要网络 io(集中式缓存需要额外网络 io)

如何使用 Guava 缓存

Guava Cache 是 Google 开源的 Java 重用工具集库 Guava 里的一款缓存工具,下面介绍接入 guava 缓存的步骤及使用过程中踩过的坑

Guava Cache 接入demo

  • 1、导入 Maven 引用
<dependency>  
    <groupId>com.google.guava</groupId>  
    <artifactId>guava</artifactId>
    <version>19.0</version>  
</dependency> 
  • 2、Cache 初始化,使用了 Builder 设计模式,可自行设置各类参数
private final LoadingCache<Long, TestDemo> demo = CacheBuilder.newBuilder()  
        //设置 cache 的初始大小为10,要合理设置该值  
        .initialCapacity(10)  
        //设置并发数为5,即同一时间最多只能有5个线程往 cache 执行写入操作  
        .concurrencyLevel(5) 
        //最大 key 个数
        .maximumSize(100)
        //移除监听器
        .removalListener(removalListener)
        //设置 cache 中的数据在写入之后的存活时间为10秒  
        .expireAfterWrite(10, TimeUnit.SECONDS)  
        //构建 cache 实例  
        .build(new CacheLoader<Long, TestDemo>() {
              @Override
              public TestDemo load(Long id) throws Exception {
                  // 读取 db 数据
              }
}); 
                 
RemovalListener<String, String> removalListener = new RemovalListener<String, String>() {
    public void onRemoval(RemovalNotification<id, TestDemo> removal) {
		System.out.println("[" + removal.getKey() + ":" + removal.getValue() + removal.getCause() +  "] is evicted!");
    }
};
  • 3、 如果缓存加载方法时出现了异常,那么下一次是缓存异常还是正常数据?
public class TestDemoImpl implements TestDemo {
  int time;
	private final LoadingCache<Long, TestDemo> demo;
    this.demo1 = CacheBuilder.newBuilder()
          .expireAfterWrite(duration, TimeUnit.HOURS)
          .build(new CacheLoader<Long, TestDemo>() {
                @Override
                 public TestDemo load(Long id) throws Exception {
                     // 查询 db
                     if (time++==0) {
                         throw new RunTimeException();
                     }
                     return result;
                 }
             });
}
 public static void main(String[] args) {
      try {
          try {
              TestDemo testDemo = demoCache.getUnchecked(21L);
              System.out.println("第一次查询:" + testDemo);
          } catch (Exception e) {
          }
           TestDemo testDemo1 = testDemo.getUnchecked(21L);
           System.out.println("第二次查询:" + testDemo1);
       } catch (Exception e) {
           e.printStackTrace();
       }
}
  • 结论:第二次返回正常数据

guava 使用踩坑

  • 1、使用 guava cache 时避免使用 weakkeys。weakkeys 对 key 的命中规则是 ==,如果使用非基本类型,会因为 key 判断不相等导致缓存无法命中。
  • 2、仅仅需缓存元数据本身,不要缓存其关系,否则造成笛卡尔积。如缓存的数据由A,B,C三张表的维度组成,缓存关系会导致A X B X C的数据量,如果缓存元数据,则缓存的数据量仅为 A+B+C;
  • 3、使用缓存前必须预估缓存的数据大小,并设置缓存的数量或大小。如果不设置过期方式的话,也不设置大小,缓存数据将无法回收,会引起 OOM

如何清理 Guava 缓存

在日常开发过程中,最常见的问题是,如何清理 Guava 缓存,下面介绍两种清理方式

手动清理

通过 rest 后门等手动调用

// 获取最新 db 数据更新缓存
testDemoCache.refresh(key);

// 清理 guava cache 缓存
estDemoCache.invalidateAll();

自动清理缓存

手动清理缓存,人力成本较高,现在有一种方案是,在新增/修改数据后,通过 MQ 广播来实现所有机器自动清理缓存。或通过 canal 等 binlog 中间件监听 db 变更来清理缓存。

Guava cache 原理解析

Guava cache 继承了 ConcurrentHashMap 的思路,使用多个 segments 方式的细粒度锁,在保证线程安全的同时,支持高并发场景需求。下面介绍它的底层实现,分析其性能优异的原因

几个重要的组件

1、CacheBuilder 缓存构建器。构建缓存的入口,指定缓存配置参数并初始化本地缓存。采用 Builder 设计模式提供了设置好各种参数的缓存对象。 2、LocalCache 数据结构。缓存核心类 LocalCache 数据结构与 ConcurrentHashMap 很相似,由多个 segment 组成,且各 segment 相对独立,互不影响,所以能支持并行操作,每个 segment 由一个 table 和若干队列组成。缓存数据存储在 table 中,其类型为AtomicReferenceArray。

在这里插入图片描述

上图为 Guava cache 底层的数据结构图,下表对其结构做了说明

序号数据结构特点详解
1Segment<K, V>[] segmentsSegment 继承于ReetrantLock,减小锁粒度,提高并发效率
2AtomicReferenceArray<ReferenceEntry<K, V>> table类似于HasmMap 中的table 一样,相当于 entry 的容器
3ReferenceEntry<K, V> referenceEntry基于引用的Entry,其实现类有弱引用 Entry,强引用 Entry 等Cache 由多个 Segment 组成,而每个 Segment 包含一个ReferenceEntry 数组,每个 ReferenceEntry 数组项都是一条 ReferenceEntry 链,且一个 ReferenceEntry 包含key、hash、valueReference、next 字段。除了在ReferenceEntry 数组项中组成的链,在一个 Segment中,所有 ReferenceEntry 还组成 access 链(accessQueue)和 write 链(writeQueue)。ReferenceEntry 可以是强引用类型的 key,也可以WeakReference 类型的 key,为了减少内存使用量,还可以根据是否配置了 expireAfterWrite、expireAfterAccess、maximumSize 来决定是否需要 write 链和 access 链确定要创建的具体 Reference:StrongEntry、StrongWriteEntry、StrongAccessEntry、StrongWriteAccessEntry等
4ReferenceQueue keyReferenceQueue已经被 GC,需要内部清理的键引用队列
5ReferenceQueue valueReferenceQueue已经被 GC,需要内部清理的值引用队列因为 Cache 支持强引用的 Value、SoftReference Value 以及 WeakReference Value,因而它对应三个实现类:StrongValueReference、SoftValueReference、WeakValueReference。为了支持动态加载机制,它还有一个 LoadingValueReference,在需要动态加载一个 key的值时,先把该值封装在 LoadingValueReference 中,以表达该 key 对应的值已经在加载了,如果其他线程也要查询该 key 对应的值,就能得到该引用,并且等待改值加载完成,从而保证该值只被加载一次,在该值加载完成后,将 LoadingValueReference 替换成其他 ValueReference类型。ValueReference 对象中会保留对 ReferenceEntry 的引用,这是因为在 Value 因为 WeakReference、SoftReference 被回收时,需要使用其 key 将对应的项从Segment 的 table 中移除
6Queue<ReferenceEntry<K, V>> recencyQueue记录升级可访问列表清单时的entries ,当segment 上达到临界值或发生写操作时该队列会被清空
7Queue<ReferenceEntry<K, V>> writeQueue按照写入时间进行排序的元素队列,写入一个元素时会把它加入到队列尾部为了实现最近最少使用算法,Guava Cache 在 Segment 中添加了两条链:write 链(writeQueue)和 access 链(accessQueue),这两条链都是一个双向链表,通过ReferenceEntry 中的 previousInWriteQueue、nextInWriteQueue 和 previousInAccessQueue、nextInAccessQueue 链接而成,但是以 Queue 的形式表达。WriteQueue 和 AccessQueue 都是自定义了 offer、add(直接调用 offer)、remove、poll 等操作的逻辑,对offer(add)操作,如果是新加的节点,则直接加入到该链的结尾,如果是已存在的节点,则将该节点链接的链尾;对 remove 操作,直接从该链中移除该节点;对 poll操作,将头节点的下一个节点移除,并返回
8Queue<ReferenceEntry<K, V>> accessQueue按照访问时间进行排序的元素队列,访问(包括写入)一个元素时会把它加入到队列尾部

guava常用接口

/** 
 * 该接口的实现被认为是线程安全的,即可在多线程中调用 
 * 通过被定义单例使用 
 */  
public interface Cache<K, V> {  
/** 
* 通过key获取缓存中的value,若不存在直接返回null 
*/  
V getIfPresent(Object key);  

在这里插入图片描述

上图为方法 getIfPresent 执行流程图

/** 
* 通过 key 获取缓存中的 value,若不存在就通过 valueLoader 来加载该 value 
* 整个过程为 "if cached, return; otherwise create, cache and return" 
* 注意 valueLoader 要么返回非 null 值,要么抛出异常,绝对不能返回 null 
*/  
V get(K key, Callable<? extends V> valueLoader) throws ExecutionException; 

在这里插入图片描述

上图为 get 方法执行流程图

/** 
* 添加缓存,若key存在,就覆盖旧值
*/  
void put(K key, V value); 

在这里插入图片描述

上图为 put 方法执行流程图

/** 
* 删除该key关联的缓存 
*/  
void invalidate(Object key);  

/** 
* 删除所有缓存 
*/  
void invalidateAll();  

/** 
* 执行一些维护操作,包括清理缓存 
*/  
void cleanUp();  
}

在这里插入图片描述

上图为移除缓存的流程图

缓存回收

Guava Cache 提供了三种基本的缓存回收方式:基于容量回收、定时回收和基于引用回收。 基于容量的方式内部实现采用 LRU 算法,基于引用回收很好的利用了 Java 虚拟机的垃圾回收机制。

1、基于容量的回收(size-based eviction)

如果要规定缓存项的数目不超过固定值,只需使用 CacheBuilder.maximumSize(long)。缓存将尝试回收最近没有使用或总体上很少使用的缓存项。在缓存项的数目达到限定值之前,缓存就可能进行回收操作,通常来说,这种情况发生在缓存项的数目逼近限定值时。

2、定时回收(Timed Eviction)

CacheBuilder 提供两种定时回收的方法: expireAfterAccess(long, TimeUnit) :缓存项在给定时间内没有被读/写访问,则回收。请注意这种缓存的回收顺序和基于容量回收一样。 expireAfterWrite(long, TimeUnit):缓存项在给定时间内没有被写访问(创建或覆盖),则回收。如果认为缓存数据总是在固定时候后变得陈旧不可用,这种回收方式是可取的。
定时回收周期性地在写操作中执行,偶尔在读操作中执行

3、基于引用的回收(Reference-based Eviction)

通过使用弱引用的键、或弱引用的值、或软引用的值,Guava Cache 可以把缓存设置为允许垃圾回收 CacheBuilder.weakKeys():使用弱引用存储键。当键没有其它(强或软)引用时,缓存项可以被垃圾回收。因为垃圾回收仅依赖恒等式(= =),使用弱引用键的缓存用= = 而不是 equals 比较键。 CacheBuilder.weakValues():使用弱引用存储值。当值没有其它(强或软)引用时,缓存项可以被垃圾回收。因为垃圾回收仅依赖恒等式(= =),使用弱引用值的缓存用= =而不是 equals 比较值。 CacheBuilder.softValues():使用软引用存储值。软引用只有在响应内存需要时,才按照全局最近最少使用的顺序回收。考虑到使用软引用的性能影响,我们通常建议使用更有性能预测性的缓存大小限定(见上文,基于容量回收)。使用软引用值的缓存同样用==而不是 equals 比较值。

4、显式清除

任何时候,你都可以显式地清除缓存项,而不是等到它被回收: 个别清除:Cache.invalidate(key) 批量清除:Cache.invalidateAll(keys) 清除所有缓存项Cache.invalidateAll()

5、移除监听器

通过 CacheBuilder.removalListener (RemovalListener),你可以声明一个监听器,以便缓存项被移除时做一些额外操作。缓存项被移除时,RemovalListener 会获取移除通知 [RemovalNotification],其中包含移除原因 [RemovalCause]、键和值

6、统计 CacheBuilder.recordStats():用来开启 Guava Cache 的统计功能。统计打开后,Cache.stats() 方法会返回 CacheStats 对象以提供如下统计信息:

  • hitRate():缓存命中率;
  • averageLoadPenalty():加载新值的平均时间,单位为纳秒;
  • evictionCount():缓存项被回收的总数,不包括显式清除。

此外,还有其他很多统计信息。这些统计信息对于调整缓存设置是至关重要的,在性能要求高的应用中我们建议密切关注这些数据。

清理什么时候发生?

使用 CacheBuilder 构建的缓存不会"自动"执行清理和回收工作,也不会在某个缓存项过期后马上清理,也没有诸如此类的清理机制。相反,它会在写操作时顺带做少量的维护工作,或者偶尔在读操作时做(如果写操作实在太少的话)。 这样做的原因在于:如果要自动地持续清理缓存,就必须有一个线程,这个线程会和用户操作竞争共享锁。此外,某些环境下线程创建可能受限制,这样 CacheBuilder 就不可用了。 相反,我们把选择权交到你手里。如果你的缓存是高吞吐的,那就无需担心缓存的维护和清理等工作。如果你的 缓存只会偶尔有写操作,而你又不想清理工作阻碍了读操作,那么可以创建自己的维护线程,以固定的时间间隔调用 Cache.cleanUp()。ScheduledExecutorService 可以帮助你很好地实现这样的定时调度。

优化方向

  • 1、在使用便利性优化,SpringBoot 集成 Guava Cache 实现本地缓存
  • 2、在性能上优化,使用 Caffeine 缓存替代 Guava 缓存

Caffeine 是基于 Java8 实现的新一代缓存工具,缓存性能接近理论最优。可以看作是 Guava Cache 的增强版,API 上两者类似,主要区别体现在内存淘汰机制;因为在现有算法的局限性下,会导致缓存数据的命中率或多或少的受损,而命中率又是缓存的重要指标 。Caffeine 使用 Window TinyLfu 回收策略,提供了一个近乎最佳的命中率 。

Caffeine 实现机制

  • TinyLFU 维护了近期访问记录的频率信息,作为一个过滤器,当新记录来时,只有满足 TinyLFU 要求的记录才可以被插入缓存。它需要解决两个挑战:一个是如何避免维护频率信息的高开销;另一个是如何反应随时间变化的访问模式。

  • 在 Count-Min Sketch 中,如果你的缓存大小是100,他会生成一个 long 数组大小是和100最接近的2的幂的数,也就是128。而这个数组将会记录我们的访问频率。在 caffeine 中规定频率最大为15,15的二进制位1111,总共是4位,而 Long 型是64位。而 Caffeine 用了四种 hash 算法,每个 Long 型被分为四段,每段里面保存的是四个算法的频率。这样做的好处是可以进一步减少 Hash 冲突,原先128大小的 hash,就变成了128X4。

算法细节如下图所示: 在这里插入图片描述

1、读写性能

  • 在 guava cache 中其读写操作中夹杂着过期时间的处理,也就是你在一次 Put 操作中有可能还会做淘汰操作,所以其读写性能会受到一定影响,而在 caffeine,对这些事件的操作是通过异步操作,他将事件提交至队列,这里的队列的数据结构是 RingBuffer,然后会通过默认的 ForkJoinPool.commonPool(),或者自己配置线程池,进行取队列操作,然后在进行后续的淘汰,过期操作。读写也是有不同的队列,在 caffeine 中认为快取读比写多很多,所以对于写操作是所有执行共享一个 Ringbuffer。

2、数据淘汰策略

  • 在 caffeine 所有的数据都在 ConcurrentHashMap 中,这个和 guava cache 不同,guava cache 是自己实现了个类似ConcurrentHashMap 的结构。在 caffeine 中有三个记录引用的 LRU 队列:

  • Eden 队列:在 caffeine 中规定只能为缓存容量的1%,如果 size =100,那这个队列的有效大小就等于1。这个队列中记录的是新到的数据,防止突发流量由于之前没有访问频率,而导致被淘汰。新建最开始其实是没有访问频率的,防止上线之后被其他缓存淘汰出去,而加入这个区域。伊甸区,最舒服最安逸的区域,在这里很难被其他数据淘汰。

  • Probation 队列:叫做缓刑队列,在这个队列就代表你的数据相对比较冷,马上就要被淘汰了。这个有效大小为 size 减去 eden 减去protected。

  • Protected 队列:在这个队列中,暂时不会被淘汰,如果 Probation 队列没有数据了或者 Protected 数据满了,你也将会被面临淘汰的尴尬局面。当然想要变成这个队列,需要把 Probation 访问一次之后,就会提升为 Protected 队列。这个有效大小为(size 减去 eden) X 80% 如果 size =100,就会是79。

在这里插入图片描述

1、所有的新数据都会进入 Eden。 2、Eden 满了,淘汰进入 Probation。 3、如果在 Probation 中访问了其中某个数据,则这个数据升级为 Protected。 4、如果 Protected 满了又会继续降级为 Probation。 5、对于发生数据淘汰的时候,会从 Probation 中进行淘汰。会把这个队列中的数据队头称为受害者,这个队头肯定是最早进入的,按照 LRU 队列的算法的话那他其实他就应该被淘汰,但是在这里只能叫他受害者,这个队列是缓刑队列,代表马上要给他行刑了。这里会取出队尾叫候选者,也叫攻击者。这里受害者会和攻击者PK决出应该被淘汰的。

通过我们的Count-Min Sketch中的记录的频率数据有以下几个判断:

1、如果攻击者大于受害者,那么受害者就直接被淘汰。 2、如果攻击者<=5,那么直接淘汰攻击者,作者认为设置一个预热的门槛会让整体命中率更高。 3、其他情况,随机淘汰。

3、其他优化 过期策略:在 Caffeine 中有个 scheduleDrainBuffers 方法,用来进行我们的过期任务的调度,在读写之后都会对其进行调用: 更新策略:建立一个 CacheLoader 来进行重新整理,这里是同步进行的,可以通过 buildAsync 方法进行非同步构建,自动刷新只存在读操作之后;

总结

Guava Cache 基于 ConcurrentHashMap 的优秀设计借鉴,在高并发场景支持和线程安全上都有相应的改进策略,使用 Reference引用命令,提升高并发下的数据访问速度并保持了 GC 的可回收,有效节省空间;同时,write 链和 access 链的设计,能更灵活、高效的实现多种类型的缓存清理策略,包括基于容量的清理、基于时间的清理、基于引用的清理等;编程式的 build 生成器管理,让使用者有更多的自由度,能够根据不同场景设置合适的模式。

GuavaCache 的实现代码中没有启动任何线程,Cache 中的所有维护操作,包括清除缓存、写入缓存等,都需要外部调用来实现。这在需要低延迟服务场景中使用时,需要关注,可能会在某个调用的响应时间突然变大。GuavaCache 毕竟是一款面向本地缓存的,轻量级的Cache,适合缓存少量数据。如果你想缓存上千万数据,可以为每个 key 设置不同的存活时间,并且高性能,那并不适合使用GuavaCache

参考链接