本文目錄
一.題是什么題?
二.阿里Java開發規范。
2.1 正例代碼。
2.2 反例代碼。
三.層層揭秘,為什么發生異常了呢?
3.1 第一層:異常信息解讀。
3.2 第二層:拋出異常的條件解讀。
3.3 第三層:什么是modCount?它是干啥的?什么時候發生變化?
3.4 第四層:什么是expectedModCount?它是干啥的?什么時候發生變化?
3.5 第五層:組裝線索,直達真相。
四.這題的坑在哪?
4.1 回頭再看。
4.2 還有一個騷操作。
五.線程安全版的ArrayList。
六.總結一下。
七.回答另外一個面試題。
八.擴展閱讀。
7.1 fail-fast機制和safe-fast機制。
7.2 Java語法糖。
7.3 阿里Java開發手冊。
一.題是什么題?
我第一次遇到這個題的時候,是在一個微信群里,阿里著名的"Java勸退師"小馬哥拋出了這樣的一個問題:

然后大家紛紛給出了自己的見解(注:刪除了部分聊天記錄):

后面在另外的群里聊天的時候(注:刪除了部分聊天記錄),我也拋出了這樣的問題:

總結一下圖片中的各種回答:
1.什么也不會發生,remove之后,list中的數據會被清空。
2.remove的方法調用錯誤,入參應該是index(數組下標)。
3.並發操作的時候會出現異常。
4.會發生ConcurrentModifyException。
你的答案又是什么呢?
在這里,我先不說正確的答案是什么,也先不評價這些回答是對是錯,我們一起去探索真相,尋找答案。
二.阿里Java開發規范
有人看到題的第一眼(沒有認真讀題),就想起了阿里java開發手冊(先入為主),里面是這樣說的:

正是因為大多數人都知道並且讀過這個規范(畢竟是業界權威)。所以呼聲最高的答案是【會發生ConcurrentModifyException】。因為他們知道阿里java開發手冊里面是強制要求:
不要在foreach循環里面進行元素的remove/add操作。remove元素請使用Iterator方式,如果並發操作,需要對Iterator對象加鎖。
但是不能因為他是權威,我們就全盤接受吧?
2.1 正例代碼
所以我們眼見為實,先把手冊里面提到的【正例代碼】跑一下,如下:

細心的讀者可能發現了:咦,這個代碼的22行為啥顏色不一樣呢?
我幫你看看。

替換之后的代碼是這樣的:

從上面我們可以得到一個結論.......
等等,到這一步你就想得到結論了?你不對【一行代碼為什么就替換了七行代碼】好奇嗎?
看到真相的時候,有時候再往前一步就是本質了。
源碼之下無秘密,我再送你一張圖,JDK1.8中Collection.removeIf的源碼:

好了,已經到源碼級別了,從這里我們驗證了,阿里java開發手冊里面的正例是對的,而且我還想給他加上一句:
如果你的JDK版本是1.8以上,沒有並發訪問的情況下,可以使用Collection.removeIf(Predicate<? super E> filter)方法。使代碼更加優雅。
2.2 反例代碼
接下來我們看看【反例代碼】的運行結果:

從執行結果來看,和我們預期的結果是一致。看着沒有問題呀?
但是你別忘了,下面還有一句話啊:

我們執行試一試:

什么情況?真的是"出乎意料"啊!

把刪除元素的條件從【公眾號】修改為【why技術】就發生了異常:
java.util.ConcurrentModificationException
三.層層揭秘,為什么發生了異常呢?
我們現在明白為什么阿里強制要求不要在foreach循環里面進行元素的remove/add操作,因為會發生異常了。
但是開發手冊里面並沒有告訴你,為什么會發生異常。需要我們自己層層深入,積極探索。
3.1 第一層:異常信息解讀
所以這一小節我們就一起探索,為什么會發生異常。我們再解析一下程序的運行結果,如下:

正如上圖里面異常信息的體現,異常是在代碼的第21行觸發的。而代碼的第21行,是一個foreach循環。foreach循環是Java的語法糖,我們可以從編譯后的class文件中看出,如下圖所示:

請注意圖中的第26行代碼:
list.remove(item) (這句話很關鍵!!!)
很關鍵,很重要,后面會講到。
這也解釋了,異常信息里面的這一個問題:

好了,到這一步,我們把異常信息都解讀完畢了。
3.2 第二層:拋出異常的條件解讀
我再看看真實拋出異常的那一個方法:

很簡單,很清晰的四行代碼。拋出異常的條件是:
modCount !=expectedModCount
所以,我們需要解開的下兩層面紗就是下面兩大點:
第一:什么是modCount?它是干啥的?什么時候發生變化?
第二:什么是expectedModCount?它是干啥的?什么時候發生變化?
3.3 第三層:什么是modCount?它是干啥的?什么時候發生變化?
先來第一個:什么是modCount?

modCount上的注釋很長,我只截取了最后一段。在這一段中,提到了兩個關鍵點。
1.modCount這個字段位於java.util.AbstractList抽象類中。
2.modCount的注釋中提到了"fail-fast"機制。
3.如果子類希望提供"fail-fast"機制,需要在add(int,E)方法和remove(int)方法中對這個字段進行處理。
4.從第三點我們知道了,在提供了"fail-fast"機制的容器中(比如ArrayList),除了文中示例的remove(Obj)方法會導致ConcurrentModificationException異常,add及其相關方法也會導致異常。
知道了什么是modCount。那modCount是干啥的呢?
在提供了"fail-fast"機制的集合中,modCount的作用是記錄了該集合在使用過程中被修改的次數。
證據就在源碼里面,如下:
這是java.util.ArrayList#add(int, E)方法的源碼截圖:

這是java.util.ArrayList#remove(int)方法的源碼截圖:

注:這里不討論手動設置為null是否對GC有幫助,我個人認為,在這里有這一行代碼並沒有壞處。在實際開發過程中,一般不需要考慮到這點。
同時,上面的源碼截圖也回答了這一層的最后一個問題:它什么時候被修改?
拿ArrayList來說,當調用add相關和remove相關方法時,會觸發modCount++操作,從而被修改。
好了,通過上面的分析,我們知道了什么是modCount和modCount是干啥的。准備進入第四層。
3.4 第四層:什么是expectedModCount?它是干啥的?什么時候發生變化?
接下來:什么是expectedModCount?

expectedModCount是ArrayList中一個名叫Itr內部類的成員變量。
第二問:expectedModCount它是干啥的:
它代表的含義是在這個迭代器中,預期的修改次數
第三問:expectedModCount什么時候發生變化?
情況一:從上圖中也可以看出當Itr初始化的時候,會對expectedModCount字段賦初始值,其值等於modCount。
情況二:如下圖所示,調用Itr的remove方法后會再次把modCount的值賦給expectedModCount。
換句話說就是:調用迭代器的remove會維護expectedModCount=modCount。(這句話很關鍵!!!)

好了分析到了這里,我們知道了下面這個六連擊:
1.什么是modCount?
2.modCount是干啥的?
3.modCount什么時候發生變化?
4.什么是expectedModCount?
5.expectedModCount是干啥的?
6.expectedModCount什么時候發生變化?
3.5 第五層:組裝線索,直達真相
為什么發生了異常呢?
如果說前四層是線索的話,真相其實已經隱藏在線索里面了。我帶你梳理一下:
【第一層:異常信息解讀】中說到:

【第二層:拋出異常的條件解讀】中說到:

【第三層:什么是modCount?它是干啥的?什么時候發生變化?】中說到:

【第四層:什么是expectedModCount?它是干啥的?什么時候發生變化?】中說到:

為什么發生了異常呢?我想你大概已經有了一個答案了,我再去Debug一下,為了方便演示,我們去掉語法糖,程序修改如下:

並確認一下這個循環體會執行三次,如下:


第一次循環取出的【公眾號】,不滿足條件if("why技術".equals(item)),不會觸發list.remove(Obj)方法。

如圖所示,第二次循環取到了“why技術”。滿足條件if("why技術".equals(item)),會觸發list.remove(Obj)方法,如下所示:

第三次循環

總結一下在foreach循環里面進行元素的remove/add操作拋出異常的真相:
因為foreach循環是Java的語法糖,經過編譯后還原成了迭代器。
但是從經過編譯后的代碼的第26行可以看出,remove方法的調方是list,而不是迭代器。
經過前面的源碼分析我們知道,由於ArrayList的"fail-fast"機制,調用remove方法會觸發【modCount++】操作,對expectedModCount沒有任何操作。只有調用迭代器的remove方法,才會維護expectedModCount=modCount。
所以調用了list的remove方法后,再調用Itr的next方法時,導致了expectedModCount!=modCount,拋出異常。
四.這題的坑在哪里?
前面講了阿里開發手冊。講了在foreach循環里面進行元素的remove/add為什么會發生異常。有了這些鋪墊之后。
4.1 回頭再看
我們再回過頭來看小馬哥出的這個題:

我靠,這乍一看,foreach循環里面調用list.remove(obj)。我們剛剛分析過,會拋出ConcurrentModificationException異常。
你要這樣答,你就進了小馬哥的坑了。
這個題的坑在這三個點里面。小馬哥並沒有說這個list是ArrayList吧?如果你沒有認真審題,先入為主的默認了這個list就是ArrayList。第一步就錯了。
這是真正的高手,借力打力。借阿里開發手冊的力,讓你第一步就走錯。

請看下面這張圖:

當使用CopyOnWriteArrayList的時候,程序正常執行。
4.2 還有一個騷操作
既然我們知道為什么會拋出異常,也知道怎么不拋出異常,List本來就是一個接口,那我們是不是可以實現這個接口,弄一個自定義的List呢?
比如下面的這個WhyTechnologyList,就是我自己的List,狸貓換太子,這操作,夠"騷"啊。

只有掌握了原理,我們想怎么玩就怎么玩。
五.線程安全版的ArrayList
CopyOnWriteArrayList是什么?我們看一下源碼注釋上面是怎么說的:

相對於ArrayList而言,CopyOnWriteArrayList集合是線程安全的容器。在遍歷的時候,由於它操作是數組的"快照","快照"不會發生變化。所以它不需要額外加鎖,也不會拋出ConcurrentModificationException異常。
我們主要看一下,示例程序中用到的三個方法,add(E e)、next()、remove(Obj)
先看add(E e)方法:

我們看一下它的next()方法:

再看一下它的remove(Obj)方法:

next、remove都是操作的快照,並沒有看到ArrayList里面的modCount和expectedModCount。所以它沒有拋出ConcurrentModificationException
之前看小馬哥說的這句話的時候還不太明白集合和一致性之間的關系(老問題,還是先入為主,一說到一致性首先想到的是緩存和數據庫之間的一致性)。

但是當我閱讀源碼,從add方法可以看出CopyOnWriteArrayList並不保證數據的實時一致性。只能保證最終一致性。
同時我們從源碼中可以看出CopyOnWriteArrayList增刪改數據的時候需要搞一個"快照",這一點是比較耗內存的,使用過程中需要注意。
六.總結一下
我們再回到最開始的地方,看看大家的回答:
1.什么也不會發生,remove之后,list中的數據會被清空。
2.remove的方法調用錯誤,入參應該是index(數組下標)。
3.並發操作的時候會出現異常。
4.會發生ConcurrentModifyException。
現在,你知道這些回答的問題在哪里了吧?這一部分的總結也很簡單,上一個對比圖就好了,如果看不清楚,你可以點開看大圖:


七.回答另外一個面試題
現在面試官經常問的一個問題,你讀過源碼嗎?
咦,巧了。你看了這篇文章,就相當於了讀了ArrayList和CopyOnWriteArrayList的部分源碼。
那你就可以這樣回答啦:我之前看阿里Java開發手冊的時候看到一條規則是
不要在foreach循環里面進行元素的remove/add操作。remove元素請使用Iterator方式,如果並發操作,需要對Iterator對象加鎖。
我對這條規則很感興趣,所以我對其進行了深入的研究,閱讀了
ArrayList和CopyOnWriteArrayList的部分源碼。
如果碰巧面試官也讀過這塊源碼,這個問題,你們可以相談甚歡。
如果面試官沒有讀過這塊源碼,你可以給他講的明明白白。
當然,還有一個前提是:我希望你讀完這篇文章后,如果是第一次知道這個知識點,那你可以自己實際操作一下。
看懂了是一回事,自己再實際操作一下,是另外一回事。
八.擴展閱讀
8.1 fail-fast和safe-fast機制
文中多次提到了"fail-fast"機制(快速失敗),與其對應的還有"safe-fast"機制(失敗安全)。
這種機制是一種思想,它不僅僅是體現在Java的集合中。在我們常用的rpc框架Dubbo中,在集群容錯時也有相關的實現。

Dubbo 主要提供了這樣幾種容錯方式:
Failover Cluster - 失敗自動切換
Failfast Cluster - 快速失敗
Failsafe Cluster - 失敗安全
Failback Cluster - 失敗自動恢復
Forking Cluster - 並行調用多個服務提供者
如果對這兩種機制感興趣的朋友可以查閱相關資料,進行了解。如果想要了解Dubbo的集群容錯機制,可以看官方文檔,地址如下:
http://dubbo.apache.org/zh-cn/docs/source_code_guide/cluster.html
8.2 Java語法糖
文中說到foreach循環的時候提到了Java的語法糖。如果對這一塊有興趣的讀者,可以在網上查閱相關資料,也可以看看《深入理解Java虛擬機》的第10.3節,有專門的介紹。

書中說到:
總而言之,語法糖可以看做是編譯器實現的一些“小把戲”,這些“小把戲”可能會使得效率“大提升”,但我們也應該去了解這些“小把戲”背后的真實世界,那樣才能利用好它們,而不是被它們所迷惑。
8.3 阿里Java開發手冊
阿里的孤盡大佬作為主要作者寫的這本《阿里Java開發手冊》,可以說是嘔心瀝血推出的業界權威,非常值得閱讀。讀完此書,你不僅能夠獲得很多干貨,甚至你還能讀出一點技術情懷在里面。
對於技術情懷,孤盡大佬是這樣的說的:
熱愛、思考、卓越。熱愛是一種源動力,而思考是一個過程,而卓越是一個結果。如果給這三個詞加一個定語,使技術情懷更加立體、清晰地被解讀,那就是奉獻式的熱愛,主動式的思考,極致式的卓越。
關注公眾號並回復關鍵字【Java】。即可獲得此書的電子版。
九.最后說一點
這篇文章寫之前我一直在糾結,因為感覺這個知識點其實我已經掌握了,那我還有寫的必要嗎?我在寫的這個過程中還能收獲一些東西嗎?
但是在寫的過程中,我翻閱了大量的源碼,雖然之前已經看過,但是沒有這樣一行一行仔細的去分析。之前只是一個大概的模糊的影像,現在具象化清晰了起來,在這個過程中,我還是學到了很多很多。
其實想到寫什么內容並不難,難的是你對內容的把控。關於技術性的語言,我是反復推敲,查閱大量文章來進行證偽,總之慎言慎言再慎言,畢竟做技術,我認為是一件非常嚴謹的事情,我常常想象自己就是在故宮修文物的工匠,在工匠精神的認知上,目前我可能和他們還差的有點遠,但是我時常以工匠精神要求自己。就像我之前表達的:對於技術文章(因為我偶爾也會荒腔走板的聊一聊生活,寫一寫書評,影評),我盡量保證周推,全力保證質量。
才疏學淺,難免會有紕漏,如果你發現了錯誤的地方,還請你留言給我指出來,我對其加以修改。
如果你覺得文章還不錯,你的點贊、留言、轉發、分享、贊賞就是對我最大的鼓勵
以上。
謝謝您的閱讀,感謝您的關注。公眾號會是文章首發平台,關注可以第一時間看到原創文章哦。
