原文:http://blog.csdn.net/cleverGump/article/details/51105235
轉載請注明本文出自 clevergump 的博客:http://blog.csdn.net/clevergump/article/details/51105235, 謝謝!
前言
本文原先發表在我的 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
- 1
在其父類 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>
- 1
發現, 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);
- 1
並且也知道了, 子 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()
這兩個方法的命名方式類似, 於是根據經驗主義而寫出了錯誤的代碼.
參考資料:
- 《編寫高質量代碼 改善java程序的151個建議》 建議72 生成子列表后不要再操作原列表