Guava-Cache

Guava-Cache

摘要

本文讲解Google Guava Cache基本用法和写入、过期原理。

1 简介

Guava Cache十分流行,在Apache Calcite等重量级项目中都有使用,比如org.apache.calcite.avatica.jdbc.JdbcMeta

private final Cache<String, Connection> connectionCache;

this.connectionCache = CacheBuilder.newBuilder()
        .concurrencyLevel(concurrencyLevel)
        .initialCapacity(initialCapacity)
        .maximumSize(maxCapacity)
        .expireAfterAccess(connectionExpiryDuration, connectionExpiryUnit)
        .removalListener(new ConnectionExpiryHandler())
        .build();

Guava Cache可选的优秀特点如下:

  • 线程安全
  • 当最大阈值超过时,自动删除最近最少使用的Entry
  • 基于时间的过期策略(最后访问/写入)
  • 清除Entry时触发通知方法
  • 累积缓存访问统计信息
  • 可设置key不存在时load默认值等。

2 用法

import com.google.common.cache.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;

public class CacheTest1 {
    private static final Logger logger = LoggerFactory.getLogger(CacheTest1.class);

    /**
     * 元素参数说明
     * @throws InterruptedException
     */
    public static void method1() throws InterruptedException{
        Cache kk = CacheBuilder.newBuilder()
                // 1分钟不使用就移除
                .expireAfterAccess(1, TimeUnit.SECONDS)
                // 并行指hashtable
                .concurrencyLevel(8)
                // 这里就会创建8个segment每个含有一个hashTable(大小是8)
                // 该值设定可以有效避免后续resizing,但也不要设过大造成内存浪费
                .initialCapacity(64)
                // 设置存放的最大entry数.在达到阈值前cache可能会清除最近很少使用的entry
                // 如果设为0,放进cache会被立刻清理
                // 该值不能和maximumWeight同时使用
                .maximumSize(1024)
                // 每当entry被移除时触发该方法
                // 注意是移除,不是过期!!
                .removalListener((RemovalListener<String, String>) rn -> {
                    try {
                        logger.info("发生移除 {}", rn.getKey());
                        rn.getValue();
                    }
                    catch (Exception e) {
                        e.printStackTrace();
                    }
                })
                .build();
        kk.put("helloKey","helloValue");
        System.out.println("kk=" + kk.getIfPresent("helloKey"));
        Thread.sleep(2000);
        System.out.println("kk=" + kk.size());
        System.out.println("kk=" + kk.getIfPresent("helloKey"));
    }

    /**
     * 测试元素remove
     * @throws InterruptedException
     */
    public static void method2() throws InterruptedException{
        Cache kk = CacheBuilder.newBuilder()
                .expireAfterAccess(1, TimeUnit.SECONDS)
                .concurrencyLevel(1)
                .initialCapacity(1)
                .maximumSize(1)
                // 每当entry被移除时触发该方法
                .removalListener((RemovalListener<String, String>) rn -> {
                    try {
                        logger.info("发生移除 {}", rn.getKey());
                        rn.getValue();
                    }
                    catch (Exception e) {
                        e.printStackTrace();
                    }
                })
                .build();
        kk.put("helloKey1","helloValue");
        kk.put("helloKey2","helloValue");
        kk.put("helloKey3","helloValue");
        kk.put("helloKey4","helloValue");
        kk.put("helloKey5","helloValue");

        System.out.println("kk=" + kk.getIfPresent("helloKey1"));
        Thread.sleep(2000);
        System.out.println("kk=" + kk.size());
        System.out.println("kk=" + kk.getIfPresent("helloKey2"));
        System.out.println("kk=" + kk.getIfPresent("helloKey3"));
        System.out.println("kk=" + kk.getIfPresent("helloKey4"));
        System.out.println("kk=" + kk.getIfPresent("helloKey5"));
    }

    /**
     * LoadingCache和Cache不同,
     * 使用它,CacheLoader才会有效,调用其load方法
     * @throws InterruptedException
     * @throws ExecutionException
     */
    public static void method3() throws InterruptedException, ExecutionException {
        // 注意这里要用LoadingCache
        LoadingCache lc3 = CacheBuilder.newBuilder()
                //1分钟不使用就移除
                .expireAfterAccess(1, TimeUnit.SECONDS)
                .removalListener((RemovalListener<String, String>) rn -> {
                    try {
                        logger.info("关闭 PalDb reader {}", rn.getKey());
                        rn.getValue();
                    }
                    catch (Exception e) {
                        e.printStackTrace();
                    }
                })
                .build(new CacheLoader<String, String>() {
                    @Override
                    public String load(String key) throws Exception {
                        return "loadValue";
                    }
                });
        // get,key不存在时,会调用load方法
        System.out.println("helloKey1 get=" + lc3.get("helloKey1"));
        System.out.println("helloKey1 get=" + lc3.get("helloKey1"));
        // getIfPresent,key不存在时,不会调用load方法
        System.out.println("helloKey2 getIfPresent=" + lc3.getIfPresent("helloKey2"));
        System.out.println("helloKey3 get=" + lc3.get("helloKey3"));
        lc3.put("helloKey3","helloValue3");
        System.out.println("helloKey3 get=" + lc3.get("helloKey3"));
        Thread.sleep(2000);
        // size返回的只是近似值
        System.out.println("lc3.size =" + lc3.size());
        // 数据过期后,getIfPresent拿不到值了,也不会调用load方法
        System.out.println("helloKey3 getIfPresent=" + lc3.getIfPresent("helloKey3"));
        // 数据过期后,get拿不到值了,会调用load方法
        System.out.println("helloKey3 get=" + lc3.get("helloKey3"));
    }

    public static void main(String[] args) throws Exception {
        method3();
    }
}

3 源码

3.1 初始化

这里分析时,构建的Cache:

Cache kk = CacheBuilder.newBuilder()
        .expireAfterWrite(1, TimeUnit.MINUTES)
        .maximumSize(5)
        // 每当entry被移除时触发该方法
        .removalListener((RemovalListener<String, String>) rn -> {
            try {
                logger.info("发生移除 {}", rn.getKey());
                rn.getValue();
            } catch (Exception e) {
                e.printStackTrace();
            }
        })
        .build();

初始化LocalCache,进行初始化工作,部分重要内容如下:

  • expireAfterAccessNanos、expireAfterWriteNanos和refreshNanos
  • removalListener和removalNotificationQueue(是ConcurrentLinkedQueue<RemovalNotification<K, V>>())
    在元素移除时触发通知,执行自定义行为
  • 初始容量initialCapacity,这里是5
  • 根据并行度等信息(CacheBuilder的concurrencyLevel等)创建Segment数组
  • 创建所有Segment对象,为每个Segment创建:
    • AtomicReferenceArray<ReferenceEntry<K, V>> table
      这里的泛型就是LocalCache<K, V>
    • recencyQueue
      ConcurrentLinkedQueue<ReferenceEntry<K, V>>,记录该元素被读过,最近读过的放到队列末尾。
    • writeQueue
      WriteQueue<K, V>,以写入顺序记录所有缓存元素,最近写入的放到末尾。每个元素有前置和后置,且最后一个元素和head元素有前后置的索引连接方便查找和添加元素到末尾。
    • accessQueue
      AccessQueue<K, V>,以访问顺序记录所有缓存元素,最近访问的放到末尾。每个元素有前置和后置,且最后一个元素和head元素有前后置的索引连接方便查找和添加元素到末尾。

3.2 写入

  1. 写入时,通过算出key的hash,找到Segment数组(大小取决于CacheBuilder的concurrencyLevel等,默认值4)中对应Segment(return segments[(hash >>> segmentShift) & segmentMask];),然后再put

    public V put(K key, V value) {
      checkNotNull(key);
      checkNotNull(value);
      int hash = hash(key);
      return segmentFor(hash).put(key, hash, value, false);
    }
    
  2. put时先尝试对该Segment加锁(Segment继承自ReentrantLock,所以可以看出LocalCache的并行度是Segment级别)。

  3. 拿到锁后调用expireEntries,会

    • drainRecencyQueue
      从队头开始往后排空recencyQueue中所有ReferenceEntry。且如果某个元素包含在accessQueue内就会该元素重新放在accessQueue队列的末尾,以示该元素最近访问过。
    • 清理writeQueue中过期ReferenceEntry
    • 清理accessQueue中过期ReferenceEntry
  4. 按需扩容table

  5. 查找新元素应放入AtomicReferenceArray<ReferenceEntry<K, V>> table的位置

    int index = hash & (table.length() - 1);
    ReferenceEntry<K, V> first = table.get(index);
    
  6. 从找到的ReferenceEntry<K, V> first位置开始往后查找是否有 Key 相等的元素,这相当于是一个链表开始从头往尾方向查找。

    • 如果没找到直接table.set(index, newEntry)放入index位置
    • 如果找到,就进行替换
    • 替换时,如果定义了RemovalListener还会先放入一个queue进行通知
      在这里插入图片描述
  7. 放置key value流程

    1. 先创建newEntry
      注意,这里将原来该table.index位置的链表的first元素作为了当前新元素.next

      ReferenceEntry<K, V> newEntry = newEntry(key, hash, first)
      
    2. 将值包装为ValueReference,然后放入ReferenceEntry
      在这里插入图片描述

    3. 再次drainRecencyQueue

    4. 然后记录下当前使用该Entry时间

      if (map.recordsAccess()) {
        entry.setAccessTime(now);
      }
      if (map.recordsWrite()) {
        entry.setWriteTime(now);
      }
      
    5. 随后放入两个队列accessQueuewriteQueue,以便将来驱逐

      accessQueue.add(entry);
      writeQueue.add(entry);
      
    6. 最后将newEntry放入到该Segment的table中

      注意,这里相当于是头插法,将当前元素作为了该table.index位置的新的first元素!

      table.set(index, newEntry)
      
  8. 判断是否当前LocalCache元素是否超限,并进行相关清理工作

    1. drainRecencyQueue
    2. 判断 totalWeight(元素写入一个增加一个,移除(比如由于过期、替换等)时就减少一个) 是否大于 maxSegmentWeight(maxSegmentWeight = maxWeight / segmentCount + 1,maxWeight = maximumSize = 5,还要处理个余数,这里值为5)来判断是否需要清理元素
      while (totalWeight > maxSegmentWeight) {
        // 从accessQueue里,从头往后取首个weight>0的元素(其实就是取最久没有访问的元素)
        ReferenceEntry<K, V> e = getNextEvictable();
        // 1. writeQueue.remove(entry)
        // 2. accessQueue.remove(entry)
        if (!removeEntry(e, e.getHash(), RemovalCause.SIZE)) {
          throw new AssertionError();
        }
      }
      
  9. processPendingNotifications

    void processPendingNotifications() {
       RemovalNotification<K, V> notification;
       while ((notification = removalNotificationQueue.poll()) != null) {
         try {
           removalListener.onRemoval(notification);
         } catch (Throwable e) {
           logger.log(Level.WARNING, "Exception thrown by removal listener", e);
         }
       }
     }
    

3.3 读取

    System.out.println(kk.getIfPresent("helloKey1"));
  1. 通过算出key的hash,找到Segment数组(大小取决于CacheBuilder的concurrencyLevel等,默认值4)中对应Segment(return segments[(hash >>> segmentShift) & segmentMask];)

  2. 通过 目标 Segment.get(key, hash)

  3. getLiveEntry(key, hash, now) 查找到对应元素
    找到后还需要判断是否过期

    这里就根据用户配置的expireAfterWriteexpireAfterAccess来决定判定过期规则

    boolean isExpired(ReferenceEntry<K, V> entry, long now) {
       checkNotNull(entry);
       if (expiresAfterAccess() && (now - entry.getAccessTime() >= expireAfterAccessNanos)) {
         return true;
       }
       if (expiresAfterWrite() && (now - entry.getWriteTime() >= expireAfterWriteNanos)) {
         return true;
       }
       return false;
     }
    

    如果该元素过期,就尝试获取锁并执行expireEntries(now)进行过期元素处理

  4. 如果找到了该元素:

    • 若开启了expireAfterWrite则会entry.setAccessTime(now)
    • 将该Entry放入recencyQueue队列尾部
    • 如果开启了refreshAfterWrite就判断是否需要refresh该key的值,需要就更新后返回新值;否则返回旧值
  5. postReadCleanup
    通常是在写入时清理过期Entry,但如果在大量读取后仍未观察到清理工作发生,则读取线程尝试清理。清理主要是指:

    • drainRecencyQueue
      排空recencyQueue,已访问的就移动到accessQueue尾部
    • 清理writeQueue和accessQueue过期数据
    • readCount.set(0)
    • processPendingNotifications
      RemovalNotification通知removalListener
  6. 返回读到的Value

3.4 过期

主要是在数据写入时进行过期行为。

写入时加入writeQueueaccessQueue,清理时也会判断writeQueue/accessQueue中元素是否过期,过期就remove。

读数据时,如果读到会将该entry放入recencyQueue队列尾部。

drainRecencyQueue时,会排空recencyQueue,其中已访问的就移动到accessQueue尾部,表示最近访问过,这符合LRU。

3.5 LRU

在写入元素的倒数第二步判断是否当前LocalCache元素是否超限,并进行相关清理工作,主要就是用了 accessQueue ,从头往尾遍历移除,直到不超限位置。

4 对比其他

4.1 Caffeine

4.2 EhCache

更多好文

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值