不會這些“高級貨”,活該你面試當炮灰...
今天聊一個非常硬核的技術知識,給大家分析一下 CopyOnWrite 思想是什么,以及在 Java 并發包中的具體體現,包括在 Kafka 內核源碼中是如何運用這個思想來優化并發性能的。
這個 CopyOnWrite 在面試的時候,很可能成為面試官的一個殺手锏把候選人給一擊必殺,也很有可能成為候選人拿下 Offer 的獨門秘籍,是相對高級的一個知識。
讀多寫少的場景下引發的問題?
大家可以設想一下現在我們的內存里有一個 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();
- }
大家想想,類似上面的代碼有什么問題呢?***的問題,其實就在于寫鎖和讀鎖的互斥。假設寫操作頻率很低,讀操作頻率很高,是寫少讀多的場景。
那么偶爾執行一個寫操作的時候,是不是會加上寫鎖,此時大量的讀操作過來是不是就會被阻塞住,無法執行?
這個就是讀寫鎖可能遇到的***的問題。
引入 CopyOnWrite 思想解決問題
這個時候就要引入 CopyOnWrite 思想來解決問題了。
它的思想就是,不用加什么讀寫鎖,鎖統統給我去掉,有鎖就有問題,有鎖就有互斥,有鎖就可能導致性能低下,你阻塞我的請求,導致我的請求都卡著不能執行。
那么它怎么保證多線程并發的安全性呢?很簡單,顧名思義,利用“CopyOnWrite”的方式,這個英語翻譯成中文,大概就是“寫數據的時候利用拷貝的副本來執行”。
你在讀數據的時候,其實不加鎖也沒關系,大家左右都是一個讀罷了,互相沒影響。
問題主要是在寫的時候,寫的時候你既然不能加鎖了,那么就得采用一個策略。
假如說你的 ArrayList 底層是一個數組來存放你的列表數據,那么這時比如你要修改這個數組里的數據,你就必須先拷貝這個數組的一個副本。
然后你可以在這個數組的副本里寫入你要修改的數據,但是在這個過程中實際上你都是在操作一個副本而已。
這樣的話,讀操作是不是可以同時正常的執行?這個寫操作對讀操作是沒有任何的影響的吧!
大家看下面的圖,一起來體會一下這個過程:
關鍵問題來了,那那個寫線程現在把副本數組給修改完了,現在怎么才能讓讀線程感知到這個變化呢?
關鍵點來了,劃重點!這里要配合上 Volatile 關鍵字的使用。
筆者之前寫過文章,給大家解釋過 Volatile 關鍵字的使用,核心就是讓一個變量被寫線程給修改之后,立馬讓其他線程可以讀到這個變量引用的最近的值,這就是 Volatile 最核心的作用。
所以一旦寫線程搞定了副本數組的修改之后,那么就可以用 Volatile 寫的方式,把這個副本數組賦值給 Volatile 修飾的那個數組的引用變量了。
只要一賦值給那個 Volatile 修飾的變量,立馬就會對讀線程可見,大家都能看到***的數組了。
下面是 JDK 里的 CopyOnWriteArrayList 的源碼:
- // 這個數組是核心的,因為用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();
- }
- }
大家看看寫數據的時候,他是怎么拷貝一個數組副本,然后修改副本,接著通過 Volatile 變量賦值的方式,把修改好的數組副本給更新回去,立馬讓其他線程可見的。
然后大家想,因為是通過副本來進行更新的,萬一要是多個線程都要同時更新呢?那搞出來多個副本會不會有問題?
當然不能多個線程同時更新了,這個時候就是看上面源碼里,加入了 Lock 鎖的機制,也就是同一時間只有一個線程可以更新。
那么更新的時候,會對讀操作有任何的影響嗎?絕對不會,因為讀操作就是非常簡單的對那個數組進行讀而已,不涉及任何的鎖。
而且只要他更新完畢對 Volatile 修飾的變量賦值,那么讀線程立馬可以看到***修改后的數組,這是 Volatile 保證的:
- private E get(Object[] a, int index) {
- // 最簡單的對數組進行讀取
- return (E) a[index];
- }
這樣就***解決了我們之前說的讀多寫少的問題。如果用讀寫鎖互斥的話,會導致寫鎖阻塞大量讀操作,影響并發性能。
但是如果用了 CopyOnWriteArrayList,就是用空間換時間,更新的時候基于副本更新,避免鎖,然后***用 Volatile 變量來賦值保證可見性,更新的時候對讀線程沒有任何的影響!
CopyOnWrite 思想在 Kafka 源碼中的運用
在 Kafka 的內核源碼中,有這么一個場景,客戶端在向 Kafka 寫數據的時候,會把消息先寫入客戶端本地的內存緩沖,然后在內存緩沖里形成一個 Batch 之后再一次性發送到 Kafka 服務器上去,這樣有助于提升吞吐量。
話不多說,大家看下圖:
這個時候 Kafka 的內存緩沖用的是什么數據結構呢?大家看源碼:
- private final ConcurrentMap<TopicPartition, Deque<RecordBatch>> batches =
- new CopyOnWriteMap<TopicPartition, Deque<RecordBatch>>();
這個數據結構就是核心的用來存放寫入內存緩沖中的消息的數據結構,要看懂這個數據結構需要對很多 Kafka 內核源碼里的概念進行解釋,這里先不展開。
但是大家關注一點,他是自己實現了一個 CopyOnWriteMap,這個CopyOnWriteMap 采用的就是 CopyOnWrite 思想。
我們來看一下這個 CopyOnWriteMap 的源碼實現:
- // 典型的volatile修飾普通Map
- private volatile Map<K, V> map;
- @Override
- public synchronized V put(K k, V v) {
- // 更新的時候先創建副本,更新副本,然后對volatile變量賦值寫回去
- Map<K, V> copy = new HashMap<K, V>(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 的底層源碼進一步向面試官進行闡述,面試官對你的印象肯定大大的加分。
中華石杉:十余年 BAT 架構經驗,一線互聯網公司技術總監。帶領上百人團隊開發過多個億級流量高并發系統。現將多年工作中積累下的研究手稿、經驗總結整理成文,傾囊相授。微信公眾號:石杉的架構筆記(ID:shishan100)。