List并發(fā)線程安全問題

一敏弃、發(fā)現(xiàn)并發(fā)問題

1.1 測試代碼
public class Client {
    public static void main(String[] args) {
        List<Integer> list = new ArrayList<>();

        new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                list.add(1);
            }
        },"A").start();

        new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                list.add(1);
            }
        },"B").start();

        new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                list.add(1);
            }
        },"C").start();

        try {
            TimeUnit.SECONDS.sleep(3);
        }catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("list.size() = " + list.size());
    }
}

開啟三個線程笛辟,每個線程向ArrayList中插入1w條數(shù)據(jù)羽利。之后等待三秒宫患,等到每個線程都執(zhí)行完畢時再查看ArrayList中的元素個數(shù)。運行結(jié)果:

Exception in thread "A" Exception in thread "B" java.lang.ArrayIndexOutOfBoundsException: 366
    at java.util.ArrayList.add(ArrayList.java:459)
    at Client.lambda$main$0(Client.java:15)
    at java.lang.Thread.run(Thread.java:748)
java.lang.ArrayIndexOutOfBoundsException: 1851
    at java.util.ArrayList.add(ArrayList.java:459)
    at Client.lambda$main$1(Client.java:21)
    at java.lang.Thread.run(Thread.java:748)
list.size() = 11680

Process finished with exit code 0
1.2 問題一:ArrayIndexOutOfBoundsException

我們先來看看ArrayList.add()的源碼

/**
 * Appends the specified element to the end of this list.
 *
 * @param e element to be appended to this list
 * @return <tt>true</tt> (as specified by {@link Collection#add})
 */
public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}

在多線程環(huán)境中時这弧,多個線程同時進入add()方法娃闲,同時檢查容量,例如當前容量為5匾浪,而已占用4皇帮。三個線程同時檢查,都發(fā)現(xiàn)還有容量户矢,則都同時添加元素玲献。由此導(dǎo)致ArrayIndexOutOfBoundsException。

1.3 問題二:實際插入元素個數(shù)小于預(yù)期插入元素個數(shù)

從運行結(jié)果可以看出梯浪,最終list.size()只有11680 <= 30000捌年。我們希望能夠插入30000個元素,可是實際上只插入了<= 30000個元素挂洛。還是從源碼入手:

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

試想一下礼预,如果多個線程同時向size位插入元素,且都沒有來得及size++虏劲,那么導(dǎo)致的結(jié)果就是多個元素被插入在了同一個位置托酸,相互抵消。

二柒巫、解決并發(fā)問題

2.1 使用Vector

早期励堡,IT前人為了解決List在并發(fā)時出現(xiàn)的問題,引入了Vector實現(xiàn)類堡掏。Vetor的實現(xiàn)方式與ArrayList大同小異应结,它的底層也是一個數(shù)組,在添加時自增長泉唁。我們先來看看Vector.add()的源碼

/**
 * Appends the specified element to the end of this Vector.
 *
 * @param e element to be appended to this Vector
 * @return {@code true} (as specified by {@link Collection#add})
 * @since 1.2
 */
public synchronized boolean add(E e) {
    modCount++;
    ensureCapacityHelper(elementCount + 1);
    elementData[elementCount++] = e;
    return true;
}

與ArrayList不同的是鹅龄,它的add()方法帶有synchronized關(guān)鍵字。這表明當線程調(diào)用該方法時亭畜,會自動占用鎖扮休,直到這個線程的任務(wù)完成,期間不會放棄該鎖拴鸵。而且當線程占有該鎖時玷坠,別的線程無法進入Vetor類調(diào)用帶有synchronized關(guān)鍵字的方法蜗搔。這很好的避免了多線程競爭的現(xiàn)象,從而保證了并發(fā)安全

我們現(xiàn)在將ArrayList換成Vetor再試試:

public class Client {
    public static void main(String[] args) {
        Vector<Integer> list = new Vector<>();

        new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                list.add(1);
            }
        },"A").start();

        new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                list.add(1);
            }
        },"B").start();

        new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                list.add(1);
            }
        },"C").start();

        try {
            TimeUnit.SECONDS.sleep(3);
        }catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("list.size() = " + list.size());  // 30000
    }
}
2.2 使用Collections.synchronizedList(List<T> list)

我們現(xiàn)在先將ArrayList換成Collections.synchronizedList(List<T> list)試試效果:

public class Client {
    public static void main(String[] args) {
        List<Integer> list = Collections.synchronizedList(new ArrayList<>());

        new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                list.add(1);
            }
        }, "A").start();

        new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                list.add(1);
            }
        }, "B").start();

        new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                list.add(1);
            }
        }, "C").start();

        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("list.size() = " + list.size());  // 30000
    }
}

我們通過Collections.synchronizedList(List<T> list)源碼來分析:

List<Object> list = Collections.synchronizedList(new ArrayList<>());

// Collections.synchronizedList 源碼
public static <T> List<T> synchronizedList(List<T> list) {
    return (list instanceof RandomAccess ?   //暫且不說
            new SynchronizedRandomAccessList<>(list) :
            new SynchronizedList<>(list));   //創(chuàng)建一個 SynchronizedList 實例
}

SynchronizedList(List<E> list) {
    super(list);   // 調(diào)用父類構(gòu)造器
    this.list = list;
}

SynchronizedCollection(Collection<E> c) {
    this.c = Objects.requireNonNull(c);   //要求傳入的 集合類實例 非空  并將這個集合賦值給 c 變量
    mutex = this;   // 將自己賦值給 互斥鎖變量
}

public static <T> T requireNonNull(T obj) {
    if (obj == null)
        throw new NullPointerException();   //為空則拋出異常
    return obj;
}

Collections.synchronizedList()方法會返回一個SynchronizedList類的實例侨糟,其中包含了調(diào)用該方法時傳入的集合碍扔,在構(gòu)造期間瘩燥,將SynchronizedCollection作為互斥鎖秕重。此時,當我們再調(diào)用add()方法:

public boolean add(E e) {
    synchronized (mutex) {  //鎖住 SynchronizedCollection 集合類
        return c.add(e);
    }
}

這是厉膀,當調(diào)用add()方法溶耘,SynchronizedCollection會鎖住自己,從而保證線程安全服鹅。當有線程正在使用mutex互斥鎖時凳兵,其他變量無法占有該鎖。

2.3 使用CopyOnWriteArrayList

我們現(xiàn)在先將ArrayList換成CopyOnWriteArrayList試試效果:

public class Client {
    public static void main(String[] args) {
        List<Integer> list = new CopyOnWriteArrayList<>();

        new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                list.add(1);
            }
        }, "A").start();

        new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                list.add(1);
            }
        }, "B").start();

        new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                list.add(1);
            }
        }, "C").start();

        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("list.size() = " + list.size());  // 30000
    }
}

我們通過CopyOnWriteArrayList源碼來分析:

List<String> copyOnWriteArrayList = new CopyOnWriteArrayList<>();

final transient ReentrantLock lock = new ReentrantLock();

private transient volatile Object[] array;  // volatile線程可見  底層數(shù)組

//調(diào)用構(gòu)造器
public CopyOnWriteArrayList() {
    setArray(new Object[0]);   //設(shè)置底層 array為一個空數(shù)組
}

//將傳入 array 設(shè)置給 底層 array
final void setArray(Object[] a) {
    array = a;
}


// 寫時
public boolean add(E e) {
    final ReentrantLock lock = this.lock;  // 獲取鎖對象
    lock.lock();   //鎖住
    try {
        Object[] elements = getArray();  //獲取到原有 數(shù)組
        int len = elements.length;  //獲取原有長度
        // 創(chuàng)建新數(shù)組為原有長度 + 1企软,將原有數(shù)據(jù)拷貝到新數(shù)組里庐扫,此時新數(shù)組有一個空位
        Object[] newElements = Arrays.copyOf(elements, len + 1);  
        // 向空位添加新元素
        newElements[len] = e;
        // 將新數(shù)組 替換掉 舊數(shù)組
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();   //解鎖
    }
}

// 獲取列表長度
public int size() {
    // 由于底層array線程可見,所以array一旦改變 size 也會被其他線程發(fā)現(xiàn)
    return getArray().length;  
}

final Object[] getArray() {
    return array;
}

一個寫時復(fù)制的List仗哨,寫操作時加鎖形庭,過程中創(chuàng)建一個新的數(shù)組長度為原來的數(shù)組+1,并將原有數(shù)組元素添加到新數(shù)組中厌漂,之后添加新元素到末尾萨醒。讀時不加鎖,底層數(shù)組被volatile修飾苇倡,線程可見富纸。他的核心就是:讀時不阻塞,大大提升了讀的速度旨椒。

2.3.1 線程可見的重要性

由于JVM中晓褪,棧空間是線程私有的综慎。而棧中存在一個局部變量表涣仿,用于存儲運行時需要的變量。在線程被開啟時寥粹,線程會將自己所需要的變量都拷貝到自己的棧內(nèi)存中变过。在循環(huán)過程中,局部變量表中的元素是無法感知到變量的改變的涝涤。簡單來說媚狰,線程在使用外部變量時,無法感知到變量的改變阔拳。

2.3.1.1 線程不可見的一個栗子
List<Integer> list = new CopyOnWriteArrayList<>();

list.add(0);

new Thread(() -> {
    while (list.get(0) == 0) {   //循環(huán)獲取 list 的 0 位元素

    }
    System.out.println("結(jié)束了");
}).start();


try {
    TimeUnit.SECONDS.sleep(3);
}catch (InterruptedException e) {
    e.printStackTrace();
}

list.add(0,1);   //三秒后修改 list 的 0 位元素

//  .... 程序無法結(jié)束  因為主線程對list的修改崭孤,線程內(nèi)部無法感知

跑起來后类嗤,可以發(fā)現(xiàn)程序無法結(jié)束。

2.3.1.2 使用CopyOnWriteArrayList線程可見的栗子
// 使用 CopyOnWriteArrayList
List<Integer> list = new CopyOnWriteArrayList<>();

list.add(0);

new Thread(() -> {
    while (list.get(0) == 0) {

    }
    System.out.println("結(jié)束了");
}).start();


try {
    TimeUnit.SECONDS.sleep(3);
}catch (InterruptedException e) {
    e.printStackTrace();
}

list.add(0,1);

//  .... 程序可以結(jié)束辨宠,因為CopyOnWriteArrayList底層數(shù)組被 volatile 修飾
//  所以是線程間可見的

三遗锣、3個并發(fā)集合容器性能比較

3.1 只讀10并發(fā)
3.1.1 測試代碼

Vector

public class RunTester {
    private static List<Integer> list = null;
    public static void main(String[] args) throws InterruptedException {
        list = new Vector<>();
        list.add(0);

        int theadNum = 10;

        CountDownLatch countDownLatch = new CountDownLatch(theadNum);
        long start = System.currentTimeMillis();
        for (int i = 0; i < theadNum; i++) {
            new Thread(() -> {
                for (int j = 0; j < 500000; j++) {
                    list.get(0);
                }
                countDownLatch.countDown();
            }).start();
        }
        countDownLatch.await();  //阻塞,直到執(zhí)行完畢
        long end = System.currentTimeMillis();

        //list.size() = 1  耗時 => 204 ms
        System.out.println("list.size() = " + list.size() + "  耗時 => " + (end - start) + " ms");
    }
}

SynchronizedCollection

public class RunTester {
    private static List<Integer> list = null;
    public static void main(String[] args) throws InterruptedException {
        list = Collections.synchronizedList(new ArrayList<>());
        list.add(0);

        int theadNum = 10;

        CountDownLatch countDownLatch = new CountDownLatch(theadNum);
        long start = System.currentTimeMillis();
        for (int i = 0; i < theadNum; i++) {
            new Thread(() -> {
                for (int j = 0; j < 500000; j++) {
                    list.get(0);
                }
                countDownLatch.countDown();
            }).start();
        }
        countDownLatch.await();  //阻塞嗤形,直到執(zhí)行完畢
        long end = System.currentTimeMillis();

        //list.size() = 1  耗時 => 205 ms
        System.out.println("list.size() = " + list.size() + "  耗時 => " + (end - start) + " ms");
    }
}

CopyOnWriteArrayList

public class RunTester {
    private static List<Integer> list = null;
    public static void main(String[] args) throws InterruptedException {
        list = new CopyOnWriteArrayList<>();
        list.add(0);

        int theadNum = 10;

        CountDownLatch countDownLatch = new CountDownLatch(theadNum);
        long start = System.currentTimeMillis();
        for (int i = 0; i < theadNum; i++) {
            new Thread(() -> {
                for (int j = 0; j < 500000; j++) {
                    list.get(0);
                }
                countDownLatch.countDown();
            }).start();
        }
        countDownLatch.await();  //阻塞精偿,直到執(zhí)行完畢
        long end = System.currentTimeMillis();

        //list.size() = 1  耗時 => 53 ms
        System.out.println("list.size() = " + list.size() + "  耗時 => " + (end - start) + " ms");
    }
}
3.1.2 分析
// Vector.get()
public synchronized E get(int index) {
    if (index >= elementCount)
        throw new ArrayIndexOutOfBoundsException(index);

    return elementData(index);
}

// Synchronized 集合 get()
public E get(int index) {
    synchronized (mutex) {
        return list.get(index);  //調(diào)用 List.get()方法
    }
}

// CopyOnWriteArrayList.get()
public E get(int index) {
    return get(getArray(), index);
}

private E get(Object[] a, int index) {
    return (E) a[index];
}

從運行耗時來看,CopyOnWriteArrayList的性能比其他兩個好得多赋兵。從源碼來看笔咽,Vector和Synchronized集合類都使用到了synchronized關(guān)鍵字,鎖住了整個方法霹期。這樣使得多條線程并發(fā)訪問get()方法時叶组,只會同時有一個線程在真正調(diào)用get()方法。而CopyOnWriteArrayList的get()方法沒有使用到鎖历造,所以所有線程都是一起訪問的甩十,所以它的性能更好。

3.2 只寫10并發(fā)
3.2.1 測試代碼

Vector

public class RunTester {
    private static List<Integer> list = null;
    public static void main(String[] args) throws InterruptedException {
        list = new Vector<>();
        list.add(0);

        int theadNum = 10;

        CountDownLatch countDownLatch = new CountDownLatch(theadNum);
        long start = System.currentTimeMillis();
        for (int i = 0; i < theadNum; i++) {
            new Thread(() -> {
                for (int j = 0; j < 500000; j++) {
                    list.add(0);
                }
                countDownLatch.countDown();
            }).start();
        }
        countDownLatch.await();  //阻塞吭产,直到執(zhí)行完畢
        long end = System.currentTimeMillis();

        //list.size() = 5000001  耗時 => 228 ms
        System.out.println("list.size() = " + list.size() + "  耗時 => " + (end - start) + " ms");
    }
}

SynchronizedCollection

public class RunTester {
    private static List<Integer> list = null;
    public static void main(String[] args) throws InterruptedException {
        list = Collections.synchronizedList(new ArrayList<>());
        list.add(0);

        int theadNum = 10;

        CountDownLatch countDownLatch = new CountDownLatch(theadNum);
        long start = System.currentTimeMillis();
        for (int i = 0; i < theadNum; i++) {
            new Thread(() -> {
                for (int j = 0; j < 500000; j++) {
                    list.add(0);
                }
                countDownLatch.countDown();
            }).start();
        }
        countDownLatch.await();  //阻塞侣监,直到執(zhí)行完畢
        long end = System.currentTimeMillis();

        //list.size() = 5000001  耗時 => 260 ms
        System.out.println("list.size() = " + list.size() + "  耗時 => " + (end - start) + " ms");
    }
}

CopyOnWriteArrayList

public class RunTester {
    private static List<Integer> list = null;
    public static void main(String[] args) throws InterruptedException {
        list = new CopyOnWriteArrayList<>();
        list.add(0);

        int theadNum = 10;

        CountDownLatch countDownLatch = new CountDownLatch(theadNum);
        long start = System.currentTimeMillis();
        for (int i = 0; i < theadNum; i++) {
            new Thread(() -> {
                for (int j = 0; j < 500000; j++) {
                    list.add(0);
                }
                countDownLatch.countDown();
            }).start();
        }
        countDownLatch.await();  //阻塞,直到執(zhí)行完畢
        long end = System.currentTimeMillis();

        //大于10分鐘  不想等了
        System.out.println("list.size() = " + list.size() + "  耗時 => " + (end - start) + " ms");
    }
}
3.2.2 分析

從運行結(jié)果可以看出垮刹,CopyOnWriteArrayList的寫性能达吞,實在是太差了,這是為什么呢荒典?我們來看看源碼:

// CopyOnWriteArrayList.add()
public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();  //獲取舊數(shù)組
        int len = elements.length;  //長度
        //創(chuàng)建一個原來數(shù)組長度 + 1 的新數(shù)組酪劫,復(fù)制原有內(nèi)容到新數(shù)組
        Object[] newElements = Arrays.copyOf(elements, len + 1);  
        //添加一個元素
        newElements[len] = e;
        //替換掉舊數(shù)組
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}

CopyOnWriteArrayList使用寫時復(fù)制。添加新元素時寺董,每次僅擴容1覆糟,然后將舊數(shù)組內(nèi)容拷貝到新數(shù)組中,所以它的擴容遮咖,是每次都會觸發(fā)的滩字。這也是導(dǎo)致寫性能差最主要的原因。但是為什么要只擴容1呢御吞?為何不像Vector一樣麦箍,每次擴容兩倍呢?我猜測陶珠,這應(yīng)該是為了避免維護一個size變量挟裂,size表示當前實際存入元素的個數(shù)。而每次擴容1揍诽,那么數(shù)組的長度就是size诀蓉,所以省去了size的并發(fā)修改栗竖。簡單來說,強悍的讀取性能渠啤,是犧牲寫入性能換來的狐肢。所以這也表明,CopyOnWriteArrayList容器雖好沥曹,可不要濫用份名。在寫多于讀的情況下,它的性能甚至比其他兩個并發(fā)容器都要差得多架专。

轉(zhuǎn)載自:List線程安全問題

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末同窘,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子部脚,更是在濱河造成了極大的恐慌,老刑警劉巖裤纹,帶你破解...
    沈念sama閱讀 211,194評論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件委刘,死亡現(xiàn)場離奇詭異,居然都是意外死亡鹰椒,警方通過查閱死者的電腦和手機锡移,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,058評論 2 385
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來漆际,“玉大人淆珊,你說我怎么就攤上這事〖榛悖” “怎么了施符?”我有些...
    開封第一講書人閱讀 156,780評論 0 346
  • 文/不壞的土叔 我叫張陵,是天一觀的道長擂找。 經(jīng)常有香客問我戳吝,道長,這世上最難降的妖魔是什么贯涎? 我笑而不...
    開封第一講書人閱讀 56,388評論 1 283
  • 正文 為了忘掉前任听哭,我火速辦了婚禮,結(jié)果婚禮上塘雳,老公的妹妹穿的比我還像新娘陆盘。我一直安慰自己,他們只是感情好败明,可當我...
    茶點故事閱讀 65,430評論 5 384
  • 文/花漫 我一把揭開白布隘马。 她就那樣靜靜地躺著,像睡著了一般肩刃。 火紅的嫁衣襯著肌膚如雪祟霍。 梳的紋絲不亂的頭發(fā)上杏头,一...
    開封第一講書人閱讀 49,764評論 1 290
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死恋谭,一個胖子當著我的面吹牛蔑歌,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播趟径,決...
    沈念sama閱讀 38,907評論 3 406
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了棘伴?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,679評論 0 266
  • 序言:老撾萬榮一對情侶失蹤屁置,失蹤者是張志新(化名)和其女友劉穎焊夸,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體蓝角,經(jīng)...
    沈念sama閱讀 44,122評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡阱穗,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,459評論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了使鹅。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片揪阶。...
    茶點故事閱讀 38,605評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖患朱,靈堂內(nèi)的尸體忽然破棺而出鲁僚,到底是詐尸還是另有隱情,我是刑警寧澤裁厅,帶...
    沈念sama閱讀 34,270評論 4 329
  • 正文 年R本政府宣布冰沙,位于F島的核電站,受9級特大地震影響姐直,放射性物質(zhì)發(fā)生泄漏倦淀。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,867評論 3 312
  • 文/蒙蒙 一声畏、第九天 我趴在偏房一處隱蔽的房頂上張望撞叽。 院中可真熱鬧,春花似錦插龄、人聲如沸愿棋。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,734評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽糠雨。三九已至,卻和暖如春徘跪,著一層夾襖步出監(jiān)牢的瞬間甘邀,已是汗流浹背琅攘。 一陣腳步聲響...
    開封第一講書人閱讀 31,961評論 1 265
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留松邪,地道東北人坞琴。 一個月前我還...
    沈念sama閱讀 46,297評論 2 360
  • 正文 我出身青樓,卻偏偏與公主長得像逗抑,于是被迫代替她去往敵國和親剧辐。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 43,472評論 2 348

推薦閱讀更多精彩內(nèi)容