Kotlin刨根問底(二):for循環引發的一塊兒「血案」

本文靈感來源於:羣友遍歷列表時remove元素引起異常,後對for循環的實現原理進行一系列的探究~java


0x0、要點提煉


  • 普通for循環」相似代碼,Java不報錯,Kotlin卻數組越界,因「循環條件不同

Java先判斷是否知足條件執行循環體自增
Kotlin遍歷的是範圍,直接進循環體編程

  • 加強for循環」= while循環 + 迭代器Iterator
  • 迭代器的設計哲學」→ 將 遍歷行爲被遍歷對象 分離,無需關心容器底層結構;
  • ConcurrentModificationException」異常緣由(經過兩個字段配合)

modCount → 記錄列表結構修改次數,調用列表的remove方法,此值+1
 
expectedModCount → 預計修改次數,調用Itr迭代器中的remove方法時,會調用列表的remove方法,然後將modCount賦值給expectedModCount,即保證遍歷過程當中兩個值是相等的;
 
若是此時調用列表的remove方法modCount增長1,而迭代器中的expectedModCount沒變,二者不等,就會引起ConcurrentModificationException異常。設計模式

  • fail-fast(快速失敗)」

作系統設計的時候先考慮異常狀況,一旦發生異常,直接中止並上報,一種用於提早預警的Bug檢測機制;在集合中的應用就是:在遍歷一個集合時,當集合結構被修改,直接拋出異常。數組

  • 規避ConcurrentModificationException的幾種方法」
  • 單線程:使用迭代器進行remove;
  • 多線程:在使用迭代器處加鎖(如synchronize);
  • 使用fail-safe(安全失敗)機制的同步容器,如CopyOnWriteArrayList,在java.util.concurrent包中;
  • Kotlin中使用toList()後能夠規避異常的緣由

建立了新的ArrayList用做遍歷,remove移除的是舊ArrayList的元素,故互不影響緩存


0x一、原由


寒冷的午後,在一個交流羣裏,一位開發者盆友拋出了下面的問題:安全

同時附帶兩張截圖多線程

恰好在寫代碼的我,隨手點開了 toList() 的源碼:併發

慣性思惟 回了句:涉及到可變列表和不可變列表吧app

接着截了個 Collection.kt 的文件結構圖後,追加:函數式編程

其實我並無理解他的問題,就開始跟起了RecyclerView的源碼,再經歷過一番排查得出:

應該和 Iterable,普通for循環,加強for循環有關

而後被拉去開了個兩個半小時的用例評審…回來看到問題還沒解決,看了聊天記錄,捋了一下才弄懂他的問題:

一、普通for循環(下標遍歷),相似的代碼,Java不報錯,Kotlin卻拋 IndexOutOfBoundsException
二、加強for循環(foreach),remove移除,都會拋 ConcurrentModificationException

看不懂?寫個簡單的代碼演示下問題:

Java

Kotlin

初始化列表:

行吧,接着一個個講解~


0x二、數組越界問題解析


緣由其實很簡單「循環條件不同」,Java中是:

先判斷是否知足條件執行循環體自增 ,打斷點跟下i、ls.size(),記錄以下:

0→五、1→四、2→三、3(此時size=3,判斷條件不成立,不會自增,走循環體),因此上面看到只打印了3次

而Kotlin則不同,點開 for(i in l.indices) 裏的indices

噢,indices是一個擴展屬性,值爲:0..size - 1,一個範圍(Range),好比如今有5個元素,這個值就是:0..(5-1) → 0..4,你能夠簡單地把Range當作一個List(列表),那麼這裏的 循環條件 就變成了:遍歷這個範圍,因此

0→五、1→四、2→三、3→3(這裏是 直接進循環體,數組長度爲3,訪問3個,因此就引起了異常!)

到此:普通for循環的「血案(數組越界)」就此水落石出,接着看下加強for循環~


0x三、加強for循環異常分析


有讀者可能有疑問,上述的Kotlin代碼用的是 forEach 擴展方法,加強for循環不是應該這樣寫嗎?

其實,forEach也是用的加強for循環,點開源碼就知道了:

在加強for循環的基礎上,傳入一個處理函數,不瞭解函數式編程的可移步至:《八、Kotlin高階函數與lambda表達式》


① 加強for循環的原理


反編譯下加強for循環字節碼,以下:

逐行源碼解析:

  • Iterator var → 定義迭代器類型的臨時變量var1
  • l.iterator() → 獲取列表l的迭代器
  • var1.hasNext() → 判斷迭代器中是否有未遍歷的元素
  • Integer i = (Integer)var1.next() → 獲取第一個未遍歷元素,賦值給臨時變量i
  • l.remove(i) → 移除列表中的i元素

不難看出底層是:while循環 + Iterator(迭代器)


② 迭代器的設計「哲學」


設計模式中有一種模式「迭代器模式

  • 針對 =>「容器對象中元素的迭代訪問」;
  • 核心思想 => 將「遍歷行爲」與「被遍歷對象」分離;
  • 好處 => 無需關心容器的底層結構,拿到對象,使用迭代器便可遍歷對象內部;

打開 Collection.kt,文件結構以下:

這裏要對「Iterable」和「Iterator」進行區分:

  • Iterable接口:實現此接口的集合對象支持迭代(可配合foreach使用),定義了一個iterator()函數,返回一個Iterator迭代器對象。

  • Iterator:迭代器,提供迭代機制的對象,代碼以下:

兩個函數:next() => 返回當前迭代元素;hasNext() => next前調用此方法判斷是否迭代到終點
能夠跟下實現看看:ListIteratorAbstractList

IteratorImpl

以上就是 迭代器的設計「哲學」 的簡單講解。


③ 尋根問底


回到加強for循環中remove元素引發的「ConcurrentModificationException」,此異常直接翻譯:併發修改異常
可是問題是:咱們並無用多線程修改集合,卻引發這樣的異常?跟一波異常,定位到:
java.util.ArrayList$Itr.checkForComodification

直譯下變量名:modCount(修改次數),expectedModCount(預計修改次數),em…這是判斷兩值不等就直接拋異常?往上看:

modCount賦值給expectedModCount,說明二者一開始是相等的,那modCount是在哪裏賦初值的呢?
點進去來到ArrayList的父類AbstractList

Tips:transient關鍵字 用來標記的成員變量不參與序列化過程)

AbstractList中搜了下modCount,沒發現有值變化的操做;
回到ArrayList,觀察源碼能夠發現「在涉及結構變化的方法中modCount都會自增1

而add(),addAll() 方法雖然沒有明寫modCount++,但暗地裏調用ensureCapacityInternal()完成自增:

到此,咱們知道了modCount →「會在ArrayList的結構發生變化時變化
接着,到expectedModCount,定位到內部類 Itr

類的組成比較簡單:實現了Iterator迭代器接口,重寫next()和hasNext()方法;定義了兩個變量:cursor(下一個元素的下標),lastRet(上一個元素的下標);讀者有疑問的多是next()裏的:ArrayList.this.elementData,跟一下:

噢,就是一個緩存數組,長度爲列表的容量,當第一個元素添加進來,會擴展至DEFAULT_CAPACITY

看回remove()方法,調用ArrayList.this.remove()移除元素後,把modCount的賦值給expectedModCount
繼續往下走,由於此時二者相等,hasNext()執行checkForComodification()沒拋出異常,按流程走是沒問題的。

但:咱們上面跳過了迭代器,直接對列表進行了remove,而此時modCount已經+1,但expectedModCount沒變,因此執行到checkForComodification就拋出異常了!

嗯,你有個大膽的想法?直接用迭代器進行remove會拋異常嗎?那就試試:

行吧,用迭代器的方式remove,但此時已經不是加強for循環了,哈哈!


0x四、fail-fast(快速失敗)


從上面咱們知道了異常發生的緣由了,那爲什麼要這樣設計呢?這種玩法有個專業名詞 →「fail-fast(快速失敗)機制

在作系統設計的時候先考慮異常狀況,一旦發生異常,直接中止並上報。

舉個簡單的例子,寫個兩數相除的方法,若是不當心除以0,運行時就會引起異常ArithmeticException by/zero,而及時失敗,則是在執行運算前檢測被除數是否爲0,是直接拋出異常。示例以下:

行吧,一種檢測Bug機制,用於提早預警,那ArrayList裏爲什麼要用到這種機制呢?

→ 由於ArrayList不是線程安全的,多個線程同時訪問同一個ArrayList可能會引發異常;
→ 引入及時失敗是想着:若某個線程經過Iterator遍歷時,集合內容被其餘線程改變,拋出異常;

在上面的例子中,咱們利用迭代器遍歷remove的方式來規避ConcurrentModificationException,
但那是隻有在單個線程訪問列表,若是是有多個線程在訪問呢?寫個例子試試:

行吧,拋異常了,那有在多線程的時候有什麼辦法規避呢?換個線程安全的容器,好比:Vector

同樣會拋異常,Vector雖然採用synchronized進行同步,但仍是繼承自AbstarctList,直接經過Iterator便可訪問容器元素,根本不須要獲取鎖。

哪有解決方法嗎?

有,直接在使用iterator處加鎖便可,代碼示例以下:

能夠,那除了對iterator加鎖的方式外,還有其餘方法嗎?

有,上併發容器,它採用的是fail-safe(安全失敗)機制:迭代時不是直接在集合內容上訪問的,而是先複製原有集合內容,在拷貝的集合上進行遍歷,遍歷期間原集合發生的修改迭代器是不知道的,以此避免了ConcurrentModificationException,但一樣的迭代器不能訪問到修改後的內容。Java中的併發容器放在java.util.concurrent包中,好比可使用CopyOnWriteArrayList來代替ArrayList和Vector。


0x五、Kotlin中toList()的原理


差點漏了,那位盤友問的爲啥加個toList()就能夠了,代碼示例以下:

跟下源碼:

傳入的集合類型,大小爲5,走else,進toMutableList

嘖嘖,看到這裏,你get到原理沒?

  • 直接建立了一個新的ArrayList,把原列表做爲參數傳入初始化;
  • 拿新列表的迭代器遍歷,移除的是舊列表中的元素,確定互不影響啊,和fail-safe(安全失敗)的玩法一模一樣。


參考文獻

相關文章
相關標籤/搜索