01 初识缓存-了解缓存

  • 背景 边学变笔记,本文不会针对某个缓存技术进行深入的解密,仅仅是一个简要解析,后续文章会针对某个缓存技术进行深入解密。

缓存的使用历程:

无缓存

  • 应用的初步阶段,一般都是没有缓存,一般开发过程中,第一步一般都没有使用缓存的,而是直接查库。

  • 在DB性能完全没有任何的负担的时候,无须考虑缓存。因为维护缓存也会带来额外的负担,增加编程难度,提高缓存的维护难度。

  • 在流量不大的时候,查数据库或者读取文件是最为方便,也能完全满足我们的业务要求

进程内部缓存

无论是因为流量原因,还是别的原因,当我们需要提供缓存功能的时候,一般我们都会有这几种选择供我们选择:

  1. java中自带的HashMap或者ConcurrentHashMap
  2. LRUHashMap
  3. Guava cache
  4. Caffeine

HashMap

下面就是使用HashMap作为一个的样例;

  • 这种做法就有个问题HashMap无法进行数据淘汰,内存会无限制的增长。

  • 但并不是说HashMap不能作为缓存,我们一般可以使用HashMap作为缓存一些静态变量(不需要淘汰的数据),比如在一些反射框架中可以缓存一些类的属性信息:Method,field。毕竟每次都用反射那也是比较损耗性能。

public class HashMapCache {

    private static HashMap<String, String> hashMap = new HashMap<>();
    private static UserService service = new UserService();

    public static String getUser(String name) {
        String user = hashMap.get(name);
        if (user == null) {
            user = service.get(name);
            hashMap.put(name, user);
        }
        return user;
    }
    
    static class UserService{
        /**
         * 模拟DB
         */
        String get(String name) {
            System.out.println("load user from db");
            return "name : " + name;
        }
    }

    public static void main(String[] args) {
        getUser("aaa");
        //此处从缓存中获取数据
        getUser("aaa");
    }
}

HashMap升级 LRUHashMap

既然不能让缓存无限制的增长,所以就必须存在缓存减少:缓存淘汰。

那么问题来了:如何淘汰缓存,随机淘汰?列举下常见的三种FIFO,LRU,LFU(还有一些ARC,MRU感兴趣的可以自行搜索):这三种,实现成本是一个比一个高,同样的命中率也是一个比一个好。

  • FIFO :先进先出,在这种淘汰算法中,先进入缓存的会先被淘汰。这种可谓是最简单的了,但是会导致我们命中率很低。试想一下我们如果有个访问频率很高的数据是所有数据第一个访问的,而那些不是很高的是后面再访问的,那这样就会把我们的首个数据但是他的访问频率很高给挤出。

  • LRU:最近最少使用算法。在这种算法中避免了上面的问题,每次访问数据都会将其放在我们的队尾,如果需要淘汰数据,就只需要淘汰队首即可。但是这个依然有个问题,如果有个数据在1个小时的前59分钟访问了1万次(可见这是个热点数据),再后一分钟没有访问这个数据,但是有其他的数据访问,就导致了我们这个热点数据被淘汰。

  • LFU:最近最少频率使用。在这种算法中又对上面进行了优化,利用额外的空间记录每个数据的使用频率,然后选出频率最低进行淘汰。这样就避免了LRU不能处理时间段的问题。

下面是一个简易LRU的实现Demo:

public class LRUHashMap extends LinkedHashMap {

    private final int max;
    private Object lock;

    public LRUHashMap(int max, Object lock) {
        this.max = max;
        this.lock = lock;
    }

    /**
     * 重写LinkedHashMap的removeEldestEntry方法即可,在Put的时候判断,如果为true,就会删除最老的
     *
     * @param eldest
     * @return
     */
    @Override
    protected boolean removeEldestEntry(Map.Entry eldest) {
        return size() > max;
    }

    public Object getValue(Object key) {
        synchronized (lock) {
            return get(key);
        }
    }

    public void putValue(Object key, Object value) {
        synchronized (lock) {
            put(key, value);
        }
    }

    public boolean removeValue(Object key) {
        synchronized (lock) {
            return remove(key) != null;
        }
    }

    public boolean removeAll() {
        clear();
        return true;
    }


    //Test
    static class UserService {

        static Object lock1 = new Object();
        private static LRUHashMap lruHashMap = new LRUHashMap(2, lock1);
        private static HashMapCache.UserService service = new HashMapCache.UserService();

        public static String getUser(String name) {
            String user = (String) lruHashMap.getValue(name);
            if (user == null) {
                user = (String) service.get(name);
                lruHashMap.put(name, user);
            }
            return user;
        }

        public static void main(String[] args) {
            System.out.println(getUser("aaa"));
            System.out.println(getUser("bbb"));
            System.out.println(getUser("ccc"));
            System.out.println("---------此处从缓存中获取数据---------");
            System.out.println(getUser("bbb"));
            System.out.println(getUser("ccc"));
            //a 缓存已经被删除,所以会重新加载DB
            System.out.println(getUser("aaa"));
        }
        /**
         * 模拟DB
         */
        String get(String name) {
            System.out.println("load user from db");
            return "name : " + name;
        }
    }
}

输出结果:

load user from db
name : aaa
load user from db
name : bbb
load user from db
name : ccc
load user from db
---------此处从缓存中获取数据---------
name : bbb
name : ccc
load user from db
name : aaa

LRUMap,用来进行缓存数据的淘汰,但是有几个问题:

  • 锁竞争严重,可以看见代码中,Lock是全局锁,在方法级别上面的,当调用量较大时,性能必然会比较低。
  • 不支持过期时间
  • 不支持自动刷新

Guava cache

谷歌提供了Guava cache,在Guava cache中你可以如下面的代码一样,轻松使用:

public class GuaveCache {

    public static void main(String[] args){
        LoadingCache<String, String> cache = CacheBuilder.newBuilder()
                .maximumSize(100)
                //写之后30ms过期
                .expireAfterWrite(30L, TimeUnit.MILLISECONDS)
                //访问之后30ms过期
                .expireAfterAccess(30L, TimeUnit.MILLISECONDS)
                //20ms之后刷新
                .refreshAfterWrite(20L, TimeUnit.MILLISECONDS)
                //开启weakKey key 当启动垃圾回收时,该缓存也被回收
                .weakKeys()
                .build(createCacheLoader());
        try {
            System.out.println(cache.get("key1"));
            System.out.println(cache.get("key2"));
            cache.put("key1", "key1-aaa");
            cache.put("key2", "key2-bbb");

            Thread.sleep(10);
            System.out.println("key1 >>> " + cache.get("key1"));
            System.out.println("key2 >>> " + cache.get("key2"));

            Thread.sleep(40);
            System.out.println("key1 >>> " + cache.get("key1"));
            System.out.println("key2 >>> " + cache.get("key2"));
        } catch (Exception e) {
            e.printStackTrace();
        }

    }

    public static CacheLoader<String, String> createCacheLoader() {
        return new CacheLoader<String, String>() {
            @Override
            public String load(String key) throws Exception {
                return "default";
            }
        };
    }
}

输出结果:

default
default
key1 >>> key1-aaa
key2 >>> key2-bbb
key1 >>> default
key2 >>> default

Guava cache锁竞争

  • Guava cache在设计实现上, LocalCache的并发策略和 concurrentHashMap的并发策略是一致的,也是根据分段锁来提高并发能力,分段锁可以很好的保证 并发读写的效率。因此,该map支持非阻塞读和不同段之间并发写。。

  • 在Guava根据一定的算法进行分段,这里要说明的是,如果段太少那竞争依然很严重,如果段太多会容易出现随机淘汰,比如大小为100的,给他分100个段,那也就是让每个数据都独占一个段,而每个段会自己处理淘汰的过程,所以会出现随机淘汰。在guava cache中通过如下代码,计算出应该如何分段。

  • segmentCount就是最后的分段数,其保证了每个段至少10个Entry。如果没有设置concurrencyLevel这个参数,那么默认就会是4,最后分段数也最多为4(例如size为100,会分为4段,每段最大的size是25),

/**
 * 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;
}

Guava cache TTL

Guava cache 对比 LRUMap 有两种过期时间,在guava cache中对于过期的Entry并没有马上过期(也就是并没有后台线程一直在扫),而是通过进行读写操作的时候进行过期处理,这样做的好处是避免后台线程扫描的时候进行全局加锁

  • 个是写后多久过期expireAfterWrite;

  • 一个是读后多久过期expireAfterAccess。

每个Segment中维护了两个队列:

Queue<ReferenceEntry<K, V>> writeQueue; writeQueue维护了写队列,队头代表着写得早的数据,队尾代表写得晚的数据。

Queue<ReferenceEntry<K, V>> accessQueue; accessQueue维护了访问队列,和LRU一样,用来进行访问时间的淘汰,如果当这个Segment超过最大容量,就会把accessQueue这个队列的第一个元素进行淘汰。

  • Guava cache的expireEntries过程,会对两个队列一次进行peek操作,如果过期就进行删除。一般处理过期Entries可以在我们的put操作的前后,或者读取数据时发现过期了,然后进行整个Segment的过期处理,又或者进行二次读lockedGetOrLoad操作的时候调用。
void expireEntries(long now) {
  drainRecencyQueue();

  ReferenceEntry<K, V> e;
  while ((e = writeQueue.peek()) != null && map.isExpired(e, now)) {
    if (!removeEntry(e, e.getHash(), RemovalCause.EXPIRED)) {
      throw new AssertionError();
    }
  }
  while ((e = accessQueue.peek()) != null && map.isExpired(e, now)) {
    if (!removeEntry(e, e.getHash(), RemovalCause.EXPIRED)) {
      throw new AssertionError();
    }
  }
}

Guava cache removalListener

在guava cache中,当有数据被淘汰时,但是你不知道他到底是过期,还是被驱逐,还是因为虚引用的对象被回收?这个时候你可以调用这个方法removalListener(RemovalListener listener)添加监听器进行数据淘汰的监听,可以打日志或者一些其他处理,可以用来进行数据淘汰分析。

Caffeine

Caffeine缓存和其他缓存的一些比较图(图片扒自网络):看着图片性能🐂了不止一点点啊

Caffeine-1.png

Caffeine cache实现了W-TinyLFU(LFU+LRU算法的变种)。

Caffeine 性能分析

Guava cache的过期处理逻辑为了避免后台进程扫描数据造成🔐等待,采用的方式是在读写操作中进行过期逻辑处理,所以每一次的操作都能进行一次过期处理。当然其读写性能会受到一定影响。

Caffeine cache性能优于Guava cached主要是因为在caffeine,对这些事件的操作是通过异步操作,他将事件提交至队列,这里的队列的数据结构是RingBuffer,(对于读操作比写操作更加频繁,进一步减少竞争,其为每个线程配备了一个RingBuffer)。然后通过会通过默认的ForkJoinPool.commonPool(),或者自己配置线程池,进行取队列操作,然后在进行后续的淘汰,过期操作。

Caffeine 淘汰策略

Caffeine cache所有的数据都在ConcurrentHashMap中,这个和guava cache不同,guava cache是自己实现了个类似ConcurrentHashMap的结构。在caffeine中有三个记录引用的LRU队列:

Caffeine数据结构
  • Eden队列:在caffeine中规定只能为缓存容量的%1,如果size=100,那这个队列的有效大小就等于1。这个队列中记录的是新到的数据,防止突发流量由于之前没有访问频率,而导致被淘汰。伊甸区,最舒服最安逸的区域,在这里很难被其他数据淘汰。

  • Probation队列:叫做缓刑队列,在这个队列就代表你的数据相对比较冷,马上就要被淘汰了。这个有效大小为 = size-eden-protected。

  • Protected队列:在这个队列中,暂时不会被淘汰。如果Probation队列没有数据了或者Protected数据满了,也将会被面临淘汰的尴尬局面。当然想要变成这个队列,需要把Probation访问一次之后,就会提升为Protected队列。这个有效大小为(size减去eden) X 80%。 如果size =100,就会是79。

三个队里的数据交换:
  1. 所有的新数据都会进入Eden。
  2. Eden满了,淘汰进入Probation。
  3. 如果在Probation中访问了其中某个数据,则这个数据升级为Protected。
  4. 如果Protected满了又会继续降级为Probation。
数据的淘汰过程:

对于发生数据淘汰的时候,会从Probation中进行淘汰,会把这个队列中的数据队头称为受害者,这个队头肯定是最早进入的,按照LRU队列的算法的话那他其实他就应该被淘汰,但是在这里只能叫他受害者,这个队列是缓刑队列,代表马上要给他行刑了。这里会取出队尾叫候选者,也叫攻击者。这里受害者会和攻击者做PK,通过我们的Count-Min Sketch中的记录的频率数据有以下几个判断:

  • 如果攻击者大于受害者,那么受害者就直接被淘汰。

  • 如果攻击者<=5,那么直接淘汰攻击者。

  • 其他情况,随机淘汰。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,590评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,808评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,151评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,779评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,773评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,656评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,022评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,678评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,038评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,756评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,411评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,005评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,973评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,053评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,495评论 2 343

推荐阅读更多精彩内容

  • 简介 Caffeine,是一种建立在java8基础上的高性能缓存框架。它是一种本地缓存,功能类似Guava cac...
    Jerry_06ed阅读 9,160评论 0 25
  • 1.老兵让我敬佩 2.东北兄弟带妈妈旅游 3.护士患病,不离不弃的男朋友勇敢坚毅 4.画画的女孩没有听从妈妈的命运...
    晶晶201708阅读 103评论 0 0
  • 001 儿童是自我发展的 儿童是带着精神胚胎来到这个世界,并不是由我们塑造出他们的精神世界,只需条件准备,他们会自...
    婷婷姐_2019阅读 188评论 0 1
  • 作者是戴维·L·华生,美国心理学协会会员,他所著有的教材《普通心理学》《社会心理学》和《学习技能》等都深受学生好评...
    落扬虚虚阅读 361评论 2 3