Java List集合常见的坑

Arrays.asList转换基本类型数组的坑

在实际的业务开发中,我们通常会进行数组转List的操作,通常我们会使用Arrays.asList来进行转换。

但是在转换基本类型的数组的时候,却出现转换的结果和我们想象的不一致。

1
2
3
4
int[] arr = {1, 2, 3}; 
List list = Arrays.asList(arr);
System.out.println(list.size());
// 1

实际上,我们想要转成的List应该是有三个对象而现在只有一个。这是asList的源码:

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

可以看到,接收的是一个泛型T的参数,而Java的泛型是基于Object的,int[]是一个对象,而int是基本数据类型,不是Object,所以程序会把arr数组当成一个整体传进去。那我们该如何解决呢?

方案一:Java8以上,利用Arrays.stream(arr).boxed()将装箱为Integer数组

1
2
3
4
List collect = Arrays.stream(arr).boxed().collect(Collectors.toList()); System.out.println(collect.size()); 
System.out.println(collect.get(0).getClass());
// 3
// class java.lang.Integer

方案二:声明数组的时候,声明类型改为包装类型

1
2
3
4
5
Integer[] integerArr = {1, 2, 3}; 
List integerList = Arrays.asList(integerArr);
System.out.println(integerList.size()); System.out.println(integerList.get(0).getClass());
// 3
// class java.lang.Integer

Arrays.asList返回的List不支持增删操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private static void asListAdd(){
String[] arr = {"1", "2", "3"};
List<String> strings = new ArrayList<>(Arrays.asList(arr));
arr[2] = "4";
System.out.println(strings.toString());
Iterator<String> iterator = strings.iterator();
while (iterator.hasNext()){
if ("4".equals(iterator.next())){
iterator.remove();
}
}
strings.forEach(val ->{
strings.remove("4");
strings.add("3");
});


System.out.println(Arrays.asList(arr).toString());
}

[1, 2, 4]
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.remove(AbstractCollection.java:293) at JavaBase.List.AsListTest.lambda$asListAdd$0(AsListTest.java:47) at java.util.Arrays$ArrayList.forEach(Arrays.java:3880) at JavaBase.List.AsListTest.asListAdd(AsListTest.java:46) at JavaBase.List.AsListTest.main(AsListTest.java:20)

初始化一个字符串数组,将字符串数组转换为 List,在遍历List的时候进行移除和新增的操作时抛出异常信息UnsupportedOperationException。

根据异常信息java.lang.UnsupportedOperationException,我们看到他是从AbstractList里面出来的,让我们进入源码一看究竟。

我们在什么时候调用到了这个 AbstractList 呢?

其实 Arrays.asList(arr) 返回的 ArrayList 不是 java.util.ArrayList,而是 Arrays的内部类。

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
private static class ArrayList<E> extends AbstractList<E>
implements RandomAccess, java.io.Serializable{
private static final long serialVersionUID = -2764017481108945198L;
private final E[] a;
ArrayList(E[] array) {
a = Objects.requireNonNull(array);
}

@Override
public E get(int index) {}

@Override
public E set(int index, E element) {...}

...
}
public abstract class AbstractList<E> extends AbstractCollection<E> implements List<E> {
public boolean add(E e) {
add(size(), e);
return true;
}
public void add(int index, E element) {
throw new UnsupportedOperationException();
}

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

}

是没有实现 AbstractList 中的 add()remove() 方法,这里就很清晰了为什么不支持新增和删除。

对原始数组的修改会影响到我们获得的那个List

一不小心修改了父List,却影响到了子List,在业务代码中,这会导致产生的数据发生变化,严重的话会造成影响较大的生产问题。

第二个坑的源码中,完成字符串数组转换为List之后,我们将字符串数组的第三个对象的值修改为4,但是很奇怪在打印List的时候,发现List也发生了变化。asList中创建了 ArrayList,但是他直接引用了原本的数组对象。所以只要原本的数组对象一发生变化,List也跟着变化。所以在使用到引用的时候,我们需要特别的注意。

解决方案:

重新new一个新的 ArrayList 来装返回的 List。

1
List strings = new ArrayList<>(Arrays.asList(arr));  

快速失败机制

modCountAbstractList 类中的一个字段,用于记录对 List 结构进行修改的次数。

modCount(modification count)是一个用于实现快速失败机制的计数器。它主要用于检测在迭代期间是否对 List 进行了结构上的修改。每当进行增加、删除或者替换操作时,modCount 的值都会增加。当使用迭代器(例如 Iterator 或者 ListIterator)遍历 List 时,如果发现 modCount 的值与初始时的值不一致,就会抛出 ConcurrentModificationException 异常,以确保在并发环境下不会产生不可预料的结果。

通过快速失败机制,我们可以在多线程环境下及时发现并防止并发修改带来的问题。当一个线程在迭代 List 的同时,另一个线程对其进行结构上的修改时,由于 modCount 值的不一致,就能够在迭代器的下一个操作时触发异常,从而保证了数据一致性和线程安全性。

并非所有的 List 实现类都具有该字段。但是,大多数标准的 Java 集合类(如 ArrayListLinkedListVector 等)都继承自 AbstractList,因此都会有 modCount 字段。

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
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class FailFastExample {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("Alice");
list.add("Bob");
list.add("Charlie");

// 获取迭代器
Iterator<String> iterator = list.iterator();

// 在迭代过程中进行结构上的修改
list.add("David");

try {
// 遍历集合
while (iterator.hasNext()) {
String name = iterator.next();
System.out.println(name);
}
} catch (ConcurrentModificationException e) {
System.out.println("ConcurrentModificationException caught!");
}
}
}

因此java.util.ArrayList如果不正确操作也不支持增删操作。

正确的删除方式有:

  1. 普通for循环删除
  2. 调用Iterator中的remove方法,确保预期的modCount和真实的一致
  3. Stream流中的Filter
  4. 使用fail-safe集合类,如ConcurrentLinkedDeque

ArrayList中的 subList 强转 ArrayList 导致异常

说明: subList 返回的是ArrayList 的内部类SubList, 并不是ArrayList ,而是ArrayList的一个视图,対于SubList子列表的所有操作最终会反映到原列表上。

1
2
3
4
5
6
7
8
9
10
11
private static void subListTest(){  
List<String> names = new ArrayList<String>() {{
add("one");
add("two");
add("three");
}};
ArrayList strings = (ArrayList) names.subList(0, 1);
System.out.println(strings.toString());
}

Exception in thread "main" java.lang.ClassCastException: java.util.ArrayList$SubList cannot be cast to java.util.ArrayList

因为是引用的关系,所以在这里也需要特别的注意,如果对原来的List进行修改,会对产生的 subList结果产生影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
List<String> names = new ArrayList<String>() {{
add("one");
add("two");
add("three");
}};

List strings = names.subList(0, 1);

strings.add(0, "ongChange");

System.out.println(strings.toString());

System.out.println(names.toString());

[ongChange, one]

[ongChange, one, two, three]

对subList产生的List做出结构型修改,操作会反应到原来的List上,ongChange也添加到了names中

但如果修改原来的List则会抛出异常ConcurrentModificationException,下面是代码演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
List<String> names = new ArrayList<String>() {{

add("one");
add("two");
add("three");

}};
// SubList的modCount为3
List strings = names.subList(0, 1);
// ArrayList的modCount为4
names.add("four");

// 不一致,报错
System.out.println(strings.toString());

System.out.println(names.toString());

Exception in thread "main" java.util.ConcurrentModificationException

原来的List插入了一个新元素,导致this.modCount不第一次保存的不一致则抛出异常

解决方案:在操作SubList的时候,new一个新的ArrayList来接收创建subList结果的拷贝

1
List strings = new ArrayList(names.subList(0, 1));

ArrayList中的subList切片造成OOM

演示代码如下:

1
2
3
4
5
6
private static void subListOomTest() {  
IntStream.range(0, 1000).forEach(i -> {
List<Integer> collect = IntStream.range(0, 100000).boxed().collect(Collectors.toList());
data.add(collect.subList(0, 1));
});
}

这段代码展示了一种可能引发内存溢出(OOM)错误的情况,涉及到对 subList() 方法的连续调用。

代码的逻辑如下:

  1. 创建一个整数范围为 0 到 1000 的流,并对每个元素执行以下操作:
  2. 在循环中,创建一个大小为 100000 的整数列表,元素值为 0 到 99999。
  3. 调用 subList(0, 1) 方法,该方法会返回原始列表的一个子列表,包含第一个元素。
  4. 将子列表添加到名为 data 的列表中。

这段代码会导致内存溢出的原因是,subList() 方法返回的是原始列表的一个视图,而不是新的独立列表。因此,在每次迭代过程中,都会将一个新的子列表添加到 data 中。由于大量的子列表对象被持续创建并保存在内存中,最终导致内存耗尽。

在subList方法返回SubList,重新使用new ArrayList,来构建一个独立的ArrayList

List list = new ArrayList<>(collect.subList(0, 1));

利用Java8的Stream中的skip和limit来达到切片的目的

List list = collect.stream().skip(0).limit(1).collect(Collectors.toList());

CopyOnWriteArrayList内存占用过多

CopyOnWrite,顾名思义就是写的时候会将共享变量新复制一份出来,这样做的好处是读操作完全无锁。

CopyOnWriteArrayListadd()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public boolean add(E e) {
// 获取独占锁
final ReentrantLock lock = this.lock;
lock.lock();
try {
// 获取array
Object[] elements = getArray();
// 复制array到新数组,添加元素到新数组
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
// 替换数组
setArray(newElements);
return true;
} finally {
// 释放锁
lock.unlock();
}
}

CopyOnWriteArrayList 内部维护了一个数组,成员变量 array 就指向这个内部数组,所有的读操作都是基于新的array对象进行的。

因为上了独占锁,所以如果多个线程调用add()方法只有一个线程会获得到该锁,其他线程被阻塞,知道锁被释放, 由于加了锁,所以整个操作的过程是原子性操作

CopyOnWriteArrayList 会将 新的array复制一份,然后在新复制处理的数组上执行增加元素的操作,执行完之后再将复制的结果指向这个新的数组。

由于每次写入的时候都会对数组对象进行复制,复制过程不仅会占用双倍内存,还需要消耗 CPU 等资源,所以当列表中的元素比较少的时候,这对内存和 GC 并没有多大影响,但是当列表保存了大量元素的时候,

对 CopyOnWriteArrayList 每一次修改,都会重新创建一个大对象,并且原来的大对象也需要回收,这都可能会触发 GC,如果超过老年代的大小则容易触发Full GC,引起应用程序长时间停顿。

CopyOnWriteArrayList是弱一致性的

CopyOnWriteArrayList的Iterator源码

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
public Iterator<E> iterator() {
return new COWIterator<E>(getArray(), 0);
}

static final class COWIterator<E> implements ListIterator<E> {
/** Snapshot of the array */
private final Object[] snapshot;
/** Index of element to be returned by subsequent call to next. */
private int cursor;

private COWIterator(Object[] elements, int initialCursor) {
cursor = initialCursor;
snapshot = elements;
}

public boolean hasNext() {
return cursor < snapshot.length;
}

public boolean hasPrevious() {
return cursor > 0;
}

@SuppressWarnings("unchecked")
public E next() {
if (! hasNext())
throw new NoSuchElementException();
return (E) snapshot[cursor++];
}
...

调用iterator方法获取迭代器返回一个COWIterator对象

COWIterator的构造器里主要是 保存了当前的list对象的内容和遍历list时数据的下标。

snapshot是list的快照信息,因为CopyOnWriteArrayList的读写策略中都会使用getArray()来获取一个快照信息,生成一个新的数组

所以在使用该迭代器元素时,其他线程对该list操作是不可见的,因为操作的是两个不同的数组所以造成弱一致性。

演示代码如下:

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
private static void CopyOnWriteArrayListTest(){
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList();
list.add("test1");
list.add("test2");
list.add("test3");
list.add("test4");

Thread thread = new Thread(() -> {
System.out.println(">>>> start");
list.add(1, "replaceTest");
list.remove(2);
});

// 在启动线程前获取迭代器
Iterator<String> iterator = list.iterator();

thread.start();

try {
// 等待线程执行完毕
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}

while (iterator.hasNext()){
System.out.println(iterator.next());
}
}

>>>> start
test1
test2
test3
test4

CopyOnWriteArrayList的迭代器不支持增删改

opyOnWriteArrayList 迭代器是只读的,不支持增删操作

CopyOnWriteArrayList迭代器中的 remove()add()方法,没有支持增删而是直接抛出了异常。

因为迭代器遍历的仅仅是一个快照,而对快照进行增删改是没有意义的。

这个很简单,就不演示代码了。

还在用 list.contains() 做去重?该换换了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 使用 list.contain 去重
*
* @param testList
*/
private static void useContain2Distinct(List<String> testList) {
System.out.println("contains 开始去重,条数:" + testList.size());
List<String> testListDistinctResult = new ArrayList<>();
for (String str : testList) {
if (!testListDistinctResult.contains(str)) {
testListDistinctResult.add(str);
}
}
System.out.println("contains 去重完毕,条数:" + testListDistinctResult.size());
}

上面这个代码的复杂度是$O(n^2)$,可想而知有多慢。

解决办法:使用Set去重

1
2
3
4
5
6
7
8
9
10
/**
* 使用set去重
*
* @param testList
*/
private static void useSetDistinct(List<String> testList) {
System.out.println("HashSet.add 开始去重,条数:" + testList.size());
List<String> testListDistinctResult = new ArrayList<>(new HashSet(testList));
System.out.println("HashSet.add 去重完毕,条数:" + testListDistinctResult.size());
}

复杂度是$O(n)$。