Java集合迭代時修改
本文主要分如下幾個要點:
0)Java集合分類
1)對於熟悉JDK集合源碼的幫你加深對ConcurrentModificationException的下印象
2)對於迭代時修改提供一個正確的姿勢。
3)單線程和多線程環境下迭代時修改的方案
PS:本文不會詳細講解每個集合的源碼,也不會畫出集合的繼承關係(網上有太多詳細的講解和關係圖)我們從另一個角度來看下集合,看你是否真正理解集合(容器)。
集合(也叫容器)歸類
- 普通容器: List/Set/Map
- 同步容器:Vector/HashTable
- 併發容器:CopyOnWriteArrayList、ConcurrentHashMap、ArrayBlockQueue
java多線程與集合以及與你的應用程序的性能有着千絲萬縷的關係。
什麼是集合?
對java而言就是對一些數據結構如:數組、鏈表、隊列、棧、以及KV對, 進行增、刪、改、查、統計的內存操作,
我們都知道在內存中操作要比查詢數據庫寫文件性能高得多,集合就是裝你要做操數據的內存容器。
集合在框架中使用一定要謹慎,我們的應用大部分都是基於Spring的,那麼你的Controller也基本都是單例的,如果你在Controller中有個成員是集合,你的瀏覽器(本質是SocketClient)每次請求到你Contorller(web容器如tomtcat接收到請求後分配一個線程來調用你的Servlet,你的應用如果是SpringMvc的話DispatchServert會將請求Mapping到你的Contorller上),這樣就成了多個線程操作同一個集合了。
我們以List來舉例說明下這幾類結合的差別:
數組:有序可放入重複元素的同類型連續的內存區域。
幾乎沒什麼方法,只有幾個屬性,你可以想象下如果在特定位置刪除或者添加一個元素?
以添加的爲例:
檢查數組是否是滿了
1)滿了:換個大點的,特定位置的元素和原先老數組的全都放入到這個大的數組,
2)未滿:這個位置的元素之後的每個都往後移動一下,將新的元素插入進來。
總之寫代碼的話就是一大坨,重複性的
List就是就是爲了解決這個數組沒有方法的問題的,提供add、remove、迭代、統計
我們以迭代時修改爲例對比下這幾類集合。
單線程環境迭代修改
最原始的迭代刪除方式:
單線程環境下都不能完美正常運行,最明顯的問題就是連續的集合值是符合條件的就少刪除,原因就是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
在修改時候是獨佔的,這裏補充下。