Java集合迭代時修改

Java集合迭代時修改

本文主要分如下幾個要點:

0)Java集合分類

1)對於熟悉JDK集合源碼的幫你加深對ConcurrentModificationException的下印象

2)對於迭代時修改提供一個正確的姿勢。

3)單線程和多線程環境下迭代時修改的方案

PS:本文不會詳細講解每個集合的源碼,也不會畫出集合的繼承關係(網上有太多詳細的講解和關係圖)我們從另一個角度來看下集合,看你是否真正理解集合(容器)。

集合(也叫容器)歸類

  1. 普通容器: List/Set/Map
  2. 同步容器:Vector/HashTable
  3. 併發容器:CopyOnWriteArrayList、ConcurrentHashMap、ArrayBlockQueue

java多線程與集合以及與你的應用程序的性能有着千絲萬縷的關係。

什麼是集合?

對java而言就是對一些數據結構如:數組、鏈表、隊列、棧、以及KV對, 進行增、刪、改、查、統計的內存操作,
我們都知道在內存中操作要比查詢數據庫寫文件性能高得多,集合就是裝你要做操數據的內存容器。

集合在框架中使用一定要謹慎,我們的應用大部分都是基於Spring的,那麼你的Controller也基本都是單例的,如果你在Controller中有個成員是集合,你的瀏覽器(本質是SocketClient)每次請求到你Contorller(web容器如tomtcat接收到請求後分配一個線程來調用你的Servlet,你的應用如果是SpringMvc的話DispatchServert會將請求Mapping到你的Contorller上),這樣就成了多個線程操作同一個集合了。

我們以List來舉例說明下這幾類結合的差別:

數組:有序可放入重複元素的同類型連續的內存區域。

幾乎沒什麼方法,只有幾個屬性,你可以想象下如果在特定位置刪除或者添加一個元素?

以添加的爲例:
檢查數組是否是滿了
1)滿了:換個大點的,特定位置的元素和原先老數組的全都放入到這個大的數組,
2)未滿:這個位置的元素之後的每個都往後移動一下,將新的元素插入進來。
總之寫代碼的話就是一大坨,重複性的

List就是就是爲了解決這個數組沒有方法的問題的,提供add、remove、迭代、統計
我們以迭代時修改爲例對比下這幾類集合。

單線程環境迭代修改

init

最原始的迭代刪除方式:
這裏寫圖片描述

單線程環境下都不能完美正常運行,最明顯的問題就是連續的集合值是符合條件的就少刪除,原因就是List中的數組的下標變化了【我用的是list1.size()方法】。解決方法也就顯而易見了:
這裏寫圖片描述
刪除的時候將下標減去1,保持下標是下一個真正要迭代的元素。

正確的姿勢:
這裏寫圖片描述

ps增強的for底層是Iterator,把for循環迭代看做是迭代iterator就行
這裏寫圖片描述

前面說過for和iterator迭代的方式是一樣的。可以看出我們這裏只是用iterator.remove和list.remove不同而已。
拋出的異常就是ConcurrentModificationException,看下它是怎麼出來的這個異常。

這裏寫圖片描述
只要expectedModeCount!=modCount就會拋出異常

這裏寫圖片描述

這裏寫圖片描述

每次迭代 即便是增強的for都會new Itr 所以這個expectedModeCount=modCount

在看下這個modCount是怎麼回事
這裏寫圖片描述

就是一個ArrayList的成員,什麼時候modCount的值會變化,add和remove方法都modCount++ 也就是容器被修改的時候會調用導致這個值發生變化,也就是說在迭代的過程中如果有容器被修改就會拋出這個異常。

用增強的for或者迭代器本身迭代的時候如果不是調用迭代器自身的remove方法,而是調用了list自身的方法的時候就會拋出ConcurrentModificationException異常。說到這這裏單線程環境下調用使用迭代就完了。

多線程環境迭代修改

3個方法

updateRef在修改集合中的應用,並沒有調用list的能使得mountCount值發生變化的

public class ListModifyGo {

    static List<User> list1 = null;
    static {
        list1 = new ArrayList<User>();
        list1.add(new User(0, "王五"));
        list1.add(new User(1, "張三"));
        list1.add(new User(2, "張三"));
        list1.add(new User(3, "李四"));
    }
//迭代結合
    void list() {
        for (User user : list1) {
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(user.getName());
        }
    }
//使用集合方法刪除某個元素,目的是引起mountCount++
    void update() {
        list1.remove(2);
    }
//不用引起mountCount++
    void updateRef() {
        User user = list1.get(3);
        user.setName(user.getName() + " update");
    }

    public static void main(String[] args) throws Exception {
        ListModifyGo listModifyGo = new ListModifyGo();

        new Thread(new Runnable() {

            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + ",listModifyGo.list();");
                listModifyGo.list();
            }
        }, "t1").start();

        new Thread(new Runnable() {

            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + ",listModifyGo.update();");
                listModifyGo.update();
            }
        }, "t2").start();
    }

}

這個類很簡單就是模擬兩個線程一個操作同一個對象static List< User > list1
一個線程在調用list一個在調用update 本來兩個方法互不干擾,但是在多線程環境下還是出現了我們不希望看到的ConcurrentModificationException異常,當然你可以在每個方法上加上synchronize,但這是你用容器的本質(提升訪問速度),這樣一來成了排隊了違反了你使用容器的本質(性能降低了)。

有人就可能說把List換成線程安全的Vector。答案其實是否定的

public class VectorModifyGo {

    static Vector<User> vector = null;
    static {
        vector = new Vector<User>();
        vector.add(new User(0, "王五"));
        vector.add(new User(1, "張三"));
        vector.add(new User(2, "張三"));
        vector.add(new User(3, "李四"));
    }

    // synchronized 將list()變成是原子操作
    void list() {
        for (User user : vector) {// next方法被多次調用,list()是複合操作
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(user.getName());
        }
    }

    void update() {
        vector.remove(2);
    }

    void updateRef() {
        User user = vector.get(3);
        user.setName(user.getName() + " update");
    }

    public static void main(String[] args) throws Exception {
        VectorModifyGo vectorModifyGo = new VectorModifyGo();

        new Thread(new Runnable() {

            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + ",vectorModifyGo.list();");
                vectorModifyGo.list();
            }
        }, "t1").start();

        new Thread(new Runnable() {

            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + ",vectorModifyGo.update();");
                vectorModifyGo.update();
            }
        }, "t2").start();
    }

}

我自己試過,也會看了下Vector源碼,只是在Vector相應的讀寫方法上加上了synchronized關鍵字,所有的線程走在搶佔Vectory對象的鎖this 但是他不能堅決此類問題,它可以解決諸如線程1在統計時候線程2不能修改(add、remove、某個下標的對象裏的值都不能修改)能做到互斥。

我說下這裏爲什麼不能,並且這個異常是隨機出現的
這裏寫圖片描述
這裏寫圖片描述

for迭代的時候也就是new Itr()這個步驟是互斥了將modeCount賦值成了初始值,但是new完之後退出了方法體沒有synchronized保護了,這個時候有可能線程2獲取鎖執行了remove方法modeCount++了,再迭代next方法此時就有問題。
如果線程2在remove搶佔鎖失敗即被迭代時每次next都能成功獲取鎖,這個時候就不會出現異常,這要是這個異常出現的偶然性。

這種場景是典型的複合操作,多個加鎖的方法被同一個方法,方法體內還是暴露了共享對象,這樣在多線程環境下還是有問題的。

解決方案1:
在類的list方法上在加上synchronized讓所有的next方法都在外層的然而這樣鎖上鎖會損失性能。

解決方案2:
使用java.util.concurrent併發包下的併發容器 注入CopyOnWriteArrayList
原理就是當線程1在迭代的時候迭代的當前數組
線程2修改的時候將當前數組拷貝一份進行迭代,然後將拷貝和修改之後的數組賦值給當前數組
所以線程1迭代的時候老的數據,有些人可能不明白爲什麼線程1迭代的時老的數據一張圖解析下:

這裏寫圖片描述

再多說一嘴,CopyOnWriteArrayList的成員array是被volatile修飾的線程2將引用賦值之後其他線程拷貝了應用之後都能感知到array的變化。但是由於線程1執行的list已經在使用原始array了,能感知到也沒有用了,而其他線程3如果剛進入方法執行list此時在如果還沒有使用這塊原始區域則還會重新從主存load即拷貝過後的array,這塊其實是java內存模型的之後可以關注下volatile的內存原語。

這種併發容器雖然能解決多線程環境操作同一個集合的情況,但是拷貝一份的代價其實也是很大的,所以更加適用於讀多寫少的場景。

順便貼一下代碼修改的時候能夠看到是賦值了一份:
這裏寫圖片描述

可以看到CopyOnWriteArrayList在執行修改數組的時候拷貝了一份並且加鎖了,我上圖中沒有表示出線程2
在修改時候是獨佔的,這裏補充下。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章