作者简介:大家好,我是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,无论从代码可读性还是健壮性来说,都会好很多。