Redian新闻
>
细数 List 的10个坑,保证你一定遇到过!

细数 List 的10个坑,保证你一定遇到过!

公众号新闻


点击关注公众号,Java干货及时送达👇

前言

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

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

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

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

上代码

int[] arr = {123}; 
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 = {123}; 
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());
}

[124
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<Eextends AbstractList<E>
        implements RandomAccessjava.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<Eextends AbstractCollection<Eimplements 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(01);  
    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<Eimplements 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(01);

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

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(01000).forEach(i ->{  
        List<Integer> collect = IntStream.range(0100000).boxed().collect(Collectors.toList());  
            data.add(collect.subList(01));  
  
        });  
  
    }}  
  
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<Eimplements 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下藏着许多的陷阱。

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

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

感谢阅读,希望对你有所帮助 :) 

来源:juejin.cn/post/7143266514722881544


(完)

码农突围资料链接

1、卧槽!字节跳动《算法中文手册》火了,完整版 PDF 开放下载!
2、计算机基础知识总结与操作系统 PDF 下载
3、艾玛,终于来了!《LeetCode Java版题解》.PDF
4、Github 10K+,《LeetCode刷题C/C++版答案》出炉.PDF

欢迎添加鱼哥个人微信:smartfish2020,进粉丝群或围观朋友圈

微信扫码关注该文公众号作者

戳这里提交新闻线索和高质量文章给我们。
相关阅读
陈老湿用脑袋保证,这趟一定看到翠青蛇!天哪!还在拿着点读笔刷分级?这个坑,一不小心,就会踩一定会让你心动的10个瞬间,不分男女房价高吃饭贵...海外侨民细数在澳生活“缺点”,直言亚洲“经济实惠”!网友热议保时捷卖菜刀、法拉利卖风衣,细数大牌们的“不务正业”TVB金牌配角去世, 塑造“姜子牙”等众多经典角色, 你一定看过!算盘珠子崩一脸!那些年遇到过的神Deal:玩转PC Points积分你遇到过“榨菜头”老板吗?术前偷着抽烟的患者,你遇到过吗?台故宫文物想疏散给八国联军吗细颈瓶从头到脚都辱华大温最危险路段大汇总!总有一个坑你肯定“栽”过!来自15年产品老司机的忠告:转岗产品这4个坑,千万别踩!直播预告 | 10个活动,150个字符,如何靠AC List打动TOP30招生官?加拿大名牌店,商场巨头发联名信:这些人购物退GST!华人:“退钱从来没轮到过我”“小学不建议提前学,但这一科例外”,保姆级教程来了,保证不费妈~知乎高赞:你遇到过的最温暖的瞬间是什么?Rosalía 登意大利版《VOGUE》封面!甘孜泸定遇强震 南加四川乡亲捐款献爱心过了三十的女人,你一定要明白这10个道理英女王伊丽莎白二世逝世,新加坡为她降半旗,细数她和新加坡的那些事....再也藏不住了!精心筹划一年之久的惊喜,终于面世了!雪球基金自由志,见证你的财富自由之路!细数美国奇葩专业,倒数第三个专业是真的“刑”!别再说酸奶不能加热了!天冷给娃喝奶的这几个坑,医生都看不下去了老司机开车最怕的遇到什么?最怕遇到“Sunday driver”!什么意思?忙了一天发现这里在打群架。。。 大家都少说两句,在网上反复争执从来都是没有意义的。我也经常跟人争,其实也很傻,知道毫无低投入,高回报的自我提升?细数那些值得考的美国短期职业证书垃圾人的特征归纳,你遇到过吗?你是否遇到过这些皮肤问题?教你安抚肌肤「小任性」!1024程序员节,细数“改变世界”的程序员们周杰伦带儿子来伦敦了!细数他与英国的不解之缘...大英博物馆, 与港台青年的“晚会”,1986伦敦穷游记(2)你一定见过这树,却不一定叫得出名字35岁前职场一定要避的4种坑,你中了哪个?老电影《绿里奇迹》:感动与震撼
logo
联系我们隐私协议©2024 redian.news
Redian新闻
Redian.news刊载任何文章,不代表同意其说法或描述,仅为提供更多信息,也不构成任何建议。文章信息的合法性及真实性由其作者负责,与Redian.news及其运营公司无关。欢迎投稿,如发现稿件侵权,或作者不愿在本网发表文章,请版权拥有者通知本网处理。