高性能缓存 - JUC(六)

1,110 阅读13分钟

我曾踏足山巅,也曾进入低谷,二者使我受益良多。

摘要

  • 为了对并发知识进行一个完整性的回顾,这儿采用一步步实现高性能缓存来收尾
  • 缓存是什么?我们为什么需要缓存?
  • 如何一步步实现高性能缓存,这其中会有什么问题?又如何解决?
  • 这其中涉及到:缓存的原理探究、并发问题的解决思路、并发工具的使用与并发线程治理

高性能缓存

一、缓存是什么?为什么需要?

缓存是在实际生产中非常常用的工具,用了缓存以后,我们可以避免重复计算,提高吞吐量。 举一个二级缓存的例子:

1)场景:我们数据库中有一些业务相关的配置(业务名称,业务ID,业务描述,业务字段等等),这些配置存放在若干个表中,每一次组装信息的时候我们都可能需要做多次查询和计算,但是这些配置我们又不会频繁的去修改。

2)设计:基于这种情况,我们可以设计一个二级缓存(本地缓存 + 分布式缓存)

3)实现:

  1. 本地使用Google的Guava实现内存中的缓存,过期时间为5s
  2. 分布式缓存使用Redis(这里有涉及缓存穿透和缓存雪崩的解决)
/**
 * 从本地缓存获取业务信息
 */
public AppInfo getAppInfoFromLocal(String appId) {
    try {
        return appInfoLocalCache.get(appId);
    } catch (ExecutionException e) {
        log.error("从本地缓存获取app信息失败, appId: {}", appId);
        throw new XXXException(ErrorCode.APP_NOT_EXIST);
    }
}
/**
 * 本地AppInfo缓存
 */
private LoadingCache<String, AppInfo> appInfoLocalCache =
        CacheBuilder.newBuilder()
                .expireAfterWrite(LOCAL_CACHE_EXPIRE_SECONDS_AFTER_WRITE, TimeUnit.SECONDS)
                .build(new CacheLoader<String, AppInfo>() {
                    @Override
                    public AppInfo load(String appId) {
                        log.info("AppInfo本地缓存重新加载, appId:{}", appId);
                        AppInfo appInfo = getAppInfoFromRedis(appId);
                        if (appInfo == null) {
                            appInfo = appInfoService.getInfo(appId);
                            log.info("从数据库获取AppInfo: {}", appInfo);
                            if (appInfo == null) {
                                log.error("从数据库获取app信息失败, appId: {}", appId);
                                // 为了防止缓存穿透,这里设置redis中的值为 new AppInfo
                                appInfo = new AppInfo();
                            }
                            refreshAppInfo(appId, appInfo);
                        }
                        return appInfo;
                    }
                });
/**
 * 刷新业务信息
 * 业务信息可能为 new AppInfo
 */
public void refreshAppInfo(String appId, AppInfo appInfo) {
    String redisKey = RedisKeyUtils.getAppInfoRedisKey(appId);
    // 为了防止缓存雪崩,这里设置永不过期
    kvManager.set(redisKey, appInfo);
    appInfoLocalCache.refresh(appId);
    log.info("成功刷新AppInfo的Redis和本地缓存,appInfo: {}", appInfo);
}
/**
 * 从redis获取业务信息
 */
public AppInfo getAppInfoFromRedis(String appId) {
    String redisKey = RedisKeyUtils.getAppInfoRedisKey(appId);
    return kvManager.getObject(redisKey, AppInfoPo.class);
}

成效

分别对DB、Redis、LocalCache做2000次查询,耗时如下(单位ms):

由此可见,在使用缓存后,我们可以极大的提高系统的吞吐量。

二、如何去一步步实现一个高性能缓存?

虽然缓存咋一看很简单,不就是一个Map吗?最初级的缓存确实可以用一个Map来实现,不过一个功能完备、性能强劲的缓存,需要考虑的点就非常多了,我们从最简单的HashMap开始,一步步筑起大厦。

第一阶段:最简单Key-Value


场景思路:现在有一个特别复杂耗时的计算,然而这种计算的请求又比较频繁,这个时候我们就想,能不能把这个结果存起来,下次直接返回呢?

public class SimpleCache {

    public static void main(String[] args) throws InterruptedException {
        SimpleCache cache = new SimpleCache();
        System.out.println("===开始计算===");
        Integer result = cache.computer("18");
        System.out.println("===第一次计算结果:"+result);
        result = cache.computer("18");
        System.out.println("===第二次计算结果:"+result);
    }

    /**
     * Key-Value 缓存
     * 加上 final 关键字,增加安全性
     * 标识该变量只被赋值一次,后面不再允许修改
     */
    private final HashMap<String,Integer> cache = new HashMap<>();

    /**
     * 拿到一个结果:
     *  - 计算出结果
     *  - 从缓存中拿
     */
    public Integer computer(String userId) throws InterruptedException {
        Integer result = cache.get(userId);
        // 先检查HashMap里面有没有保存过之前的计算结果
        if (result == null) {
            // 如果缓存中找不到,那么需要现在计算一下结果
            result = doCompute(userId);
            // 本次计算完成之后,就把结果缓存下来
            cache.put(userId, result);
        }
        return result;
    }

    /**
     * 耗时的计算
     */
    private Integer doCompute(String userId) throws InterruptedException {
        // 模拟计算
        TimeUnit.SECONDS.sleep(5);
        return new Integer(userId);
    }
}

第二阶段:逻辑解耦


在上面的代码中,我们在一个缓存类里面还实现了计算的功能,显然,这是不合理的。

其实,计算应该是计算,缓存只是缓存,计算不应该关心缓存。

(1)代码复用性问题解决 -- 装饰模式

  1. 我们设置一个 ExpensiveFunction 是耗时计算的实现类,它实现了 Computable 接口,但是本身不具备缓存的功能,也不考虑缓存的事情。
2. DecoupleCache类的核心功能就是实现缓存
public class DecoupleCache<A, V> implements Computable<A, V> {

    public static void main(String[] args) throws Exception {
        // AggregationFunction 实现了具体的计算逻辑
        DecoupleCache<String, Integer> aggregationComputer = new DecoupleCache<>(
                new AggregationFunction());
        Integer result = aggregationComputer.compute("777");
        System.out.println("第一次计算结果:" + result);
        result = aggregationComputer.compute("777");
        System.out.println("第二次计算结果:" + result);
    }

    /**
     * 缓存:Key-Value
     */
    private final Map<A, V> cache = new HashMap();

    /**
     * 具体计算逻辑的实现类,通过构造函数注入
     */
    private final Computable<A, V> c;

    /**
     * 构造函数
     */
    public DecoupleCache(Computable<A, V> c) {
        this.c = c;
    }

    @Override
    public synchronized V compute(A arg) throws Exception {
        System.out.println("===进入缓存机制===");
        V result = cache.get(arg);
        if (result == null) {
            // 实质调用具体的计算逻辑
            result = c.compute(arg);
            cache.put(arg, result);
        }
        return result;
    }
}
  1. 但我们想要修改缓存逻辑时,只需要修改DecoupleCache中的compute方法即可;当我们想使用不同的计算逻辑时,只需要传入其他具体计算类(如:StatisticsFunction)即可。

第三阶段:安全的缓存


仔细想想,上面实现还存在一个很大的问题的:并发安全问题

  1. 重复计算:在计算逻辑那儿,我们并没有对多线程情况就行处理,也就是说,可能一个结果同时在被多个线程计算,其实这是没必要的)
  2. 同时 put 扩容导致CPU100%:因为我们使用了HashMap,这是线程不安全的

那为了解决这个问题,该如何做呢?

public synchronized V compute(A arg) throws Exception {
    ......
}

这还不简单,加个synchronized不就得了。

但这样的话,我们来想想这个场景:

现在有很多线程想获取计算结果,他们可能持有相同或不同的key,在进入这个方法的时候,他们却不得不一个个排队。我们模拟一下看看~

Cache<String, Integer> aggregationComputer = new Cache<>(
            new AggregationFunction());
    new Thread(() -> {
        try {
            Integer result = aggregationComputer.compute("666");
            System.out.println("第一次的计算结果:"+result);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }).start();
    new Thread(() -> {
        try {
            // 休眠20ms模拟 667 先拿到锁
            Thread.sleep(20);
            Integer result = aggregationComputer.compute("666");
            System.out.println("第三次的计算结果:"+result);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }).start();
    new Thread(() -> {
        try {
            // 休眠10ms
            Thread.sleep(20);
            Integer result = aggregationComputer.compute("667");
            System.out.println("第二次的计算结果:"+result);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }).start();

这里有3个线程,第一个先拿到锁想算666,第二个线程拿到锁后想算667,此时第三个线程来了,它想算666,但是他不得不等 667 算完,而这是没有必要的,他本可以从缓存直接获取结果就好了。

那针对这个问题,该怎么解决?

(1)并发安全问题解决

1、锁优化经验 -- 缩小锁粒度

synchronized (this) {
    cache.put(arg, result);
}

那我们只把Put操作保护起来可以吗?那其实还是有两个问题:

  1. 同时Put的性能问题
  2. 同时读写的并发问题

2、ConcurrentHashMap

private final Map<A, V> cache = new ConcurrentHashMap<>();

到这里,我们如愿以偿解决了并发的安全问题,但代码写到这个份上,是不是又出现了一个问题?

第四阶段:规避重复计算


比如这样的场景:

当第一个线程接入 66 的计算,在它还没有计算完成的时候,线程2来了,也想计算666,这个时候是不是他们就开始同时计算?这样就造成了资源的浪费,这不是缓存愿意看到的。

避免重复计算的最核心的问题其实就是需要让后来的线程知道,你要的东西已经在算了,你就别算了,等就完事了。

那如何做到这个事情呢?这个时候就不得不讲一下治理线程的第二大法宝了:Future

PS: 如果对Future不是很熟悉的可以参考一下这篇文章 《线程治理 Future和Callable

(1)代码实现:

public class FutureCache1<A, V> implements Computable<A, V> {

    public static void main(String[] args) {
        FutureCache1<String, Integer> expensiveComputer = new FutureCache1<>(
                new AggregationFunction());
        new Thread(() -> {
            try {
                Integer result = expensiveComputer.compute("66");
                System.out.println(Thread.currentThread().getName() + "计算结果:" + result);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();
        new Thread(() -> {
            try {
                Integer result = expensiveComputer.compute("66");
                System.out.println(Thread.currentThread().getName() + "计算结果:" + result);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();
        new Thread(() -> {
            try {
                Integer result = expensiveComputer.compute("67");
                System.out.println(Thread.currentThread().getName() + "计算结果:" + result);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();
    }

    /**
     * 缓存:Key-Value,这次Map里存储的是Future
     */
    private final Map<A, Future<V>> cache = new ConcurrentHashMap<>();

    /**
     * 具体计算逻辑的实现类,通过构造函数注入
     */
    private final Computable<A, V> c;

    /**
     * 构造函数
     */
    public FutureCache1(Computable<A, V> c) {
        this.c = c;
    }

    @Override
    public V compute(A arg) throws Exception {
        System.out.println("===" + Thread.currentThread().getName() + "进入缓存机制===");
        // 先从尝试缓存中获取计算结果
        Future<V> f = cache.get(arg);
        if (f == null) { // 如果缓存中没有,代表这个结果没有算过,也没有线程正在算
            // 包装具体计算逻辑
            Callable<V> callable = () -> c.compute(arg);
            // 新建一个Future,准备放入Map
            FutureTask<V> futureTask = new FutureTask<>(callable);
            f = futureTask;
            // 放入缓存,表示这个结果我正在算,其他人就不要算啦
            cache.put(arg, f);
            // 开始计算
            System.out.println(Thread.currentThread().getName() + "开始 FutureTask 计算: " + arg);
            futureTask.run();
        }
        // 返回计算结果
        return f.get();
    }
}

我们来看看结果:

咦,为什么就这儿的结果看来,还是发生了重复计算呢?我们思考一下问题出在哪儿:

在这里,如果两个同时计算66的线程,同时调用cache.get方法,那么返回的结果都为null,后面还是会裁剪两个任务去计算相同的值。

那这个时候,我们是不是需要加一个检查性的操作呢 -- putIfabsent

(2)代码实现:

public class FutureCache2<A, V> implements Computable<A, V> {

    public static void main(String[] args) {
        FutureCache2<String, Integer> expensiveComputer = new FutureCache2<>(
                new AggregationFunction());
        new Thread(() -> {
            try {
                Integer result = expensiveComputer.compute("66");
                System.out.println(Thread.currentThread().getName() + "计算结果:" + result);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();
        new Thread(() -> {
            try {
                Integer result = expensiveComputer.compute("66");
                System.out.println(Thread.currentThread().getName() + "计算结果:" + result);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();
        new Thread(() -> {
            try {
                Integer result = expensiveComputer.compute("67");
                System.out.println(Thread.currentThread().getName() + "计算结果:" + result);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();
    }

    /**
     * 缓存:Key-Value,这次Map里存储的是Future
     */
    private final Map<A, Future<V>> cache = new ConcurrentHashMap<>();

    /**
     * 具体计算逻辑的实现类,通过构造函数注入
     */
    private final Computable<A, V> c;

    /**
     * 构造函数
     */
    public FutureCache2(Computable<A, V> c) {
        this.c = c;
    }

    @Override
    public V compute(A arg) throws Exception {
        System.out.println("===" + Thread.currentThread().getName() + "进入缓存机制===");
        // 先从尝试缓存中获取计算结果
        Future<V> f = cache.get(arg);
        if (f == null) { // 如果缓存中没有,代表这个结果没有算过,也没有线程正在算
            // 包装具体计算逻辑
            Callable<V> callable = () -> c.compute(arg);
            // 新建一个Future,准备放入Map
            FutureTask<V> futureTask = new FutureTask<>(callable);
            // 放入缓存,表示这个结果我正在算,其他人就不要算啦
            f = cache.putIfAbsent(arg, futureTask);
            if (f == null) { // 缓存Map中不存在这个结果,我可以进行计算
                f = futureTask;
                // 开始计算
                System.out.println(Thread.currentThread().getName() + "开始 FutureTask 计算: " + arg);
                futureTask.run();
            }
        }
        // 返回计算结果
        return f.get();
    }
}

再看看结果:

现在就没有重复计算了,哪怕是两个线程同时到达,由于ConcurrentHashMap.putIfAbsent的原子性保证,就只有一个会put成功并执行计算。

第五阶段:处理计算异常&缓存污染


计算过程并不是一帆风顺的,假设有一个计算类,它有一定概率计算失败,应该如何处理呢?

我们先来模拟一下这个失败的场景:

public class MayFailFunction implements Computable<String, Integer> {

    @Override
    public Integer compute(String arg) throws Exception {
        double random = Math.random();
        if (random > 0.5) {
            // 50%的概率会计算失败,这里就抛出数组越界的异常
            throw new ArrayIndexOutOfBoundsException("数组越界");
        }
        Thread.sleep(3000);
        return Integer.valueOf(arg);
    }
}

现在我们知道了计算过程中可能出现异常,那在处理过程中需要考虑什么问题呢?

  1. 任务相关的异常(CancellationException | InterruptedException
  2. 计算相关的异常(数组越界、空指针、IO等等)
  3. 缓存污染

(1)代码实现

public class MayFailCache<A, V> implements Computable<A, V> {

    public static void main(String[] args) {
        MayFailCache<String, Integer> expensiveComputer = new MayFailCache<>(
                new MayFailFunction());
        new Thread(() -> {
            try {
                Integer result = expensiveComputer.compute("66");
                System.out.println(Thread.currentThread().getName() + "计算结果:" + result);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();
    }

    /**
     * 缓存:Key-Value,这次Map里存储的是Future
     */
    private final Map<A, Future<V>> cache = new ConcurrentHashMap<>();

    /**
     * 具体计算逻辑的实现类,通过构造函数注入
     */
    private final Computable<A, V> c;

    /**
     * 构造函数
     */
    public MayFailCache(Computable<A, V> c) {
        this.c = c;
    }

    @Override
    public V compute(A arg) throws InterruptedException, CancellationException {
        System.out.println("===" + Thread.currentThread().getName() + "进入缓存机制===");
        // 先从尝试缓存中获取计算结果
        while (true) { // 这儿可以根据具体的业务逻辑使用无限重试还是有限重试
            Future<V> f = cache.get(arg);
            if (f == null) { // 如果缓存中没有,代表这个结果没有算过,也没有线程正在算
                // 包装具体计算逻辑
                Callable<V> callable = () -> c.compute(arg);
                // 新建一个Future,准备放入Map
                FutureTask<V> futureTask = new FutureTask<>(callable);
                // 放入缓存,表示这个结果我正在算,其他人就不要算啦
                f = cache.putIfAbsent(arg, futureTask);
                if (f == null) { // 缓存Map中不存在这个结果,我可以进行计算
                    f = futureTask;
                    // 开始计算
                    System.out.println(Thread.currentThread().getName() + "开始 FutureTask 计算: " + arg);
                    futureTask.run();
                }
            }
            // 返回计算结果
            try {
                return f.get();
            } catch (CancellationException | InterruptedException e) {
                // 防止缓存污染
                cache.remove(arg);
                // 如果是任务被取消或者中断异常,则直接抛出,交由调用者处理
                throw e;
            } catch (ExecutionException e) {
                // 计算出现异常,可以使用重试逻辑
                System.out.println("尝试重新计算");
                // 防止缓存污染,因为Future会记录结果为 "发生异常" ,如果一直获取到同一个Future,则一直是异常
                cache.remove(arg);
            }
        }
    }
}

运行结果:

(2)缓存污染

在上面代码中,我们使用了cache.remove(arg)方法来保证解决缓存污染问题,让线程去重新计算。

第六阶段:缓存过期


通常我们的缓存都不是永久有效的,它可能需要一个过期时间,那这个又如何实现呢?

(1)代码实现

public class ExpireCache<A, V> implements Computable<A, V> {

    public static void main(String[] args) throws InterruptedException {
        ExpireCache<String, Integer> expensiveComputer = new ExpireCache<>(
                new MayFailFunction());
        new Thread(() -> {
            try {
                Integer result = expensiveComputer.compute("66", 1, TimeUnit.SECONDS);
                System.out.println("[" + Thread.currentThread().getName() + "]: " + "计算结果:" + result);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();

        Thread.sleep(3000);
        Integer result = expensiveComputer.compute("66", 10, TimeUnit.SECONDS);
        System.out.println("[" + Thread.currentThread().getName() + "]: " + "计算结果:" + result);
    }

    /**
     * 缓存:Key-Value,这次Map里存储的是Future
     */
    private final Map<A, Future<V>> cache = new ConcurrentHashMap<>();

    /**
     * 具体计算逻辑的实现类,通过构造函数注入
     */
    private final Computable<A, V> c;

    /**
     * 构造函数
     */
    public ExpireCache(Computable<A, V> c) {
        this.c = c;
    }

    /**
     * 用于执行过期清除的线程池
     */
    private static ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(5);

    /**
     * 带过期时间的缓存方法,仅是有定时清除策略,也可查看Redis的清除策略
     * 这里的过期时间的设置可使用随机值,以防止缓存雪崩
     *
     * @param arg    缓存的Key
     * @param expire 过期时间
     * @param unit   时间单位
     * @return 缓存的value
     * @throws InterruptedException  中断异常
     * @throws CancellationException 任务取消异常
     */
    public V compute(A arg, long expire, TimeUnit unit) throws InterruptedException, CancellationException {
        if (expire > 0) { // 如果设置的过期时间大于0才生效
            // 提交给线程池执行定时清除工作
            scheduledExecutorService.schedule(() -> expire(arg), expire, unit);
        }
        // 执行计算逻辑
        return compute(arg);
    }

    /**
     * 执行缓存的清除工作
     * 加上synchronized避免重复取消
     *
     * @param key
     */
    public synchronized void expire(A key) {
        Future<V> future = cache.get(key);
        if (future != null) { // 如果缓存中有值才执行
            if (!future.isDone()) { // 如果任务没有完成,则取消正在计算的任务
                System.out.println("[" + Thread.currentThread().getName() + "]: " + "任务被取消, key: " + key);
                future.cancel(true);
            }
            // 清除缓存中的记录
            System.out.println("[" + Thread.currentThread().getName() + "]: " + "过期时间到,缓存被清除, key: " + key);
            cache.remove(key);
        }
    }

    @Override
    public V compute(A arg) throws InterruptedException, CancellationException {
        System.out.println("===" + "[" + Thread.currentThread().getName() + "]: " + "进入缓存机制===");
        // 先从尝试缓存中获取计算结果
        while (true) { // 这儿可以根据具体的业务逻辑使用无限重试还是有限重试
            Future<V> f = cache.get(arg);
            if (f == null) { // 如果缓存中没有,代表这个结果没有算过,也没有线程正在算
                // 包装具体计算逻辑
                Callable<V> callable = () -> c.compute(arg);
                // 新建一个Future,准备放入Map
                FutureTask<V> futureTask = new FutureTask<>(callable);
                // 放入缓存,表示这个结果我正在算,其他人就不要算啦
                f = cache.putIfAbsent(arg, futureTask);
                if (f == null) { // 缓存Map中不存在这个结果,我可以进行计算
                    f = futureTask;
                    // 开始计算
                    System.out.println("[" + Thread.currentThread().getName() + "]: " + "开始 FutureTask 计算: " + arg);
                    futureTask.run();
                }
            }
            // 返回计算结果
            try {
                return f.get();
            } catch (CancellationException | InterruptedException e) {
                // 防止缓存污染
                cache.remove(arg);
                System.out.println("[" + Thread.currentThread().getName() + "]: " + "任务被取消或者中断, key: " + arg);
                // 如果是任务被取消或者中断异常,则直接抛出,交由调用者处理
                throw e;
            } catch (ExecutionException e) {
                // 计算出现异常,可以使用重试逻辑
                System.out.println("[" + Thread.currentThread().getName() + "]: " + "尝试重新计算, key: " + arg);
                // 防止缓存污染,因为Future会记录结果为 "发生异常" ,如果一直获取到同一个Future,则一直是异常
                cache.remove(arg);
            }
        }
    }
}

执行效果:

到这里,我们的主流程就全部结束啦,下面是激动人心的测试时刻。

三、并发测试

I. 测试思路

  1. 使用countDownLatch来模拟并发场景。
  2. 使用ThreadLocal来做时间的格式化。
  3. 使用缓存的最终版本:可以设置过期时间的缓存。

II. 测试代码

public class ConcurrencyTestCache {

    /**
     * 缓存类,使用可能失败的计算逻辑
     */
    private static ExpireCache<String, Integer> expireCache = new ExpireCache<>(new MayFailFunction());

    /**
     * 使用countDownLatch模拟并发场景
     */
    private static CountDownLatch countDownLatch = new CountDownLatch(1);

    public static void main(String[] args) throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(100);
        for (int i = 0; i < 100; i++) {
            executorService.submit(() -> {
                Integer result = null;
                try {
                    System.out.println("[" + Thread.currentThread().getName() + "]: 开始等待");
                    countDownLatch.await();
                    SimpleDateFormat dateFormat = ThreadSafeFormatter.dateFormatter.get();
                    String time = dateFormat.format(new Date());
                    System.out.println("[" + Thread.currentThread().getName() + "]: " + time + " 开始执行");
                    result = expireCache.compute("66");
                    String endTime = dateFormat.format(new Date());
                    System.out.println("[" + Thread.currentThread().getName() + "]: 在" + endTime + "计算结束, result: " + result);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } catch (CancellationException e) {
                    e.printStackTrace();
                }
            });
        }
        Thread.sleep(2000);
        countDownLatch.countDown();
        executorService.shutdown();
    }

}

class ThreadSafeFormatter {

    //每个线程会调用本方法一次,用于初始化
    public static ThreadLocal<SimpleDateFormat> dateFormatter = ThreadLocal.withInitial(() -> new SimpleDateFormat("HH:mm:ss:SSS"));
}

III. 测试预期

我们希望实际的计算操作只被执行了一次,其他都是命中缓存

IV. 测试结果

显然,这个并不是我们想看到的,那在哪儿出问题了呢?

V. 问题追踪

来来来,我们把目光聚集到缓存污染的处理:

try {
    return f.get();
} catch (CancellationException | InterruptedException e) {
    // 防止缓存污染
    cache.remove(arg);
    // 如果是任务被取消或者中断异常,则直接抛出,交由调用者处理
    throw e;
} catch (ExecutionException e) {
    // 计算出现异常,可以使用重试逻辑
    System.out.println(Thread.currentThread().getName() + "尝试重新计算");
    // 防止缓存污染,因为Future会记录结果为 "发生异常" ,如果一直获取到同一个Future,则一直是异常
    cache.remove(arg);
}

在这里,我们使用的是cache.remove(arg)方法,那在并发从场景中,是不是会出现这样一种状况:

好啦,现在知道问题在哪儿了,这就好办了。

VI. 问题解决

修改 cache.remove(arg)cache.remove(arg, f)

VII. 测试结果

收工!

总结

至此,这个系列就结束啦,我们一起来简要回顾一下:

  1. 线程池:线程池如何实现线程复用的?非核心线程又是如何回收的?
  2. ThreadLocal:这货底层如何实现的?有什么适用场景?
  3. 锁:什么是可重入锁?什么是非公平锁?tryLock()方法是公平的吗?
  4. CAS:它在java中是通过什么实现的?
  5. 并发容器:有哪些被遗弃的并发容器,为什么?CopyOnWrite是一种什么思想?
  6. 并发控制:有哪些并发控制的类?Semaphore有何应用场景?
  7. AQS:如果没有AQS,那些并发工具类需要自己实现什么?在CountDownLatch里面,AQS扮演着什么样的角色?
  8. 线程治理:如何拿到来自未来的值?