day633.list列表操作问题-java业务开发常见错误

阿昌喜欢吃黄桃      2022-06-01     365

关键词:

List列表操作问题

Hi,阿昌来也!

Java 的集合类包括 Map 和 Collection 两大类。Collection 包括 List、Set 和 Queue 三个小类,其中 List 列表集合是最重要也是所有业务代码都会用到的。

一、使用 Arrays.asList 把数据转换为 List 的三个坑

Java 8 中 Stream 流式处理的各种功能,大大减少了集合类各种操作(投影、过滤、转换)的代码量。

所以,在业务开发中,我们常常会把原始的数组转换为 List 类数据结构,来继续展开各种 Stream 操作。

使用 Arrays.asList 方法可以把数组一键转换为 List,但其实没这么简单。

接下来,就让我们看看其中的缘由,以及使用 Arrays.asList 把数组转换为 List 的几个坑。在如下代码中,我们初始化三个数字的 int[]数组,然后使用 Arrays.asList 把数组转换为 List:

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,元素类型是整数数组。

12:50:39.445 [main] INFO org.geekbang.time.commonmistakes.collection.aslist.AsListApplication - list:[[I@1c53fd30] size:1 class:class [I

其原因是,只能是把 int 装箱为 Integer,不可能把 int 数组装箱为 Integer 数组。

我们知道,Arrays.asList 方法传入的是一个泛型 T 类型可变参数,最终 int 数组整体作为了一个对象成为了泛型类型 T:

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

直接遍历这样的 List 必然会出现 Bug,修复方式有两种,如果使用 Java8 以上版本可以使用 Arrays.stream 方法来转换,否则可以把 int 数组声明为包装类型 Integer 数组:

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


Integer[] arr2 = 1, 2, 3;
List list2 = Arrays.asList(arr2);
log.info("list: size: class:", list2, list2.size(), list2.get(0).getClass());

修复后的代码得到如下日志,可以看到 List 具有三个元素,元素类型是 Integer:

13:10:57.373 [main] INFO org.geekbang.time.commonmistakes.collection.aslist.AsListApplication - list:[1, 2, 3] size:3 class:class java.lang.Integer

可以看到第一个坑是,不能直接使用 Arrays.asList 来转换基本类型数组

那么,我们获得了正确的 List,是不是就可以像普通的 List 那样使用了呢?我们继续往下看。把三个字符串 1、2、3 构成的字符串数组,使用 Arrays.asList 转换为 List 后,将原始字符串数组的第二个字符修改为 4,然后为 List 增加一个字符串 5,最后数组和 List 会是怎样呢?

String[] arr = "1", "2", "3";
List list = Arrays.asList(arr);
arr[1] = "4";
try 
    list.add("5");
 catch (Exception ex) 
    ex.printStackTrace();

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

可以看到,日志里有一个 UnsupportedOperationException,为 List 新增字符串 5 的操作失败了,而且把原始数组的第二个元素从 2 修改为 4 后,asList 获得的 List 中的第二个元素也被修改为 4 了:

java.lang.UnsupportedOperationException
  at java.util.AbstractList.add(AbstractList.java:148)
  at java.util.AbstractList.add(AbstractList.java:108)
  at org.geekbang.time.commonmistakes.collection.aslist.AsListApplication.wrong2(AsListApplication.java:41)
  at org.geekbang.time.commonmistakes.collection.aslist.AsListApplication.main(AsListApplication.java:15)
13:15:34.699 [main] INFO org.geekbang.time.commonmistakes.collection.aslist.AsListApplication - arr:[1, 4, 3] list:[1, 4, 3]

第二个坑Arrays.asList 返回的 List 不支持增删操作

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

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

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

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

修复方式比较简单,重新 new 一个 ArrayList 初始化 Arrays.asList 返回的 List 即可:

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

修改后的代码实现了原始数组和 List 的“解耦”,不再相互影响。同时,因为操作的是真正的 ArrayList,add 也不再出错:

13:34:50.829 [main] INFO org.geekbang.time.commonmistakes.collection.aslist.AsListApplication - arr:[1, 4, 3] list:[1, 2, 3, 5]

二、使用 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 变量:

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:

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)

出现 OOM 的原因是,循环中的 1000 个具有 10 万个元素的 List 始终得不到回收,因为它始终被 subList 方法返回的 List 强引用

那么,返回的子 List 为什么会强引用原始的 List,它们又有什么关系呢?我们再继续做实验观察一下这个子 List 的特性。

首先初始化一个包含数字 1 到 10 的 ArrayList,然后通过调用 subList 方法取出 2、3、4;随后删除这个 SubList 中的元素数字 3,并打印原始的 ArrayList;最后为原始的 ArrayList 增加一个元素数字 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();

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

[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 的源码:

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 维护了一个叫作 modCount 的字段,表示集合结构性修改的次数。所谓结构性修改,指的是影响 List 大小的修改,所以 add 操作必然会改变 modCount 的值。
  • 第二,分析第 21 到 24 行的 subList 方法可以看到,获得的 List 其实是内部类 SubList,并不是普通的 ArrayList,在初始化的时候传入了 this。
  • 第三,分析第 26 到 39 行代码可以发现,这个 SubList 中的 parent 字段就是原始的 List。SubList 初始化的时候,并没有把原始 List 中的元素复制到独立的变量中保存。我们可以认为 SubList 是原始 List 的视图,并不是独立的 List。双方对元素的修改会相互影响,而且 SubList 强引用了原始的 List,所以大量保存这样的 SubList 会导致 OOM。
  • 第四,分析第 47 到 55 行代码可以发现,遍历 SubList 的时候会先获得迭代器,比较原始 ArrayList modCount 的值和 SubList 当前 modCount 的值。获得了 SubList 后,我们为原始 List 新增了一个元素修改了其 modCount,所以判等失败抛出 ConcurrentModificationException 异常。

既然 SubList 相当于原始 List 的视图,那么避免相互影响的修复方式有两种:

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

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

修复后代码输出如下:

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

可以看到,删除 SubList 的元素不再影响原始 List,而对原始 List 的修改也不会再出现 List 迭代异常。


三、让合适的数据结构做合适的事情

在介绍并发工具时,我提到要根据业务场景选择合适的并发工具或容器。

在使用 List 集合类的时候,不注意使用场景也会遇见两个常见误区。第一个误区是,使用数据结构不考虑平衡时间和空间。首先,定义一个只有一个 int 类型订单号字段的 Order 类:

@Data
@NoArgsConstructor
@AllArgsConstructor
static class Order 
    private int orderId;

然后,定义一个包含 elementCount 和 loopCount 两个参数的 listSearch 方法,初始化一个具有 elementCount 个订单对象的 ArrayList,循环 loopCount 次搜索这个 ArrayList,每次随机搜索一个订单号:

private static Object listSearch(int elementCount查看详情  

day647.接口设计规范问题-java业务开发常见错误(代码片段)

...的设计需要考虑的点非常多,比如接口的命名、参数列表、包装结构体、接口粒度、版本策略、幂等性实现、同步异步处理方式等。这其中,和接口设计相关比较重要的点有三个,分 查看详情

day653.数据源头问题-java业务开发常见错误(代码片段)

...限验证等后才能使用,并且这些数据只能认为是用户操作的意图,不能直接 查看详情

day666.java程序从虚拟机迁移到k8s的问题-java业务开发常见错误(代码片段)

Java程序从虚拟机迁移到k8s的问题Hello,这里是阿昌,今天学习记录的是关于Java程序从虚拟机迁移到k8s的问题的记录。使用Kubernetes大规模部署应用程序,可以提升整体资源利用率,提高集群稳定性,还能提供快... 查看详情

day659.streamapi-java业务开发常见错误(代码片段)

...天记录学习的内容是针对JDK8中的新特性StreamAPIStream流式操作,用于对集合进行投影、转换、过滤、排序等,更进一步地,这些操作能链式串联在一起使用,类似于SQL语句,可以大大简化代码。一、Stream操作详... 查看详情

day641.javaoom问题-java业务开发常见错误

JavaOOM问题Hi,阿昌来也,今天学习记录的是JavaOOM问题的学习Java是自动垃圾收集,针对Java,经过这么多年的发展,Java的垃圾收集器已经非常成熟了。有了自动垃圾收集器,绝大多数情况下我们写程序时可... 查看详情

day624.并发编程代码加锁注意点-java业务开发常见错误

...提供几点可能在我们代码编程中会出现的问题。一、判断操作共享资源的代码是否需要加锁在一个类里有两个int类型的字段a和b,有一个add方法循环1万次对a和b进行++操作,有另一个compar 查看详情

day2-------列表元组字典等

1.列表(list) 列表是我们最以后最常用的数据类型之一,通过列表可以对数据实现最方便的存储、修改等操作1.定义列表1names=[‘Alex‘,"Tenglan",‘Eric‘] 2.列表中可以嵌套任何类型中括号起来,分割每个元素列表中的元素可... 查看详情

day632.数值计算问题-java业务开发常见错误

数值计算问题Hi,阿昌来也!今天学习记录的是数值计算问题:注意精度、舍入和溢出一、“危险”的Double我们先从简单的反直觉的四则运算看起。对几个简单的浮点数进行加减乘除运算:System.out.println(0.1+0.2);System.... 查看详情

day49:css属性操作(文本背景边框列表display边距)

一、CSS属性操作1、CSStext文本颜色:color颜色属性被用来设置文字的颜色。颜色是通过CSS最经常的指定:十六进制值-如: #FF0000一个RGB值-如: RGB(255,0,0)颜色的名称-如: redp{color:rebeccapurple;}水平对齐方式text-align属性规定... 查看详情

day49:css属性操作(文本背景边框列表display边距)

一、CSS属性操作1、CSStext文本颜色:color颜色属性被用来设置文字的颜色。颜色是通过CSS最经常的指定:十六进制值-如: #FF0000一个RGB值-如: RGB(255,0,0)颜色的名称-如: redp{color:rebeccapurple;}水平对齐方式text-align属性规定... 查看详情

day627.http调用常见问题--java业务开发常见错误

HTTP调用常见问题Hi,这里是阿昌!今天学习记录的是HTTP调用过程中需要考虑的问题:超时、重试、并发SpringCloud是Java微服务架构的代表性框架。如果使用SpringCloud进行微服务开发,就会使用Feign进行声明式的服务调用。... 查看详情

day630.数据库索引并非万能-java业务开发常见错误

...何存储数据的?索引结构,MySQL把数据存储和查询操作抽象成了存储引擎,不同的存储引擎,对数据的存储和读取方式各不相同。MySQL支持多种存储引擎,并且可以以表为粒度设置存储引擎。 查看详情

day640.java8的日期时间类问题-java业务开发常见错误

Java8的日期时间类问题Hi,阿昌来也!今天记录分享的是Java8的日期时间类问题在Java8之前,我们处理日期时间需求时,使用Date、Calender和SimpleDateFormat,来声明时间戳、使用日历处理日期和格式化解析日期时间。但是... 查看详情

day648.缓存设计问题-java业务开发常见错误(代码片段)

缓存设计问题Hi,阿昌来也!今天学习分享记录的是关于缓存设计的一系列问题。通常我们会使用更快的介质(比如内存)作为缓存,来解决较慢介质(比如磁盘)读取数据慢的问题,缓存是用空间换时... 查看详情

day648.缓存设计问题-java业务开发常见错误(代码片段)

缓存设计问题Hi,阿昌来也!今天学习分享记录的是关于缓存设计的一系列问题。通常我们会使用更快的介质(比如内存)作为缓存,来解决较慢介质(比如磁盘)读取数据慢的问题,缓存是用空间换时... 查看详情

day660.定位排查应用问题-java业务开发常见错误(代码片段)

定位排查应用问题Hi,阿昌来也,今天是关于学习一些定位排查应用问题的学习记录。一、在不同环境排查问题,有不同的方式要说排查问题的思路,我们首先得明白是在什么环境排错。如果是在自己的开发环境... 查看详情

day642.反射注解和泛型问题-java业务开发常见错误

反射、注解和泛型问题Hi,我是阿昌,今天记录学习分享的是一些反射、注解和泛型问题如果你从来没用过反射、注解和泛型,可以先通过官网有一个大概了解:JavaReflectionAPI&ReflectionTutorials;Annotations&Les... 查看详情