打造高性能缓存(一)实现最简单的缓存

缓存是在实际生产中非常常用的工具,用了缓存以后,我们可以避免重复计算,提高吞吐量

虽然缓存乍一看很简单,不就是一个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

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注