Guava探险-1、Cache

1 篇文章 0 订阅

一、Cache 简介:

  缓存分为本地缓存远端缓存。常见的远端缓存有 Redis、MongoDB 等;本地缓存一般使用 map 的方式保存在本地内存中。
  一般在业务中操作缓存,都会操作缓存数据源两部分。如:put 数据时,先插入DB,再删除原来的缓存;get 数据时,先查缓存,命中则返回,没有命中时,需要查询 DB,再把查询结果放入缓存中 。如果访问量大,还得兼顾本地缓存的线程安全问题。必要的时候也要考虑缓存的回收策略。

  Guava Cache 是 guava 中的一个内存缓存模块,用于将数据缓存到 JVM 内存中。它很好的解决了上面提到的几个问题:
    ●很好的封装了 get、put 操作,能够集成数据源。
    ●线程安全的缓存,与 ConcurrentMap 相似,但前者增加了更多的元素失效策略,后者只能显示的移除元素。
    ●Guava Cache 提供了三种基本的缓存回收方式:基于容量回收(内部实现采用 LRU 算法)、定时回收和基于引用回收(利用了 JVM 的垃圾回收机制)。定时回收有两种:按照写入时间,最早写入的最先回收;按照访问时间,最早访问的最先回收。
    ●监控缓存加载/命中情况。

  Guava Cache 的架构设计灵感来源于 ConcurrentHashMap,在简单场景中可以通过 HashMap 实现简单数据缓存,但如果要实现缓存随时间改变、存储的数据空间可控等功能,则缓存工具还是很有必要的。Cache 存储的是键值对的集合,不同的是还需要处理缓存过期、动态加载等算法逻辑,需要额外信息实现这些操作。主要实现的缓存功能有:自动将节点加载至缓存结构中,当缓存的数据超过最大值时,使用 LRU 算法替换;它具备根据节点上一次被访问或写入时间计算缓存过期机制,缓存的 key 被封装在 WeakReference 引用中,缓存的 value 被封装在 WeakReference 或 SoftReference 引用中;还可以统计缓存使用过程中的命中率、异常率和命中率等统计数据。

  在代码结构上,缓存构造器 CacheBuilder 采用构建者模式提供了设置好各种参数的缓存对象,缓存核心类 LocalCache 里面的内部类 Segment 与 jdk1.7 及以前的 ConcurrentHashMap 非常相似,都继承于 ReetrantLock,还有六个队列,以实现丰富的本地缓存方案。


二、基础类:

  ①LocalCache

LocalCache
    从结构上看,LocalCache 继承自 AbstractMap,实现了 ConcurrentMap,是根据 jdk1.7 中的 ConcurrentHashMap 中的分段锁的原理来实现的,构造方法为:

LocalCache(
    CacheBuilder<? super K, ? super V> builder, @Nullable CacheLoader<? super K, V> loader) {
    concurrencyLevel = Math.min(builder.getConcurrencyLevel(), MAX_SEGMENTS);

    keyStrength = builder.getKeyStrength();
    valueStrength = builder.getValueStrength();

    keyEquivalence = builder.getKeyEquivalence();
    valueEquivalence = builder.getValueEquivalence();

    maxWeight = builder.getMaximumWeight();
    weigher = builder.getWeigher();
    expireAfterAccessNanos = builder.getExpireAfterAccessNanos();
    expireAfterWriteNanos = builder.getExpireAfterWriteNanos();
    refreshNanos = builder.getRefreshNanos();

    removalListener = builder.getRemovalListener();
    removalNotificationQueue =
        (removalListener == NullListener.INSTANCE)
            ? LocalCache.<RemovalNotification<K, V>>discardingQueue()
            : new ConcurrentLinkedQueue<RemovalNotification<K, V>>();

    ticker = builder.getTicker(recordsTime());
    entryFactory = EntryFactory.getFactory(keyStrength, usesAccessEntries(), usesWriteEntries());
    globalStatsCounter = builder.getStatsCounterSupplier().get();
    defaultLoader = loader;

    int initialCapacity = Math.min(builder.getInitialCapacity(), MAXIMUM_CAPACITY);
    if (evictsBySize() && !customWeigher()) {
        initialCapacity = Math.min(initialCapacity, (int) maxWeight);
    }

    // Find the lowest power-of-two segmentCount that exceeds concurrencyLevel, unless
    // maximumSize/Weight is specified in which case ensure that each segment gets at least 10
    // entries. The special casing for size-based eviction is only necessary because that eviction
    // happens per segment instead of globally, so too many segments compared to the maximum size
    // will result in random eviction behavior.
    int segmentShift = 0;
    int segmentCount = 1;
    while (segmentCount < concurrencyLevel && (!evictsBySize() || segmentCount * 20 <= maxWeight)) {
        ++segmentShift;
        segmentCount <<= 1;
    }
    this.segmentShift = 32 - segmentShift;
    segmentMask = segmentCount - 1;

    this.segments = newSegmentArray(segmentCount);

    int segmentCapacity = initialCapacity / segmentCount;
    if (segmentCapacity * segmentCount < initialCapacity) {
        ++segmentCapacity;
    }

    int segmentSize = 1;
    while (segmentSize < segmentCapacity) {
        segmentSize <<= 1;
    }

    if (evictsBySize()) {
        // Ensure sum of segment max weights = overall max weights
        long maxSegmentWeight = maxWeight / segmentCount + 1;
        long remainder = maxWeight % segmentCount;
        for (int i = 0; i < this.segments.length; ++i) {
            if (i == remainder) {
                maxSegmentWeight--;
            }
            this.segments[i] =
                createSegment(segmentSize, maxSegmentWeight, builder.getStatsCounterSupplier().get());
        }
    } else {
        for (int i = 0; i < this.segments.length; ++i) {
            this.segments[i] =
                createSegment(segmentSize, UNSET_INT, builder.getStatsCounterSupplier().get());
        }
    }
}

    从构造方法可以看到,先分 N 个 Segment 加锁,Segment 下面才是 HashMap。这样就把原本一个锁,打散成 N 个锁。但和 ConcurrentHashMap 默认 16 个锁不一样,Guava Cache 默认是 4 个锁。下面的 concurrencyLevel 是根据这个值来设置的。

  ②Cache

    接口 Cache 代表一块缓存,它有如下方法:

@GwtCompatible
public interface Cache<K, V> {
	V getIfPresent(Object key);
	
	V get(K key, Callable<? extends V> loader) throws ExecutionException;
	
	ImmutableMap<K, V> getAllPresent(Iterable<?> keys);
	
	void put(K key, V value);
	
	void putAll(Map<? extends K, ? extends V> m);
	
	void invalidate(Object key);
	
	void invalidateAll(Iterable<?> keys);
	
	void invalidateAll();
	
	long size();
	
	CacheStats stats();
	
	ConcurrentMap<K, V> asMap();
	
	void cleanUp();
}

    可以看到,定义了一些缓存相关的基本方法。

    使用 put(key, value) 方法可以直接向缓存中插入值,这会直接覆盖掉给定键之前映射的值。
    使用 asMap() 视图提供的任何方法也能修改缓存。
    但请注意,asMap 视图的任何方法都不能保证缓存项被原子地加载到缓存中。进一步说,asMap 视图的原子运算在 Guava Cache 的原子加载范畴之外,所以相比于 asMap().putIfAbsent(K,V)方法,Cache.get(K, Callable) 应该总是优先使用。
    但自动加载还是首选的,因为它可以更容易地推断所有缓存内容的一致性。

  ③CacheBuilder

    CacheBuilder 是 Guava 提供的一个快速构建缓存对象的工具类。CacheBuilder 类采用 builder 设计模式,它的每个方法都返回 CacheBuilder 本身,直到 build 方法被调用。该类中提供了很多的参数设置选项,支持设置 cache 的默认大小、并发数、存活时间、过期策略等等。

    1、设置缓存并发级别

      Guava 提供了设置并发级别的 api,使得缓存支持并发的写入和读取。在一般情况下,将并发级别设置为服务器 cpu 核心数是一个比较不错的选择:

CacheBuilder.newBuilder()
    // 设置并发级别为cpu核心数
    .concurrencyLevel(Runtime.getRuntime().availableProcessors())
    .build();

    2、设置缓存初始容量

      在构建缓存时可以为缓存设置一个合理大小初始容量,由于 Guava 的缓存使用了分离锁的机制,扩容的代价非常昂贵,所以合理的初始容量能够减少缓存容器的扩容次数。

CacheBuilder.newBuilder()
    // 设置初始容量为100
    .initialCapacity(100)
    .build();

    3、设置缓存最大存储量

      Guava Cache 可以在构建缓存对象时指定缓存所能够存储的最大记录数量。当 Cache 中的记录数量达到最大值后,如果再调用 put 方法向其中添加对象,Guava 会先从当前缓存的对象记录中选择一条删除掉,腾出空间后再将新的对象存储到 Cache 中。
      如何从当前缓存的对象记录中选择一条要删除的记录呢?Guava 提供了两种方法:
        ●基于容量清除(size-based eviction):通过 CacheBuilder.maximumSize(long) 方法可以设置 Cache 的最大容量数,当缓存数量达到或接近该最大值时,Cache 将清除掉那些最近最少使用的缓存。
        ●基于权重清除:使用 CacheBuilder.weigher(Weigher) 指定一个权重函数,并且用 CacheBuilder.maximumWeight(long) 指定最大总重。比如每一项缓存所占据的内存空间大小都不一样,可以看作它们有不同的“权重”(weights)。

    4、设置缓存清除策略

      ⅰ、基于存活时间清除

          ●expireAfterWrite:写缓存后多久过期。
          ●expireAfterAccess:读写缓存后多久过期。
          ●refreshAfterWrite:写入数据后多久过期。(只阻塞当前数据加载线程,其他线程返回旧值)
        这几个策略时间可以单独设置,也可以组合配置。

      ⅱ、基于容量清除

        参考上面 3 中的基于容量清除。

      ⅲ、显式清除

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

      ⅳ、基于引用的清除(Reference-based Eviction)

        在构建 Cache 实例过程中,通过设置使用弱引用的键、或弱引用的值、或软引用的值,从而使 JVM 在 GC 时顺带实现缓存的清除,不过一般不轻易使用这个特性。
          ●CacheBuilder.weakKeys():使用弱引用存储键。当键没有其它(强或软)引用时,缓存项可以被垃圾回收。因为垃圾回收仅依赖恒等式,使用弱引用键的缓存用相等(两个=)而不是 equals 比较键。
          ●CacheBuilder.weakValues():使用弱引用存储值。当值没有其它(强或软)引用时,缓存项可以被垃圾回收。因为垃圾回收仅依赖恒等式,使用弱引用值的缓存用相等(两个=)而不是 equals 比较值。
          ●CacheBuilder.softValues():使用软引用存储值。软引用只有在响应内存需要时,才按照全局最近最少使用的顺序回收。考虑到使用软引用的性能影响,通常建议使用更有性能预测性的缓存大小限定(参考上面 3 中的基于容量回收)。使用软引用值的缓存同样用相等(两个=)而不是 equals 比较值。

    5、清理发生的时机

      如果设置的存活时间为一分钟,一分钟后这个 key 就会立即清除掉吗?
      如果要实现这个功能,那 Cache 中就必须存在线程来进行周期性地检查、清除等工作,很多 cache 如 redis、ehcache 都是这样实现的。

      使用 CacheBuilder 构建的缓存不会”自动”执行清理和回收工作,也不会在某个缓存项过期后马上清理,也没有诸如此类的清理机制。相反,它会在写操作时顺带做少量的维护工作,或者偶尔在读操作时做——如果写操作实在太少的话。
      这样做的原因在于:如果要自动地持续清理缓存,就必须有一个线程,这个线程会和用户操作竞争共享锁。此外,某些环境下线程创建可能受限制,这样 CacheBuilder 就不可用了。

      可以为 Cache 对象添加一个移除监听器,这样当有记录被删除时可以感知到这个事件:

RemovalListener<String, String> listener = notification -> System.out.println("[" + notification.getKey() + ":" + notification.getValue() + "] is removed!");
Cache<String,String> cache = CacheBuilder.newBuilder()
    .maximumSize(5)
    .removalListener(listener)
    .build();

      但是这里要注意的是:默认情况下,监听器方法是在移除缓存时同步调用的。因为缓存的维护和请求响应通常是同时进行的,代价高昂的监听器方法在同步模式下会拖慢正常的缓存请求。在这种情况下,可以使用 RemovalListeners.asynchronous(RemovalListener, Executor) 把监听器装饰为异步操作。

    6、自动加载

      上面说过使用 get 方法的时候,如果 key 不存在,可以使用指定方法去加载这个 key。在 Cache 构建的时候可以通过指定 CacheLoder 的方式实现。如果没有指定,也可以在 get 的时候显式的调用 call 方法来设置 key 不存在的补救策略。

      Cache 的 get 方法有两个参数,第一个参数是要从 Cache 中获取记录的 key,第二个记录是一个 Callable 对象。

      当缓存中已经存在 key 对应的记录时,get 方法直接返回 key 对应的记录。如果缓存中不包含 key 对应的记录,Guava 会启动一个线程执行 Callable 对象中的 call 方法,call 方法的返回值会作为 key 对应的值被存储到缓存中,并且被 get 方法返回。

    7、统计信息

      Guava Cache 支持对 Cache 的命中率、加载数据时间等信息进行统计。在构建 Cache 对象时,可以通过 CacheBuilder 的 recordStats 方法开启统计信息的开关。开关开启后,Cache 会自动对缓存的各种操作进行统计,调用 Cache 的 stats 方法可以查看统计后的信息。

      CacheStats 中常用的统计参数有以下几个:
        ●hitRate:缓存命中率。
        ●averageLoadPenalty:加载新值的平均时间,单位为纳秒。
        ●evictionCount:缓存项被回收的总数,不包括显式清除。
      此外,还有其他很多统计信息。这些统计信息对于调整缓存设置是至关重要的,在性能要求高的应用中建议密切关注这些数据。

  ④CacheLoader

    CacheLoader 主要负责构建相关功能。

public <K1 extends K, V1 extends V> Cache<K1, V1> build() {
    checkWeightWithWeigher();
    checkNonLoadingCache();
    return new LocalCache.LocalManualCache<K1, V1>(this);
}

三、LoadingCache:

  LoadingCache 是 Cache 的子接口,相比较于 Cache,当从 LoadingCache 中读取一个指定 key 的记录时,如果该记录不存在,则 LoadingCache 可以自动执行加载数据到缓存的操作。因此,在实际应用中更加常用。

@GwtCompatible
public interface LoadingCache<K, V> extends Cache<K, V>, Function<K, V> {
	V get(K key) throws ExecutionException;
	
	V getUnchecked(K key);
	
	ImmutableMap<K, V> getAll(Iterable<? extends K> keys) throws ExecutionException;
	
	@Deprecated
	@Override
	V apply(K key);
	
	void refresh(K key);
	
	@Override
	ConcurrentMap<K, V> asMap();
}

  ①构建缓存

    与构建 Cache 类型的对象类似,LoadingCache 类型的对象也是通过 CacheBuilder进行构建。不同的是,在调用 CacheBuilder 的 build 方法时,必须传递一个 CacheLoader 类型的参数,CacheLoader 的 load 方法需要我们提供实现。

public <K1 extends K, V1 extends V> LoadingCache<K1, V1> build(
    CacheLoader<? super K1, V1> loader) {
    checkWeightWithWeigher();
    return new LocalCache.LocalLoadingCache<K1, V1>(this, loader);
}

  从 LoadingCache 查询的常用方式是使用 get(K) 方法。这个方法要么返回已经缓存的值,要么使用 CacheLoader 向缓存原子地加载新值。由于 CacheLoader 可能抛出异常,get(K) 也声明为抛出 ExecutionException 异常。

  ②缓存加载

    当调用 LoadingCache 的 get 方法时,如果缓存不存在对应 key 的记录,则 CacheLoader 中的 load 方法会被自动调用从外存加载数据,load 方法的返回值会作为 key 对应的 value 存储到 LoadingCache 中,并从 get 方法返回。

static class LocalLoadingCache<K, V> extends LocalManualCache<K, V>
    implements LoadingCache<K, V> {

    LocalLoadingCache(
        CacheBuilder<? super K, ? super V> builder, CacheLoader<? super K, V> loader) {
        super(new LocalCache<K, V>(builder, checkNotNull(loader)));
    }

    // LoadingCache methods
    @Override
    public V get(K key) throws ExecutionException {
        return localCache.getOrLoad(key);
    }

    @Override
    public V getUnchecked(K key) {
        try {
            return get(key);
        } catch (ExecutionException e) {
            throw new UncheckedExecutionException(e.getCause());
        }
    }

	...
}

V getOrLoad(K key) throws ExecutionException {
    return get(key, defaultLoader);
}

V get(K key, CacheLoader<? super K, V> loader) throws ExecutionException {
    int hash = hash(checkNotNull(key));
    return segmentFor(hash).get(key, hash, loader);
}

    所有类型的 Guava Cache,不管有没有自动加载功能,都支持 get(K, Callable) 方法。这个方法返回缓存中相应的值,或者用给定的 Callable 运算并把结果加入到缓存中。在整个加载方法完成前,缓存项相关的可观察状态都不会更改。这个方法简便地实现了模式"如果有缓存则返回;否则运算、缓存、然后返回"(get-if-absent-compute)。

    其中,Segment 的创建是通过 loadSync 去加载的:

V loadSync(
   		K key,
		int hash,
		LoadingValueReference<K, V> loadingValueReference,
		CacheLoader<? super K, V> loader)
		throws ExecutionException {
    ListenableFuture<V> loadingFuture = loadingValueReference.loadFuture(key, loader);
    return getAndRecordStats(key, hash, loadingValueReference, loadingFuture);
}

public ListenableFuture<V> loadFuture(K key, CacheLoader<? super K, V> loader) {
    try {
		...
        ListenableFuture<V> newValue = loader.reload(key, previousValue);
        if (newValue == null) {
            return Futures.immediateFuture(null);
        }
        ...
    } catch (Throwable t) {
        ...
    }
}

@GwtIncompatible // Futures
public ListenableFuture<V> reload(K key, V oldValue) throws Exception {
    checkNotNull(key);
    checkNotNull(oldValue);
    return Futures.immediateFuture(load(key));
}

    在 loadSync 和 loadAsync 方法中都有 loadFuture(key, loader) 的操作, 在其内部调用的是 reload 方法,使用的是 Futures.immediateFuture(load(key)) 阻塞式的返回方法。如果 load 的过程比较耗时,会造成卡顿,需要设置缓存后台刷新。

  ③设置缓存后台刷新

    可以通过两种方法实现:
      ●使用 asyncReloading 方法创建 CacheLoader:(外部传入一个Executor)

@GwtIncompatible // Executor + Futures
public static <K, V> CacheLoader<K, V> asyncReloading(
	final CacheLoader<K, V> loader, final Executor executor) {
	...
}

      ●自己实现 CacheLoader,同时实现 reload 方法,给这个 reload 方法一个 Executor 线程(如果多处使用,可以使用只有一个线程的池)。

  ④常用操作流程

    源码均在 LocalCache 中。各种队列的介绍可以参考下方拓展部分。

    1、put

      该方法显式往本地缓存里面插入值。在执行每次 put 前都会进行 preWriteCleanUP,在 put 返回前如果更新了 entry 则要进行 evictEntries 操作。
put

    2、preWriteCleanup

      上述 put 的子方法。调用只需要传入当前时间。
preWriteCleanup

    3、evictEntries

      上述 put 的子方法。传入的参数为最新的 Entry,可能是刚插入的,也可能是刚更新过的。该方法只有在设置了在构建缓存的时候指定了 maximumSize 时才会往下执行。
      其中 accessQueue 的队首元素即是最不常访问的元素。
evictEntries

    4、getIfPresent

      该方法从本地缓存中找值,如果找不到返回 null,找到就返回相应的值。
getIfPresent

    5、get

      首先会在缓存中查找,缓存中找不到再通过 load 加载。
get

    6、remove

      调用 LocalManualCache 的 invalidate(Object key) 方法即可调用到 remove 方法。
remove


四、总结:

  Guava Cache是在内存中缓存数据,相比较于数据库或redis存储,访问内存中的数据会更加高效。

  Guava官网介绍,下面的这几种情况可以考虑使用Guava Cache:
    ●愿意消耗一些内存空间来提升速度。
    ●预料到某些键会被多次查询。
    ●缓存中存放的数据总量不会超出内存容量。


五、拓展:

  ①LRU 缓存回收算法

    LRU(Least recently used,最近最少使用)算法是根据数据的历史访问记录来淘汰数据的,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”(因此从数据最近最少访问开始淘汰)。
LRU
    根据定义可以得到简单的实现思路:
      ●新数据插入到链表头部。
      ●每当缓存命中(即缓存数据被访问),则将数据移到链表头部。
      ●当链表满的时候,将链表尾部的数据丢弃。

    在 Guava Cache 中,借助读写队列来实现LRU算法。

  ②LocalCache 数据结构

    LocalCache 的数据结构与 ConcurrentHashMap 很相似,都由多个 segment 组成,且各 segment 相对独立,互不影响,所以能支持并行操作。每个 segment 由一个 table 和若干队列组成。缓存数据存储在 table 中,其类型为 AtomicReferenceArray。
      ●Segment<K, V>[] segments:Segment 继承于 ReetrantLock,减小锁粒度,提高并发效率。
      ●AtomicReferenceArray<ReferenceEntry<K, V>> table:类似于 HasmMap 中的 table,相当于 entry 的容器。
      ●ReferenceEntry<K, V> referenceEntry:基于引用的 Entry,其实现类有弱引用 Entry、强引用 Entry等。
      ●ReferenceQueue keyReferenceQueue:已经被 GC,需要内部清理的键引用队列。
      ●ReferenceQueue valueReferenceQueue:已经被 GC,需要内部清理的值引用队列。
      ●Queue<ReferenceEntry<K, V>> recencyQueue:记录升级可访问列表清单时的 entries,当 segment 上达到临界值或发生写操作时该队列会被清空。
      ●Queue<ReferenceEntry<K, V>> writeQueue:按照写入时间进行排序的元素队列,写入一个元素时会把它加入到队列尾部。
      ●Queue<ReferenceEntry<K, V>> accessQueue:按照访问时间进行排序的元素队列,访问(包括写入)一个元素时会把它加入到队列尾部。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值