概述
Caffeine/Guava性能测试
处于性能优化考虑,项目准备将本地缓存从guava cache 转到caffeine cache,于是着手对caffeine进行了一波调研,首先通过一系列测试,通过caffeine和guava从结果来看,在相同cpu负载下,Caffeine Cache的读取和写入速度优于Guava Cache,差距在4倍以上。
但在内存占用方面来看,两者无明显区别。
测试环境:
- CPU:i7-8700 3.20GHz 6核
- 内存:16g
- 系统:Windows 10
- JDK版本:8
- IDE:IDEA
- 内存监控工具:JProfiler
一、速度测试:
测试逻辑:
- 构建Cache,load方法为简单的字符串拼接
- 将250000个字符串加载到cache后,启动任务线程,预热10s后,开始计时,统计每10秒的count,共6轮,最后统计每轮中的每秒平均值
测试结果:
(1) 6个线程纯读:
(2) 4个线程读+2个线程写:
二、内存占用测试
测试逻辑:
- 基于项目中使用内存缓存需求最大的数据,构建缓存
- 先初始化缓存对象,10s后将数据库中的20w+条数据存入缓存中,并主动触发一次gc,对比剩余内存占用
测试结果:
Guava和Caffeine在加载完24w条柜机数据后,通过GC清理掉临时占用的内存,最后都保持了420M的内存占用,无明显区别,整个内存变化如下:
(1)Guava:
(2)Caffeine:
三、源码分析
Caffeine是在guava基础上进行优化的产物,也是带着替代guava的目的而来的,因而在使用上差别不大,但是通过测试可以明显看到Caffeine在性能上的优势,进而,通过源码,进一步探究了一下Caffeine和guava的区别
一、初始化
Caffeine、Guava都通过builder的方式进行初始化操作,生成缓存对象,通过builder方式可以生成两种缓存对象LoadingCache(同步填充)和Cache(手动填充),LoadingCache继承Cache,相比于Cache,提供了get获取值时,如果不存在值,自动通过CacheLoader的load方法下载数据并返回的功能,此处load方法在初始化时通过重写进行定义,项目中基本通过同步填充的方式,从数据库中加载数据,需要提的是,通过手动加载的方式,也可以在put时传递可执行函数
方式一 cache:
//guava
Cache cache = CacheBuilder.newBuilder()
.maximumSize(maximumSize).
expireAfterWrite(expireAfterWriteDuration, timeUnit)
.recordStats().build();
//caffeine
Cache cache = Caffeine.newBuilder()
.maximumSize(maximumSize).
expireAfterWrite(expireAfterWriteDuration, timeUnit)
.recordStats().build();
方式二 LoadingCache:
//guava
LoadingCache<K, V> cache = CacheBuilder.newBuilder().maximumSize(maximumSize)
.expireAfterWrite(expireAfterWriteDuration, timeUnit)
.recordStats().build(new CacheLoader<K, V>() {
@Override
public V load(K key) throws Exception {
return loadData(key);
}
});
//caffeine
LoadingCache<K, V> cache = Caffeine.newBuilder().maximumSize(maximumSize)
.expireAfterWrite(expireAfterWriteDuration, timeUnit)
.recordStats().build(new CacheLoader<K, V>() {
@Override
public V load(K key) throws Exception {
return loadData(key);
}
});
初始化过程,guava 和Caffeine基本上没有太大区别,都是通过builder方式进行构建,设置过期方式,刷新时间,统计信息等。
虽然两者初始化方式大致一致,但有个问题需要注意,guava初始化时重写的load()方法,不能返回null值,但caffeine可以。
guava 调用get()时,load方法返回null时的报错代码:
从caffeine的doComputeIfAbsent()方法可以看出,在load返回null时,get()调用直接返回null
guava 和Caffeine 都可以通过这两种方式初始化缓存,代码几乎完全一样,除了这两种初始化方式外,caffeine cache还提供了第三种初始化方式,异步加载方式
caffeine 异步加载方式:
AsyncLoadingCache<K, V> cache = Caffeine.newBuilder()
.recordStats()
.maximumSize(maximumSize)
.expireAfterWrite(expireAfterWriteDuration, timeUnit)
.buildAsync((CacheLoader<K, V>) key -> {
return loadData(key);
});
AsyncLoadingCache是继承自LoadingCache类的,异步加载使用Executor去调用方法并返回一个CompletableFuture,相比于同步填充模式,在load数据时,使用异步线程来执行load方法,默认使用ForkJoinPool.commonPool()来执行异步线程,我们可以通过Caffeine.executor(Executor) 方法来替换线程池。
通过asyncLoad对load方法进行了异步执行封装
生成executor
从初始化方式看不出两者太大的区别,caffeine提供的异步加载方式,在某些特定场景应该可以进一步提升数据加载的性能
二、基于时间的过期驱逐策略
guava 和caffeine都支持通过两种策略来进行数据的回收策略,分别是expireAfterWrite、expireAfterAccess,此外caffeine还支持通过expireAfter来通过重新相关方法自定义过期策略,这些过期策略都是初始化时进行指定
guava:
Cache guavaCache = CacheBuilder.newBuilder()
.expireAfterWrite(expireAfterWriteDuration, timeUnit)
.expireAfterAccess(expireAfterAccessDuration, timeUnit);
guava提供了两种回收策略,但是他并不是通过后台监听线程进行数据的清除工作,而是在获取值时进行值回收,所以如果一个key一直不被访问,虽然设置了过期策略,他依然会一直存在
通过get方法可以看出,guava在每次get时,通过getLiveValue方法去判断数据是否过期,并对过期数据进行清除,然后返回null,由判断条件可以看到在过期数据被驱逐后又会去调用lockedGetOrLoad进行数据加载,也就是说,对于数据过期,并不会导致数据直接失效,而是在get时,去load新的值,这就导致了一个问题,一旦一个key value存入缓存中,通过设置过期时间无法将它真正的从缓存中去除,如果没有设置maximumSize的话,就可能出现内存泄漏的情况
caffeine:
Cache guavaCache = Caffeine.newBuilder()
.expireAfterWrite(expireAfterWriteDuration, timeUnit)
.expireAfterAccess(expireAfterAccessDuration,timeUnit)
.expireAfter(...);
caffine和guava相同的两种过期策略也是惰性删除,在get时去进行过期判断过期
并且从代码可以看出,和guava一样,如果数据过期,也会通过load方法去重新加载数据,这也导致caffine在设置过期策略时,会有和guava相同的问题,即如果没有设置maximumSize的话,就可能出现内存泄漏的情况
相比于guava使用队列的方式进行过期数据的处理,caffeine使用ConcurrentHashMap的compute进行旧值替换,并且在返回前使用生成异步任务的方式进行旧数据的清除,这里可以看出,caffeine相比于guava,在过期处理逻辑上减少了对get操作的影响
caffeine的自定义过期策略expireAfter也是在进行特定操作是进行过期校验,并进行过期的,一般情况下caffeine提供的两种方式就已经够用了,所以不做深究
通过对guava和caffeine时间过期策略的比较,可以看出,caffine通过异步删除旧值,优化了guava通过队列同步移除旧值,减少了过期处理对get性能的影响,并且caffeine使用面向JDK8的ConcurrentHashMap进行数据存储,由于在JDK8中ConcurrentHashMap增加了红黑树,在hash冲突严重时也有良好的可读性
三、基于大小的驱逐策略
无论是caffeine还是guava,通过设置过期时间,是无法使缓存值从缓存中驱逐出去的,只会在指定时间后被新值替代,所以,在使用caffeine或者guava的时候,设置maximumSize是很有必要的caffeine和guava也是在get或者put操作的时候根据设置的大小进行清除的,但是两者的清除算法存在区别,guava使用LRU算法进行实现,而caffeine使用综合LFU和LRU优点产生的W-TinyLFU进行数据清除,改良的算法可以更科学的进行非热点数据的驱逐,较大程度的增加缓存的命中率。
guava chache通过LRU(Least Recently Used)算法进行数据驱逐:
guava 在每次调用get方法时,如果获取到了值,会调用recordRead方法,来利用recencyQueue队列记录访问的信息
在storeLoadedValue()方法中插入新值时,会调用evictEntries()方法来判断是否超过设定的最大值
首先是调用drainRecencyQueue()方法,通过recencyQueue队列存储信息的顺序来移动accessQueue中的数据对象
如果超过了设置的容量大小,就会调用getNextEvictable()方法从accessQueue中获取需要被驱逐的数据,然后调用removeEntry(e, e.getHash(), RemovalCause.SIZE)方法进行数据驱逐
这里,guava cache主要通过recencyQueue、accessQueue两个队列来实现LRU算法对数据进行驱逐,但是LRU算法的缺点是对偶发性、周期性的批量操作会导致LRU命中率急剧下,降缓存污染情况比较严重。
caffeine chache通过W-TinyLFU算法进行数据驱逐:
caffeine chache主要通过accessOrderWindowDeque、accessOrderProbationDeque、accessOrderProtectedDeque三个队列来实现W-TinyLFU算法,首先,前面说过,caffeine是通过异步方式执行过期任务的,在执行任务中,会将数据放到对应的accessOrderWindowDeque队列中
在maintenance()方法中调用evictEntries()进行数据驱逐,maintenance()在scheduleDrainBuffers(),performCleanUp()等方法中被调用
在evictEntries()方法中,首先会通过evictFromWindow()方法,将可能被驱逐的数据从accessOrderWindowDeque队列区转入到accessOrderProbationDeque队列
在新增更新任务时,任务执行时,调用onAccess方法,会将accessOrderProbationDeque转移到accessOrderProtectedDeque中
通过这三个地方完成了数据在accessOrderWindowDeque、accessOrderProbationDeque、accessOrderProtectedDeque三个队列中的转化,实现W-TinyLFU算法,完成对guava cache算法的优化。
总结
通过对guava cache 和caffeine 从性能到算法及使用的对比中,可以发现Caffeine基本是在Guava的基础上进行优化而来的,提供的功能基本一致,但是通过对算法和部分逻辑的优化,完成了对性能极大的提升,而且我们可以发现,两者切换几乎没有成本,毕竟caffeine就是以替换guava cache为目的而来的。
最后
以上就是留胡子书本为你收集整理的Caffeine与Guava对比Caffeine/Guava性能测试的全部内容,希望文章能够帮你解决Caffeine与Guava对比Caffeine/Guava性能测试所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复