01 初识缓存-了解缓存

2019-05-24  本文已影响0人  花神子

缓存的使用历程:

无缓存

进程内部缓存

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

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

HashMap

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

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感兴趣的可以自行搜索):这三种,实现成本是一个比一个高,同样的命中率也是一个比一个好。

下面是一个简易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,用来进行缓存数据的淘汰,但是有几个问题:

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锁竞争

/**
 * 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并没有马上过期(也就是并没有后台线程一直在扫),而是通过进行读写操作的时候进行过期处理,这样做的好处是避免后台线程扫描的时候进行全局加锁

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

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

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

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

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

上一篇下一篇

猜你喜欢

热点阅读