打造高性能缓存(一)实现最简单的缓存
最后更新于:2022-08-12 11:13:32
缓存是在实际生产中非常常用的工具,用了缓存以后,我们可以避免重复计算,提高吞吐量
虽然缓存乍一看很简单,不就是一个Map么?最初级的缓存确实可以用一个缓存实现,不过一个功能完备、性能强劲的缓存,需要考虑的点就非常多了,我们从最简单的hashMap入手,一步步提高我们缓存的性能
public class TestCache1 {
private final HashMap<String, Integer> cache = new HashMap<>();
public Integer computer(String userId) throws InterruptedException {
// 先检查map中有没有保存过之前的计算结果
Integer ret = cache.get(userId);
if (ret == null) {
// 如果缓存中找不到,那么需要现在计算一个结果,并且保存到hashMap中
ret = doComputer(userId);
cache.put(userId, ret);
}
return ret;
}
// 模拟实际应用场景中的数据库查询操作
private Integer doComputer(String userId) throws InterruptedException {
TimeUnit.SECONDS.sleep(5);
return new Integer(userId);
}
public static void main(String[] args) throws InterruptedException {
TestCache1 cache1 = new TestCache1();
System.out.println("开始计算:" + System.currentTimeMillis());
Integer ret = cache1.computer("13");
System.out.println("第一次计算结果:" + System.currentTimeMillis());
ret = cache1.computer("13");
System.out.println("第二次计算结果:" + System.currentTimeMillis());
}
}
运行结果
开始计算:1658288210465
第一次计算结果:1658288215466
第二次计算结果:1658288215466
可以看出,第一次计算时间很长,可以类比成实际项目中的慢查询,而当运行完成以后,保存到hashMap中,再次运行的时候,速度就很快了
重复计算和复用性问题
多线程情况下是并发不安全的,当多个线程同时访问computer()方法时,是有可能出现,都判断为空,然后发生重复计算的问题。在并发情况下,如果多个线程都出现扩容时,还可能导致CPU100%,进入死循环的可能。
解决方法
首先添加final关键字,属性被声明为final后,该变量则之恶能被赋值一次,且一旦被赋值,final的变量就不能在被改变。增强安全性。
添加synchronized关键字
只有一个线程结束以后,才能有下一个线程访问,可以解决线程不安全的问题,但是性能差,不符合缓存的应用场景。代码复用性差。
用装饰者模式解决复用性差
假设ExpensiveFunction类是耗时计算的实现类,实现了Computable接口,但是其本身不具备缓存功能,也不需要考虑缓存的事情
1. 创建计算函数compute,用来代表耗时计算,每个计算器都要实现这个接口,这样就可以实现无侵入实现缓存功能
public interface Computable<A, V> {
V computer(A arg) throws Exception;
}
2. 耗时计算的实现类,实现了Computable接口,但是其本身不具备缓存功能,也不需要考虑缓存的事情
public class ExpensiveFunction implements Computable<String, Integer> {
@Override
public Integer computer(String arg) throws Exception {
TimeUnit.SECONDS.sleep(5);
return new Integer(arg);
}
}
如果需要改变缓存,只需要修改对应的function即可
测试
public class TestCache2<A, V> implements Computable<A, V> {
private final Map<A, V> cache = new HashMap<>();
private final Computable<A, V> c;
// 具体的计算,在c的Computable 方法中完成
public TestCache2(Computable<A, V> c) {
this.c = c;
}
@Override
public synchronized V computer(A arg) throws Exception {
System.out.println("进入缓存机制");
V result = cache.get(arg);
if (result == null) {
result = c.computer(arg);
cache.put(arg, result);
}
return result;
}
public static void main(String[] args) throws Exception {
TestCache2<String, Integer> expensiveComputer = new TestCache2<>(new ExpensiveFunction());
System.out.println("开始计算:" + System.currentTimeMillis());
Integer result = expensiveComputer.computer("666");
System.out.println("第一次计算结果:" + result + ",时间:" + System.currentTimeMillis());
result = expensiveComputer.computer("666");
System.out.println("第二次计算结果:" + result + ",时间:" + System.currentTimeMillis());
}
}
实现帮助缓存的逻辑,需要修改,只需要修改computer即可,实现了解耦
结果
开始计算:1658369148279
进入缓存机制
第一次计算结果:666,时间:1658369153280
进入缓存机制
第二次计算结果:666,时间:1658369153280
缺点
当多个线程同时想计算的时候,需要慢慢等待,严重时性能甚至比不用缓存更差
优化方式:缩小锁的粒度
public V computer(A arg) throws Exception {
System.out.println("进入缓存机制");
V result = cache.get(arg);
if (result == null) {
result = c.computer(arg);
synchronized(this){
cache.put(arg, result);
}
}
return result;
}
缺点
虽然提高了并发效率,但是并不意味着线程安全,还需要考虑到同时读写等情况
使用ConcurrentHashMap保证并发安全
public class TestCache2<A, V> implements Computable<A, V> {
private final Map<A, V> cache = new ConcurrentHashMap<>();
private final Computable<A, V> c;
// 具体的计算,在c的Computable 方法中完成
public TestCache2(Computable<A, V> c) {
this.c = c;
}
//ConcurrentHashMap 本身就是线程安全的,所以可以不使用synchronized
@Override
public V computer(A arg) throws Exception {
System.out.println("进入缓存机制");
V result = cache.get(arg);
if (result == null) {
result = c.computer(arg);
cache.put(arg, result);
}
return result;
}
}
缺点
在计算完成前,另一个要求计算相同值的请求到来,会导致计算两边,这和缓存想要避免多次计算的2初衷恰恰相反,是不可接受的
使用Future和Callable避免重复计算
public class TestCache4<A, V> implements Computable<A, V> {
private final Map<A, Future<V>> cache = new ConcurrentHashMap<A, Future<V>>();
private final Computable<A, V> c;
// 具体的计算,在c的Computable 方法中完成
public TestCache4(Computable<A, V> c) {
this.c = c;
}
@Override
public V computer(A arg) throws Exception {
// 因为ConcurrentHashMap的可见性保证,当其他线程在里面赋值以后,当前线程一定不会拿到为null的值
Future<V> f = cache.get(arg);
if (f == null) {
Callable<V> callable = new Callable<V>() {
@Override
public V call() throws Exception {
return c.computer(arg);
}
};
FutureTask<V> ft = new FutureTask<>(callable);
f = ft;
// 先把FutureTask放入ConcurrentHashMap,然后在再进行计算,保证其他线程不会重复进入当前方法
cache.put(arg, ft);
System.out.println("从FutureTask调用了计算逻辑");
ft.run();
}
// get获取值,一定会在run方法以后
return f.get();
}
public static void main(String[] args) throws Exception {
TestCache4<String, Integer> expensiveComputer = new TestCache4<>(new ExpensiveFunction());
new Thread(() -> {
try {
Integer result = expensiveComputer.computer("666");
System.out.println("第一次计算结果:" + result + ",时间:" + System.currentTimeMillis());
} catch (Exception e) {
e.printStackTrace();
}
}).start();
new Thread(() -> {
try {
Integer result = expensiveComputer.computer("666");
System.out.println("第二次计算结果:" + result + ",时间:" + System.currentTimeMillis());
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
结果
从FutureTask调用了计算逻辑
从FutureTask调用了计算逻辑
触发computer
触发computer
第一次计算结果:666,时间:1660196778746
第二次计算结果:666,时间:1660196778746
缺点
如果有两个同时计算666的线程,同时调用cache.get()方法,返回的结果都为null,后面还会创建两个任务去计算相同的值
使用原子操作,putIfAbsent
putIfAbsent 如果当前值不存在的话,才会往里面存放对应的值
如果两个线程同时向map中存放值的时候,一定会有先有后,当第一个线程向里面存值的时候,会返回当key的value为null,而第二个线程存放值的时候,因为第一个线程已经存放过了,就不会再继续存值了,并且返回当前的值。
修改computer 方法
public V computer(A arg) throws Exception {
Future<V> f = cache.get(arg);
if (f == null) {
Callable<V> callable = new Callable<V>() {
@Override
public V call() throws Exception {
return c.computer(arg);
}
};
FutureTask<V> ft = new FutureTask<>(callable);
// 修改put为putIfAbsent填补漏洞
f = cache.putIfAbsent(arg, ft);
if (f==null){
f = ft;
System.out.println("从FutureTask调用了计算逻辑");
ft.run();
}
}
return f.get();
}
结果
从FutureTask调用了计算逻辑
触发computer
第一次计算结果:666,时间:1660196862908
第二次计算结果:666,时间:1660196862908