【問題總結】萬萬沒想到,竟然栽在了List手裡

弗蘭克的貓發表於2019-05-18

說明

昨天同事開發的時候遇到了一個奇怪的問題。

【問題總結】萬萬沒想到,竟然栽在了List手裡

使用Guava做快取,往裡面存一個List,為了方便描述,稱它為列表A,在另一個地方取出來,再跟列表B中的元素進行差集處理,簡單來說,就像是下面這樣:

public class ArrayListTest {
    // 方便起見,這裡用HashMap來做快取
    private Map<String, List<Long>> cache = new HashMap<>();
    
    private void save(){
        List<Long> listA = createListA();
        cache.put("listA", listA);
    }
    
    private void get(){
        List<Long> listB = createListB();
        List<Long> listA = cache.get("listA");
        listA.removeAll(listB);
    }
    
    private List<Long> createListA(){
        ···
    }

    private List<Long> createListB(){
        ···
    }

    public static void main(String[] args){
        ArrayListTest test = new ArrayListTest();
        test.save();
        test.get();
    }
}

先呼叫save方法,然後呼叫get方法,然後就丟擲了異常:

【問題總結】萬萬沒想到,竟然栽在了List手裡

Exception in thread "main" java.lang.UnsupportedOperationException
    at java.util.AbstractList.remove(AbstractList.java:161)
    at java.util.AbstractList$Itr.remove(AbstractList.java:374)
    at java.util.AbstractCollection.removeAll(AbstractCollection.java:376)
    ...

【問題總結】萬萬沒想到,竟然栽在了List手裡

問題探索

究竟是人性的泯滅還是道德的淪喪,一個小小的List竟然也玩不轉了,面對突如其來的打擊,我跟同事都開始反思,複製貼上一時爽,debug火葬場。

但作為一名優秀的程式猿,怎麼能被這點困難所難倒呢?於是開始了問題排查之旅。

先來驗證一下自己對ArrayList是否有什麼誤解:

@Test
public void testArrayList() {
    List<Long> listA = new ArrayList<>();
    listA.add(1L);
    listA.add(2L);
    List<Long> listB = new ArrayList<>();
    listB.add(2L);
    listB.add(3L);
    listA.removeAll(listB);
    System.out.println(JSON.toJSONString(listA));
}

輸出如下:

[1]

嗯,看來並沒有。

【問題總結】萬萬沒想到,竟然栽在了List手裡

再回過頭看看,丟擲的異常是 UnsupportedOperationException 異常,而且是在 AbstractList 裡丟擲的,於是開啟了 AbstractList的原始碼。

public E remove(int index) {
    throw new UnsupportedOperationException();
}

AbstractList 類對remove方法的預設實現就是直接丟擲一個異常,所以如果子類並沒有覆蓋該方法,就會出現上面的問題。

那麼問題應該就出在列表A的建立方式上。

結果一找,發現列表A是通過 Arrays.asList() 建立的,再跟進程式碼:

public static <T> List<T> asList(T... a) {
    return new ArrayList<>(a);
}

感覺好像也沒哪裡不對,這裡也是建立一個 ArrayList ,講道理的話,應該沒問題才對,不過等等,ArrayList 好像沒有能傳入可變長引數的建構函式吧,於是朝著這個ArrayList小手一點,終於發現了問題所在。

原來通過 Arrays.asList() 建立的 List 物件是通過例項化 Arrays 內部類 ArrayList 來建立的,所以這個 ArrayList 並不是我們常用的那個 ArrayList

20190518101356.png

20190518101255.png

這個內部類並沒有覆蓋父類 AbstractListremove 方法,所以呼叫的時候就會直接呼叫父類的 remove 方法,於是便發生了上面的異常。

Arrays.asList的正確開啟方式

為了更好的使用這裡方法,我們先來看看它的註釋說明:

 /**
* Returns a fixed-size list backed by the specified array.  (Changes to
* the returned list "write through" to the array.)  This method acts
* as bridge between array-based and collection-based APIs, in
* combination with {@link Collection#toArray}.  The returned list is
* serializable and implements {@link RandomAccess}.
*
* <p>This method also provides a convenient way to create a fixed-size
* list initialized to contain several elements:
* <pre>
*     List&lt;String&gt; stooges = Arrays.asList("Larry", "Moe", "Curly");
* </pre>
*
* @param <T> the class of the objects in the array
* @param a the array by which the list will be backed
* @return a list view of the specified array
*/

從說明可以發現,有這麼幾點需要注意:

1、該方法返回的是一個固定長度的列表

所以它的長度是不能被改變的,也就不能對它進行新增和刪除元素的操作,從它的內部類ArrayList的方法列表也可以看出,並沒有覆蓋add和remove方法,因此對這兩個方法的呼叫都會導致丟擲異常。

雖然不能改變列表的長度,但是可以改變列表中的元素,以及元素的位置。比如通過set方法來重新設值,通過replaceAll方法來批量替換,通過sort方法來排序等等。

2、任何對列表的改動都會回寫到原來是陣列

也就是說對返回的列表進行的任何修改操作,都會導致原陣列的改變。可以通過一個Test來測試一下:

@Test
public void testArrays() {
    Long[] longs = {1L,2L,4L,3L};
    List<Long> longList = Arrays.asList(longs);
    System.out.println("longList:" + JSON.toJSONString(longList) + "longs:" + JSON.toJSONString(longs));

    longList.set(1, 5L);
    System.out.println("longList:" + JSON.toJSONString(longList) + "longs:" + JSON.toJSONString(longs));

    longList.replaceAll(a -> a + 1L);
    System.out.println("longList:" + JSON.toJSONString(longList) + "longs:" + JSON.toJSONString(longs));

    longList.sort(Long::compareTo);
    System.out.println("longList:" + JSON.toJSONString(longList) + "longs:" + JSON.toJSONString(longs));

    longs[2] = 7L;
    System.out.println("longList:" + JSON.toJSONString(longList) + "longs:" + JSON.toJSONString(longs));
}

輸出如下:

longList:[1,2,4,3]longs:[1,2,4,3]
longList:[1,5,4,3]longs:[1,5,4,3]
longList:[2,6,5,4]longs:[2,6,5,4]
longList:[2,4,5,6]longs:[2,4,5,6]
longList:[2,4,7,6]longs:[2,4,7,6]

注意最後一個輸出,我們修改原陣列的元素,也會導致列表元素的改變,究其原因,當然是因為列表只是將陣列封裝了起來而已,最終指向的都是同一個記憶體地址,因此修改自然也是同步的。

3、不能使用基本資料型別陣列來作為引數

舉個例子:

@Test
public void testArrays2() {
    int[] ints = { 1, 2, 3 };
    List list = Arrays.asList(ints);
    System.out.println(list.size());
}

這裡並不會報錯,而是會輸出1。為什麼呢?

再回過頭去看下說明:

@param <T> the class of the objects in the array

引數的型別T指的是陣列中的元素型別,如果陣列中元素型別是基本型別,就會把整個陣列當成一個元素,我們把上面的栗子稍微修改一下就清楚了。

@Test
public void testArrays2() {
    int[] ints = { 1, 2, 3 };
    System.out.println(ints.getClass());
    List list = Arrays.asList(ints);
    System.out.println(JSON.toJSONString(list));
}

輸出如下:

class [I
[[1,2,3]]

注意第二行的輸出是一個二維陣列。變長引數本質上就是一個物件陣列,所以如果傳入一個Integer陣列,就能正常接收:

@Test
public void testArrays2() {
    Integer[] ints = { 1, 2, 3 };
    System.out.println(ints.getClass());
    List list = Arrays.asList(ints);
    System.out.println(list.size());
}
class [Ljava.lang.Integer;
3

總結

至此,關於 Arrays.asList() 的探索之旅就結束了,遇到問題一般跟一跟原始碼就差不多能解決了,但對於常用的類,如果對其內部的執行機制不熟悉的話,程式碼就會容易出現一些不符合預期的行為,報錯的異常並不可怕,因為可以根據異常很快定位,最怕的就是不報錯,能正常執行,但是資料處理卻是錯誤的,等到真正發現的時候,可能已經造成了難以挽回的損失。

【問題總結】萬萬沒想到,竟然栽在了List手裡

看來主動閱讀原始碼還是相當有必要的,其實Arrays.asList()並不難使用,推而廣之,就像Guava、fastjson這些模組,或者spring、redis、dubbo之類,學習使用並不難,但如果不熟悉內部執行機制,僅僅當成一個黑盒的話,無法探索內部的精妙設計,遇到問題也比較難處理,如果只是把功能框定在其設定的能力範圍之內,就沒有辦法進行定製化的改造。

嗯,看來我的歷練路程還很長啊。最後用荀子的一句話來共勉吧。

“路雖彌,不行不至,

事雖小,不做不成。”

【問題總結】萬萬沒想到,竟然栽在了List手裡

相關文章