java併發——構建高效且可伸縮的結果緩存

幾乎所有的服務器應用都會使用某種形式的緩存。重用之前的計算結果能降低延遲,提高吞吐量,但卻要消耗更多內存。看上去簡單的緩存,可能會將性能瓶頸轉變成伸縮性瓶頸,即使緩存是用來提高單線程性能的。本文將開發一個高效且可伸縮的緩存,用於改進一個高計算開銷的計算,我們會從HashMap開始,逐步完善功能,分析它們的併發問題,並討論如何修改它們。

下面基於一個計算任務開始緩存的設計


public interface Computable <A, R>{
    R compute(A a) throws InterruptedException;
}

public class Function implements Computable <String, BigInteger>{

    @Override
    public BigInteger compute(String a) throws InterruptedException {
        return new BigInteger(a);
    }

}

第一階段 HashMap

public class Memorizer1<A, V> implements Computable<A, V>{
    private final Computable<A, V> compute;
    private final Map<A, V> cache;
    public Memorizer1(Computable<A, V> compute){
        this.compute = compute;
        cache = new HashMap<A, V>();
    }
    @Override
    public synchronized V compute(A a) throws InterruptedException {
        V result = cache.get(a);
        if(result == null){
            result = compute.compute(a);
            cache.put(a, result);
        }
        return result;
    }
}

如上所示,Memorizer1將Computable實現類的計算結果緩存在Map<A, V> cache。因爲HashMap不是線程安全的,爲了保證併發性,Memorizer1用了個很保守的方法,對整個compute方法進行同步。這導致了Memorizer1會有很明顯的可伸縮性問題,當有很多線程調用compute方法,將排一列很長的隊,考慮到這麼多線程的阻塞,線程狀態切換,內存佔用,這種方式甚至不如不使用緩存。

第二階段 ConcurrentHashMap

public class Memorizer2<A, V> implements Computable<A, V>{
    private final Computable<A, V> compute;
    private final Map<A, V> cache;
    public Memorizer2(Computable<A, V> compute){
        this.compute = compute;
        cache = new ConcurrentHashMap<A, V>();
    }
    @Override
    public V compute(A a) throws InterruptedException {
        V result = cache.get(a);
        if(result == null){
            result = compute.compute(a);
            cache.put(a, result);
        }
        return result;
    }
}

Memorizer2比Memorizer1擁有更好的併發性,並且具有良好的伸縮性。但它仍然有一些不足——當兩個線程同時計算同一個值,它們並不知道有其它線程在做同一的事,存在着資源被浪費的可能。這個不足,對於緩存的對象只提供單次初始化,會帶來安全性問題。

第三階段 ConcurrentHashMap+FutureTask
事實上,第二階段的功能已經符合大部分情況的功能,但是當計算時間很長導致很多線程進行同一個運算,或者緩存的對象只提供單次初始化,問題就會很棘手,在這裏,我們引入FutureTask來讓進行運算的線程獲知是否已經有其它正在,或已經進行該運算的線程。

public class Memorizer3<A, V> implements Computable<A, V>{
    private final Computable<A, V> compute;
    private final Map<A, FutureTask<V>> cache;
    public Memorizer3(Computable<A, V> compute){
        this.compute = compute;
        cache = new ConcurrentHashMap<A, FutureTask<V>>();
    }
    @Override
    public V compute(A a) throws InterruptedException {
        V f = cache.get(a);
        if(f == null){
            Callable<V> eval = new Callable<V>(){
                public V call() throw InterruptedException{
                    return c.compute(arg);
                }
            }
            FutureTask<V> ft = new FutureTask<V>(eval);
            f = ft;
            cache.put(a, ft);
            ft.run();
        }
        try{
            return f.get();
        }cache(ExecutionException e){
            throw launderThrowable(e.getCause());
        }
    }
}

Memorizer3緩存的不是計算的結果,而是進行運算的FutureTask。因此Memorizer3首先檢查有沒有執行該任務的FutureTask。如果有,則直接獲得FutureTask,如果計算已經完成,FutureTask.get()方法可以立刻獲得結果,如果計算未完成,後進入的線程阻塞直到get()返回結果;如果沒有,則創建一個FutureTask進行運算,後續進了的同樣的運算可以直接拿到結果或者等待運算完成獲得結果。
Memorizer3的實現近乎完美,但是仍然存在一個問題,當A線程判斷沒有緩存是,進入到cache.put(a, ft);這一步前,B線程恰好判斷緩存爲空,B線程創建的FutureTask會把A創建的FutureTask覆蓋掉。雖然這相比Memorizer2已經是小概率事件,但是問題還是沒根本解決。

第四階段 ConcurrentHashMap + FutureTask + Map原子操作

第三階段的ConcurrentHashMap + FutureTask由於存在“先檢查再執行“的操作,會有併發問題,我們給cache使用複合操作(“若沒有則添加“),避免該問題。
public class Memorizer4<A, V> implements Computable<A, V>{
    private final Computable<A, V> compute;
    private final Map<A, FutureTask<V>> cache;
    public Memorizer4(Computable<A, V> compute){
        this.compute = compute;
        cache = new ConcurrentHashMap<A, FutureTask<V>>();
    }
    @Override
    public V compute(A a) throws InterruptedException {
        while(true){
            V f = cache.get(a);
            if(f == null){
                Callable<V> eval = new Callable<V>(){
                    public V call() throw InterruptedException{
                        return c.compute(arg);
                    }
                }
                FutureTask<V> ft = new FutureTask<V>(eval);
                f = cache.putIfAbsent(a, ft);
                if(f == null){
                    f = ft;
                    ft.run();
                }
            }
            try{
                return f.get();
            }catch(CancellationException e){
                cache.remove(arg, f);
            }catch(ExecutionException e){
                throw launderThrowable(e.getCause());
            }
        }
    }
}

Memorizer4做了兩點改進:
1. 插入時會再次檢查是否有緩存,並且這是個複合操作

f = cache.putIfAbsent(a, ft);
if(f == null){
    f = ft;
    ft.run();
}
  1. 這裏考慮到了一種情況,如果正在運行的FutureTask被終止,那進行該運算的所有請求都會出問題,始料未及的遭遇CancellationException異常。Memorizer4的compute操作是一個循環,當在get()阻塞的線程catch到CancellationException異常,則會再一次申請一個創建FutureTask的機會。

至此,整個設計過程就結束了。我們得到了一個在極端環境下依然能夠保證高效且可伸縮運行的結果緩存。

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