为计算结果简历高效、可伸缩的高速缓存

高速缓存:复用已有的计算结果,目的缩短等待时间,提高吞吐量。代价是占用更多的内存。

/**
 * Computable接口提供一个功能,输入一个A,计算返回一个V
 * @param <A>
 * @param <V>
 */
public interface Computable<A,V> {
    V compute(A a)throws  InterruptedException;
}

/**
 * Computable的一个实现类
 */
public class ExpensiveFunction implements  Computable<String,Integer> {
    @Override
    public Integer compute(String s) throws InterruptedException {
        //有很大很大的一断逻辑,这里省略
        // 假设耗时100秒
        return new Integer(s);
    }
}

一个包装器,缓存Computable计算的结果,也就是备忘录技术(memoization)
想想下单例模式。OK!
第一个人写的代码:

/**
 * HashMap并不是线程安全的,所以为了安全,添加了synchronized锁
 * 然而,这个重量级的锁一次只能有一个线程访问,若是执行compute方法时间超长,
 * 其他所有线程都得等待,这不是我们希望的。
 * sad!
 * @param <A>
 * @param <V>
 */
public class Memoizer1<A,V> implements  Computable<A,V> {

    private final Map<A,V> cache=new HashMap<A,V>();
    private final  Computable<A,V> c;

    public Memoizer1(Computable<A, V> c) {
        this.c = c;
    }

    @Override
    public synchronized V compute(A a) throws InterruptedException {
        V result=cache.get(a);
        if(result==null){
            result=c.compute(a);
            cache.put(a, result);
        }
        return result;
    }

}

由于HashMap不是线程安全的,为了不让两个线程同时访问HashMap,就同步了整个compute方法。
问题:首先这么做,肯定不会错,是正确的。但是,这样做耗时,带来一个可伸缩性问题。一次只有一个线程能够执行compute方法,如果一个线程正在忙于计算结果,假如它计算时间要100秒,那其他线程就只有等待了,而这些线程可能计算很快,1纳秒,1微秒,难道让他们都等着吗?
这张图(图5.2)就展示了弱并发的问题
第一个人的解决办法:

/**
 * 针对HashMap问题改用了ConcurrentHashMap,
 * 解决了map的问题,程序拥有了更好的并发性。
 * 然而,还存在一个问题,就是
 * 当两个线程同时调用compute时,会计算相同的值,
 * 而我们希望只计算一次。会计算100秒,甚至更长时,怎么办。
 * sad!
 * 我们希望,当一个线程计算时,其他线程到达时,等待计算结果就可以了。
 * @param <A>
 * @param <V>
 */
public class Memoizer2<A,V> implements  Computable<A,V> {

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

    public Memoizer2(Computable<A, V> c) {
        this.c = c;
    }

    @Override
    public  V compute(A a) throws InterruptedException {
        V result=cache.get(a);
        if(result==null){
            result=c.compute(a);
            cache.put(a, result);
        }
        return result;
    }

}

这个人的解决办法就是用 ConcurrentHashMap 替换HashMap,ConcurrentHashMap是线程安全的,所以就不需要在访问底层Map时对它进行同步。
但是,当两个线程同时调用compute时,它会计算相同的值。违反了避免重复计算相同的数据原则。另外一个缓存对象不应该只能被初始化一次。
图5.3展示了这种问题
我们希望:
用一种方法,能够表现出“线程X正在计算f(1)”,这样当另一个线程到达并查找f(1)时,它能够判断出最有效的方法时,等下线程X,直到线程结束,然后拿到结果。
第一个人的第三种方法代码:

/**
 * 针对Memoizer2中 我们希望,当一个线程计算时,其他线程到达时,等待计算结果就可以了。
 * 采用Future<V>取代 V。
 * 首先检查一个相应的计算是否已经开始
 * 如果不是,就创建一个FutureTask,把他注册到Map中,并开始计算。
 * 如果是,他会等待正在进行的计算,直到结果计算出来。
 * 附注: futureTask.get()只要结果可用就会立刻返回结果;否则会一直阻塞,直到结果被计算出来,并返回。
 * 但是,它依然存在问题,
 *if代码块非原子,可能 两个线程几乎同时调用compute计算相同的值,双方在缓存Map中都没有找到期望的值,
 * 就都开始了计算。
 * 另外缓存一个Future<V>带来了缓存污染的可能性。
 * ->如果一个计算被取消或者失败,未来尝试对这个值进行计算都会表现为取消或失败。
 *
 * @param <A>
 * @param <V>
 */
public class Memoizer3<A,V> implements  Computable<A,V> {

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

    public Memoizer3(Computable<A, V> c) {
        this.c = c;
    }

    @Override
    public  V compute(A a) throws InterruptedException {
        Future<V> future=cache.get(a);
        if(future==null){//计算没开始
            Callable<V> eval=new Callable<V>() {
                @Override
                public V call() throws Exception {
                    return c.compute(a);
                }
            };
            FutureTask<V> futureTask=new FutureTask<>(eval);//新建FutureTask任务
            future=futureTask;
            cache.put(a, futureTask);//注册到map
            futureTask.run();//调用c.compute在此处
        }
        try {
            return future.get();
        } catch (ExecutionException e) {
            e.printStackTrace();
            throw new InterruptedException(e.getMessage());
        }
    }

}

这段代码是用Future取代了V。Memoizer3收下检查一个相应的计算是否已经开始,
如果没开始,就创建一个FutureTask,把它注册到Map中,并开始计算;
如果开始了,那么它会等待正在进行的计算,知道结果出来。
Memoizer3已经是几乎完美了,但是,它依然存在问题——两个线程几乎在同一时间调用compute方法计算相同的值,双方都没在缓存中找到期望的值,并都开始计算。

在这里插入图片描述
为啥memoizer3这个是个漏洞,因为复合操作(缺少即加入)运行在底层的map中,不能加锁来使它原子化。(这段没看懂,因为put方法不安全吗?if代码块确实是不安全的,会重新计算,但是用putIfAbsent方法,为啥就能消除这个隐患?put方法会放两次,putIfAbsent只能放一次,用putIfAbsent后,后面的FutureTask就只会运行一次吗?还是put放两次这个问题?)
另一个问题:引入Future,带来一个缓存污染(cache pollution)的可能性——如果一个
计算被取消或者失败,未来尝试对这个值进行计算都会表现为取消或失败。

第一个人第四次的代码:

/**
 * 代码中的putIfAbsent消除了Memoizer3的隐患——if代码块非原子
 * 也消除了缓存污染
 * 但是,存在缓存过期的问题(可以通过FutureTask的一个子类来完成,它会为每个结果关联一个过期时间,
 * 并周期性地扫描缓存中过期的访问)
 * @param <A>
 * @param <V>
 */
public class Memoizer4<A, V> implements Computable<A, V> {

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

    public Memoizer4(Computable<A, V> c) {
        this.c = c;
    }

    @Override
    public V compute(final A a) throws InterruptedException {
        while (true) {
            Future<V> future = cache.get(a);
            if (future == null) {
                Callable<V> eval = new Callable<V>() {
                    @Override
                    public V call() throws InterruptedException {
                        return c.compute(a);
                    }
                };
                FutureTask<V> futureTask = new FutureTask<>(eval);
                future = cache.putIfAbsent(a, futureTask);
                if (future == null) {
                    future = futureTask;
                    futureTask.run();//调用c.compute在此处
                }
            }
            try {
                return future.get();
            } catch (CancellationException e) {
                cache.remove(a, future);
            } catch (ExecutionException e) {
                e.printStackTrace();
                throw new InterruptedException(e.getMessage());
            }
        }
    }

}

这段代码采用了putIfAbsent就不会放两次了,不会重复计算了。
另外当FutureTask失败或取消时,可以移除失败或取消的FutureTask,这样就可以重复了,成功计算了。
当然,这段代码也不是完善的,它存在缓存过期的问题。

本文转载于《Java并发编程实战》第五章,如有问题,欢迎指正。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值