【极客源码】JetCache源码(三)Cache类结构和代码解析1

2,212 阅读9分钟

banner窄.png

铿然架构  |  作者  /  铿然一叶 这是铿然架构的第 75 篇原创文章

相关阅读:

【极客源码】JetCache源码(一)开篇
【极客源码】JetCache源码(二)顶层视图
JAVA基础(一)简单、透彻理解内部类和静态内部类
JAVA基础(二)内存优化-使用Java引用做缓存
JAVA基础(三)ClassLoader实现热加载
JAVA基础(四)枚举(enum)和常量定义,工厂类使用对比
JAVA基础(五)函数式接口-复用,解耦之利刃
JAVA编程思想(一)通过依赖注入增加扩展性
JAVA编程思想(二)如何面向接口编程
JAVA编程思想(三)去掉别扭的if,自注册策略模式优雅满足开闭原则
JAVA编程思想(四)Builder模式经典范式以及和工厂模式如何选?
HikariPool源码(二)设计思想借鉴
人在职场(一)IT大厂生存法则


1. 类结构

Cache相关类结构如下:

1.1. Cache

1.定义了缓存操作方法,如get,put,remove等等。
2.接口中使用了默认方法,提供默认实现。
3.接口中提供了大写的方法和小写的方法,小写为默认接口方法,大写的未实现,交由子类实现,其中小写方法调用大写方法使用了模板方法模式。

    default V get(K key) throws CacheInvokeException {
        CacheGetResult<V> result = GET(key);  // 模板方法设计模式
        if (result.isSuccess()) {
            return result.getValue();
        } else {
            return null;
        }
    }
    
    CacheGetResult<V> GET(K key);  // 接口方法,待子类实现

1.2. AbstractCache

1.2.1. 实现了cache接口

实现了cache接口,但并未完全实现,只是封装了公共部分,然后再次使用模板方法设计模式交给子类实现。

    @Override
    public final CacheGetResult<V> GET(K key) {
        long t = System.currentTimeMillis();
        CacheGetResult<V> result;
        if (key == null) {
            result = new CacheGetResult<V>(CacheResultCode.FAIL, CacheResult.MSG_ILLEGAL_ARGUMENT, null);
        } else {
            result = do_GET(key);  // 模板方法设计模式
        }
        result.future().thenRun(() -> {
            CacheGetEvent event = new CacheGetEvent(this, System.currentTimeMillis() - t, key, result);
            notify(event);
        });
        return result;
    }

    protected abstract CacheGetResult<V> do_GET(K key);  // 模板方法,由子类实现

1.2.2. 增加了一些通用方法

  • 发送通知
  •     // 发送缓存事件给监控者,监控者可对缓存事件监控
        public void notify(CacheEvent e) {
            List<CacheMonitor> monitors = config().getMonitors();
            for (CacheMonitor m : monitors) {
                m.afterOperation(e);
            }
        }
    
  • 记录错误日志
  • 记录错误日志,方法是protected的,可以被子类重载。

        protected void logError(String oper, Object key, Throwable e) {
            ....
        }
    

    1.2.3. 异步编程

        @Override
        public final CacheGetResult<V> GET(K key) {
            long t = System.currentTimeMillis();
            CacheGetResult<V> result;
            if (key == null) {
                result = new CacheGetResult<V>(CacheResultCode.FAIL, CacheResult.MSG_ILLEGAL_ARGUMENT, null);
            } else {
                result = do_GET(key);
            }
            // 这里的thenRun用法并没有特别之处,本质上就是一个异步线程,只不过利用了result中的feature对象
            // 异步处理的目的是不影响GET方法的性能
            result.future().thenRun(() -> {
                CacheGetEvent event = new CacheGetEvent(this, System.currentTimeMillis() - t, key, result);
                notify(event);
            });
            return result;
        }
    

    关于异步编程可参考“Java并发编程入门(十九)异步任务调度工具CompleteFeature ”,里面有各个方法的详细介绍,看这篇就够了。

    1.2.4. 函数式接口的使用

    在JetCache中大量使用到函数式接口,函数式接口的好处是高度抽象,灵活性高,可用于兼容遗留方法,只需要方法参数和返回值一致就可以复用,不依赖类实现的接口和方法名称;

    其缺点是由于高度抽象,不能明确表达方法的具体意图。

    如果没有兼容遗留方法的必要,还是定义明确的接口方法更好一些。

    对于函数式接口,参考“JAVA基础(五)函数式接口-复用,解耦之利刃 ”。

        // 方法参数使用了函数式接口来传递loader
        static <K, V> V computeIfAbsentImpl(K key, Function<K, V> loader, boolean cacheNullWhenLoaderReturnNull,
                                                   long expireAfterWrite, TimeUnit timeUnit, Cache<K, V> cache) {
    
            // 这里需要获取abstractCache是因为接下来要使用的方法在cache接口中没有,这种用法有那么点别扭,也就是从方法签名看
            // 不出来在方法内部要转换为子类,并用到子类中的方法,如果在cache中定义此方法可能更自然一些
            AbstractCache<K, V> abstractCache = CacheUtil.getAbstractCache(cache);
    
            // abstractCache::notify就是cache中不存在的方法,这里用了函数式接口,可以将一个函数作为参数传入
            // 这里使用了代理模式,创建了一个缓存load代理对象,用于记录load耗时
            CacheLoader<K, V> newLoader = CacheUtil.createProxyLoader(cache, loader, abstractCache::notify);
            CacheGetResult<V> r;
    
            // 从这里判断实例可以思考接口的定义方式
            if (cache instanceof RefreshCache) {
                RefreshCache<K, V> refreshCache = ((RefreshCache<K, V>) cache);
                r = refreshCache.GET(key);
                refreshCache.addOrUpdateRefreshTask(key, newLoader);
            } else {
                r = cache.GET(key);
            }
            if (r.isSuccess()) {
                return r.getValue();
            } else {
                // 当获取缓存失败时,使用函数式接口和匿名内部类来更新缓存
                Consumer<V> cacheUpdater = (loadedValue) -> {
                    if(needUpdate(loadedValue, cacheNullWhenLoaderReturnNull, newLoader)) {
                        if (timeUnit != null) {
                            cache.PUT(key, loadedValue, expireAfterWrite, timeUnit).waitForResult();
                        } else {
                            cache.PUT(key, loadedValue).waitForResult();
                        }
                    }
                };
    
                V loadedValue;
                // 缓存穿透保护,需要同步加载缓存数据
                if (cache.config().isCachePenetrationProtect()) {
                    loadedValue = synchronizedLoad(cache.config(), abstractCache, key, newLoader, cacheUpdater);
                } else {
                    loadedValue = newLoader.apply(key);
                    
                    // 函数式接口的方法签名,仅仅消费数据
                    cacheUpdater.accept(loadedValue);
                }
    
                return loadedValue;
            }
        }
    

    1.2.5. load代理对象

    在上面的方法里创建了一个缓存load代理,用于记录load耗时:

            // 这里使用了代理模式,创建了一个缓存load代理对象,用于记录load耗时
            CacheLoader<K, V> newLoader = CacheUtil.createProxyLoader(cache, loader, abstractCache::notify);
    
    // CacheUtil.java        
        public static <K, V> ProxyLoader<K, V> createProxyLoader(Cache<K, V> cache,
                                                              Function<K, V> loader,
                                                              Consumer<CacheEvent> eventConsumer) {
            if (loader instanceof ProxyLoader) {
                return (ProxyLoader<K, V>) loader;
            }
            if (loader instanceof CacheLoader) {
                return createProxyLoader(cache, (CacheLoader) loader, eventConsumer);
            }
            return k -> {
                long t = System.currentTimeMillis();
                V v = null;
                boolean success = false;
                try {
                    v = loader.apply(k);
                    success = true;
                } finally {
                    t = System.currentTimeMillis() - t;
                    CacheLoadEvent event = new CacheLoadEvent(cache, t, k, v, success);
                    eventConsumer.accept(event);
                }
                return v;
            };
        }
    

    为啥不直接在原生的loader类中实现记录耗时的功能呢?

    因为这里传入的loader是个函数式接口,且通过依赖注入的方式传入,它允许开发者提供不同的实现,当不同的开发者有不同的实现时,就没有必要都再去实现一遍记录load耗时的功能,因此这里统一用代理模式做了封装,不管开发者如何实现,都会具备这个公共能力。

    另外从这个层面来看,代理模式的用法和模板方法模式也有相同之处,都可以将公共部分抽象,并剥离出来统一实现,避免重复实现。

    关于接口注入可参考:“JAVA编程思想(一)通过依赖注入增加扩展性”。

    1.2.6. 接口和类结构的定义

    在上面的方法中有两个地方用到了类型强转:

            AbstractCache<K, V> abstractCache = CacheUtil.getAbstractCache(cache);
            if (cache instanceof RefreshCache) {
    

    之所以用强转,是因为在cache接口中不具备子类的方法,这种处理方式其实不利于面向接口编程,但有时不同子类的行为又避免不了差异,所以就导致了这种情况存在。当子类存在不同接口中的方法时有两种处理方式:

    一种就是上述的处理方式,随着类继承的层次加深,底层类和上一层出现了差异,底层增加了上一层不存在的方法,此时要么方法中传入具体的底层类,要么就是传入接口类,在方法内部做强转。

    另外一种方式就是当子类有不同实现方法时,增加接口定义,通过子类实现不同的接口来处理差异,这样仍然可以面向接口编程,试比较两种实现方式的差异:

    如图,左边直接在RefreshCache类中增加了一个接口和父类不存在的方法,当方法参数为cache,又需要使用子类特有方法时必然会导致强转。

    而右边增加了RefreshAbility接口定义,在该接口中定义了新方法,RefreshCache同时实现了两个接口,这样,当方法的输入参数是RefreshAbility接口时,仍然可以面向接口编程,不需要强转。

    当然,有的场景没有这么简单,但我们仍然需要注意考虑:如何尽可能面向接口编程,避免强转,一定有比较优雅的方式来解决这个问题。

    1.2.7. 缓存穿透保护

    回顾下在【极客源码】JetCache源码(一)开篇 中提到的缓存穿透的概念:

    数据库没有数据,缓存中也没有数据,这样每次访问都会访问数据库,导致数据压力增大。
    因此,当缓存里没有数据时,要避免多个并发线程同时访问数据库,此时需要进行加锁,只允许一个线程去访问数据库,其他线程等待。

    实现代码片段如下:

                // 缓存穿透保护,需要同步加载缓存数据
                if (cache.config().isCachePenetrationProtect()) {
                    loadedValue = synchronizedLoad(cache.config(), abstractCache, key, newLoader, cacheUpdater);
                } else {
                    loadedValue = newLoader.apply(key);
    
                    // 函数式接口的方法签名,仅仅消费数据,更新缓存数据
                    cacheUpdater.accept(loadedValue);
                }
                
        // 避免缓存穿透的方法
        static <K, V> V synchronizedLoad(CacheConfig config, AbstractCache<K,V> abstractCache,
                                         K key, Function<K, V> newLoader, Consumer<V> cacheUpdater) {
    
            // 利用ConcurrentHashMap来对访问key加锁
            ConcurrentHashMap<Object, LoaderLock> loaderMap = abstractCache.initOrGetLoaderMap();
            Object lockKey = buildLoaderLockKey(abstractCache, key);
            while (true) {
                boolean create[] = new boolean[1];
                LoaderLock ll = loaderMap.computeIfAbsent(lockKey, (unusedKey) -> {
                    create[0] = true;
                    LoaderLock loaderLock = new LoaderLock();
                    // 利用CountDownLatch加锁,使用CountDownLatch的好处是支持设置锁等待超时时间
                    loaderLock.signal = new CountDownLatch(1);
                    loaderLock.loaderThread = Thread.currentThread();
                    return loaderLock;
                });
                // 如果是同一个线程,那么加载数据并更新缓存,并释放锁
                if (create[0] || ll.loaderThread == Thread.currentThread()) {
                    try {
                        // 加载数据
                        V loadedValue = newLoader.apply(key);
                        ll.success = true;  // 这行代码总是返回成功,看起来会导致下面无法走到continue分支。continue哪里只是加了一个保护,可能永远走不到
                        ll.value = loadedValue;
                        // 更新缓存
                        cacheUpdater.accept(loadedValue);
                        return loadedValue;
                    } finally {
                        if (create[0]) {
                            // 释放锁
                            ll.signal.countDown();
                            // 移除加锁key,释放内存
                            loaderMap.remove(lockKey);
                        }
                    }
                } else { // 不同线程则等待获取锁的线程处理完,避免同时访问数据库
                    try {
                        // 有超时时间设置则等待超时时间后放弃,否则就一直等待
                        Duration timeout = config.getPenetrationProtectTimeout();
                        if (timeout == null) {
                            ll.signal.await();
                        } else {
                            boolean ok = ll.signal.await(timeout.toMillis(), TimeUnit.MILLISECONDS);
                            // 超时后锁还没有被加锁线程释放,则自行load数据,这里没有放入缓存的原因可能是避免重复放入缓存(另一个加载线程的结果不可知)
                            if(!ok) {
                                logger.info("loader wait timeout:" + timeout);
                                return newLoader.apply(key);
                            }
                        }
                    } catch (InterruptedException e) {
                        logger.warn("loader wait interrupted");
                        // 如果被中断则自行加载数据
                        return newLoader.apply(key);
                    }
                    // 走到这里,加锁线程的锁已经被释放,如果加载缓存成功,则返回结果,否则继续循环处理
                    if (ll.success) {
                        return (V) ll.value;
                    } else {
                        continue;
                    }
    
                }
            }
        }
    

    其中CountDownLatch用法参考:“Java并发编程入门(十四)CountDownLatch应用场景”.

    需要注意的是,这段代码适用于进程内缓存,对分布式缓存无效。

    1.2.8. 静态内部类

    使用静态内部类来定义加载锁:

        static class LoaderLock {
            CountDownLatch signal;
            Thread loaderThread;
            // volatile修饰保证可见性
            volatile boolean success;
            volatile Object value;
        }
    

    关于静态内部类可参考“JAVA基础(一)简单、透彻理解内部类和静态内部类”.

    2. 小结

    本节通过JetCache的代码重温和学习了如下知识点:

    1. 异步编程
    2. 函数式接口
    3. 设计模式中的模板方法和代理模式,为什么要使用代理模式,和模板方法的相同点
    4. 接口定义如何面向接口编程
    5. 如何编码解决了我们开篇中提到的缓存穿透问题。
    6. CountDownLatch用法
    7. 静态内部类

    end.


    <--阅过留痕,左边点赞!