【多線程併發編程】十二 CopyOnWriteArrayList源碼分析

程序猿學社的GitHub,歡迎Star
https://github.com/ITfqyd/cxyxs
本文已記錄到github,形成對應專題。

前言:

在學習多線程的過程中,我們經常會聽到ArrayList線程不安全。有個別社友就在說,我們在項目中用的好好的,也沒有什麼報錯,實際上,大部分的人,在項目開發過程中,確實很少接觸到這塊。來我們一探究竟唄。看看爲什麼說ArrayList線程不安全。

1.淺談ArrayList

一文了解ArrayList源碼及擴容
在多線程環境下,操作同一資源,會有各種各樣的問題出現,例如,在多線程環境下,操作ArrayList進行添加操作,就會出現java.util.ConcurrentModificationException的異常。我們常說某某是不是線程安全,實際上,是有一個前提的,是在多線程環境裏,因爲單線程過程中不會存在線程不安全的問題。

package com.cxyxs.thread.twelve;

import java.lang.reflect.Array;
import java.util.*;
import java.util.concurrent.TimeUnit;

/**
 * Description:轉發請註明來源  程序猿學社 - https://ithub.blog.csdn.net/
 * Author: 程序猿學社
 * Date:  2020/2/20 20:00
 * Modified By:
 */
public class Demo1 {
    public static void main(String[] args){
        List<Integer> lists = new ArrayList<>();
        for (int i = 0; i < 50; i++) {
            new Thread(()->{
                for (int j = 0; j < 50; j++) {
                    lists.add(j);
                    try {
                        TimeUnit.MILLISECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            },"A").start();
        }

       while (true){
           if (Thread.activeCount() == 2){
               System.out.println(lists.size());
               return;
           }
       }
        //Thread.currentThread().getThreadGroup().list();
    }
}

  • TimeUnit.MILLISECONDS.sleep(1) 線程休眠一毫秒,不要使用 Thread.sleep(1),這兩句代碼都是一樣的意思,我們應該儘量使用juc下的內容。
  • 50個線程,打印50次,每次休眠1毫秒,鬼知道什麼時候能打完,看到社長之前的文章的社友,應該都知道,啓動main方法後,會有兩個線程一直處於運行狀態,所以,我們只需要判斷正在運行的線程數是不是爲2,就可以知道子線程的業務邏輯是否已經跑完,因爲run執行完後,線程會進入死亡狀態。

讀過小學的,都知道50*50,是2500,爲什麼打印的結果是2463?

  • 這就是爲什麼說ArrayList是線程不安全的原因。

話不多說,上ArrayList的add源碼

private int size;
public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // Increments modCount!!
        elementData[size++] = e;
        return true;
    }

size++就是線程不安全的,需要大家瞭解JMM的可見性,不瞭解可以大致看看我之前的文章。
【多線程併發編程】六 什麼是線程安全?

  • 假設size爲10,主內存會存size=10,其他的線程,會copy一份,放到各自的工作內存裏面,線程A,發現size爲10,所以+1後,size就會11了,注意,這時候線程B,發現size也是10,也執行+1操作,size變成11,線程A和線程B會把操作的結果通知給主內存,所以主內存的值變成11,實際上,應該爲12纔對。

2.解決線程不安全問題

使用Vector

package com.cxyxs.thread.twelve;

import java.lang.reflect.Array;
import java.util.*;
import java.util.concurrent.TimeUnit;

/**
 * Description:轉發請註明來源  程序猿學社 - https://ithub.blog.csdn.net/
 * Author: 程序猿學社
 * Date:  2020/2/20 20:00
 * Modified By:
 */
public class Demo1 {
    public static void main(String[] args){
        //線程不安全
        //List<Integer> lists = new ArrayList<>();
        // 優化方案1
        List<Integer> lists = new Vector<>();
        for (int i = 0; i < 50; i++) {
            new Thread(()->{
                for (int j = 0; j < 50; j++) {
                    lists.add(j);
                    try {
                        Thread.sleep(1);
                        TimeUnit.MILLISECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            },"A").start();
        }

       while (true){
           if (Thread.activeCount() == 2){
               System.out.println(lists.size());
               return;
           }
       }
        //Thread.currentThread().getThreadGroup().list();
    }
}

  • 社長運行了幾次,每次的結果都是2500,說明線程不安全的問題,已經解決。
    我們來看一看Vector源碼,來看看他是怎麼解決線程不安全問題的。
    使用ctrl+F12(idea),輸入add快速定位到這個方法
public synchronized boolean add(E e) {
        modCount++;
        ensureCapacityHelper(elementCount + 1);
        elementData[elementCount++] = e;
        return true;
    }
  • synchronized 關鍵字,上鎖,意思同一時刻只能有一個線程,能操作add方法。類似於,有一個茅坑,裏面有幾個坑,有一個怪癖,每次上茅坑,都把大門都關起來。造成資源的浪費,其他的人想上茅坑,只能等隔壁老王上完才能使用。所以這種方式,不怎麼建議使用,根據場景,自己合理選擇。
  • modCount看起來很陌生,他表示結構被修改的次數。新增,修改等modCount都會增加1

多線程開發經常遇到的報錯ConcurrentModificationException

之前在開發過程中,經常發現有ConcurrentModificationException這個報錯。
看名字其義,意思就是多線程修改異常,也就說多個線程對同一份資源進行寫操作會有這種異常報錯。

final void checkForComodification() {
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
}

我們看一看哪裏調用了這個方法

 public E next() {
            synchronized (Vector.this) {
                checkForComodification();
                int i = cursor;
                if (i >= elementCount)
                    throw new NoSuchElementException();
                cursor = i + 1;
                return elementData(lastRet = i);
            }
        }
  • 發現迭代器next方法會檢查修改的次數。調用Itr時,會把modCount賦值給expectedModCount。因爲modCount涉及到修改刪除等操作時,就會變動。
public synchronized Iterator<E> iterator() {
        return new Itr();
}
  • list的循環輸出iterator方法,就是上就是new Itr()

疑問

本人在調用add時,發現modCount每次調用add就會+1,但是expectedModCount一直沒有變,就有點困惑。個人感覺應該跟這些類的add方法應該有關係。對這方面瞭解很深的大佬,可以在下方留言

使用Collections集合類

  //優化方案二:
 List<Integer> lists =Collections.synchronizedList(new ArrayList<>());
  • 經過多次測試,結果都是2500.
  • 有了synchronized,爲什麼還用弄出一個Collections,就是爲了方便我們開發,使我們更關注於業務邏輯的開發。

CopyOnWriteArrayList

package com.cxyxs.thread.twelve;

import java.lang.reflect.Array;
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;

/**
 * Description:轉發請註明來源  程序猿學社 - https://ithub.blog.csdn.net/
 * Author: 程序猿學社
 * Date:  2020/2/20 20:00
 * Modified By:
 */
public class Demo1 {
    public static void main(String[] args){
        //線程不安全
        //List<Integer> lists = new ArrayList<>();
        // 優化方案1
        //List<Integer> lists = new Vector<>();
        //優化方案二:
        //List<Integer> lists =Collections.synchronizedList(new ArrayList<>());
        //優化方案三
        List<Integer> lists = new CopyOnWriteArrayList();

        for (int i = 0; i < 50; i++) {
            new Thread(()->{
                for (int j = 0; j < 50; j++) {
                    lists.add(j);
                    try {
                        Thread.sleep(1);
                        TimeUnit.MILLISECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            },"A").start();
        }

       while (true){
           if (Thread.activeCount() == 2){
               System.out.println(lists.size());
               return;
           }
       }
        //Thread.currentThread().getThreadGroup().list();
    }
}

通過多次測試,發現結果爲2500.

  • CopyOnWriteArrayList是一個一個線程安全ArrayList,其中所有可變操作( add , set ,等等)通過對底層數組的最新副本實現
    瞭解CopyOnWriteArrayList,我們首先需要先了解copyonwrite機制.

寫入時複製(CopyOnWrite,簡稱COW)思想是計算機程序設計領域中的一種優化策略。其核心思想是,如果有多個調用者(callers)同時請求相同資源(如內存或磁盤上的數據存儲),他們會共同獲取相同的指針指向相同的資源,直到某個調用者試圖修改資源的內容時,系統纔會真正複製一份專用副本(private copy)給該調用者,而其他調用者所見到的最初的資源仍然保持不變。優點是如果調用者沒有修改該資源,就不會有副本(private copy)被建立,因此多個調用者只是讀取操作時可以共享同一份資源。
我們通過源碼來看一看,jdk大佬是如何實現CopyOnWriteArrayList的add方法的。

 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;
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }
    }
  • 看到我們熟悉的人"lock",爲了避免死鎖,jdk官方的代碼就是跟try finally配套使用的,也是爲了try裏面的代碼報錯,鎖沒有釋放。
  • 這簡單很簡潔,上鎖,獲取原來數組內容,再在原來數組長度上增加1,產生一個新的數組,並給最後一個元素複製。最後,再set進去。
  • 看了這個代碼後,如果多個線程在操作過程中,我們讀,會讀到舊數據,這也是CopyOnWrite,寫入時複製,實際上換一個說法,就是讀寫分離。
    我們看了CopyOnWriteArrayList的add方法,我們來看一看,他的iterator方法
 public ListIterator<E> listIterator() {
        return new COWIterator<E>(getArray(), 0);
    }
   static final class COWIterator<E> implements ListIterator<E> {
        /** Snapshot of the array */
        private final Object[] snapshot;
        /** Index of element to be returned by subsequent call to next.  */
        private int cursor;

        private COWIterator(Object[] elements, int initialCursor) {
            cursor = initialCursor;
            snapshot = elements;
        }  

     @SuppressWarnings("unchecked")
        public E next() {
            if (! hasNext())
                throw new NoSuchElementException();
            return (E) snapshot[cursor++];
        } 
}
  • 不知道大家注意到沒有,我們可以發現調用iterator,實際上都用的是另外一個類,實際上就是把外面的object,複製給裏面這個類的數組。所以,個人認爲,讀到的也是舊的數據(多線程環境下)。
  • 看了ArrayList,Vetoct,CopyOnWriteArrayList等等一些源碼,發現他們都是沒有直接迭代輸出的方法,都是藉助一個類實現ListIterator接口,大致都是這種套路。
    總結:CopyOnWrite適用於讀多寫少的場景。因爲我們知道每次調用寫操作,都會重新開闢一個數組。對頻繁的寫操作,性能十分的底下。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章