Guava学习总结

本文深入解析Guava缓存库的功能与使用,包括线程安全、缓存回收策略、监控统计等特性。同时,探讨Guava中RateLimiter的令牌桶算法实现,分析其在限流场景下的工作原理。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

Cache

guava cache是一个本地缓存。

优点

  • 线程安全的缓存,与ConcurrentMap相似,但前者增加了更多的元素失效策略,后者只能显示的移除元素。
  • 提供了三种基本的缓存回收方式:基于容量回收、定时回收和基于引用回收。定时回收有两种:按照写入时间,最早写入的最先回收;按照访问时间,最早访问的最早回收。
  • 监控缓存加载/命中情况。
  • 集成了多部操作,调用get方式,可以在未命中缓存的时候,从其他地方获取数据源(DB,redis),并加载到缓存中。

缺点

  • Guava Cache的超时机制不是精确的。
public static void main(String[] args) throws ExecutionException, InterruptedException{
        //缓存接口这里是LoadingCache,LoadingCache在缓存项不存在时可以自动加载缓存
        LoadingCache<Integer,Student> studentCache
                //CacheBuilder的构造函数是私有的,只能通过其静态方法newBuilder()来获得CacheBuilder的实例
                = CacheBuilder.newBuilder()
                //设置并发级别为8,并发级别是指可以同时写缓存的线程数
                .concurrencyLevel(8)
                //设置写缓存后8秒钟过期
                .expireAfterWrite(8, TimeUnit.SECONDS)
                //设置缓存容器的初始容量为10
                .initialCapacity(10)
                //设置缓存最大容量为100,超过100之后就会按照LRU最近虽少使用算法来移除缓存项
                .maximumSize(100)
                //设置要统计缓存的命中率
                .recordStats()
                //设置缓存的移除通知
                .removalListener(new RemovalListener<Object, Object>() {
                    @Override
                    public void onRemoval(RemovalNotification<Object, Object> notification) {
                        System.out.println(notification.getKey() + " was removed, cause is " + notification.getCause());
                    }
                })
                //build方法中可以指定CacheLoader,在缓存不存在时通过CacheLoader的实现自动加载缓存
                .build(
                        new CacheLoader<Integer, Student>() {
                            @Override
                            public Student load(Integer key) throws Exception {
                                System.out.println("load student " + key);
                                Student student = new Student();
                                student.setId(key);
                                student.setName("name " + key);
                                return student;
                            }
                        }
                );

        for (int i=0;i<20;i++) {
            //从缓存中得到数据,由于我们没有设置过缓存,所以需要通过CacheLoader加载缓存数据
            Student student = studentCache.get(1);
            System.out.println(student);
            //休眠1秒
            TimeUnit.SECONDS.sleep(1);
        }

        System.out.println("cache stats:");
        //最后打印缓存的命中率等 情况
        System.out.println(studentCache.stats().toString());
    }

常用方法:

  • V getIfPresent(Object key) 获取缓存中key对应的value,如果缓存没命中,返回null。
  • V get(K key) throws ExecutionException 获取key对应的value,若缓存中没有,则调用LocalCache的load方法,从数据源中加载,并缓存。
  • void put(K key, V value) 如果缓存有值,覆盖,否则,新增
  • void putAll(Map m);循环调用单个的方法
  • void invalidate(Object key); 删除缓存
  • void invalidateAll(); 清楚所有的缓存,相当远map的clear操作。
  • long size(); 获取缓存中元素的大概个数。为什么是大概呢?元素失效之时,并不会实时的更新size,所以这里的size可能会包含失效元素。
  • CacheStats stats(); 缓存的状态数据,包括(未)命中个数,加载成功/失败个数,总共加载时间,删除个数等。
  • asMap()方法获得缓存数据的ConcurrentMap快照
  • cleanUp()清空缓存
  • refresh(Key) 刷新缓存,即重新取缓存数据,更新缓存
  • ImmutableMap getAllPresent(Iterable keys) 一次获得多个键的缓存值

核心类

  • CacheBuilder:类,缓存构建器。构建缓存的入口,指定缓存配置参数并初始化本地缓存。
  • CacheBuilder在build方法中,会把前面设置的参数,全部传递给LocalCache,它自己实际不参与任何计算。这种初始化参数的方法值得借鉴,代码简洁易读。
  • CacheLoader:抽象类。用于从数据源加载数据,定义load、reload、loadAll等操作。
  • Cache:接口,定义get、put、invalidate等操作,这里只有缓存增删改的操作,没有数据加载的操作。
  • AbstractCache:抽象类,实现Cache接口。其中批量操作都是循环执行单次行为,而单次行为都没有具体定义。
  • LoadingCache:接口,继承自Cache。定义get、getUnchecked、getAll等操作,这些操作都会从数据源load数据。
  • AbstractLoadingCache:抽象类,继承自AbstractCache,实现LoadingCache接口。
  • LocalCache:类。整个guava cache的核心类,包含了guava cache的数据结构以及基本的缓存的操作方法。
  • LocalManualCache:LocalCache内部静态类,实现Cache接口。其内部的增删改缓存操作全部调用成员变量 localCache(LocalCache类型)的相应方法。
  • LocalLoadingCache:LocalCache内部静态类,继承自LocalManualCache类,实现LoadingCache接口。 其所有操作也是调用成员变量localCache(LocalCache类型)的相应方法。
  • CacheStats:缓存加载/命中统计信息。

RateLimiter

限流的定义如下:

In computer networks, rate limiting is used to control the rate of traffic sent or received by a network interface controller and is used to prevent DoS attacks.

通过控制数据的网络数据的发送或接收速率来防止可能出现的DOS攻击。而实际的软件服务过程中,限流也可用于API服务的保护。由于提供服务的计算机资源(包括CPU、内存、磁盘及网络带宽等)是有限的,则其提供的API服务的QPS也是有限的,限流工具就是通过限流算法对API访问进行限制,保证服务不会超过其能承受的负载压力。

常用限流算法

  1. Token bucket-令牌桶
  2. Leaky bucket-漏桶
  3. Fixed window counter-固定窗口计数
  4. Sliding window log-滑动窗口日志
  5. Sliding window counter-滑动窗口计数

固定窗口算法

固定窗口计数法思想比较简单,只需要确定两个参数:计数周期T及周期内最大访问(调用)数N。请求到达时使用以下流程进行操作:

固定窗口计数实现简单,并且只需要记录上一个周期起始时间与周期内访问总数,几乎不消耗额外的存储空间。

算法缺陷

固定窗口计数缺点也非常明显,在进行周期切换时,上一个周期的访问总数会立即置为0,这可能导致在进行周期切换时可能出现流量突发,

假设在两个周期T0中a时刻有n1个访问同时到达,周期T1中b时刻有n2个访问同时到达,且n1和n2均小于设定的最高访问次数N(否则会触发限流)。
根据以上假设可以推断,限流器不会限流,n1+n2次访问均可以通过。

根据观察可发现,在 t t t的时间内,出现了 n 1 + n 2 n1+n2 n1+n2次请求,且 n 1 + n 2 n1+n2 n1+n2是可能大于 N N N的,所以在实际使用过程中,固定窗口计数器存在突破限额N的可能。
举例,限制QPS为10,某用户在周期切换的前后的0.1秒内,分两次发送10次请求,根据算法规则此20次请求可通过限流器,则0.1面秒请求数20,超过每秒最多10次请求的限制。

滑动窗口计数

为解决固定窗口计数带来的周期切换处流量突发问题,可以使用滑动窗口计数。滑动窗口计算本质上也是固定窗口计数,区别在于将计数周期进行细化。

滑动窗口计数法与股固定窗口计数法相比较,除了计数周期T及周期内最大访问(调用)数N两个参数,增加一个参数M,用于设置周期T内的滑动窗口数。限流流程如下:

周期切换问题

滑动窗口针对周期进行了细分,不存在周期到后计数直接重置为0的情况,故不会出现跨周期的流量限制问题。

漏桶限流

简单说明为:人为设定漏桶流出速度及漏桶的总容量,在请求到达时判断当前漏桶容量是否已满,不满则可将请求存入桶中,否则抛弃请求。程序以设定的速率取出请求进行处理。

根据描述,需要确定参数为漏桶流出速度r及漏桶容量N,流程如下:

漏桶算法主要特点在于可以保证无论收到请求的速率如何,真正抵达服务方接口的请求速率最大为r,能够对输入的请求进行平滑处理。

漏桶算法的缺点也非常明显,由于其只能以特定速率处理请求,则如何确定该速率就是核心问题,如果速率设置太小则会浪费性能资源,设置太大则会造成资源不足。并且由于速率的设置,无论输入速率如何波动,均不会体现在服务端,即使资源有空余,对于突发请求也无法及时处理,故对有突发请求处理需求时,不宜选择该方法。

令牌桶限流

令牌桶限流的实现原理在wiki有详细说明。简单总结为:设定令牌桶中添加令牌的速率,并且设置桶中最大可存储的令牌,当请求到达时,向桶中请求令牌(根据应用需求,可能为1个或多个),若令牌数量满足要求,则删除对应数量的令牌并通过当前请求,若桶中令牌数不足则触发限流规则。

根据描述需要设置的参数为,令牌添加速率r,令牌桶中最大容量N,流程如下:

令牌桶算法通过设置令牌放入速率可以控制请求通过的平均速度,且由于设置的容量为N的桶对令牌进行缓存,可以容忍一定流量的突发。

Guava中RateLimiter的令牌桶实现

使用方法

1.引入相关的依赖
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>19.0</version>
        </dependency>
2.编写相关的Demo
public class RateLimiterTest {
    public static void main(String[] args) throws InterruptedException {
        RateLimiter limiter = RateLimiter.create(10);// 代码1
        Thread.currentThread().sleep(1000);//步骤1
        if (limiter.tryAcquire(20))//代码2
            System.out.println("======== Time1:" + System.currentTimeMillis() / 1000);

        Thread.currentThread().sleep(1001);
        if (limiter.tryAcquire(1))//代码3
            System.out.println("======== Time2:" + System.currentTimeMillis() / 1000);

        if (limiter.tryAcquire(5))
            System.out.println("======== Time3:" + System.currentTimeMillis() / 1000);

    }
}
3.运行结果

场景1:

======== Time1:1533114071
======== Time2:1533114072

场景2:修改代码:去掉步骤1,运行结果如下:

======== Time1:1533114155

场景3:修改相关代码如下:

public class RateLimiterTest {
    public static void main(String[] args) throws InterruptedException {
        RateLimiter limiter = RateLimiter.create(10);// 代码1
        Thread.currentThread().sleep(2000);
        if (limiter.tryAcquire(21))//代码2
            System.out.println("======== Time1:" + System.currentTimeMillis() / 1000);

        Thread.currentThread().sleep(1001);
        if (limiter.tryAcquire(1))//代码3
            System.out.println("======== Time2:" + System.currentTimeMillis() / 1000);

        if (limiter.tryAcquire(5))
            System.out.println("======== Time3:" + System.currentTimeMillis() / 1000);

    }
}

​ 结果如下:

======== Time1:1533114623

下面我们来分析这三种情况产生的原因,顺便也分析下RateLimiter中的令牌桶算法是如何实现的。

在分析之前,说明一点,我之前一直以为令牌桶算法,是定时器机制,定时往桶里面放令牌,但是有些时候并不是这样的。先声明一下。

我们来分析下代码:

代码行1:

 RateLimiter limiter = RateLimiter.create(10);

这行代码,我们知道是创建一个每秒产生10个permit的速率器

代码行2:

limiter.tryAcquire(20)  //尝试从速率器中获取20个permit,获取成功 true;失败 false

代码行3:

limiter.tryAcquire(1) //尝试从速率器中获取1个permit,获取成功 true;失败 false

为什么相同的代码,不同的休眠时间导致不同的结果呢?

RateLimiter 速率器,通过预支将来的令牌来进行限制频控,什么意思呢?打个比方:速率器相当于工厂,获取令牌许可的线程相当于经销商,经销商过来取货,工厂每天的生产的货品是一定的(100吨/天),A经销商来取货,第一天取了200吨货,工厂没有这么多货,怎么办呢?为了留住这个经销商,厂长做了决定,给200吨,现在的100吨先给你,明天的100吨也给你,然后把200吨货品的提取清单给了A经销商,A很满意的离开了。过了一会,B来了,B要10吨货物,这个时候,厂长就没有那么好说话了(谁让大客户已经到手了呢?),说10吨货物可以,你后天来吧,明天和今天的活已经都卖完了。这个时候通过这种方式,来限制一天只卖/生产100吨的货物。

代码分析

RateLimiter limiter = RateLimiter.create(10);

调用的是:

@VisibleForTesting
  static RateLimiter create(SleepingStopwatch stopwatch, double permitsPerSecond) {
    RateLimiter rateLimiter = new SmoothBursty(stopwatch, 1.0 /* maxBurstSeconds 注意 这里的maxBurstSeconds指定的是1s 直接影响后面的maxPermit*/);
    rateLimiter.setRate(permitsPerSecond);//见下文代码
    return rateLimiter;
  }

setRate(permitsPerSecond)如下:

public final void setRate(double permitsPerSecond) {
    checkArgument(
        permitsPerSecond > 0.0 && !Double.isNaN(permitsPerSecond), "rate must be positive");
    synchronized (mutex()) {
      doSetRate(permitsPerSecond, stopwatch.readMicros());//stopwatch.readMirco 获取的是创建以来的系统时间 这里调用SmoothRateLimiter.doSetRate()
    }
  }

SmoothRateLimiter.doSetRate()

 @Override
  final void doSetRate(double permitsPerSecond, long nowMicros) {
    resync(nowMicros);//你可以认为这边是重设相关的nextFreeTicketMicros和storedPermits 这个函数是相关计算频控的重要组成部分  ------1
    double stableIntervalMicros = SECONDS.toMicros(1L) / permitsPerSecond;
    this.stableIntervalMicros = stableIntervalMicros;
    doSetRate(permitsPerSecond, stableIntervalMicros);//这个函数是RateLimiter创建时候 初始化maxpermits和StorePermits的相关部分 也是一个重要的部分 ---2
  }

我们来看1的实现:

/**
   * Updates {@code storedPermits} and {@code nextFreeTicketMicros} based on the current time.
   * 基于当前的时间 计算相关的storedPermits和nextFreeTicketMicros 
   *  storedPermits:当前存储的令牌数
   *  nextFreeTicketMicros:下次可以获取令牌的时间 其实这么讲不太准确 应该说是,上次令牌获取之后预支到下次可以获取令牌的最早时间
   *         此处再创建的时候 nextFreeTicketMicros基本就是创建时候的系统时间
   */
  void resync(long nowMicros) {
    // if nextFreeTicket is in the past, resync to now
    if (nowMicros > nextFreeTicketMicros) {
      storedPermits = min(maxPermits,
          storedPermits
            + (nowMicros - nextFreeTicketMicros) / coolDownIntervalMicros());
      nextFreeTicketMicros = nowMicros; 
    }
  }

我们可以看到,我们这里通过计算当前时间和下次可以获取令牌的时间,相互计算差值,然后除以一个令牌产生的时间间隔,来计算当前时段可以产生多少令牌,然后和我们的 maxPermits来取最小值,由此我们可以看到storedPermits最多只能存储maxPermits数量的令牌,这也是令牌桶大小所限制的。

我们再来看2代码的实现:

@Override
    void doSetRate(double permitsPerSecond, double stableIntervalMicros) {
      double oldMaxPermits = this.maxPermits;
      maxPermits = maxBurstSeconds * permitsPerSecond;//设置最大可存储的令牌数 这里的maxBurstSeconds 就是之前设置的1s 所以maxPermits数值上等于我们设置的permitsPerSecond
      if (oldMaxPermits == Double.POSITIVE_INFINITY) {
        // if we don't special-case this, we would get storedPermits == NaN, below
        storedPermits = maxPermits;
      } else {
        storedPermits = (oldMaxPermits == 0.0)
            ? 0.0 // initial state
            : storedPermits * maxPermits / oldMaxPermits;
      }
    }

到这里我们的初始化RateLimiter结束了。我们来明确其中的几个概念:

  • maxPermits:最大存储的令牌数,即令牌桶的大
  • storedPermits:已存储的令牌数<=maxPermits,当然这个是通过计算算出来的
  • nextFreeTicketMicros:上次获取令牌时预支的最早能够再次获取令牌的时间
  • nowMicros:当前系统时间

我们接下来看如何获取令牌:

代码2:

limiter.tryAcquire(20)

具体的代码实现如下:

public boolean tryAcquire(int permits, long timeout, TimeUnit unit) {//timeout = 0 unit=MICROSECONDS
    long timeoutMicros = max(unit.toMicros(timeout), 0);
    checkPermits(permits);//校验参数
    long microsToWait;
    synchronized (mutex()) {//互斥量 
      long nowMicros = stopwatch.readMicros();
      if (!canAcquire(nowMicros, timeoutMicros)) {//此处判断当前时间是否大于等于上次预支最早时间  ----1
        return false;
      } else {
        microsToWait = reserveAndGetWaitLength(permits, nowMicros);//当前线程获取到permit需要等待的时间 ---2
      }
    }
    stopwatch.sleepMicrosUninterruptibly(microsToWait);//线程等待 获取permit
    return true;
  }

我们来看1的实现部分:

private boolean canAcquire(long nowMicros, long timeoutMicros) {
    return queryEarliestAvailable(nowMicros) - timeoutMicros <= nowMicros;
  }
@Override
  final long queryEarliestAvailable(long nowMicros) {
    return nextFreeTicketMicros;
  }

有此可见,如果当前时间+超时时间>=预支的最早时间,那么是可以获取许可的,反之则不能获取许可

再看代码2的实现:

final long reserveAndGetWaitLength(int permits, long nowMicros) {
    long momentAvailable = reserveEarliestAvailable(permits, nowMicros);
    return max(momentAvailable - nowMicros, 0);//计算需要等待的时间
  }

SmoothRateLimiter.reserveEarliestAvailable()

@Override
  final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
    resync(nowMicros);//这里是重设相关的storedPermits和nenextFreeTicketMicros 这个在前文我们讲过 需要注意的是 这边的nextFreeTicketMicros设置的是nowMicros 可能会有人有疑问,nextFreeTicketMicros不是预支的最早获取permit的时间吗?怎么是nowMicros了呢?我们下面看
    long returnValue = nextFreeTicketMicros;//这里返回的其实就是nowMiscros
    double storedPermitsToSpend = min(requiredPermits, this.storedPermits);//本次能消费的最多的permit
    double freshPermits = requiredPermits - storedPermitsToSpend;//需要预支的permit
    long waitMicros = storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)
        + (long) (freshPermits * stableIntervalMicros);//预支的生产的时间

    try {
      this.nextFreeTicketMicros = LongMath.checkedAdd(nextFreeTicketMicros, waitMicros);//这里就是重设了预支下次能够获取permit的最早时间了 这边将waitMiscros加上了
    } catch (ArithmeticException e) {
      this.nextFreeTicketMicros = Long.MAX_VALUE;
    }
    this.storedPermits -= storedPermitsToSpend;//扣除本地消费的permit
    return returnValue;//返回当前时间
  }

这样就完成了前后两个permit之间获取的的联动性,并不是有一个定时任务往中间放permit,而是直接预支的后面消费者的时间来进行控制的,这样有一个好处就是,第一次获取permit的时候,其实可以获取N多个permit,并不做限制,只是这么多的permit会导致后面消费者卡死在那边,当然,消费者在timeOut范围内获取不到permit也就直接返回了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值