1. Arrays.asList

1.1. 基本类型数组

在如下代码中,我们初始化三个数字的 int[]数组,然后使用 Arrays.asList 把数组转换为 List:

1
2
3
4
5
int[] arr = {1, 2, 3};

List list = Arrays.asList(arr);

log.info("list:{} size:{} class:{}", list, list.size(), list.get(0).getClass());

但,这样初始化的 List 并不是我们期望的包含 3 个数字的 List。通过日志可以发现,这个 List 包含的其实是一个 int 数组,整个 List 的元素个数是 1,元素类型是整数数组。

其原因是,只能是把 int 装箱为 Integer,不可能把 int 数组装箱为 Integer 数组。我们知道,Arrays.asList 方法传入的是一个泛型 T 类型可变参数,最终 int 数组整体作为了一个对象成为了泛型类型 T:

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

1.1.1. 解决方法1

如果使用 Java8 以上版本可以使用 Arrays.stream 方法来转换,否则可以把 int 数组声明为包装类型 Integer 数组:

1
2
3
4
5
int[] arr1 = {1, 2, 3};

List list1 = Arrays.stream(arr1).boxed().collect(Collectors.toList());

log.info("list:{} size:{} class:{}", list1, list1.size(), list1.get(0).getClass());

1.1.2. 解决方法2

把 int 数组声明为包装类型 Integer 数组

1
2
3
4
5
Integer[] arr2 = {1, 2, 3};

List list2 = Arrays.asList(arr2);

log.info("list:{} size:{} class:{}", list2, list2.size(), list2.get(0).getClass());

1.2. 增删操作

Arrays.asList 返回的 List 不支持增删操作。Arrays.asList 返回的 List 并不是我们期望的 java.util.ArrayList,而是 Arrays 的内部类 ArrayList。ArrayList 内部类继承自 AbstractList 类,并没有覆写父类的 add 方法,而父类中 add 方法的实现,就是抛出 UnsupportedOperationException。相关源码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public static <T> List<T> asList(T... a) {

return new ArrayList<>(a);

}

private static class ArrayList<E> extends AbstractList<E>

implements RandomAccess, java.io.Serializable

{

private final E[] a;

ArrayList(E[] array) {

a = Objects.requireNonNull(array);

}

...

@Override

public E set(int index, E element) {

E oldValue = a[index];

a[index] = element;

return oldValue;

}

...

}

public abstract class AbstractList<E> extends AbstractCollection<E> implements List<E> {

...

public void add(int index, E element) {

throw new UnsupportedOperationException();

}

}

1.3. 数组共享

对原始数组的修改会影响到我们获得的那个 List。看一下 ArrayList 的实现,可以发现 ArrayList 其实是直接使用了原始的数组。所以,我们要特别小心,把通过 Arrays.asList 获得的 List 交给其他方法处理,很容易因为共享了数组,相互修改产生 Bug。

1.3.1. 解决方法

需要增删和防止数组共享的方式比较简单,重新 new 一个 ArrayList 初始化 Arrays.asList 返回的 List 即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
String[] arr = {"1", "2", "3"};

List list = new ArrayList(Arrays.asList(arr));

arr[1] = "4";

try {

list.add("5");

} catch (Exception ex) {

ex.printStackTrace();

}

log.info("arr:{} list:{}", Arrays.toString(arr), list);

2. List.subList导致OOM

业务开发时常常要对 List 做切片处理,即取出其中部分元素构成一个新的 List,我们通常会想到使用 List.subList 方法。但,和 Arrays.asList 的问题类似,List.subList 返回的子 List 不是一个普通的 ArrayList。这个子 List 可以认为是原始 List 的视图,会和原始 List 相互影响。如果不注意,很可能会因此产生 OOM 问题。接下来,我们就一起分析下其中的坑。

如下代码所示,定义一个名为 data 的静态 List 来存放 Integer 的 List,也就是说 data 的成员本身是包含了多个数字的 List。循环 1000 次,每次都从一个具有 10 万个 Integer 的 List 中,使用 subList 方法获得一个只包含一个数字的子 List,并把这个子 List 加入 data 变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
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));

}

}

你可能会觉得,这个 data 变量里面最终保存的只是 1000 个具有 1 个元素的 List,不会占用很大空间,但程序运行不久就出现了 OOM

出现 OOM 的原因是,循环中的 1000 个具有 10 万个元素的 List 始终得不到回收,因为它始终被 subList 方法返回的 List 强引用。那么,返回的子 List 为什么会强引用原始的 List,它们又有什么关系呢?我们再继续做实验观察一下这个子 List 的特性。

首先初始化一个包含数字 1 到 10 的 ArrayList,然后通过调用 subList 方法取出 2、3、4;随后删除这个 SubList 中的元素数字 3,并打印原始的 ArrayList;最后为原始的 ArrayList 增加一个元素数字 0,遍历 SubList 输出所有元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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();

}

代码运行后得到如下输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[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)

可以看到两个现象:

原始 List 中数字 3 被删除了,说明删除子 List 中的元素影响到了原始 List;

尝试为原始 List 增加数字 0 之后再遍历子 List,会出现 ConcurrentModificationException。

我们分析下 ArrayList 的源码,看看为什么会是这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
protected transient int modCount = 0;
...
public List<E> subList(int fromIndex, int toIndex) {
subListRangeCheck(fromIndex, toIndex, size);
return new SubList(this, 0, 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;
}
}
private void checkForComodification() {
if (ArrayList.this.modCount != this.modCount)
throw new ConcurrentModificationException();
}
...
}

第一,ArrayList 维护了一个叫作 modCount 的字段,表示集合结构性修改的次数。所谓结构性修改,指的是影响 List 大小的修改,所以 add 操作必然会改变 modCount 的值。

第二,分析 subList 方法可以看到,获得的 List 其实是内部类 SubList,并不是普通的ArrayList,在初始化的时候传入了 this。

第三,分析整段代码可以发现,这个 SubList 中的 parent 字段就是原始的 List。SubList 初始化的时候,并没有把原始 List 中的元素复制到独立的变量中保存。我们可以认为 SubList 是原始 List 的视图,并不是独立的 List。双方对元素的修改会相互影响,而且 SubList 强引用了原始的 List,所以大量保存这样的 SubList 会导致 OOM。

第四,分析checkForComodification方法可以发现,遍历 SubList 的时候会先获得迭代器,比较原始 ArrayList modCount 的值和 SubList 当前 modCount 的值。获得了 SubList 后,我们为原始 List 新增了一个元素修改了其 modCount,所以判等失败抛出 ConcurrentModificationException 异常。

2.1. 解决方法

  1. 不直接使用 subList 方法返回的 SubList,而是重新使用 new ArrayList,在构造方法传入 SubList,来构建一个独立的 ArrayList;
1
List<Integer> subList = new ArrayList<>(list.subList(1, 4));
  1. 对于 Java 8 使用 Stream 的 skip 和 limit API 来跳过流中的元素,以及限制流中元素的个数,同样可以达到 SubList 切片的目的。
1
List<Integer> subList = list.stream().skip(1).limit(3).collect(Collectors.toList());

3. 让合适的数据结构做合适的事情

3.1. 使用数据结构不考虑平衡时间和空间

要对大 List 进行单值搜索的话,可以考虑使用 HashMap,其中 Key 是要搜索的值,Value 是原始对象,会比使用 ArrayList 有非常明显的性能优势。

即使我们要搜索的不是单值而是条件区间,也可以尝试使用 HashMap 来进行“搜索性能优化”。如果你的条件区间是固定的话,可以提前把 HashMap 按照条件区间进行分组,Key 就是不同的区间。

的确,如果业务代码中有频繁的大 ArrayList 搜索,使用 HashMap 性能会好很多。类似,如果要对大 ArrayList 进行去重操作,也不建议使用 contains 方法,而是可以考虑使用 HashSet 进行去重。

关于“含金量”,我们使用 ObjectSizeCalculator 工具打印 ArrayList 和 HashMap 的内存占用,可以看到 ArrayList 占用内存 21M,而 HashMap 占用的内存达到了 72M,是 List 的三倍多。进一步使用 MAT 工具分析堆可以再次证明,ArrayList 在内存占用上性价比很高,77% 是实际的数据(如第 1 个图所示,16000000/20861992),而 HashMap 的“含金量”只有 22%(如第 2 个图所示,16000000/72386640)。

所以,在应用内存吃紧的情况下,我们需要考虑是否值得使用更多的内存消耗来换取更高的性能。这里我们看到的是平衡的艺术,空间换时间,还是时间换空间,只考虑任何一个方面都是不对的。

3.2. 过于迷信教科书的大 O 时间复杂度

翻看 LinkedList 源码发现,插入操作的时间复杂度是 O(1) 的前提是,你已经有了那个要插入节点的指针。但,在实现的时候,我们需要先通过循环获取到那个节点的 Node,然后再执行插入操作。前者也是有开销的,不可能只考虑插入操作本身的代价:所以,对于插入操作,LinkedList 的时间复杂度其实也是 O(n)。继续做更多实验的话你会发现,在各种常用场景下,LinkedList 几乎都不能在性能上胜出 ArrayList。
讽刺的是,LinkedList 的作者约书亚 · 布洛克(Josh Bloch),在其推特上回复别人时说,虽然 LinkedList 是我写的但我从来不用,有谁会真的用吗?

4. 参考

极客时间