尽量避免删改List

发布时间:2023年12月29日

作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO

联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬

学习必须往深处挖,挖的越深,基础越扎实!

阶段1、深入多线程

阶段2、深入多线程设计模式

阶段3、深入juc源码解析

阶段4、深入jdk其余源码解析

阶段5、深入jvm源码解析

尽管在之前介绍了如何避免并发修改异常,但那篇文章的目的,更多的是为了介绍底层原理及应付面试,实际开发中并不推荐大家对原List做增删改操作。

我的观点是,对于一个初始化完毕的List,尽量把它当做只读的,不要贸然做增删改操作。比如Java8的Stream,它所有的操作都是基于新的List,并不会改变原数据,包括JDK、Google Common以及Apache Common等工具类提供的不可变集合(Immutable Collections),其实都是在传递这种思想(Google Common甚至直接屏蔽了增删改方法):

接下来,给大家分享两个实际开发中遇到的问题,都与List操作有关。

用skip()、limit()代替subList()

对于List的截取,可能大家都习惯用List.subList(),但它有个隐形的坑:对截取后的List进行元素修改,会影响原List(除非你就希望改变原List)。

究其原因,subList()并非真的从原List截取出元素,而是偏移原List的访问坐标罢了:

比如你要截取(5, 6),那么下次你get(index),我就直接返回5+index给你,看起来好像真的截取了。

另外,这个方法限制太大,用起来也麻烦,比如对于一个不确定长度的原List,如果你想做以下截取操作:list.subList(0, 5)或者list.subList(2, 5),当原List长度不满足List.size()>=5时,会抛异常。为了避免误操作,你必须先判断size:

if(list != null && list.size() >= 5) {
    return list.subList(2, 5);
}

较为简便和安全的做法是借助Stream(Stream一个很重要的特性是,不修改原数据,而是新产生一个流):

public static void main(String[] args) {

    List<String> list = Lists.newArrayList("a", "b", "c", "d");

    List<String> limit3 = list.stream().limit(3).collect(Collectors.toList());
    // 超出实际长度也不会报错
    List<String> limit5 = list.stream().limit(5).collect(Collectors.toList());
    List<String> range3_4 = list.stream().skip(2).limit(2).collect(Collectors.toList());
    // 超出实际长度也不会报错
    List<String> range3_5 = list.stream().skip(2).limit(3).collect(Collectors.toList());

    System.out.println(limit3 + " " + limit5 + " " + range3_4 + " " + range3_5);
}

用filter()代替remove()

很多同学对内存占用极其敏感,恨不得用同一份内存把A、B、C三件事都干了(特别是经历了LeetCode摧残的人)。这种想法是好的,但对于List这样有并发修改限制的容器来说,一不留神就有可能出现问题。举个例子:

假设后台要支持配置定向推广的商品,并且需要将配置的商品在当前时间轴置顶(比如09:00下)。原本时间轴的列表是AList,长度为10,而后台配置的商品为BList,长度不确定,在0~10之间。考虑到后台配置的商品可能与原List中的商品重复,所以这里要加一个去重操作。很多人可能会想到利用Set或者Map的key不重复的特性去重,但试了以后会发现顺序可能被打乱。那么,最直观的方法就是双层for遍历:先遍历原来的AList,然后拿着AList的item去BList遍历,如果这个item在BList中已经存在,就把这个item从AList删除。

public class ListRemoveTest {

    public static void main(String[] args) {
        // 前台List
        List<Item> aList = Lists.newArrayList(
                new Item(1, "甲"),
                new Item(2, "乙"),
                new Item(3, "丙")
        );
        // 后台List
        List<Item> bList = Lists.newArrayList(
                new Item(99, "对照数据"),
                new Item(3, "丙")
        );
        
        // 对前台List去重
        for (int i = aList.size() - 1; i >= 0; i--) {
            for (Item user : bList) {
                if (Objects.equals(user.getId(), aList.get(i).getId())) {
                    aList.remove(i);
                }
            }
        }

        // 组合去重后的两个List,后台List置顶
        bList.addAll(aList);

        System.out.println(JSON.toJSONString(bList));
    }

    @Getter
    @Setter
    @AllArgsConstructor
    static class Item {
        private Integer id;
        private String title;
    }

}

即使我对并发修改异常“了如指掌”,在实际开发时还是写出了上面的代码。最致命的是,上面的代码还不一定会出错!如果重复商品只有一个,且恰好出现在bList的末尾,上面的代码是不会报错的。如果我们将上面bList元素顺序对调,再次运行就会发生数组越界异常:

原因是,当bList重复的元素只有一个且恰好在末尾时,第二层for在执行aList.remove()以后就直接退出第二层for,不会继续执行if逻辑,也就不会执行aList.get(i),所以不会发生数组越界(可能比较难理解,大家可以复制代码实际观察一下)。

当初虽然考虑到并发修改异常的可能,但不巧的是构造测试数据时只构造了一个重复的商品,而且排序系数设置为最高,恰好处于bList的末尾,完美地避开了问题...实际上线几天后的某个早晨,运营配置了多个商品,而且恰好重复了,于是首页直接崩了...这是一个很严重的事故。

一个可行的处理方式是:

public static void main(String[] args) {
    // 前台List
    List<Item> aList = Lists.newArrayList(
            new Item(1, "甲"),
            new Item(2, "乙"),
            new Item(3, "丙")
    );
    // 后台List
    List<Item> bList = Lists.newArrayList(
            new Item(3, "丙"),
            new Item(99, "对照数据")
    );

    // 对aList进行筛选(bList中不存在的item)
    Map<Integer, Item> bItemMap = bList.stream().collect(Collectors.toMap(Item::getId, v -> v, (v1, v2) -> v1));
    List<Item> filteredAList = aList.stream()
            .filter(aItem -> !bItemMap.containsKey(aItem.getId()))
            .collect(Collectors.toList());

    // 组合去重后的两个List,后台List置顶
    bList.addAll(filteredAList);

    System.out.println(JSON.toJSONString(bList));
}

当然,List本身提供了诸如allAll()、retainAll()、removeAll()等操作,可以很方便的实现并集、交集、差集。所以,上面的去重取并集可以这样:

public class ListRemoveTest {

    public static void main(String[] args) {
        // 前台List
        List<Item> aList = Lists.newArrayList(
                new Item(1, "甲"),
                new Item(2, "乙"),
                new Item(3, "丙")
        );
        // 后台List
        List<Item> bList = Lists.newArrayList(
                new Item(3, "丙"),
                new Item(99, "对照数据")
        );

        // 先去重,再合并
        aList.removeAll(bList);
        bList.addAll(aList);
        System.out.println(JSON.toJSONString(bList));
    }

    @Getter
    @Setter
    @AllArgsConstructor
    @EqualsAndHashCode // 注意,这里要重写equals和hash,否则默认比较地址值
    static class Item {
        private Integer id;
        private String title;
    }

}

说了这么多,就是想强调,无论是并发修改异常还是数组越界,通常情况下都不会发生,但当你企图对原List进行增删改操作时,只要没考虑周全,就有极大概率发生。由于Stream的任何操作都不会改变原数据,所以从根源上杜绝了增删改可能隐藏的问题,是比较安全的方式,也推荐大家多使用Stream,无论从代码可读性还是健壮性来说,都会好很多。

文章来源:https://blog.csdn.net/smart_an/article/details/135285274
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。