我曾踏足山巅,也曾进入低谷,二者使我受益良多。
摘要
- 为了对并发知识进行一个完整性的回顾,这儿采用一步步实现高性能缓存来收尾
- 缓存是什么?我们为什么需要缓存?
- 如何一步步实现高性能缓存,这其中会有什么问题?又如何解决?
- 这其中涉及到:缓存的原理探究、并发问题的解决思路、并发工具的使用与并发线程治理
高性能缓存
一、缓存是什么?为什么需要?
缓存是在实际生产中非常常用的工具,用了缓存以后,我们可以避免重复计算,提高吞吐量。 举一个二级缓存的例子:
1)场景:我们数据库中有一些业务相关的配置(业务名称,业务ID,业务描述,业务字段等等),这些配置存放在若干个表中,每一次组装信息的时候我们都可能需要做多次查询和计算,但是这些配置我们又不会频繁的去修改。
2)设计:基于这种情况,我们可以设计一个二级缓存(本地缓存 + 分布式缓存)
3)实现:
- 本地使用Google的Guava实现内存中的缓存,过期时间为5s
- 分布式缓存使用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)代码复用性问题解决 -- 装饰模式
- 我们设置一个
ExpensiveFunction是耗时计算的实现类,它实现了Computable接口,但是本身不具备缓存的功能,也不考虑缓存的事情。
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;
}
}
- 但我们想要修改缓存逻辑时,只需要修改
DecoupleCache中的compute方法即可;当我们想使用不同的计算逻辑时,只需要传入其他具体计算类(如:StatisticsFunction)即可。
第三阶段:安全的缓存
仔细想想,上面实现还存在一个很大的问题的:并发安全问题
- 重复计算:在计算逻辑那儿,我们并没有对多线程情况就行处理,也就是说,可能一个结果同时在被多个线程计算,其实这是没必要的)
- 同时 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操作保护起来可以吗?那其实还是有两个问题:
- 同时Put的性能问题
- 同时读写的并发问题
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();
}
}
我们来看看结果:
咦,为什么就这儿的结果看来,还是发生了重复计算呢?我们思考一下问题出在哪儿:
那这个时候,我们是不是需要加一个检查性的操作呢 -- 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);
}
}
现在我们知道了计算过程中可能出现异常,那在处理过程中需要考虑什么问题呢?
- 任务相关的异常(
CancellationException | InterruptedException) - 计算相关的异常(数组越界、空指针、IO等等)
- 缓存污染
(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. 测试思路
- 使用
countDownLatch来模拟并发场景。 - 使用ThreadLocal来做时间的格式化。
- 使用缓存的最终版本:可以设置过期时间的缓存。
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. 测试结果
总结
至此,这个系列就结束啦,我们一起来简要回顾一下:
- 线程池:线程池如何实现线程复用的?非核心线程又是如何回收的?
- ThreadLocal:这货底层如何实现的?有什么适用场景?
- 锁:什么是可重入锁?什么是非公平锁?
tryLock()方法是公平的吗? - CAS:它在java中是通过什么实现的?
- 并发容器:有哪些被遗弃的并发容器,为什么?CopyOnWrite是一种什么思想?
- 并发控制:有哪些并发控制的类?
Semaphore有何应用场景? - AQS:如果没有AQS,那些并发工具类需要自己实现什么?在
CountDownLatch里面,AQS扮演着什么样的角色? - 线程治理:如何拿到来自未来的值?