缓存之路-Guava Cache

1,179 阅读5分钟

在Java中本地缓存的实现有多种方式,最简单的实现可以使用ConcurrentHashMap,但由于ConcurrentHashMap缓存淘汰比较麻烦,所以Google的大佬们创造了Guava Cache。Guava Cache提供了缓存刷新、缓存加载以及各种淘汰机制,从功能上来说Guava Cache是一个非常强大的本地缓存组件。

基本使用

Cache<String, Object> cache = CacheBuilder.newBuilder()
				.maximumSize(1000)   //缓存最大条目,如果超出从最早条目的淘汰
				.expireAfterWrite(30, TimeUnit.MINUTES)   //从缓存写入后开始计算过期时间
				.build();
...
cache.put("key1", "val1");  //写入缓存
...
//读取缓存 姿势一
try {
	Object val1 = cache.get("key1", ()-> "callable val");  //实现Callable接口,用于缓存未命中时加载数据
} catch (ExecutionException e) {
	e.printStackTrace();
}
...
//读取缓存 姿势二
Object val1 = cache.getIfPresent("key1");  //缓存未命中时返回null

上述代码我们创建了一个  Cache  类型的缓存,在读取缓存时,提供了两种方法, get()  和  getIfPresent() ,区别在于缓存未命中时,get() 方法会调用传入的Callable接口获取数据返回并加载到缓存中,而 getIfPresent() 方法则在缓存未命中时直接返回null。另外,get() 方法将loading和put操作结合起来并且提供了线程安全的访问,如果自己通过 getIfPresent() 来实现同样逻辑的话还需要注意loading和put操作在多线程下的原子性问题。

缓存的构建分为两种,除了上述的一种外,还有一种是通过在build方法中传入  CacheLoader  接口的实现来创建一个  LoadingCache  缓存对象,如下:

LoadingCache<String, Object> cache = CacheBuilder.newBuilder()
				.maximumSize(100)
				.expireAfterWrite(30, TimeUnit.MINUTES)
				.build(new CacheLoader<String, Object>() {  //用于在缓存未命中时根据key自动加载数据
					@Override
					public Object load(String key) throws Exception {
						return "load - "+key;
					}
				});
...
Object key1 = cache.getUnchecked("key1");  //get()方法会抛出一个受检异常,也可以使用getUnchecked()方法抑制受检异常
...

缓存刷新

缓存刷新用于定时刷新缓存中现有的数据,保证数据一致性。

@Slf4j
public class GuavaCacheTest {

    private static final Object monitor = new Object();

    @Test
    public void refresh() {
        LoadingCache<String, Object> cache = CacheBuilder.newBuilder()
            .refreshAfterWrite(2, TimeUnit.SECONDS) //开启缓存刷新
            .removalListener((RemovalListener<String, Object>)removalNotification -> {
                log.info("remove listener, notify:{}", removalNotification);
            })
            .build(new CacheLoader<String, Object>() {
                @Override
                public Object load(String key) throws Exception {
                    log.info("load cache, key:{}", key);
                    return loadDataFromDB(key);
                }
                
                /**
                 * 实现该接口完成缓存刷新时reload逻辑
                 * 
                 * @param key 刷新的key值
                 * @param oldValue 旧值
                 * @return 
                 */
                @Override
                public ListenableFuture<Object> reload(String key, Object oldValue) throws Exception {
                    log.info("refresh cache, reload start...  key:{}, oldValue:{}", key, oldValue);
                    return MoreExecutors.newDirectExecutorService().submit(() -> loadDataFromDB(key));
                }
            });

        //每一秒访问一次查询缓存值是否刷新
        while (!Thread.currentThread().isInterrupted()) {
            try {
                Thread.sleep(1000);
                Object val = cache.get("zhangsan");
                log.info("get cache, value:{}, current cache record:{}", val,  cache.asMap().size());
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        //保证程序不退出
        try {
            synchronized (monitor) {
                wait(0);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    //数据加载逻辑
    private EmployeeInfo loadDataFromDB(String key) {
        String uuid = UUID.randomUUID().toString();
        EmployeeInfo employeeInfo = new EmployeeInfo(uuid, key + "_new", "6712");
        log.info("loadDataFromDB, result:{}", employeeInfo);
        return employeeInfo;
    }
}

实现 reload 方法时,返回值是 ListenableFuture 这是guava中封装的 Future 类,和 jdk中的 Future 的区别主要是他可以监听结果,在结果返回后立即执行回调逻辑。

关于 ListenableFuture 参考:blog.csdn.net/androidlush…

缓存淘汰

Guava缓存淘汰同样使用了LRU算法,除此之外,还提供了其他的淘汰方式。
上述例子中配置了maximumSize控制缓存总记录条数以及expireAfterWrite控制在缓存写入后多久失效。除此之外Guava Cache还支持如下缓存淘汰策咯:

  • expireAfterAccess - 访问后多久失效。
  • maximumWeight - 缓存的最大权重, 通过weigher方法设置每个缓存的权重比,淘汰时按从小到大淘汰。

Ticker(淘汰测试)

可以使用Ticker进行缓存淘汰测试,示例:

Ticker实现

class FakeTicker extends Ticker {

    private final AtomicLong nanos = new AtomicLong();

    /** Advances the ticker value by {@code time} in {@code timeUnit}. */
    public FakeTicker advance(long time, TimeUnit timeUnit) {
        nanos.addAndGet(timeUnit.toNanos(time));
        return this;
    }

    @Override
    public long read() {
        long value = nanos.getAndAdd(0);
        System.out.println("is called " + value);
        return value;
    }
}

Test

FakeTicker t = new FakeTicker();


LoadingCache<String, String> cache = CacheBuilder.newBuilder()
    .expireAfterWrite(20, TimeUnit.MINUTES)
    .ticker(t)
    .build(ldr);
cache.getUnchecked("hello");
assertEquals(1, cache.size());
assertNotNull(cache.getIfPresent("hello"));

// add 21 minutes
t.advance(21, TimeUnit.MINUTES);
assertNull(cache.getIfPresent("hello")); 

锁竞争

在之前的 LRUHashMap 中,为保证线程安全使用了 synchronized 对缓存读写操作进行加锁。
而在Guava Cache中使用了类似于 ConcurrentHashMap分段锁思想。在每个段里面各自负责自己的淘汰的事情。在Guava根据一定的算法进行分段,这里要说明的是,如果段太少那竞争依然很严重,如果段太多会容易出现随机淘汰,比如大小为100的,给他分100个段,那也就是让每个数据都独占一个段,而每个段会自己处理淘汰的过程,所以会出现随机淘汰。
在构造时可以通过 concurrencyLevel 来设置分段数。

参考

缓存淘汰

  • FIFO:先进先出。在这种淘汰算法中,先进入缓存的会先被淘汰。这种可谓是最简单的了,但是会导致我们命中率很低。试想一下我们如果有个访问频率很高的数据是所有数据第一个访问的,而那些不是很高的是后面再访问的,那这样就会把我们的首个数据但是他的访问频率很高给挤出。
  • LRU:最近最少使用算法。在这种算法中避免了上面的问题,每次访问数据都会将其放在我们的队尾,如果需要淘汰数据,就只需要淘汰队首即可。但是这个依然有个问题,如果有个数据在1个小时的前59分钟访问了1万次(可见这是个热点数据),再后一分钟没有访问这个数据,但是有其他的数据访问,就导致了我们这个热点数据被淘汰。
  • LFU:最近最少频率使用。在这种算法中又对上面进行了优化,利用额外的空间记录每个数据的使用频率,然后选出频率最低进行淘汰。这样就避免了LRU不能处理时间段的问题。

几种算法的命中率对比:

Guava是基于LRU算法来实现