非常硬核的技術知識-CopyOnWrite思想

連理枝_發表於2019-06-04

“ 今天聊一個非常硬核的技術知識,給大家分析一下CopyOnWrite思想是什麼,以及在Java併發包中的具體體現,包括在Kafka核心原始碼中是如何運用這個思想來優化併發效能的。

這個CopyOnWrite在面試的時候,很可能成為面試官的一個殺手鐗把候選人給一擊必殺,也很有可能成為候選人拿下Offer的獨門祕籍,是相對高階的一個知識。

1、讀多寫少的場景下引發的問題?

大家可以設想一下現在我們的記憶體裡有一個ArrayList,這個ArrayList預設情況下肯定是執行緒不安全的,要是多個執行緒併發讀和寫這個ArrayList可能會有問題。

好,問題來了,我們應該怎麼讓這個ArrayList變成執行緒安全的呢?

有一個非常簡單的辦法,對這個ArrayList的訪問都加上執行緒同步的控制。

比如說一定要在synchronized程式碼段來對這個ArrayList進行訪問,這樣的話,就能同一時間就讓一個執行緒來操作它了,或者是用ReadWriteLock讀寫鎖的方式來控制,都可以。

我們假設就是用ReadWriteLock讀寫鎖的方式來控制對這個ArrayList的訪問。

這樣多個讀請求可以同時執行從ArrayList裡讀取資料,但是讀請求和寫請求之間互斥,寫請求和寫請求也是互斥的。

大家看看,程式碼大概就是類似下面這樣:

public Object  read() {
   lock.readLock().lock();
   // 對ArrayList讀取
   lock.readLock().unlock();
}

public void write() {
   lock.writeLock().lock();
   // 對ArrayList寫
   lock.writeLock().unlock();
}
複製程式碼

大家想想,類似上面的程式碼有什麼問題呢?

最大的問題,其實就在於寫鎖和讀鎖的互斥。假設寫操作頻率很低,讀操作頻率很高,是寫少讀多的場景。

那麼偶爾執行一個寫操作的時候,是不是會加上寫鎖,此時大量的讀操作過來是不是就會被阻塞住,無法執行?

這個就是讀寫鎖可能遇到的最大的問題。

2、引入 CopyOnWrite 思想解決問題

這個時候就要引入CopyOnWrite思想來解決問題了。

他的思想就是,不用加什麼讀寫鎖,鎖統統給我去掉,有鎖就有問題,有鎖就有互斥,有鎖就可能導致效能低下,你阻塞我的請求,導致我的請求都卡著不能執行。

那麼他怎麼保證多執行緒併發的安全性呢?

很簡單,顧名思義,利用“CopyOnWrite”的方式,這個英語翻譯成中文,大概就是**“寫資料的時候利用拷貝的副本來執行”。**

你在讀資料的時候,其實不加鎖也沒關係,大家左右都是一個讀罷了,互相沒影響。

問題主要是在寫的時候,寫的時候你既然不能加鎖了,那麼就得采用一個策略。

假如說你的ArrayList底層是一個陣列來存放你的列表資料,那麼這時比如你要修改這個陣列裡的資料,你就必須先拷貝這個陣列的一個副本。

然後你可以在這個陣列的副本里寫入你要修改的資料,但是在這個過程中實際上你都是在操作一個副本而已。

這樣的話,讀操作是不是可以同時正常的執行?這個寫操作對讀操作是沒有任何的影響的吧!

大家看下面的圖,一起來體會一下這個過程:

非常硬核的技術知識-CopyOnWrite思想
關鍵問題來了,那那個寫執行緒現在把副本陣列給修改完了,現在怎麼才能讓讀執行緒感知到這個變化呢?

關鍵點來了,劃重點!這裡要配合上volatile關鍵字的使用。

筆者之前寫過文章,給大家解釋過volatile關鍵字的使用,核心就是讓一個變數被寫執行緒給修改之後,立馬讓其他執行緒可以讀到這個變數引用的最近的值,這就是volatile最核心的作用。

所以一旦寫執行緒搞定了副本陣列的修改之後,那麼就可以用volatile寫的方式,把這個副本陣列賦值給volatile修飾的那個陣列的引用變數了。

只要一賦值給那個volatile修飾的變數,立馬就會對讀執行緒可見,大家都能看到最新的陣列了。

下面是JDK裡的 CopyOnWriteArrayList 的原始碼。

大家看看寫資料的時候,他是怎麼拷貝一個陣列副本,然後修改副本,接著通過volatile變數賦值的方式,把修改好的陣列副本給更新回去,立馬讓其他執行緒可見的。

// 這個陣列是核心的,因為用volatile修飾了
  // 只要把最新的陣列對他賦值,其他執行緒立馬可以看到最新的陣列
  private transient volatile Object[] array;

  public boolean add(E e) {

      final ReentrantLock lock = this.lock;
      lock.lock();

      try {
          Object[] elements = getArray();
          int len = elements.length;

          // 對陣列拷貝一個副本出來
          Object[] newElements = Arrays.copyOf(elements, len + 1);

          // 對副本陣列進行修改,比如在裡面加入一個元素
          newElements[len] = e;

          // 然後把副本陣列賦值給volatile修飾的變數
          setArray(newElements);
          return true;


      } finally {
          lock.unlock();
      }
  }
複製程式碼

然後大家想,因為是通過副本來進行更新的,萬一要是多個執行緒都要同時更新呢?那搞出來多個副本會不會有問題?

當然不能多個執行緒同時更新了,這個時候就是看上面原始碼裡,加入了lock鎖的機制,也就是同一時間只有一個執行緒可以更新。

那麼更新的時候,會對讀操作有任何的影響嗎?

絕對不會,因為讀操作就是非常簡單的對那個陣列進行讀而已,不涉及任何的鎖。而且只要他更新完畢對volatile修飾的變數賦值,那麼讀執行緒立馬可以看到最新修改後的陣列,這是volatile保證的。

這樣就完美解決了我們之前說的讀多寫少的問題。

如果用讀寫鎖互斥的話,會導致寫鎖阻塞大量讀操作,影響併發效能。

但是如果用了CopyOnWriteArrayList,就是用空間換時間,更新的時候基於副本更新,避免鎖,然後最後用volatile變數來賦值保證可見性,更新的時候對讀執行緒沒有任何的影響!

3、CopyOnWrite 思想在Kafka原始碼中的運用

在Kafka的核心原始碼中,有這麼一個場景,客戶端在向Kafka寫資料的時候,會把訊息先寫入客戶端本地的記憶體緩衝,然後在記憶體緩衝裡形成一個Batch之後再一次性傳送到Kafka伺服器上去,這樣有助於提升吞吐量。

話不多說,大家看下圖:

非常硬核的技術知識-CopyOnWrite思想
這個時候Kafka的記憶體緩衝用的是什麼資料結構呢?大家看原始碼:

private final ConcurrentMap<topicpartition, deque<="" span="">

         batches = new CopyOnWriteMap<TopicPartition, Deque>();
複製程式碼

這個資料結構就是核心的用來存放寫入記憶體緩衝中的訊息的資料結構,要看懂這個資料結構需要對很多Kafka核心原始碼裡的概念進行解釋,這裡先不展開。

但是大家關注一點,他是自己實現了一個CopyOnWriteMap,這個CopyOnWriteMap採用的就是CopyOnWrite思想。

我們來看一下這個CopyOnWriteMap的原始碼實現:

  // 典型的volatile修飾普通Map
  private volatile Mapmap;

  @Override
  public synchronized V put(K k, V v) {

      // 更新的時候先建立副本,更新副本,然後對volatile變數賦值寫回去
      Mapcopy= new HashMap(this.map);
      V prev = copy.put(k, v);
      this.map = Collections.unmodifiableMap(copy);
      return prev;
  }

  @Override
  public V get(Object k) {

      // 讀取的時候直接讀volatile變數引用的map資料結構,無需鎖
      return map.get(k);

  }
複製程式碼

所以Kafka這個核心資料結構在這裡之所以採用CopyOnWriteMap思想來實現,就是因為這個Map的key-value對,其實沒那麼頻繁更新。

也就是TopicPartition-Deque這個key-value對,更新頻率很低。

但是他的get操作卻是高頻的讀取請求,因為會高頻的讀取出來一個TopicPartition對應的Deque資料結構,來對這個佇列進行入隊出隊等操作,所以對於這個map而言,高頻的是其get操作。

這個時候,Kafka就採用了CopyOnWrite思想來實現這個Map,避免更新key-value的時候阻塞住高頻的讀操作,實現無鎖的效果,優化執行緒併發的效能。

相信大家看完這個文章,對於CopyOnWrite思想以及適用場景,包括JDK中的實現,以及在Kafka原始碼中的運用,都有了一個切身的體會了。

如果你能在面試時說清楚這個思想以及他在JDK中的體現,並且還能結合知名的開源專案 Kafka 的底層原始碼進一步向面試官進行闡述,面試官對你的印象肯定大大的加分。

原文自:石杉的架構筆記微信公眾號

相關文章