爲計算結果簡歷高效、可伸縮的高速緩存

原文鏈接:https://blog.csdn.net/u010675729/article/details/99938299

高速緩存:複用已有的計算結果,目的縮短等待時間,提高吞吐量。代價是佔用更多的內存。

/**
 * 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併發編程實戰》第五章,如有問題,歡迎指正。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章