一、犯错经历

1.1 故事背景

最近有个需求大致的背景类似:

我已经通过一系列的操作拿到一批学生的考试成绩数据,现在需要筛选成绩大于 95 分的学生名单。

善于写 bug 的我,三下五除二完成了代码的编写:

@Test
public void shouldCompile() {
for (int i = 0; i < studentDomains.size(); i++) {
if (studentDomains.get(i).getScore() < 95.0) {
studentDomains.remove(studentDomains.get(i));
}
}
System.out.println(studentDomains);
}

测试数据中四个学生,成功筛选出了两个 95 分以上的学生,测试成功,打卡下班。

[StudentDomain{id=1, name='李四', subject='科学', score=95.0, classNum='一班'}, StudentDomain{id=1, name='王六', subject='科学', score=100.0, classNum='一班'}]

1.2 貌似,下不了班!

从业 X 年的直觉告诉我,事情没这么简单。

但是自测明明没问题,难道写法有问题?那我换个写法(增强的 for 循环):

@Test
public void commonError() {
for (StudentDomain student : studentDomains) {
if (student.getScore() < 95.0) {
studentDomains.remove(student);
}
}
System.out.println(studentDomains);
}

好家伙,这一试不得了,直接报错:ConcurrentModificationException

  • 普通 for 循环“没问题”,增强 for 循环有问题,难道是【增强 for 循环】的问题?

1.3 普通 for 循环真没问题吗?

为了判断普通 for 循环是否有问题,我将原代码加了执行次数的打印:

@Test
public void shouldCompile() {
System.out.println("studentDomains.size():" + studentDomains.size());
int index = 0;
for (int i = 0; i < studentDomains.size(); i++) {
index ++;
if (studentDomains.get(i).getScore() < 95.0) {
studentDomains.remove(studentDomains.get(i));
}
}
System.out.println(studentDomains);
System.out.println("执行次数:" + index);
}

这一加不得了,我的 studentDomains.size() 明明等于 4,怎么循环体内只执行了 2 次。

更巧合的是:执行的两次循环的数据,刚好都符合我的筛选条件,故会让我错以为【需求已完成】。

二、问题剖析

一个个分析,我们先看为什么普通 for 循环比我们预计的执行次数要少。

2.1 普通 for 循环次数减少

这个原因其实稍微有点儿开发经验的人应该都知道:在循环中删除元素后,List 的索引会自动变化,List.size() 获取到的 List 长度也会实时更新,所以会造成漏掉被删除元素后一个索引的元素。

比如:循环到第 1 个元素时你把它删了,那么第二次循环本应访问第 2 个元素,但这时实际上访问到的是原来 List 的第 3 个元素,因为第 1 个元素被删除了,原来的第 3 个元素变成了现在的第 2 个元素,这就造成了元素的遗漏。

2.2 增强 for 循环抛错

  • 我们先看 JDK 源码中 ArrayListremove() 源码是怎么实现的:
public boolean remove(Object o) {
if (o == null) {
for (int index = 0; index < size; index++)
if (elementData[index] == null) {
fastRemove(index);
return true;
}
} else {
for (int index = 0; index < size; index++)
if (o.equals(elementData[index])) {
fastRemove(index);
return true;
}
}
return false;
}

只要不为空,程序的执行路径会走到 else 路径下,最终调用 fastRemove() 方法:

private void fastRemove(int index) {
modCount++;
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index, numMoved);
elementData[--size] = null;
}

fastRemove() 方法中,看到第 2 行【把 modCount 变量的值加 1】。

  • 增强 for 循环实际执行

通过编译代码可以看到:增强 for 循环在实际执行时,其实使用的是Iterator,使用的核心方法是 hasnext()next()

next() 方法调用了 checkForComodification()

final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}

看到 throw new ConcurrentModificationException() 那么就可以结案了:

因为上面的 remove() 方法修改了 modCount 的值,所以这里肯定会抛出异常。

三、正确方式

既然知道了普通 for 循环和增强 for 循环都不能用的原因,那么我们先从这两个地方入手。

3.1 优化普通 for 循环

我们知道使用普通 for 循环有问题的原因是因为数组坐标发生了变化,而我们仍使用原坐标进行操作。

  • 移除元素的同时,变更坐标。
@Test
public void forModifyIndex() {
for (int i = 0; i < studentDomains.size(); i++) {
StudentDomain item = studentDomains.get(i);
if (item.getScore() < 95.0) {
studentDomains.remove(i);
// 关键是这里:移除元素同时变更坐标
i = i - 1;
}
}
System.out.println(studentDomains);
}
  • 倒序遍历

采用倒序的方式可以不用变更坐标,因为:后一个元素被移除的话,前一个元素的坐标是不受影响的,不会导致跳过某个元素。

@Test
public void forOptimization() {
List<StudentDomain> studentDomains = genData();
for (int i = studentDomains.size() - 1; i >= 0; i--) {
StudentDomain item = studentDomains.get(i);
if (item.getScore() < 95.0) {
studentDomains.remove(i);
}
}
System.out.println(studentDomains);
}

3.2 使用 Iterator 的 remove()

@Test
public void iteratorRemove() {
Iterator<StudentDomain> iterator = studentDomains.iterator();
while (iterator.hasNext()) {
StudentDomain student = iterator.next();
if (student.getScore() < 95.0) {
iterator.remove();
}
}
System.out.println(studentDomains);
}

你肯定有疑问,为什么迭代器的 remove() 方法就可以呢,同样的,我们来看看源码:

public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}

我们可以看到:每次执行 remove() 方法的时候,都会将 modCount 的值赋值给 expectedModCount,这样 2 个变量就相等了。

3.3 Stream 的 filter()

了解 Stream 的童鞋应该都能想到该方法,这里就不过多赘述了。

@Test
public void streamFilter() {
List<StudentDomain> studentDomains = genData();
studentDomains = studentDomains.stream().filter(student -> student.getScore() >= 95.0).collect(Collectors.toList());
System.out.println(studentDomains);
}

3.4 Collection.removeIf()【推荐】

JDK1.8 中,Collection 以及其子类新加入了 removeIf() 方法,作用是按照一定规则过滤集合中的元素。

@Test
public void removeIf() {
List<StudentDomain> studentDomains = genData();
studentDomains.removeIf(student -> student.getScore() < 95.0);
System.out.println(studentDomains);
}

看下 removeIf() 方法的源码,会发现其实底层也是用的 Iteratorremove() 方法:

default boolean removeIf(Predicate<? super E> filter) {
Objects.requireNonNull(filter);
boolean removed = false;
final Iterator<E> each = iterator();
while (each.hasNext()) {
if (filter.test(each.next())) {
each.remove();
removed = true;
}
}
return removed;
}

四、总结

详细认真的看完本文的话,最大感悟应该是:还是源码靠谱!

4.1 啰嗦几句

其实在刚从事 Java 开发的时候,这个问题就困扰过我,当时只想着解决问题,所以采用了很笨的方式:

新建一个新的 List,遍历老的 List ,将满足条件的元素放到新的元素中,这样的话,最后也完成了当时的任务。

现在想一想,几年前,如果就像现在一样,抽空好好想想为什么不能直接 remove() ,多问几个为什么,估计自己会比现在优秀很多吧。

当然,只要意识到这个,什么时候都不算晚,共勉!

4.2 文中代码示例

Github/vanDusty

【代码优化】List.remove() 剖析的更多相关文章

  1. 系统级性能分析工具perf的介绍与使用

    测试环境:Ubuntu16.04(在VMWare虚拟机使用perf top存在无法显示问题) Kernel:3.13.0-32 系统级性能优化通常包括两个阶段:性能剖析(performance pro ...

  2. 系统级性能分析工具perf的介绍与使用[转]

    测试环境:Ubuntu16.04(在VMWare虚拟机使用perf top存在无法显示问题) Kernel:3.13.0-32 系统级性能优化通常包括两个阶段:性能剖析(performance pro ...

  3. jQuery之Deferred源码剖析

    一.前言 大约在夏季,我们谈过ES6的Promise(详见here),其实在ES6前jQuery早就有了Promise,也就是我们所知道的Deferred对象,宗旨当然也和ES6的Promise一样, ...

  4. 计算机程序的思维逻辑 (51) - 剖析EnumSet

    上节介绍了EnumMap,本节介绍同样针对枚举类型的Set接口的实现类EnumSet.与EnumMap类似,之所以会有一个专门的针对枚举类型的实现类,主要是因为它可以非常高效的实现Set接口. 之前介 ...

  5. MapReduce剖析笔记之二:Job提交的过程

    上一节以WordCount分析了MapReduce的基本执行流程,但并没有从框架上进行分析,这一部分工作在后续慢慢补充.这一节,先剖析一下作业提交过程. 在分析之前,我们先进行一下粗略的思考,如果要我 ...

  6. Java代码优化(长期更新)

    前言 2016年3月修改,结合自己的工作和平时学习的体验重新谈一下为什么要进行代码优化.在修改之前,我的说法是这样的: 就像鲸鱼吃虾米一样,也许吃一个两个虾米对于鲸鱼来说作用不大,但是吃的虾米多了,鲸 ...

  7. Java 中Iterator 、Vector、ArrayList、List 使用深入剖析

    标签:Iterator Java List ArrayList Vector 线性表,链表,哈希表是常用的数据结构,在进行Java开发时,JDK已经为我们提供了一系列相应的类来实现基本的数据结构.这些 ...

  8. 深入剖析ConcurrentHashMap(2)

    转载自并发编程网 – ifeve.com本文链接地址: 深入剖析ConcurrentHashMap(2) 经过之前的铺垫,现在可以进入正题了.我们关注的操作有:get,put,remove 这3个操作 ...

  9. 深入剖析ConcurrentHashMap(1)

    转载自并发编程网 – ifeve.com本文链接地址: 深入剖析ConcurrentHashMap(1) ConcurrentHashMap是Java5中新增加的一个线程安全的Map集合,可以用来替代 ...

随机推荐

  1. Fastjson妙用之@JSONField注解

    在开发的过程中使用json格式的地方非常多,现在前后端分离的项目中,前后端数据交换的格式一般为json,这种格式的优/缺点这里不再赘述,感兴趣的可以百度.把java中的实体类序列化为json的方式也有 ...

  2. 手把手教你学Dapr - 8. 绑定

    目录 手把手教你学Dapr - 1. .Net开发者的大时代 手把手教你学Dapr - 2. 必须知道的概念 手把手教你学Dapr - 3. 使用Dapr运行第一个.Net程序 手把手教你学Dapr ...

  3. m3u8 ts 视频流爬取思路,合成

    .... 先开调试,输入查找一下有没有 m3u8 文件 然后下下来用Notepad++ 打开一下 (以下的样子) 这里就是整个视频的视频流,  .ts 的都是文件,都下下来, ------------ ...

  4. [源码解析] PyTorch 分布式(7) ----- DistributedDataParallel 之进程组

    [源码解析] PyTorch 分布式(7) ----- DistributedDataParallel 之进程组 目录 [源码解析] PyTorch 分布式(7) ----- DistributedD ...

  5. Maven 依赖调解源码解析(四):传递依赖,第一声明者优先

    本文是系列文章<Maven 源码解析:依赖调解是如何实现的?>第四篇,主要介绍依赖调解的第二条原则:传递依赖,第一声明者优先.请按顺序阅读其他系列文章,系列文章总目录参见:https:// ...

  6. MySQL5.5.33对应的JDBC驱动包怎样使用?

    双击msi文件就会自动安装,然后找到安装路径下的jar,并把它加到类路径下,如手动编译和执行时javac -classpath c:\program files\...\mysql.jar;... m ...

  7. [loj574]黄金矿工

    记$dep_{x}$为1到$x$的边权和,当$x$上的矿工挖了$y$上的黄金时($y$在$x$子树内),显然$\sum_{e}c_{e}=dep_{y}-dep_{x}$ 由此,对于$u$上权值为$v ...

  8. HashSet 如何保证元素不重复——hash码

    HashSet 不重复主要add 方法实现,使用 add 方法找到是否存在元素,存在就不添加,不存在就添加.HashSet 主要是基于HashMap 实现的,HashMap 的key就是 HashSe ...

  9. .net core 和 WPF 开发升讯威在线客服系统:使用本地IP数据库实现访客来源快速定位,支持国外

    本系列文章详细介绍使用 .net core 和 WPF 开发 升讯威在线客服与营销系统 的过程.本产品已经成熟稳定并投入商用. 免费使用 & 私有化部署:https://kf.shengxun ...

  10. myeclipse字体大小格式的设置