使用 List.subList 進(jìn)行切片操作居然會(huì)導(dǎo)致 OOM?

業(yè)務(wù)開(kāi)發(fā)時(shí)常常要對(duì) List 做切片處理,即取出其中部分元素構(gòu)成一個(gè)新的 List,我們通常會(huì)想到使用 List.subList 方法。但,和 Arrays.asList 的問(wèn)題類似,List.subList 返回的子 List 不是一個(gè)普通的 ArrayList。這個(gè)子 List 可以認(rèn)為是原始 List 的視圖,會(huì)和原始 List 相互影響。如果不注意,很可能會(huì)因此產(chǎn)生 OOM 問(wèn)題。接下來(lái),我們就一起分析下其中的坑。

如下代碼所示,定義一個(gè)名為 data 的靜態(tài) List 來(lái)存放 Integer 的 List,也就是說(shuō) data 的成員本身是包含了多個(gè)數(shù)字的 List。循環(huán) 1000 次,每次都從一個(gè)具有 10 萬(wàn)個(gè) Integer 的 List 中,使用 subList 方法獲得一個(gè)只包含一個(gè)數(shù)字的子 List,并把這個(gè)子 List 加入 data 變量:


private static List<List<Integer>> data = new ArrayList<>();

private static void oom() {
    for (int i = 0; i < 1000; i++) {
        List<Integer> rawList = IntStream.rangeClosed(1, 100000).boxed().collect(Collectors.toList());
        data.add(rawList.subList(0, 1));
    }
}

你可能會(huì)覺(jué)得,這個(gè) data 變量里面最終保存的只是 1000 個(gè)具有 1 個(gè)元素的 List,不會(huì)占用很大空間,但程序運(yùn)行不久就出現(xiàn)了 OOM:


Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
  at java.util.Arrays.copyOf(Arrays.java:3181)
  at java.util.ArrayList.grow(ArrayList.java:265)

出現(xiàn) OOM 的原因是,循環(huán)中的 1000 個(gè)具有 10 萬(wàn)個(gè)元素的 List 始終得不到回收,因?yàn)樗冀K被 subList 方法返回的 List 強(qiáng)引用。那么,返回的子 List 為什么會(huì)強(qiáng)引用原始的 List,它們又有什么關(guān)系呢?我們?cè)倮^續(xù)做實(shí)驗(yàn)觀察一下這個(gè)子 List 的特性。

首先初始化一個(gè)包含數(shù)字 1 到 10 的 ArrayList,然后通過(guò)調(diào)用 subList 方法取出 2、3、4;隨后刪除這個(gè) SubList 中的元素?cái)?shù)字 3,并打印原始的 ArrayList;最后為原始的 ArrayList 增加一個(gè)元素?cái)?shù)字 0,遍歷 SubList 輸出所有元素:


List<Integer> list = IntStream.rangeClosed(1, 10).boxed().collect(Collectors.toList());
List<Integer> subList = list.subList(1, 4);
System.out.println(subList);
subList.remove(1);
System.out.println(list);
list.add(0);
try {
    subList.forEach(System.out::println);
} catch (Exception ex) {
    ex.printStackTrace();
}

代碼運(yùn)行后得到如下輸出:


[2, 3, 4]
[1, 2, 4, 5, 6, 7, 8, 9, 10]
java.util.ConcurrentModificationException
  at java.util.ArrayList$SubList.checkForComodification(ArrayList.java:1239)
  at java.util.ArrayList$SubList.listIterator(ArrayList.java:1099)
  at java.util.AbstractList.listIterator(AbstractList.java:299)
  at java.util.ArrayList$SubList.iterator(ArrayList.java:1095)
  at java.lang.Iterable.forEach(Iterable.java:74)

可以看到兩個(gè)現(xiàn)象:
原始 List 中數(shù)字 3 被刪除了,說(shuō)明刪除子 List 中的元素影響到了原始 List;
嘗試為原始 List 增加數(shù)字 0 之后再遍歷子 List,會(huì)出現(xiàn) ConcurrentModificationException。

我們分析下 ArrayList 的源碼,看看為什么會(huì)是這樣。


public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
    protected transient int modCount = 0;
  private void ensureExplicitCapacity(int minCapacity) {
        modCount++;
        // overflow-conscious code
        if (minCapacity - elementData.length > 0)
            grow(minCapacity);
    }
  public void add(int index, E element) {
    rangeCheckForAdd(index);

    ensureCapacityInternal(size + 1);  // Increments modCount!!
    System.arraycopy(elementData, index, elementData, index + 1,
                     size - index);
    elementData[index] = element;
    size++;
  }

  public List<E> subList(int fromIndex, int toIndex) {
    subListRangeCheck(fromIndex, toIndex, size);
    return new SubList(this, offset, fromIndex, toIndex);
  }

  private class SubList extends AbstractList<E> implements RandomAccess {
    private final AbstractList<E> parent;
    private final int parentOffset;
    private final int offset;
    int size;

    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;
    }

        public E set(int index, E element) {
            rangeCheck(index);
            checkForComodification();
            return l.set(index+offset, element);
        }

    public ListIterator<E> listIterator(final int index) {
                checkForComodification();
                ...
    }

    private void checkForComodification() {
        if (ArrayList.this.modCount != this.modCount)
            throw new ConcurrentModificationException();
    }
    ...
  }
}

第一,ArrayList 維護(hù)了一個(gè)叫作 modCount 的字段,表示集合結(jié)構(gòu)性修改的次數(shù)。所謂結(jié)構(gòu)性修改,指的是影響 List 大小的修改,所以 add 操作必然會(huì)改變 modCount 的值。

第二,分析第 21 到 24 行的 subList 方法可以看到,獲得的 List 其實(shí)是內(nèi)部類 SubList,并不是普通的 ArrayList,在初始化的時(shí)候傳入了 this。

第三,分析第 26 到 39 行代碼可以發(fā)現(xiàn),這個(gè) SubList 中的 parent 字段就是原始的 List。SubList 初始化的時(shí)候,并沒(méi)有把原始 List 中的元素復(fù)制到獨(dú)立的變量中保存。我們可以認(rèn)為 SubList 是原始 List 的視圖,并不是獨(dú)立的 List。雙方對(duì)元素的修改會(huì)相互影響,而且 SubList 強(qiáng)引用了原始的 List,所以大量保存這樣的 SubList 會(huì)導(dǎo)致 OOM。

第四,分析第 47 到 55 行代碼可以發(fā)現(xiàn),遍歷 SubList 的時(shí)候會(huì)先獲得迭代器,比較原始 ArrayList modCount 的值和 SubList 當(dāng)前 modCount 的值。獲得了 SubList 后,我們?yōu)樵?List 新增了一個(gè)元素修改了其 modCount,所以判等失敗拋出 ConcurrentModificationException 異常。

既然 SubList 相當(dāng)于原始 List 的視圖,那么避免相互影響的修復(fù)方式有兩種:

一種是,不直接使用 subList 方法返回的 SubList,而是重新使用 new ArrayList,在構(gòu)造方法傳入 SubList,來(lái)構(gòu)建一個(gè)獨(dú)立的 ArrayList;

另一種是,對(duì)于 Java 8 使用 Stream 的 skip 和 limit API 來(lái)跳過(guò)流中的元素,以及限制流中元素的個(gè)數(shù),同樣可以達(dá)到 SubList 切片的目的。


//方式一:
List<Integer> subList = new ArrayList<>(list.subList(1, 4));

//方式二:
List<Integer> subList = list.stream().skip(1).limit(3).collect(Collectors.toList());

修復(fù)后代碼輸出如下:


[2, 3, 4]
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
2
4

可以看到,刪除 SubList 的元素不再影響原始 List,而對(duì)原始 List 的修改也不會(huì)再出現(xiàn) List 迭代異常。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容