建立快取記憶體機制-java版

尋找的路上發表於2021-03-14

前言

​ 一臺計算機的核心是CPU,它是計算機系統的運算和控制核心。由於它處理運算速度快,所以基本都會給CPU配置一級快取,當CPU要讀取一個資料時,首先從快取中查詢,如果沒有在從記憶體或者磁碟塊中找。
​ 同樣的,作為一個伺服器應用程式,為了讓應用程式執行更快速,響應更給力,我們會給它設定一些資料快取,這樣可以提高應用程式的吞吐量、縮短客戶端的響應時間。

建立快取過程分析

​ 我們從java最常用的方案開始——一個簡單的HashMap。

public interface Computable<A, V> {
    V compute(A arg) throws InterruptedException;
}
public class ExpensiveFunction implements Computable<String, BigInteger> {
    @Override
    public BigInteger compute(String arg) throws InterruptedException {
        // after deep thought...
        return new BigInteger(arg);
    }
}

​ Computable<A, V>介面描述了一個功能,輸入型別是A,輸出結果的型別是V。ExpensiveFunction實現了Computable。需要花比較長的時間來計算結果。所以我們計劃把計算過的值都放進一個HashMap中,這樣下一次有同一個A值進來時,直接獲取A的計算結果。

2.1 Synchronized版

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 arg) throws InterruptedException {
        V result = cache.get(arg);
        if (result == null) {
            result = c.compute(arg);
            cache.put(arg, result);
        }
        return result;
    }
}

​ Memoizer1實現了第一個版本,HashMap不是執行緒安全的,所以使用synchronzied關鍵字來保證執行緒安全,如果cache變數中有計算結果,直接從cache取,不需要再次計算,省下許多時間。但使用synchronzied使得一次只有一個執行緒能夠執行compute。如果一個執行緒正在計算結果,那其他呼叫compute的執行緒可能被阻塞很長時間,造成效能下降,這不是我們希望通過快取得到的效能優化結果。

2.2 ConcurrentHashMap版

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 arg) throws InterruptedException {
        V result = cache.get(arg);
        if (result == null) {
            result = c.compute(arg);
            cache.put(arg, result);
        }
        return result;
    }
}

​ Memoizer2用ConcurrentHashMap取代HashMap,改進了Memoizer1中那種糟糕的併發行為。因為ConcurrentHashMap是執行緒安全的,所以不需要使用Synchronized關鍵字,而是使用內部hash桶的分段鎖機制。
​ Memoizer2與Memoizer1相比,毫無疑問具有了更好的併發性:多執行緒可以真正併發訪問了。但是作為快取記憶體仍然存在缺陷:當兩個執行緒同時呼叫compute時,如果是計算同一個值,此時compute是需要很大的開銷的,在一個執行緒還在計算中時,其它執行緒不知道,所以可能會重複計算。而我們希望的是:如果A執行緒正在計算arg,那B就不要重複計算arg,等A計算好,直接取arg對應的V就好了。

2.3 ConcurrentHashMap + FutureTask版

public class Memoizer3<A, V> implements Computable<A, V> {
    private final Map<A, Future<V>> cache = new ConcurrentHashMap<>();
    private final Computable<A, V> c;
    public Memoizer3(Computable<A, V> c) {
        this.c = c;
    }
    @Override
    public V compute(A arg) throws InterruptedException {
        Future<V> f = cache.get(arg);
        if (f == null) {
            Callable<V> eval = () -> {
                return c.compute(arg);
            };
            FutureTask<V> ft = new FutureTask<>(eval);
            f = ft;
            cache.put(arg, ft);
            ft.run(); // 呼叫 c.compute發生在這裡
        }
        try {
            return f.get();
        } catch (ExecutionException e) {
            e.printStackTrace();
            throw new RuntimeException(e.getMessage());
        }
    }
}

​ Memoizer3為快取的值重新定義可儲存Map,用ConcurrentHashMap<A, Future>取代ConcurrentHashMap<A,V>。Memoizer3首先檢查一個相應的計算是否開始,如果不是,就建立一個FutureTash,並把它註冊到map中,並開始計算,如果是,那麼它就會等待正在計算的結果。
​ Memoizer3的實現近乎是完美的:它展示了非常好的併發性,能很快返回已經計算過的結果,如果新到的執行緒請求的是其它執行緒正在計算的結果,它也會耐心的等待。
​ Memoizer3只有一個問題,就是仍然存在這種可能性:2個執行緒同時計算arg,此時由於compute中的if程式碼塊是非原子性的複合操作,2個執行緒會同時進入到if程式碼塊中,依舊會同時計算同一個arg。但ConcurrentHashMap中提供了一個原子化的putIfAbsent方法,可以消除Memoizer3的隱患。

2.4 最終版

public class Memoizer<A, V> implements Computable<A, V> {
    private final Map<A, Future<V>> cache = new ConcurrentHashMap<>();
    private final Computable<A, V> c;
    public Memoizer(Computable<A, V> c) {
        this.c = c;
    }

    @Override
    public V compute(A arg) throws InterruptedException {
        Future<V> f = cache.get(arg);
        if (f == null) {
            Callable<V> eval = () -> {
                return c.compute(arg);
            };
            FutureTask<V> ft = new FutureTask<>(eval);
            f = ft;
            cache.putIfAbsent(arg, ft);
            ft.run(); // 呼叫 c.compute發生在這裡
        }
        try {
            return f.get();
        } catch (ExecutionException e) {
            e.printStackTrace();
            throw new RuntimeException(e.getMessage());
        }
    }
}

​ Memoizer可以說是快取的完美實現了:支援高併發,同一個引數計算也不會重複執行(多虧於ConcurrentHashMap的putIfAbsent原子化操作)。最終呼叫者通過呼叫Future.get(arg)方法獲取計算結果。

相關文章