Java趣談——如何構(gòu)建一個(gè)高效且可伸縮的緩存

本集概要:

  • 怎樣構(gòu)建一個(gè)線程安全而又高效、可伸縮的緩存?
  • 怎樣利用設(shè)計(jì)模式,把緩存做成通用的工具?
  • 除了synchronize和volatile,我們還能使用哪些工具來開發(fā)線程安全的代碼?

前情回顧: Volatile趣談——我是怎么把貝克漢姆的進(jìn)球弄丟的


大雄開發(fā)的門線傳感器在曼聯(lián)和阿森納的比賽中一鳴驚人,越來越多的客戶向公司訂購這款軟件......

糙版緩存

一天,哆啦對大雄說,“大雄,你看我們后臺這個(gè)統(tǒng)計(jì)用戶消費(fèi)信息的報(bào)表,每次統(tǒng)計(jì)都要去查數(shù)據(jù)庫,既消耗資源,查詢起來也慢,你看看能不能給做一個(gè)緩存?”
“緩存?這個(gè)簡單呀,緩存的原理其實(shí)就是一個(gè)鍵值對,也就是Map,只要把用戶的id作為key,把對應(yīng)的統(tǒng)計(jì)結(jié)果作為value,放到Map里頭緩存起來,下次再來查詢時(shí),先到Map去搜,搜到了就直接返回,搜不到,再去查數(shù)據(jù)庫計(jì)算,這樣就ok了”
“可以呀,思路很清晰嘛,一下子就講出了一份設(shè)計(jì)文檔”
“那是,那我開始寫代碼啦!”,大雄泡了杯茶,關(guān)掉QQ和Outlook,開始寫起了代碼。

很快,第一版代碼出爐了,UserCostStatComputer(本文的示例代碼,可到Github下載):

public class UserCostStatComputer {
    private final Map<String, BigInteger> cache = new HashMap();

    public synchronized BigInteger compute(String userId) throws InterruptedException {
        BigInteger result = cache.get(userId);
        if (result == null) {
            result = doCompute(userId);
            cache.put(userId, result);
        }
        return result;
    }

    private BigInteger doCompute(String userId) throws InterruptedException {
        // assume it cost a long time...
        TimeUnit.SECONDS.sleep(3);
        return new BigInteger(userId);
    }
}

“為了線程安全,我給整個(gè)compute方法加上了synchronized修飾符,保證每次只有一個(gè)線程可以進(jìn)入compute方法”,大雄向哆啦介紹他的代碼。
哆啦看了看,說,“大雄,你這代碼寫的快是快,但是有點(diǎn)糙啊”
“這。。。”
“咱先不說性能,就說你這設(shè)計(jì),你有沒有考慮過,要是還有其他地方也需要緩存,你難道也把這份緩存的邏輯拷貝過去?
“啊,對呀,總不能每次要用到緩存,就在原先代碼里加個(gè)Map,然后再寫上if判斷吧......”
"小伙子覺悟不錯(cuò),好好想想,怎樣給原先沒有緩存的統(tǒng)計(jì)函數(shù),加上緩存的功能?"
“給原先沒有某某功能的函數(shù),加上某某功能,這話聽著很熟悉啊...”

通用緩存

大雄在大腦中檢索著,“對了!裝飾模式!哎,之前學(xué)過的,怎么到實(shí)際運(yùn)用中就忘了呢!”
利用設(shè)計(jì)模式,大雄很快把代碼進(jìn)行了重構(gòu)。
首先,定義一個(gè)Computable接口:

public interface Computable<A, V> {
    V compute(A arg) throws Exception;
}

接著,每個(gè)要使用緩存功能的計(jì)算器,都要去實(shí)現(xiàn)這個(gè)接口,比如UserCostStatComputer:

public class UserCostStatComputer implements Computable<String, BigInteger> {
    @Override
    public BigInteger compute(String userId) throws Exception {
        // assume there is a long time compute...
        TimeUnit.SECONDS.sleep(3);
        return new BigInteger(userId);
    }
}

現(xiàn)在這個(gè)UserCostStatComputer是沒有緩存功能的,沒關(guān)系,來一個(gè)裝飾器就OK了,Memoizer1:

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;
    }

    public synchronized V compute(A arg) throws Exception {
        V result = cache.get(arg);
        if (result == null) {
            result = c.compute(arg);
            cache.put(arg, result);
        }
        return result;
    }
}

Memoizer1即實(shí)現(xiàn)了Computable接口,又持有一個(gè)Computable對象,同時(shí)在自己的compute方法中,調(diào)用了Computable對的compute方法,在這個(gè)compute方法的外圈,可以看到就是之前的緩存邏輯。
那么要怎樣使用Memoizer1給UserCostStatComputer裝飾上緩存功能呢?很簡單:

Computable computer = new Memoizer1(new UserCostStatComputer());
computer.compute("1");

利用Memoizer1這個(gè)裝飾器,我們成功的給原先不具有緩存功能的UserCostStatComputer加上了緩存。
“后面要是有其他的計(jì)算器,比如用戶地區(qū)分布統(tǒng)計(jì)計(jì)算器、用戶年齡分布統(tǒng)計(jì)計(jì)算器,我們都可以像UserCostStatComputer一樣,實(shí)現(xiàn)Computable,就可以使用裝飾器,裝飾上緩存功能了“,大雄得意洋洋地說。
“不錯(cuò),小伙子悟性很強(qiáng)啊”,哆啦拍拍大雄的肩膀,“好了,現(xiàn)在通過設(shè)計(jì)模式解決了代碼通用性問題,是時(shí)候看看你這個(gè)性能問題了”

雇個(gè)保姆

“性能問題?我這個(gè)代碼性能有問題么”,大雄翻著白眼。
“你看看,你把synchronize加在了什么地方?你加在了函數(shù)簽名的位置,意味著一次只能有一個(gè)線程能夠進(jìn)入compute方法,這就導(dǎo)致了一個(gè)用戶的id在計(jì)算的時(shí)候,其他用戶無法進(jìn)行計(jì)算,只能等到正在計(jì)算的用戶計(jì)算完了,才能進(jìn)入compute方法”
“啊,這對性能影響挺大的。。。”
“沒錯(cuò),換句話說,你這個(gè)加鎖的粒度太大了,我們先把synchronize去掉,看看有什么線程安全問題,再來逐一解決”
去掉synchronize的compute方法:

    public V compute(A arg) throws Exception {
        V result = cache.get(arg);
        if (result == null) {
            result = c.compute(arg);
            cache.put(arg, result);
        }
        return result;
    }

“很明顯,由于你用的是線程不安全的HashMap,cache.put(arg, result)這一行代碼就有線程安全問題”,哆啦說。
“那給這行代碼加鎖?像這樣”,大雄說著在紙上寫出草稿:

synchronized(this) {
  cache.put(arg, result);
}

“哈哈,你怎么那么喜歡用synchronized,這樣可以是可以,但是粒度還是太大了,Java早就給你提供了一個(gè)叫ConcurrentHashMap的保姆,我們只要把線程安全委托給它去處理就好了”
“啊,我怎么又忘了...”
說完,大雄修改了代碼:

  private final Map<A, V> cache = new ConcurrentHashMap<A, V>();
  // private final Map<A, V> cache = new HashMap<A, V>();

未來任務(wù)

"現(xiàn)在再來看其他地方,由于把synchronize去掉了,現(xiàn)在不同的用戶id可以同時(shí)計(jì)算,但是也導(dǎo)致了相同用戶id也會同時(shí)被計(jì)算,也就是說同樣的數(shù)據(jù)會被計(jì)算多次,這樣緩存就沒意義了"
“啊,假設(shè)用戶id為1的數(shù)據(jù)正在計(jì)算,這個(gè)時(shí)候又來了一個(gè)線程,也是計(jì)算用戶id為1的,如果這個(gè)線程可以知道,當(dāng)前有沒有線程正在計(jì)算id為1的數(shù)據(jù),如果有,就等待這個(gè)線程返回計(jì)算結(jié)果,如果沒有,就自己計(jì)算,那問題不就解決了?可是這個(gè)邏輯挺復(fù)雜的,好難實(shí)現(xiàn)啊”
“哈哈,很簡單,Java早就提供了FutureFutureTask,來實(shí)現(xiàn)你說的這種功能,來,把鍵盤給我,好久沒寫代碼了”,哆啦說完,馬上敲起了鍵盤。
終極版Memoizer:

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

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

    public V compute(final A arg) throws Exception {
        // use loop for retry when CancellationException
        while (true) {
            Future<V> f = cache.get(arg);
            if (f == null) {
                Callable<V> callable = new Callable<V>() {
                    public V call() throws Exception {
                        return c.compute(arg);
                    }
                };
                FutureTask<V> ft = new FutureTask<V>(callable);

                // use putIfAbsent to avoid multi thread compute the same value
                f = cache.putIfAbsent(arg, ft);

                if (f == null) {
                    f = ft;
                    ft.run();
                }
            }

            try {
                return f.get();
            } catch (CancellationException e) {
                // remove cache and go into next loop to retry
                cache.remove(arg, f);
            } catch (ExecutionException e) {
                // throw it and then end
                e.printStackTrace();
                throw e;
            }
        }
    }
}

簡單解釋一下代碼邏輯,線程進(jìn)了compute方法后:

  1. 首先去存放future的Map里,搜索有沒有已經(jīng)其他線程已經(jīng)創(chuàng)建好的future
  2. 如果有(不等于null),那就調(diào)用future的get方法
  3. 如果已經(jīng)計(jì)算完成,get方法會立刻返回計(jì)算結(jié)果
  4. 否則,get方法會阻塞,直到結(jié)果計(jì)算出來再將其返回。
  5. 如果沒有已經(jīng)創(chuàng)建的future,那么就自己創(chuàng)建future,進(jìn)行計(jì)算

有幾個(gè)小點(diǎn)要單獨(dú)解釋的:
1、為什么要使用putIfAbsent
這里之所以用putIfAbsent而不用put,原因很簡單。
如果有兩個(gè)都是計(jì)算userID=1的線程,同時(shí)調(diào)用put方法,那么返回的結(jié)果都不會為null,后面還是會創(chuàng)建兩個(gè)任務(wù)去計(jì)算相同的值。
而putIfAbsent,當(dāng)map里面已經(jīng)有對應(yīng)的值了,則會返回已有的值,否則,會返回null,這樣就可以解決相同的值計(jì)算兩次的問題。
2、為什么要while (true)
這是因?yàn)閒uture的get方法會由于線程被中斷而拋出CancellationException。
我們對于CancellationException的處理是cache.remove(arg, f);,也就是把緩存清理掉,然后進(jìn)入下一個(gè)循環(huán),重新計(jì)算,直到計(jì)算成功,或者拋出ExecutionException。

總結(jié)

這篇文章中,我們先是用裝飾模式,改良了代碼的設(shè)計(jì),接著通過使用并發(fā)容器ConcurrentHashMap以及同步工具Future,取代了原先的synchronize,實(shí)現(xiàn)了線程安全性的委托。

《Java并發(fā)編程實(shí)踐》中,將委托稱為實(shí)現(xiàn)線程安全的一個(gè)最有效策略。委托其實(shí)就是把原來通過使用synchronize和volatile來實(shí)現(xiàn)的線程安全,委托給Java提供的一些基礎(chǔ)構(gòu)建模塊(當(dāng)然你也可以寫自己的基礎(chǔ)構(gòu)建模塊)去實(shí)現(xiàn),這些基礎(chǔ)構(gòu)建模塊包括:

  • 同步容器:比如Vector,同步容器的原理大多就是synchronize,所以用的不多;
  • 并發(fā)容器:比如本文用到的ConcurrentHashMap,當(dāng)然還有CopyonWriteArrayList、LinkedBlockingQueue等,根據(jù)你的需求使用不同數(shù)據(jù)結(jié)構(gòu)的并發(fā)容器;
  • 同步工具類:比如本文用到的Future和FutureTask,還有閉鎖(Latches)、信號量(Semaphores)、柵欄(Barriers)等

這些基礎(chǔ)構(gòu)建模塊,在加上之前所講的synchronize和volatile,就形成了Java并發(fā)的基礎(chǔ)知識

其實(shí)這也就是《Java并發(fā)編程實(shí)踐》第一部分的迷你版,總結(jié)成一句話就是:
要想寫出線程安全的代碼,我們需要用到使用synchronize、volatile,或者將線程安全委托給基礎(chǔ)構(gòu)建模塊。

當(dāng)然,在實(shí)際應(yīng)用中,只有這些基礎(chǔ)知識是不夠的,最簡單的例子,你不可能每次請求過來都創(chuàng)建線程,這會吃光內(nèi)存的。所以,你需要一個(gè)線程池,來限制線程的數(shù)量,這也就引出了《Java并發(fā)編程實(shí)踐》的第二部分,結(jié)構(gòu)化并發(fā)應(yīng)用程序(Structuring Concurrent Applications),聽名字確實(shí)不知道想講什么,所以我也將把它啃下來,然后像第一部分一樣,用盡可能通俗易懂的語言和大家分享學(xué)習(xí)心得。

參考

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。