• guava之cache


    缓存

    本次主要讨论缓存。缓存在日常开发中举足轻重,如果你的应用对某类数据有着较高的读取频次,并且改动较小时那就非常适合利用缓存来提高性能。

    缓存之所以可以提高性能是因为它的读取效率很高,就像是 CPU 的 L1、L2、L3 缓存一样,级别越高相应的读取速度也会越快。

    但也不是什么好处都占,读取速度快了但是它的内存更小资源更宝贵,所以我们应当缓存真正需要的数据。其实也就是典型的空间换时间。下面谈谈 Java 中所用到的缓存。

    JVM 缓存

    首先是 JVM 缓存,也可以认为是堆缓存。

    其实就是创建一些全局变量,如 Map、List 之类的容器用于存放数据。

    这样的优势是使用简单但是也有以下问题:

    • 只能显式的写入,清除数据。
    • 不能按照一定的规则淘汰数据,如 LRU,LFU,FIFO 等。
    • 清除数据时的回调通知。
    • 其他一些定制功能等。

    Ehcache、Guava Cache

    所以出现了一些专门用作 JVM 缓存的开源工具出现了,如本文提到的 Guava Cache。

    它具有上文 JVM 缓存不具有的功能,如自动清除数据、多种清除算法、清除回调等。

    但也正因为有了这些功能,这样的缓存必然会多出许多东西需要额外维护,自然也就增加了系统的消耗。

    今天说的 Guava Cache 是google guava中的一个内存缓存模块,用于将数据缓存到JVM内存中。他很好的解决了上面提到的几个问题:

    • 很好的封装了get、put操作,能够集成数据源 ;
    • 线程安全的缓存,与ConcurrentMap相似,但前者增加了更多的元素失效策略,后者只能显示的移除元素;
    • Guava Cache提供了三种基本的缓存回收方式:基于容量回收、定时回收和基于引用回收。定时回收有两种:按照写入时间,最早写入的最先回收;按照访问时间,最早访问的最早回收;
    • 监控缓存加载/命中情况

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

    分布式缓存

    刚才提到的两种缓存其实都是堆内缓存,只能在单个节点中使用,这样在分布式场景下就招架不住了。

    于是也有了一些缓存中间件,如 Redis、Memcached,在分布式环境下可以共享内存。

    具体不在本次的讨论范围。

    Guava Cache 示例

    示例1:如果构建guava的cache

    import com.google.common.cache.CacheBuilder;
    import com.google.common.cache.CacheLoader;
    import com.google.common.cache.LoadingCache;
    
    import java.text.SimpleDateFormat;
    import java.util.Date;
    import java.util.Random;
    import java.util.concurrent.TimeUnit;
    
    public class GuavaCacheService {
    
        public void setCache() {
            LoadingCache<Integer, String> cache = CacheBuilder.newBuilder()
                    //设置并发级别为8,并发级别是指可以同时写缓存的线程数
                    .concurrencyLevel(8)
                    //设置缓存容器的初始容量为10
                    .initialCapacity(10)
                    //设置缓存最大容量为100,超过100之后就会按照LRU最近虽少使用算法来移除缓存项
                    .maximumSize(100)
                    //是否需要统计缓存情况,该操作消耗一定的性能,生产环境应该去除
                    .recordStats()
                    //设置写缓存后n秒钟过期
                    .expireAfterWrite(60, TimeUnit.SECONDS)
                    //设置读写缓存后n秒钟过期,实际很少用到,类似于expireAfterWrite
                    //.expireAfterAccess(17, TimeUnit.SECONDS)
                    //只阻塞当前数据加载线程,其他线程返回旧值
                    //.refreshAfterWrite(13, TimeUnit.SECONDS)
                    //设置缓存的移除通知
                    .removalListener(notification -> {
                        System.out.println(notification.getKey() + " " + notification.getValue() + " 被移除,原因:" + notification.getCause());
                    })
                    //build方法中可以指定CacheLoader,在缓存不存在时通过CacheLoader的实现自动加载缓存
                    .build(new DemoCacheLoader());
    
            //模拟线程并发
            new Thread(() -> {
                //非线程安全的时间格式化工具
                SimpleDateFormat simpleDateFormat = new SimpleDateFormat("HH:mm:ss");
                try {
                    for (int i = 0; i < 10; i++) {
                        String value = cache.get(1);
                        System.out.println(Thread.currentThread().getName() + " " + simpleDateFormat.format(new Date()) + " " + value);
                        TimeUnit.SECONDS.sleep(3);
                    }
                } catch (Exception ignored) {
                }
            }).start();
    
            new Thread(() -> {
                SimpleDateFormat simpleDateFormat = new SimpleDateFormat("HH:mm:ss");
                try {
                    for (int i = 0; i < 10; i++) {
                        String value = cache.get(1);
                        System.out.println(Thread.currentThread().getName() + " " + simpleDateFormat.format(new Date()) + " " + value);
                        TimeUnit.SECONDS.sleep(5);
                    }
                } catch (Exception ignored) {
                }
            }).start();
            //缓存状态查看
            System.out.println(cache.stats().toString());
    
        }
    
        /**
         * 随机缓存加载,实际使用时应实现业务的缓存加载逻辑,例如从数据库获取数据
         */
        public static class DemoCacheLoader extends CacheLoader<Integer, String> {
            @Override
            public String load(Integer key) throws Exception {
                System.out.println(Thread.currentThread().getName() + " 加载数据开始");
                TimeUnit.SECONDS.sleep(8);
                Random random = new Random();
                System.out.println(Thread.currentThread().getName() + " 加载数据结束");
                return "value:" + random.nextInt(10000);
            }
        }
    }

    上面一段代码展示了如何使用Cache创建一个缓存对象并使用它。

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

    在调用CacheBuilder的build方法时,必须传递一个CacheLoader类型的参数,CacheLoader的load方法需要我们提供实现。当调用LoadingCache的get方法时,如果缓存不存在对应key的记录,则CacheLoader中的load方法会被自动调用从外存加载数据,load方法的返回值会作为key对应的value存储到LoadingCache中,并从get方法返回。

    当然如果你不想指定重建策略,那么你可以使用无参的build()方法,它将返回Cache类型的构建对象。

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

    可选配置分析#

    缓存的并发级别

    Guava提供了设置并发级别的api,使得缓存支持并发的写入和读取。同 ConcurrentHashMap 类似Guava cache的并发也是通过分离锁实现。在一般情况下,将并发级别设置为服务器cpu核心数是一个比较不错的选择。

    CacheBuilder.newBuilder()
    		// 设置并发级别为cpu核心数
    		.concurrencyLevel(Runtime.getRuntime().availableProcessors()) 
    		.build();
    
    缓存的初始容量设置

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

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

    Guava Cache可以在构建缓存对象时指定缓存所能够存储的最大记录数量。当Cache中的记录数量达到最大值后再调用put方法向其中添加对象,Guava会先从当前缓存的对象记录中选择一条删除掉,腾出空间后再将新的对象存储到Cache中。

    1. 基于容量的清除(size-based eviction): 通过CacheBuilder.maximumSize(long)方法可以设置Cache的最大容量数,当缓存数量达到或接近该最大值时,Cache将清除掉那些最近最少使用的缓存;
    2. **基于权重的清除: ** 使用CacheBuilder.weigher(Weigher)指定一个权重函数,并且用CacheBuilder.maximumWeight(long)指定最大总重。比如每一项缓存所占据的内存空间大小都不一样,可以看作它们有不同的“权重”(weights)。
    缓存清除策略
    1. 基于存活时间的清除
    • expireAfterWrite 写缓存后多久过期
    • expireAfterAccess 读写缓存后多久过期
    • refreshAfterWrite 写入数据后多久过期,只阻塞当前数据加载线程,其他线程返回旧值

    这几个策略时间可以单独设置,也可以组合配置。

    2. 上面提到的基于容量的清除
    3. 显式清除

    任何时候,你都可以显式地清除缓存项,而不是等到它被回收,Cache接口提供了如下API:

    1. 个别清除:Cache.invalidate(key)

    2. 批量清除:Cache.invalidateAll(keys)

    3. 清除所有缓存项:Cache.invalidateAll()

    4. 基于引用的清除(Reference-based Eviction)

    在构建Cache实例过程中,通过设置使用弱引用的键、或弱引用的值、或软引用的值,从而使JVM在GC时顺带实现缓存的清除,不过一般不轻易使用这个特性。

    • CacheBuilder.weakKeys():使用弱引用存储键。当键没有其它(强或软)引用时,缓存项可以被垃圾回收。因为垃圾回收仅依赖恒等式,使用弱引用键的缓存用而不是equals比较键。
    • CacheBuilder.weakValues():使用弱引用存储值。当值没有其它(强或软)引用时,缓存项可以被垃圾回收。因为垃圾回收仅依赖恒等式,使用弱引用值的缓存用而不是equals比较值。
    • CacheBuilder.softValues():使用软引用存储值。软引用只有在响应内存需要时,才按照全局最近最少使用的顺序回收。考虑到使用软引用的性能影响,我们通常建议使用更有性能预测性的缓存大小限定(见上文,基于容量回收)。使用软引用值的缓存同样用==而不是equals比较值。
    清理什么时候发生

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

    使用CacheBuilder构建的缓存不会”自动”执行清理和回收工作,也不会在某个缓存项过期后马上清理,也没有诸如此类的清理机制。相反,它会在写操作时顺带做少量的维护工作,或者偶尔在读操作时做——如果写操作实在太少的话。

    这样做的原因在于:如果要自动地持续清理缓存,就必须有一个线程,这个线程会和用户操作竞争共享锁。此外,某些环境下线程创建可能受限制,这样CacheBuilder就不可用了。参考如下示例:

    import com.google.common.cache.Cache;
    import com.google.common.cache.CacheBuilder;
    
    import java.text.SimpleDateFormat;
    import java.util.Date;
    import java.util.concurrent.TimeUnit;
    
    public class GuavaCacheService {
    
    
        static Cache<Integer, String> cache = CacheBuilder.newBuilder()
                .expireAfterWrite(5, TimeUnit.SECONDS)
                .build();
    
        public static void main(String[] args) throws Exception {
            new Thread(() -> {
                while (true) {
                    SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss");
                    System.out.println(sdf.format(new Date()) + " size: " + cache.size());
                    try {
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
    
                    }
                }
            }).start();
            SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss");
            cache.put(1, "a");
            System.out.println("写入 key:1 ,value:" + cache.getIfPresent(1));
            Thread.sleep(10000);
            cache.put(2, "b");
            System.out.println("写入 key:2 ,value:" + cache.getIfPresent(2));
            Thread.sleep(10000);
            System.out.println(sdf.format(new Date())
                    + " sleep 10s , key:1 ,value:" + cache.getIfPresent(1));
            System.out.println(sdf.format(new Date())
                    + " sleep 10s, key:2 ,value:" + cache.getIfPresent(2));
        }
    }

    结果:

    部分输出结果:
    23:57:36 size: 0
    写入 key:1 ,value:a
    23:57:38 size: 1
    23:57:40 size: 1
    23:57:42 size: 1
    23:57:44 size: 1
    23:57:46 size: 1
    写入 key:2 ,value:b
    23:57:48 size: 1
    23:57:50 size: 1
    23:57:52 size: 1
    23:57:54 size: 1
    23:57:56 size: 1
    23:57:56 sleep 10s , key:1 ,value:null
    23:57:56 sleep 10s, key:2 ,value:null
    23:57:58 size: 0
    23:58:00 size: 0
    23:58:02 size: 0
        ...

    上面程序设置了缓存过期时间为5S,每打印一次当前的size需要2S,打印了5次size之后写入key 2,此时的size为1,说明在这个时候才把第一次应该过期的key 1给删除。

    给移除操作添加一个监听器:

    可以为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)把监听器装饰为异步操作。

    自动加载

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

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

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

    import com.google.common.cache.Cache;
    import com.google.common.cache.CacheBuilder;
    
    import java.util.concurrent.Callable;
    import java.util.concurrent.ExecutionException;
    
    public class GuavaCacheService {
    
    
        private static Cache<String, String> cache = CacheBuilder.newBuilder()
                .maximumSize(3)
                .build();
    
        public static void main(String[] args) {
    
            new Thread(() -> {
                System.out.println("thread1");
                try {
                    String value = cache.get("key", new Callable<String>() {
                        public String call() throws Exception {
                            System.out.println("thread1"); //加载数据线程执行标志
                            Thread.sleep(1000); //模拟加载时间
                            return "thread1";
                        }
                    });
                    System.out.println("thread1 " + value);
                } catch (ExecutionException e) {
                    e.printStackTrace();
                }
            }).start();
            new Thread(() -> {
                System.out.println("thread2");
                try {
                    String value = cache.get("key", new Callable<String>() {
                        public String call() throws Exception {
                            System.out.println("thread2"); //加载数据线程执行标志
                            Thread.sleep(1000); //模拟加载时间
                            return "thread2";
                        }
                    });
                    System.out.println("thread2 " + value);
                } catch (ExecutionException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    
    }

    结果:

    输出结果为:
    thread1
    thread2
    thread2
    thread1 thread2
    thread2 thread2

    可以看到输出结果:两个线程都启动,输出thread1,thread2,接着又输出了thread2,说明进入了thread2的call方法了,此时thread1正在阻塞,等待key被设置。然后thread1 得到了value是thread2,thread2的结果自然也是thread2。

    这段代码中有两个线程共享同一个Cache对象,两个线程同时调用get方法获取同一个key对应的记录。由于key对应的记录不存在,所以两个线程都在get方法处阻塞。此处在call方法中调用Thread.sleep(1000)模拟程序从外存加载数据的时间消耗。

    从结果中可以看出,虽然是两个线程同时调用get方法,但只有一个get方法中的Callable会被执行(没有打印出load2)。Guava可以保证当有多个线程同时访问Cache中的一个key时,如果key对应的记录不存在,Guava只会启动一个线程执行get方法中Callable参数对应的任务加载数据存到缓存。当加载完数据后,任何线程中的get方法都会获取到key对应的值。

    统计信息

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

    package com.transsnet.palmpay.product.web;
    import com.google.common.cache.Cache;
    import com.google.common.cache.CacheBuilder;
    
    public class GuavaCacheService {
    
    
        public static void main(String[] args) {
            Cache<String, String> cache = CacheBuilder.newBuilder()
                    .maximumSize(3)
                    .recordStats() //开启统计信息开关
                    .build();
            cache.put("1", "v1");
            cache.put("2", "v2");
            cache.put("3", "v3");
            cache.put("4", "v4");
    
            cache.getIfPresent("1");
            cache.getIfPresent("2");
            cache.getIfPresent("3");
            cache.getIfPresent("4");
            cache.getIfPresent("5");
            cache.getIfPresent("6");
    
            System.out.println(cache.stats()); //获取统计信息
        }
    
    }

    结果:

    输出:
    CacheStats{hitCount=3, missCount=3, loadSuccessCount=0, loadExceptionCount=0, totalLoadTime=0, evictionCount=1}

    示例2:利用cache的过期超时功能

    之所以想到 Guava 的 Cache,也是最近在做一个需求,大体如下:

    从 Kafka 实时读取出应用系统的日志信息,该日志信息包含了应用的健康状况。
    如果在时间窗口 N 内发生了 X 次异常信息,相应的我就需要作出反馈(报警、记录日志等)。

    对此 Guava 的 Cache 就非常适合,我利用了它的 N 个时间内不写入数据时缓存就清空的特点,在每次读取数据时判断异常信息是否大于 X 即可。

    伪代码如下:

    @Value("${alert.in.time:2}")
        private int time ;
    
        @Bean
        public LoadingCache buildCache(){
            return CacheBuilder.newBuilder()
                    .expireAfterWrite(time, TimeUnit.MINUTES)
                    .build(new CacheLoader<Long, AtomicLong>() {
                        @Override
                        public AtomicLong load(Long key) throws Exception {
                            return new AtomicLong(0);
                        }
                    });
        }
    
    
        /**
         * 判断是否需要报警
         */
        public void checkAlert() {
            try {
                if (counter.get(KEY).incrementAndGet() >= limit) {
                    LOGGER.info("***********报警***********");
    
                    //将缓存清空
                    counter.get(KEY).getAndSet(0L);
                }
            } catch (ExecutionException e) {
                LOGGER.error("Exception", e);
            }
        }   

    首先是构建了 LoadingCache 对象,在 N 分钟内不写入数据时就回收缓存(当通过 Key 获取不到缓存时,默认返回 0)。

    然后在每次消费时候调用 checkAlert() 方法进行校验,这样就可以达到上文的需求。

    我们来设想下 Guava 它是如何实现过期自动清除数据,并且是可以按照 LRU 这样的方式清除的。

    大胆假设下:

    内部通过一个队列来维护缓存的顺序,每次访问过的数据移动到队列头部,并且额外开启一个线程来判断数据是否过期,过期就删掉。有点类似于我之前写过的 动手实现一个 LRU cache

    胡适说过:大胆假设小心论证

    下面来看看 Guava 到底是怎么实现。

    原理分析

    看原理最好不过是跟代码一步步走了:

    示例代码在这里:

    https://github.com/crossoverJie/Java-Interview/blob/master/src/main/java/com/crossoverjie/guava/CacheLoaderTest.java

    8.png8.png

    为了能看出 Guava 是怎么删除过期数据的在获取缓存之前休眠了 5 秒钟,达到了超时条件。

    2.png2.png

    最终会发现在 com.google.common.cache.LocalCache 类的 2187 行比较关键。

    再跟进去之前第 2182 行会发现先要判断 count 是否大于 0,这个 count 保存的是当前缓存的数量,并用 volatile 修饰保证了可见性。

    更多关于 volatile 的相关信息可以查看 你应该知道的 volatile 关键字

    接着往下跟到:

    3.png3.png

    2761 行,根据方法名称可以看出是判断当前的 Entry 是否过期,该 entry 就是通过 key 查询到的。

    4.png4.png

    这里就很明显的看出是根据根据构建时指定的过期方式来判断当前 key 是否过期了。

    5.png5.png

    如果过期就往下走,尝试进行过期删除(需要加锁,后面会具体讨论)。

    6.png6.png

    到了这里也很清晰了:

    • 获取当前缓存的总数量
    • 自减一(前面获取了锁,所以线程安全)
    • 删除并将更新的总数赋值到 count。

    其实大体上就是这个流程,Guava 并没有按照之前猜想的另起一个线程来维护过期数据。

    应该是以下原因:

    • 新起线程需要资源消耗。
    • 维护过期数据还要获取额外的锁,增加了消耗。

    而在查询时候顺带做了这些事情,但是如果该缓存迟迟没有访问也会存在数据不能被回收的情况,不过这对于一个高吞吐的应用来说也不是问题。

    总结

    最后再来总结下 Guava 的 Cache。

    其实在上文跟代码时会发现通过一个 key 定位数据时有以下代码:

    7.png7.png

    如果有看过 ConcurrentHashMap 的原理 应该会想到这其实非常类似。

    其实 Guava Cache 为了满足并发场景的使用,核心的数据结构就是按照 ConcurrentHashMap 来的,这里也是一个 key 定位到一个具体位置的过程。

    先找到 Segment,再找具体的位置,等于是做了两次 Hash 定位。

    上文有一个假设是对的,它内部会维护两个队列 accessQueue,writeQueue用于记录缓存顺序,这样才可以按照顺序淘汰数据(类似于利用 LinkedHashMap 来做 LRU 缓存)。

    同时从上文的构建方式来看,它也是构建者模式来创建对象的。

    因为作为一个给开发者使用的工具,需要有很多的自定义属性,利用构建则模式再合适不过了。

    Guava 其实还有很多东西没谈到,比如它利用 GC 来回收内存,移除数据时的回调通知等。之后再接着讨论。

    转:https://ifeve.com/guava-source-cache/

    https://www.cnblogs.com/rickiyang/p/11074159.html

  • 相关阅读:
    软考
    十步走-阅读笔记
    软著申请
    十步走-阅读笔记
    基于Ubuntu安装部署ZooKeeper
    基于Ubuntu安装JDK(OPenJDK8)
    Intern Day89
    阿里巴巴Java研发工程师技术一面
    面试
    6_moc.md
  • 原文地址:https://www.cnblogs.com/duanxz/p/14659847.html
Copyright © 2020-2023  润新知