Java 中 List.subList() 方法的使用陷阱
前言
本文原先發表在我的 iteye部落格: http://clevergump.iteye.com/admin/blogs/2211979, 但由於在 iteye發表的這篇文章的某些渲染曾經出現過一些問題, 我曾發過多封郵件向 iteye 的工作人員進行反饋, 官方只是在我第一封郵件中回覆說會聯絡技術人員處理, 但是此後就再也沒有收到他們的任何回覆了, 我後來多次郵件詢問進度, 也沒有收到新的回覆, 及時響應使用者的提問和需求, 是提高使用者體驗和滿意度的重要因素. 我想, 即使你們不能搞定或者不想去處理, 也應該給個回覆, 即使回覆 “我們暫時無法處理”, 也比不回覆郵件要好啊. 於是我就決定今後永遠放棄 iteye, 但自認為原部落格中的這篇文章還是有一定價值的, 於是就在此重新發表一次, 並且今後就維護這篇文章吧.
另外多說一句, 不論你是從事什麼行業的什麼工作, 只要是和響應客戶需求相關的崗位, 都應該主動及時地讓客戶知道他們所關心的事情的處理進度, 這樣才能提高使用者體驗和滿意度. 包括我們做手機 APP 或者 PC 客戶端開發的崗位也是如此, 我們的客戶端在遇到網路異常, 或者記憶體不足, 或者一段時間內無響應等特殊情況, 都應該及時主動地給使用者彈出一個提示框或對話方塊. 我們在下載軟體時, 顯示下載進度條, 在載入圖片時, 顯示載入進度條. 在使用者點選任何一個可能會讓他們認為 (包括誤認為) 可以點選的地方, 都必須要麼進行頁面變化, 要麼彈出一個對話方塊或提示框, 不能什麼都不處理, 尤其是對於那些設計時沒有新增點選功能, 但卻有可能被使用者誤認為可以點選的地方, 也要做相應的使用者點選的響應處理……以上這些做法, 都是為了讓使用者及時知道他們關心的事情的進度, 都是提升使用者體驗的做法. 好了, 前言就扯這麼多吧.
正文
做 Java 或 Android 開發的朋友, 一定都很熟悉 String
類中的 subString()
方法. 下面我們先來看一個關於該方法的小例子. 假如我們有如下需求: 隨意設定一個字串, 然後從中取出一個子字串, 然後在該子字串的末尾新增一些新的字元, 但要保證原先的字串不變. 這個需求對你來說實在是 so easy, 於是你迅速寫出瞭如下程式碼:
public class SubStringDemo {
private static String str;
private static String subStr;
public static void main(String[] args) {
subStringTest();
}
private static void subStringTest() {
str = "01234";
subStr = str.substring(2, str.length());
print();
subStr += "5";
System.out.println("---------此時將 subStr 中增加一個字元 '5' ----------");
print();
}
private static void print() {
System.out.println("str = " + str);
System.out.println("subStr = " + subStr);
}
}
你假設原字串為 “01234”, 通過 subString()
方法從該字串中取出一個子字串 “234”, 然後在這個取出的子字串的末尾新增一個新的字元’5’, 這樣子字串就變為 “2345”, 而原字串則不變, 仍為 “01234”.
我們看下執行結果:
從執行結果來看, 程式碼確實沒問題. IQ極高的你甚至有些憤憤不平, “這麼 low 的需求, 簡直就是在欺 (wu) 負 (ru) 哥的智商嘛”, 不知情的人, 還以為你在衛生間看到了下面這張圖呢:
哈哈, 你可能確實有點屈才了. ^_^
沒關係, 既然你智商很高, 我們就改個需求吧, 要求你能快速響應我們的需求變化, 要體現在程式碼中. 你說, 沒問題, 儘管放馬過來吧, 哥都能 hold 住. 於是需求改為如下內容: 將原需求中的字串改為 List
(也就是 java.util.List
), 將原需求中所有對字串的要求都移植到對 List
的要求中. 具體來說就是, 隨意設定一個 List
的實現類物件, 然後從中取出一個子 List
, 然後向該子 List
中新增一些新的元素, 但要保證原先的 List
不變.
看到這個需求後, 估計你的心情可能又會像上面那張圖那樣吧. 這個變化, so easy. String
有 subString()
方法, 難道 List
就沒有 subList()
方法??? 人要學會融會貫通嘛, 所以答案是顯而易見的. 如果這都不是欺 (wu) 負 (ru) 哥的智商的話, 那麼世界上就不存在 “欺 (wu) 負 (ru) 智商” 的說法了. 但是, 你終究還是平復了你的心情, 然後奮筆疾書, 快速寫下了如下程式碼:
private static List list;
private static List subList;
private static void subListTest(Class<? extends List> listClazz)
throws IllegalAccessException, InstantiationException {
if (listClazz == null) {
throw new IllegalArgumentException(listClazz + " is null.");
}
list = listClazz.newInstance();
list.clear();
for (int i = 0; i < 5; i++) {
list.add(i);
}
subList = list.subList(2, list.size());
subList.add(5);
}
和先前 String
需求中設定的數字類似, 你在原 List
中設定該 List
中存有5個元素, 分別是整數 0, 1, 2, 3, 4. 然後將第2個元素到最末一個元素全部取出, 作為子 List
. 然後向取出的這個子 List
中新增一個整數5. 寫完這個程式碼後, 你甚至根本沒有進行自測, 就非常自信地把程式碼直接交給了測試MM.
然而, 過了一會兒, 測試MM反饋說, 你的程式碼有bug. 在子 List
新增元素後, 原 List
也變了. 你很詫異, 不可能呀, 不應該呀, 子 List
的變化, 怎麼會影響到原 List
呢? 不可能的, 一定是測試MM搞錯了, 你心裡或許在想, 難道是因為哥長得帥, 妹子想借此搭訕哥? ^_^ 但是, 測試MM一臉正經地告訴你, 確實有bug, 你的確需要修復, 先提個 bug 跟進的單子吧. 此刻, 你感覺到情況似乎有些不妙, 為了謹慎起見, 你立刻對原先的程式碼進行自測, 在原先程式碼的基礎上增加了一些日誌輸出語句, 於是就有了如下程式碼:
public class SubListDemo {
private static List list;
private static List subList;
public static void main(String[] args) {
try {
System.out.println("/*--------------------------- ArrayList -----------------------------------*/");
subListTest(ArrayList.class);
System.out.println("");
System.out.println("/*--------------------------- LinkedList -----------------------------------*/");
subListTest(LinkedList.class);
} catch (Exception e) {
e.printStackTrace();
}
}
private static void subListTest(Class<? extends List> listClazz) throws IllegalAccessException, InstantiationException {
if (listClazz == null) {
throw new IllegalArgumentException(listClazz + " is null.");
}
list = listClazz.newInstance();
list.clear();
for (int i = 0; i < 5; i++) {
list.add(i);
}
subList = list.subList(2, list.size());
print();
subList.add(5);
System.out.println("---------此時將子list中增加一個元素 5 ----------");
print();
}
private static void print() {
System.out.println("原 list: " + list);
System.out.println("子 list: " + subList);
}
}
你對 List
介面最常用的兩個實現類 ArrayList
和 LinkedList
都分別做了測試, 得到如下的列印結果:
在子 List
增加了元素5以後, 原先的 List
也相應增加了元素 5, 留意上圖中的兩個藍色圓圈.
於是你又將增加的元素改為另外一個數字, 比如: 10, 你會發現, 原 List
也會增加元素 10.
而如果你將增加元素改為刪除元素, 例如: 刪除座標為0的元素, 即: 將 subListTest()
方法改為如下程式碼:
private static void subListTest(Class<? extends List> listClazz)
throws IllegalAccessException, InstantiationException {
if (listClazz == null) {
throw new IllegalArgumentException(listClazz + " is null.");
}
list = listClazz.newInstance();
list.clear();
for (int i = 0; i < 5; i++) {
list.add(i);
}
subList = list.subList(2, list.size());
print();
subList.remove(0);
System.out.println("---------此時將子list中的第0個元素刪除 ----------");
print();
}
列印結果如下:
你會發現, 當你刪除子 List
中的第0個元素, 也就是元素2的時候, 原先的 List
中的元素2也被一同刪除了, 還是留意上圖中的藍色圓圈標註的數字, 這是原 List
中的元素2, 他們在子 List
執行刪除動作以後, 也會被一同刪除掉.
奇怪呀, 為什麼向子 List
中增加或刪除一個元素, 會同時讓原 List
也增加或刪除相同的元素呢? 此刻的你陷入了深深的疑惑與不解中…
要想解答這個疑惑, 唯有分析原始碼才是正確的方式啊. 那麼, 我們就來分析一下相關的原始碼吧.
我們就以 ArrayList
為例來進行分析吧. 下面是 ArrayList
的 subList()
方法的原始碼:
public List<E> subList(int fromIndex, int toIndex) {
subListRangeCheck(fromIndex, toIndex, size);
return new SubList(this, 0, fromIndex, toIndex);
}
該方法其實返回的是 ArrayList
的內部類 SubList
的一個例項, 同時也將當前 ArrayList
物件作為傳入該構造方法, 作為第一個引數的值. 我們看看這個構造方法的原始碼:
private class SubList extends AbstractList<E> implements RandomAccess {
private final AbstractList<E> parent;
SubList(AbstractList<E> parent,
int offset, int fromIndex, int toIndex) {
this.parent = parent;
this.parentOffset = fromIndex;
this.offset = offset + fromIndex;
this.size = toIndex - fromIndex;
this.modCount = ArrayList.this.modCount;
}
}
由上述程式碼可知, 在建立這個內部類 ArrayList.SubList
的例項時, 會將外部類 ArrayList
的引用作為該內部類物件中 parent 欄位的值, 也就是說, 這個 ArrayList.SubList
內部類例項中的 parent 欄位會持有外部類 ArrayList
物件的一個引用, 只是添加了一定的偏移量而已. 由於 List 中存放的元素都是引用型別, 而非基本型別, 所以, 這個子 List
中的每一個元素所代表的引用, 其實就和原 List
中在相同索引處偏移 fromIndex 位置後的那個位置上的元素所代表的引用, 二者指向的是相同的物件. 我們換用更直白的方式來說, 就是:
假設有 0~4 這5個整數, 先被分別裝箱成5個 Integer物件, 然後被依次新增到原 List
中, 假設我們將原 List
稱作 listA, 這時, 這5個物件中的每一個都分別被一個引用指向著, 這些引用剛好就是 listA 中存放的所有元素, 注意: listA中存放的元素其實是引用, 而不是物件本身. 這時, 對 listA 執行了 subList(2, listA.size())
方法, 建立了一個子 List
, 我們將這個子 List
稱作 listB. 那麼這時, 物件 Integer.valueOf(0) 和 Integer.valueOf(1) 各自還是隻被一個引用指向著, 但是, 物件 Integer.valueOf(2), Integer.valueOf(3), Integer.valueOf(4) 卻都分別被兩個引用指向著, 一個引用來自 listA, 另一個引用來自 listB. 可能上述描述還是不夠清晰, 我們用表格來解釋吧.
在建立子 List
( 即: listB) 之前, 各個 Integer 物件被引用指向的情況如下:
在建立子 List
( 即: listB) 之後, 各個 Integer 物件被引用指向的情況如下:
留意紅色的字. 在建立了 listB, 也就是子 List
以後, 後三行的三個物件, 都分別被 listA 和 listB中各有一個引用所指向著. 而且還有個規律: listB 中每一個元素(其實這裡的元素是引用, 不是物件本身) 所指向的物件, 都會同時被兩個引用所指向著. 所以, 對於這些同時被兩個引用所指向的物件來說, 不論是用哪個引用來修改這些物件的值, 或者對他們進行增刪, 都將影響到另外一個引用的指向結果.
先看這個內部類 ArrayList.SubList
的新增元素的方法 add(E e)
. 由於在這個類內部沒有找到這個簽名的方法, 所以只能到他的父類中去找, 看下該類的繼承關係:
private class SubList extends AbstractList<E> implements RandomAccess
在其父類 AbstractList
中找到了該方法的定義, 原始碼如下:
public boolean add(E e) {
add(size(), e);
return true;
}
該方法呼叫了 add(size(), e)
這個方法, 這個方法我們暫時先不分析, 留到後面分析. 先暫時做個記號, ——–標記0.
我們先分析 size()
方法, size()
方法在 AbstractList
類中沒有找到, 我們先向上尋找, 即: 向他的父類中去找, 先看下 AbstractList
這個類的繼承關係:
public abstract class AbstractList<E> extends AbstractCollection<E> implements List<E>
發現, AbstractList
的父類 AbstractCollection
中將 size()
定義為抽象方法, 所以, 我們只能向下去找, 也就是向 AbstractList
的子類, 即: 向本文分析的 ArrayList.SubList
這個內部類中去找, 我們在該內部類中找到了該方法的實現, 如下:
public int size() {
checkForComodification();
return this.size;
}
返回 this.size, 也就是 ArrayList.SubList
類中的 size欄位的值, 而這個 size 欄位其實在這個內部類的構造方法中就有賦值:
this.size = toIndex - fromIndex;
也就是對 listA 呼叫 subString()
方法時傳入的兩個索引值的差, 即: listB 中元素的總數.
好了, 我們繞的有點遠, 我們再回到標記0處. 該分析 add(size(), e)
這個方法了. 這個方法在我們的內部類 ArrayList.SubList
中就有定義, 原始碼如下:
public void add(int index, E e) {
rangeCheckForAdd(index);
checkForComodification();
parent.add(parentOffset + index, e);
this.modCount = parent.modCount;
this.size++;
}
第4行, 直接呼叫 parent 的 add()
方法, 也就是原 List
( listA ) 的 add()
方法, 該方法增加了偏移量 parentOffset, 並且 index 就等於 size()
的返回值, 而我們前邊分析過, size()
的返回值就是 listB 中元素的總數. 我們這裡做個記號以便後邊回到這裡繼續分析——— 標記1.
這個 parentOffset 又是什麼呢? 我們還是要看這個內部類 ArrayList.SubList
的構造方法:
SubList(AbstractList<E> parent,
int offset, int fromIndex, int toIndex) {
this.parent = parent;
this.parentOffset = fromIndex;
this.offset = offset + fromIndex;
this.size = toIndex - fromIndex;
this.modCount = ArrayList.this.modCount;
}
從第4行可知, parentOffset 就是 fromIndex, 而 fromIndex 其實就是我們建立子 List
時呼叫 ArrayList
的 subList(int fromIndex, int toIndex)
時為該方法中的 fromIndex 這個引數傳入的值. 如果你不相信, 那就請再次回顧 subList(int fromIndex, int toIndex)
的原始碼吧:
public List<E> subList(int fromIndex, int toIndex) {
subListRangeCheck(fromIndex, toIndex, size);
return new SubList(this, 0, fromIndex, toIndex);
}
看第2行, 我們為 subList()
方法傳入的 fromIndex, 作為 ArrayList.SubList
這個內部類的第三個引數, 而從該內部類的構造方法又可知, 這第三個引數最終會被賦值給該內部類的 parentOffset 欄位, 也就是說, parentOffset 就是我們呼叫 subList()
方法獲取子 List
時傳入的起始座標的值, 在我們這個例子中, 由於我們對 listA 呼叫 subList(2, 5)
獲取到 listB, 所以, parentOffset 就是 2.
好了, 我們回到標記1處繼續分析.
在標記1處, 我們分析到了如下程式碼:
parent.add(parentOffset + index, e);
並且也知道了, 子 List
(即: listB) 呼叫 add(E e)
方法, 其實最終是呼叫 parent.add(parentOffset + index, e)
方法的, 而我們前面分析過:
parentOffset = fromIndex
index = listB.size() = toIndex - fromIndex
parentOffset + index = toIndex // 也就是呼叫 subString()方法時, 所傳入的第二個值
也就是呼叫 listA 的 add(toIndex, e)
方法, 而 toIndex 位置所指向的物件, 是同時被兩個引用所指向, 所以, 如果呼叫 listB 的 add()方法向其中增加一個元素, 那麼也必定會同時向 listA 中增加相同的元素, 因為從根本上來說, 這其實就是兩個引用同時指向同一個物件嘛. 但是, 如果將這個過程反過來, 即: 向原 List
(listA) 中增加一個物件, 那麼將會丟擲 ConcurrentModificationException
併發修改異常. 我們可以通過執行如下程式碼來得到證實:
public class SubListDemo {
private static List list;
private static List subList;
public static void main(String[] args) {
try {
System.out.println("/*--------------------------- ArrayList -----------------------------------*/");
subListTest(ArrayList.class);
} catch (Exception e) {
e.printStackTrace();
}
}
private static void subListTest(Class<? extends List> listClazz)
throws IllegalAccessException, InstantiationException {
if (listClazz == null) {
throw new IllegalArgumentException(listClazz + " is null.");
}
list = listClazz.newInstance();
list.clear();
for (int i = 0; i < 5; i++) {
list.add(i);
}
subList = list.subList(2, list.size());
print();
list.add(0, 10);
System.out.println("---------此時在原list索引為0的位置上增加一個元素10, 同時將其他元素依次向後移動 ----------");
print();
}
private static void print() {
System.out.println("原 list: " + list);
System.out.println("子 list: " + subList);
}
}
得到的列印結果是:
子 List
的元素和原 List
中的後一部分是重合的, 而子 List
還在遍歷過程中時, 向原 List
中新增元素, 這樣給子 List
的遍歷過程造成了干擾甚至困擾, 於是就丟擲了併發修改異常. 同理, 我們也能合理推測出, 如果在遍歷子 List
的過程中, 對原 List
執行的是刪除元素的操作, 那麼也必定會導致子 List
的遍歷過程會丟擲併發修改異常. 但是如果不是增刪, 而是修改數值的操作, 就不會影響到子 List
的遍歷過程, 所以就不會丟擲併發修改異常.
我們還是簡單看看這個內部類的 remove()
方法的原始碼吧, 如下:
public E remove(int index) {
rangeCheck(index);
checkForComodification();
E result = parent.remove(parentOffset + index);
this.modCount = parent.modCount;
this.size--;
return result;
}
看第4行, 還是呼叫了 parent 的 remove()
方法, 所以, 後續的分析完全和前面對 add()
方法的分析是同理的, 所以就不再分析了.
那我們再看看修改數值的方法, 也就是 set()
方法吧:
public E set(int index, E e) {
rangeCheck(index);
checkForComodification();
E oldValue = ArrayList.this.elementData(offset + index);
ArrayList.this.elementData[offset + index] = e;
return oldValue;
}
第5行, 直接修改外部類 ArrayList
內部陣列中相應元素的數值, 而由於子 List
使用的是原 List
的後一部分資料, 所以, 如果我們可以合理猜測, 如果此處修改的是陣列中較為靠前的元素的數值, 那麼只有原 List
中的資料會變化, 子 List
將不變. 而如果此處修改的是陣列中較為靠後的元素的數值, 這個元素是被兩個 List
中的元素共同指向著, 那麼兩個 List
中的數值將都會發生變化. 分析方法還是和分析 add()
方法同理.
其實, 我們可以繼續修改上述程式碼, 來檢視發生增刪改查各自情況時的日誌輸出情況, 下面我對每種情況都分別進行一番例項測試, 將測試結果彙總成如下表格:
我們對該表格的測試結果進行總結, 可以得出如下結論:
這個結論對於我們日常的開發工作, 倒是起不到太大的幫助作用. 因為這些結論總結出的都是消極的結果, 而不是積極的結果. 不過, 這個結論倒是告訴我們:
如果你對一個
List
進行過subList()
的操作之後,
1. 千萬不要再對原List
進行任何改動的操作(例如: 增刪改), 查詢和遍歷倒是可以. 因為如果對原List
進行了改動, 那麼後續只要是涉及到子List
的操作就一定會出問題. 而至於會出現什麼問題呢? 具體來說就是:
(1) 如果是對原List
進行修改 (即: 呼叫set()
方法) 而不是增刪, 那麼子List
的元素也可能會被修改 (這種情況下不會丟擲併發修改異常).
(2) 如果是對原List
進行增刪, 那麼此後只要操作了子List
, 就一定會丟擲併發修改異常.
2. 千萬不要直接對子List
進行任何改動的操作(例如: 增刪改), 但是查詢和間接改動倒是可以. 不要對子List
進行直接改動, 是因為如果在對子List
進行直接改動之前, 原List
已經被改動過, 那麼此後在對子List
進行直接改動的時候就會丟擲併發修改異常.
既然獲取子 List
後會有這麼多限制條件, 一不小心就會出錯, 那我們還怎麼操作這個子 List
呢? 或者說, 怎樣才能安全地操作子 List
呢? 其實, 你可能已經注意到了我在上述結論中提到的間接二字. 是的, 我們可以通過間接的方式來安全地操作子 List
. 怎麼間接呢? 其實, “間接” 和 “直接” 是相對的, 因為根據前邊的分析, 子 List
會共用原 List
中後一部分的元素, 他們共同指向相同的物件, 這種共用物件的特性就是導致產生各種不安全結果的罪魁禍首. 如果我們將二者分別指向不同的物件, 豈不是就能避免不安全結果的產生? 也就是說, 我們需要讓子 List
指向新的物件, 並且讓新物件每個位置上的數值要和原 List
中相關位置上的數值相等即可. 於是就想到了以下兩種間接的處理方式:
建立一個新的物件作為我們最終要操作的物件, 在其構造方法中, 將通過
subList()
方法獲取到的子List
作為該構造方法的引數傳入. 這時, 這個新物件內所包含的元素和子List
的完全相同, 但卻指向的是不同的物件. 我們只需使用這個新建立的物件即可.
對於ArrayList
:List<Integer> subList = new ArrayList<>(list.subList(2, list.size()));
對於
LinkedList
:List<Integer> subList = new LinkedList<>(list.subList(2, list.size()));
建立一個新的物件作為我們最終要操作的物件, 然後呼叫這個新物件的
addAll()
方法, 將通過subList()
方法獲取到的子List
作為addAll()
方法的引數傳入, 這時, 這個新物件內所包含的元素和子List
的完全相同, 但卻指向的是不同的物件. 我們只需使用這個新建立的物件即可.
對於ArrayList
:List<Integer> subList = new ArrayList<>(); subList.addAll(list.subList(2, list.size()));
對於
LinkedList
:List<Integer> subList = new LinkedList<>(); subList.addAll(list.subList(2, list.size()));
我們可以使用以上兩種方式中的任意一種, 來解決我們在本文最開始遇到的那個 bug. 看如下程式碼:
public class SubListDemo {
private static List list;
private static List subList;
public static void main(String[] args) {
try {
System.out.println("/*--------------------------- ArrayList -----------------------------------*/");
subListTest(ArrayList.class);
System.out.println("");
System.out.println("/*--------------------------- LinkedList -----------------------------------*/");
subListTest(LinkedList.class);
} catch (Exception e) {
e.printStackTrace();
}
}
private static void subListTest(Class<? extends List> listClazz) throws IllegalAccessException, InstantiationException {
if (listClazz == null) {
throw new IllegalArgumentException(listClazz + " is null.");
}
list = listClazz.newInstance();
list.clear();
for (int i = 0; i < 5; i++) {
list.add(i);
}
subList = listClazz.newInstance();
List tempSubList = SubListDemo.list.subList(2, SubListDemo.list.size());
subList.addAll(tempSubList);
print();
subList.add(5);
System.out.println("---------此時將子list中增加一個元素 5 ----------");
print();
}
private static void print() {
System.out.println("原 list: " + list);
System.out.println("子 list: " + subList);
}
}
第28行, 我們為 subList 單獨新建了一個物件, 讓其指向這個新的物件. 然後在第30行, 呼叫 addAll()
將獲取到的子 List
作為引數傳入, 這樣, subList 不僅指向了新的物件, 而且其內部的各個數值還和子 List
都是相同的. 執行結果如下:
我們發現, 為子 List
新增一個新元素5, 將不再影響原 List
了. 原 List
內的元素依然是 [0, 1, 2, 3, 4], 而不會再像先前的 bug那樣也增加一個元素5了. 其他情況, 大家就自己測試吧.
好了, 這篇文章就到此為止. 通過本文的分析, 我們得出一個結論, 那就是, 經驗主義有時會讓你很受傷, 千萬不要亂用經驗主義. 本文所描述的主人公, 就是因為看到subList()
和 subString()
這兩個方法的命名方式類似, 於是根據經驗主義而寫出了錯誤的程式碼.