⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 juejin.cn/post/7143266514722881544 「叫我小郭_」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注**微信公众号:【芋道源码】**有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言将得到认真回复。甚至不知道如何读源码也可以请教噢
  4. 新的源码解析文章实时收到通知。每周更新一篇左右
  5. 认真的源码交流微信群。

前言

今天我们主要来说一说List操作在实际使用中有哪些坑,以及面对这些坑的时候我们要怎么解决。

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

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

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

上代码

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

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

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

可以观察到 asList方法 接收的是一个泛型T类型的参数,T继承Object对象

所以通过断点我们可以看到把 int数组 整体作为一个对象,返回了一个 List<int[]>

「那我们该如何解决呢?」

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

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

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

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不支持增删操作

我们将数组对象转成List数据结构之后,竟然不能进行增删操作了

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的内部类

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也发生了变化。

public static <T> List<T> asList(T... a) {

return new ArrayList<>(a);

}

ArrayList(E[] array) {
a = Objects.requireNonNull(array);
}

asList中创建了 ArrayList,但是他直接引用了原本的数组对象

所以只要原本的数组对象一发生变化,List也跟着变化

所以在使用到引用的时候,我们需要特别的注意。

解决方案:

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

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

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

在第二个坑的时候,我们说到了 Arrays.asList 返回的 List 不支持增删操作,

是因为他的自己实现了一个内部类 ArrayList,这个内部类继承了 AbstractList 没有实现 add()remove() 方法导致操作失败。

但是第三个坑的时候,我们利用 java.util.ArrayList 包装了返回的 List,进行增删操作还是会失败,那是为什么呢?

删除方法逻辑:

foreach中操作增删,因为因为 modCount 会被修改,与第一步保存的数组修改次数不一致,抛出异常 ConcurrentModificationException

在正确操作是什么?我总结了四种方式

ArrayList中的 subList 强转 ArrayList 导致异常

阿里《Java开发手册》上提过

[强制] ArrayList的sublist结果不可強转成ArrayList,否则会抛出ClassCastException异常,即java.util.RandomAccesSubList cannot be cast to java. util.ArrayList.

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

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

我猜问题是有八九就是出现在subList这个方法上了

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

其实 SubList 是一个继承 AbstractList 的内部类,在 SubList 的构建函数中的将 List 中的部分属性直接赋予给自己

SubList 没有创建一个新的 List,而是直接引用了原来的 List(this.parent = parent),指定了元素的范围

所以 subList 方法不能直接转成 ArrayList,他只是ArrayList的内部类,没有其他的关系

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

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

List<String> names = new ArrayList<String>() {{

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

}};

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

names.add("four");

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

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

Exception in thread "main" java.util.ConcurrentModificationException

原因:

subList的时候记录this.modCount为3

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

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

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

ArrayList中的subList切片造成OOM

在业务开发中的时候,他们经常通过subList来获取所需要的那部分数据

在上面的例子中,我们知道了subList所产生的List,其实是对原来List对象的引用

这个产生的List只是原来List对象的视图,也就是说虽然值切片获取了一小段数据,但是原来的List对象却得不到回收,这个原来的List对象可能是一个很大的对象

为了方便我们测试,将vm调整一下 -Xms20m -Xmx40m

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

});

}}

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

出现OOM的原因,循环1000次创建了1000个具有10万个元素的List

因为始终被collect.subList(0, 1)强引用,得不到回收

解决方式:

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

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

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

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

在这里我们可以看到,只要用一个新的容器来装结果,就可以切断与原始List的关系。

LinkedList的插入速度不一定比ArrayList快

学习数据结构的时候,我们就已经得出了结论

  • 对于数组,随机元素访问的时间复杂度是0(1), 元素插入操作是O(n);
  • 对于链表,随机元素访问的时间复杂度是O(n), 元素插入操作是0(1).

元素插入对于链表来说应该是他的优势

但是他就一定比数组快? 我们执行插入1000w次的操作

private static void test(){
StopWatch stopWatch = new StopWatch();
int elementCount = 100000;
stopWatch.start("ArrayList add");
List<Integer> arrayList = IntStream.rangeClosed(1, elementCount).boxed().collect(Collectors.toCollection(ArrayList::new));
// ArrayList插入数据
IntStream.rangeClosed(0, elementCount).forEach(i ->arrayList.add(ThreadLocalRandom.current().nextInt(elementCount), 1));
stopWatch.stop();

stopWatch.start("linkedList add");
List<Integer> linkedList = IntStream.rangeClosed(1, elementCount).boxed().collect(Collectors.toCollection(LinkedList::new));
// ArrayList插入数据
IntStream.rangeClosed(0, elementCount).forEach(i -> linkedList.add(ThreadLocalRandom.current().nextInt(elementCount), 1));
stopWatch.stop();
System.out.println(stopWatch.prettyPrint());
}

StopWatch '': running time = 44507882 ns
---------------------------------------------
ns % Task name
---------------------------------------------
043836412 098% elementCount 100 ArrayList add
000671470 002% elementCount 100 linkedList add

StopWatch '': running time = 196325261 ns
---------------------------------------------
ns % Task name
---------------------------------------------
053848980 027% elementCount 10000 ArrayList add
142476281 073% elementCount 10000 linkedList add

StopWatch '': running time = 26384216979 ns
---------------------------------------------
ns % Task name
---------------------------------------------
978501580 004% elementCount 100000 ArrayList add
25405715399 096% elementCount 100000 linkedList add

看到在执行插入1万、10完次操作的时候,LinkedList的插入操作时间是 ArrayList的两倍以上

那问题主要就是出现在linkedListadd()方法上

public void add(int index, E element) {  

checkPositionIndex(index);

if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}

/**
* Returns the (non-null) Node at the specified element index.
*/
Node<E> node(int index) {

// assert isElementIndex(index);

if(index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}

linkedListadd()方法主要逻辑

  1. 通过遍历找到那个节点的Node
  2. 执行插入操作

ArrayListadd()方法

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

  1. 计算最小容量
  2. 最小容量大于数组对象,则进行扩容
  3. 进行数组复制,根据插入的index将数组向后移动一位
  4. 最后在空位上插入新值

根据试验的测试,我们得出了在实际的随机插入中,LinkedList并没有比ArrayList的速度快

所以在实际的使用中,如果涉及到头尾对象的操作,可以使用LinkedList数据结构来进行增删的操作,发挥LinkedList的优势

最好再进行实际的性能测试评估,来得到最合适的数据结构。

CopyOnWriteArrayList内存占用过多

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

CopyOnWriteArrayListadd()方法

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是弱一致性的

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()来获取一个快照信息,生成一个新的数组。

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

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

上面的demo中在启动线程前获取到了原来list的迭代器,

在之后启动新建一个线程,在线程里面修改了第一个元素的值,移除了第二个元素

在执行完子线程之后,遍历了迭代器的元素,发现子线程里面操作的一个都没有生效,这里提现了迭代器弱一致性。

CopyOnWriteArrayList的迭代器不支持增删改

private static void CopyOnWriteArrayListTest(){
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("test1");
list.add("test2");
list.add("test3");
list.add("test4");

Iterator<String> iterator = list.iterator();

while (iterator.hasNext()){
if ("test1".equals(iterator.next())){
iterator.remove();
}
}

System.out.println(list.toString());
}

Exception in thread "main" java.lang.UnsupportedOperationException
at java.util.concurrent.CopyOnWriteArrayList$COWIterator.remove(CopyOnWriteArrayList.java:1178)

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

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

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

/**
* Not supported. Always throws UnsupportedOperationException.
* @throws UnsupportedOperationException always; {@code remove}
* is not supported by this iterator.
*/
public void remove() {
throw new UnsupportedOperationException();
}

/**
* Not supported. Always throws UnsupportedOperationException.
* @throws UnsupportedOperationException always; {@code set}
* is not supported by this iterator.
*/
public void set(E e) {
throw new UnsupportedOperationException();
}

/**
* Not supported. Always throws UnsupportedOperationException.
* @throws UnsupportedOperationException always; {@code add}
* is not supported by this iterator.
*/
public void add(E e) {
throw new UnsupportedOperationException();
}

总结

由于篇幅的限制,我们只对一些在业务开发中常见的关键点进行梳理和介绍

在实际的工作中,我们不单单是要清除不同类型容器的特性,还要选择适合的容器才能做到事半功倍。

我们主要介绍了Arrays.asList转换过程中的一些坑,以及因为操作不当造成的OOM和异常,

到最后介绍了线程安全类CopyOnWriteArrayList的一些坑,让我们认识到在丰富的API下藏着许多的陷阱。

在使用的过程中,需要更加充分的考虑避免这些隐患的发生。

最后一张思维导图来回顾一下~

文章目录
  1. 1. 前言
  2. 2. Arrays.asList转换基本类型数组的坑
  3. 3. Arrays.asList返回的List不支持增删操作
  4. 4. 对原始数组的修改会影响到我们获得的那个List
  5. 5. java.util.ArrayList如果不正确操作也不支持增删操作
  6. 6. ArrayList中的 subList 强转 ArrayList 导致异常
  7. 7. ArrayList中的subList切片造成OOM
  8. 8. LinkedList的插入速度不一定比ArrayList快
  9. 9. CopyOnWriteArrayList内存占用过多
  10. 10. CopyOnWriteArrayList是弱一致性的
  11. 11. CopyOnWriteArrayList的迭代器不支持增删改
  12. 12. 总结