如何編寫一個有效的緩存

緩存做爲計算機歷史上最重要的發明之一,對計算機歷史起到了舉足輕重的做用,由於緩存能夠協調兩個速度不一致的組件之間的並行運做。內存做爲CPU和非易失性存儲介質之間的緩存,避免CPU每次讀取指令,讀取數據都去速度緩慢的硬盤讀取。快速緩存做爲內存和CPU之間的緩存進一步提升了CPU的效率,如今大部分CPU都支持指令預取,CPU會預測後面要執行的指令預取到快速緩存中。而咱們平時也直接或間接地會用到緩存技術,那若是要本身實現一個線程安全的緩存,要注意哪些問題呢?咱們一步步來探討這個問題。緩存

假設咱們提供一個服務,客戶端提供一個字符串,咱們返回一個對應的Long數值(固然這只是爲了演示方便舉的簡單例子),爲了提升效率,咱們不但願每次都重複計算,所以咱們把計算結果保存在一個緩存裏,若是下次有相同的請求過來就直接返回緩存中的數據。安全

首先咱們把計算任務抽象成Compute接口:併發

public interface Compute<A,V>

{

 V compute(A args);

}

 

一個不使用緩存計算的類:異步

public class NoCache implements Compute<String, Long>

{

 

   @Override

   public Long compute(String args)

   {

     // TODO Auto-generated method stub

      return Long.valueOf(args);

   }

}

 

這樣每次都要重複計算,效率比較低,所以咱們引入了一個Map來保存計算結果:ide

public class BasicCache1<A,V> implements Compute<A, V>

{

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

private final Compute<A, V> c;

 

public BasicCache1(Compute<A, V> c)

{

   this.c=c;

}

 

@Override

public synchronized V compute(A args) throws Exception

{

   V ans=cache.get(args);

   if(ans==null)

   {

     ans=c.compute(args);

     cache.put(args, ans);

   } 

   return ans;

}

}

 

這裏由於HashMap不是線程安全的,所以計算方法被寫成了同步方法,這樣的話,每次請求最後實際都是串行執行的,大大下降了系統的吞吐量。就像下面這幅圖表示的:this

 

既然這樣,咱們就改用ConcurrentHashMap試試:spa

public class BasicCache2<A,V> implements Compute<A, V>

{

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

   private final Compute<A, V> c;

  

   public BasicCache2(Compute<A, V> c)

   {

     this.c=c;

   }

  

   @Override

   public V compute(A args) throws Exception

   {

     V ans=cache.get(args);

     if(ans==null)

     {

        ans=c.compute(args);

        cache.put(args, ans);

     }

     return ans;

   }

}

這裏沒有同步compute操做,所以系統能夠併發地執行請求,可是假如多個有相同參數的請求短期內前後到達,這個時候先到達的請求還沒來得及把結果寫入緩存(由於計算耗時),後來的請求就會重複計算,下降了緩存的效率。一圖勝前言:線程

 

 

所以咱們就想,能不能先更新緩存再去計算呢,這樣不就能夠消除了重複計算嗎?聽起來不錯,但是咱們如何在更新了緩存後獲取計算結果呢(由於這時計算尚未完成)?這裏就要用到JDK提供的Future和Callable接口,Callable接口和Runnable接口同樣,是線程任務的抽象接口,不一樣的是Callable的call方法能夠返回一個Future對象,而Future對象的get方法會阻塞等待任務執行結果。既然有這麼好的基礎設施,那咱們趕忙開擼:code

public class BasicCache3<A,V> implements Compute<A, V>

{

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

   private final Compute<A, V> c;

   private ExecutorService executors=Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()+1);

  

   public BasicCache3(Compute<A, V> c)

   {

     this.c=c;

   }

  

   @Override

   public V compute(final A args) throws Exception

   {

     Future<V> ans=cache.get(args);

     if(ans==null)

     {

        Callable<V> computeTask=new Callable<V>()

        {

           @Override

           public V call() throws Exception

           {

             return c.compute(args);

           }      

        };

        ans= executors.submit(computeTask);

        cache.put(args, ans);

     }

     return ans.get();

   }

}

 

上面這段代碼裏把計算任務提交到線程池去執行,返回了一個結果句柄供後面獲取計算結果。但是仔細觀察後,咱們發現彷佛仍是有點不對,這樣彷佛減少了重複計算的機率,可是其實只是減少了發生的窗口,由於判斷是否在緩存中和put到緩存中兩個操做雖然單獨都是線程安全的,可是仍是會發生先到達的請求還沒來得及put到緩存的狀況,而其本質緣由就是先檢查再插入這樣的複雜操做不是原子操做,就比如++這個操做,CPU須要先取原值,再操做加數,最後寫回原值也會出現後一次寫入覆蓋前一次的狀況,本質都是由於複雜操做的非原子性。下圖演示了這種狀況:對象

 

所以JDK中的ConcurrentMap接口提供了putIfAbsent的原子操做方法,但是若是咱們像前一個例子中同樣先獲取計算結果的Future句柄,即使是咱們不會重複更新緩存,計算任務仍是會執行,依然沒達到緩存的效果,所以咱們須要一個可以在任務還沒啓動就能夠獲取結果句柄,同時可以自由控制任務的啓動、中止的東西。固然JDK裏也有這樣的東西(這裏插一句,JDK的concurrent包的代碼基本都是Doug Lea寫的,老頭子代碼寫的太牛逼了),就是FutureTask,既然如此,趕忙開擼:

public class BasicCache4<A,V> implements Compute<A, V>

{

private final ConcurrentMap<A, Future<V>> cache=new ConcurrentHashMap<>();

private final Compute<A, V> c;

 

public BasicCache4(Compute<A, V> c)

{

   this.c=c;

}

@Override

public V compute(final A args) throws Exception

{

   Future<V> ans=cache.get(args);

   if(ans==null)

   {

     Callable<V> computeTask=new Callable<V>()

     {

        @Override

        public V call() throws Exception

        {

           return c.compute(args);

        }

     };

     FutureTask<V> ft=new FutureTask<V>(computeTask);

     ans=cache.putIfAbsent(args, ft);

     if(ans==null)//

     {

        ans=ft;

        ft.run();

     }

   }

   return ans.get();

}

}

 

上面這段代碼中,咱們建立了一個FutureTask任務,可是並無當即執行這個異步任務,而是先調用ConcurrentHashMap的putIfAbsent方法來嘗試把結果句柄更新到緩存中去,這個方法的返回值是Map中的舊值,所以若是返回的是null,也就是說原來緩存中不存在,那咱們就啓動異步計算任務,而若是緩存中已經存在的話,咱們就直接調用緩存中的Future對象的get方法獲取計算結果,若是其餘請求中的計算任務尚未執行完畢的話,get方法會阻塞直到計算完成。實際運行效果見下圖:

至此,咱們算是構建了一個有效線程安全的緩存了,固然這個版本其實仍是會有不少問題,好比若是異步計算任務被取消的話,咱們應該循環重試,可是一方面咱們爲了簡單隻考慮了正常狀況,另外一方面FutureTask是局部變量,在線程棧層面已經保證了其餘線程或代碼沒法拿到該對象。最後用一張xmind圖做爲總結:

參考資料:《Java Concurrency in Practice》

相關文章
相關標籤/搜索