最近在看《實戰Java虛擬機器》一書,看到有關鎖與並發章節時,看到如下一個多線程使用ArrayList的例子:
兩個線程t1和t2同時向numberList中添加資料,由於ArrayList是線程不安全的,因此會導致添加的資料有錯誤,這個我還是能理解的,但是它報的確是如下錯誤:
我就有點理解不了了,ArrayList不是自動擴容、沒有長度限制嗎,為什麼還會出現數組下標越界這種錯誤呢。
為了便於分析,我對代碼進行了一點點修改:
執行結果為:
有時還會出現null,
帶著種種不解,來看ArrayList添加流程:
首先,ArrayList是基於數組實現的,是一個動態數組,其容量能自動成長,類似於C語言中的動態申請記憶體,動態增長記憶體。
對於ArrayList而言,它實現List介面、底層使用數組儲存所有元素。其操作基本上是對數組的操作。
1、程式中報錯的 at java.util.ArrayList.elementData(ArrayList.java:400) 和 at java.util.ArrayList.add(ArrayList.java:441),它們同屬Add()方法。
源碼如下:
添加操作,首先會調用ensureCapacityInternal(size + 1),其作用為保證數組的容量始終夠用,其中size是elementData數組中元組的個數,初始為0。
在ensureCapacityInternal()函數中,用if判斷,如果數組沒有元素,給數組一個預設大小,會選擇執行個體化時的值與預設大小中較大值,然後調用ensureExplicitCapacity()。
函數體中,modCount是數組發生size更改的次數。然後if判斷,如果數組長度小於預設的容量10,則調用擴大數組大小的方法grow()。
函數grow()解釋了基於數組的ArrayList是如何擴容的。數組進行擴容時,會將老數組中的元素重新拷貝一份到新的數組中,每次數組容量的增長大約是其原容量的1.5倍。
接下來回到Add()函數,繼續執行,elementData[size++] = e; 這行代碼就是問題所在,當添加一個元素的時候,它可能會有兩步來完成:1. 在 elementData[Size] 的位置存放此元素;2. 增大 Size 的值。
在單線程啟動並執行情況下,如果 Size = 0,添加一個元素後,此元素在位置 0,而且 Size=1;
而如果是在多線程情況下,比如有兩個線程,線程 A 先將元素存放在位置 0。但是此時 CPU 調度線程A暫停,線程 B 得到啟動並執行機會。線程B也向此 ArrayList 添加元素,因為此時 Size 仍然等於 0 (注意哦,我們假設的是添加一個元素是要兩個步驟哦,而線程A僅僅完成了步驟1),所以線程B也將元素存放在位置0。然後線程A和線程B都繼續運行,都增加 Size 的值。那好,我們來看看 ArrayList 的情況,元素實際上只有一個,存放在位置 0,而 Size 卻等於 2。這就是“線程不安全”了。這就解釋了為何集合中會出現null。
但是數組下標越界還不能僅僅依靠這個來解釋。我們觀察發生越界時的數組下標,分別為10、15、22、33、49和73。結合前面講的數組自動機制,數組初始長度為10,第一次擴容為15=10+10/2,第二次擴容22=15+15/2,第三次擴容33=22+22/2...以此類推,我們不難發現,越界異常都發生在數組擴容之時。
由此給了我想法,我猜想是,由於沒有該方法沒有同步,導致出現這樣一種現象,用第一次異常,即下標為15時的異常舉例。當集合中已經添加了14個元素時,一個線程率先進入add()方法,在執行ensureCapacityInternal(size + 1)時,發現還可以添加一個元素,故數組沒有擴容,但隨後該線程被阻塞在此處。接著另一線程進入add()方法,執行ensureCapacityInternal(size + 1),由於前一個線程並沒有添加元素,故size依然為14,依然不需要擴容,所以該線程就開始添加元素,使得size++,變為15,數組已經滿了。而剛剛阻塞在elementData[size++] = e;語句之前的線程開始執行,它要在集合中添加第16個元素,而數組容量只有15個,所以就發生了數組下標越界異常。