前言
印象中list在循环中删除成员时是会抛出异常的,结果最近无意中看到了关于该操作的相关文章,就将循环删除的几种方式均试了一下,实际结果却大相径庭。
今天就借此将结果和原因好好梳理分析一下。
这里重点介绍ArrayList这个类型的数据结构。
示例代码说明
本篇使用到的示例代码如下,使用 ArrayList
,并放入“1”、“2”、“3”这三个字符串。
在注释处替换为下方各方式内的示例代码
1 | import java.util.ArrayList; |
Iterable.forEach(Consumer<? super E> action)
集合类均实现了 Iterable
接口
1 | package java.util |
1 | package java.lang; |
示例
1 | list.forEach(number -> { |
运行结果
1 | Exception in thread "main" java.util.ConcurrentModificationException |
分析
可以看到,报了 java.util.ConcurrentModificationException
错误,并提示了出错位置在 java.util.ArrayList.forEach(ArrayList.java:1260)
这是因为ArrayList重写了forEach方法:
1 |
|
其中最主要的一个就是modCount,这个是ArrayList
父类AbstractList
的成员变量,默认值为0
,表示list成员结构性修改的次数(add、remove、clear、sort等操作会使modCount值+1)。
而我们回调内调用remove()方法,该方法内部又调用了fastRemove()
,从而使得modCount++,导致modCount != expectedModCount成立后抛出异常。
1 | /** |
1 | /** |
所以Arraylist在forEach回调内不能执行删除等会导致modCount值变化的操作。
而LinkedList等则没有重写forEach方法对modCount变量进行判断,所以这类在forEach回调内进行删除等操作是正常的。
for(int i =0; i < list.size(); i ++)
示例
1 | for (int i = 0; i < list.size(); i++) { |
结果
1 | [2, 3] |
能正常运行完毕,并输出当前list内的数据,可以看到字符串“1”已经被删除了。
字节码分析
这里字节码相对比较简单,就直接带过,详细的字节码分析放在For-Each小节内。
这里字节码所表示的意思就是和Java源码所直接表示的意思一样,仅仅是循环执行for循环方法体内的代码逻辑,字符串常量“1”与列表内取出的元素做比较,一致则将其从列表中移除。
1 | javap -c TestForEach > TestForEach.c |
得到文件:
TestForEach.c
1 | Compiled from "TestForEach.java" |
for( T : Iterable)
这个就是增强for循环的写法,除了可以遍历数组,还可以用于遍历所有实现了Iterable
示例
1 | for (String number : list) { |
结果
1 | Exception in thread "main" java.util.ConcurrentModificationException |
分析
可以看到,程序又报了 java.util.ConcurrentModificationException
错误。
但是这次提示的错误信息与 Iterable.forEach(Consumer<? super E> action)内的不一样。
从这错误信息来分析,出错的地方是在ArrayList的内部类Itr内的一个方法。但是明明写的代码里面没有用到这个类啊,而且remove()方法内也是没有调用该类,那推测是这增强for循环在遍历时调用的。等同于入下代码:
1 | for (Iterator<String> iterator = list.iterator();iterator.hasNext();){ |
通过iterator.hasNext()
判断是否还有下个元素,通过iterator.next()
取出元素。
而ArrayList#iterator()
返回的就是Itr
对象。
1 | /** |
java.util.ArrayList.Itr
1 | /** |
Itr类的逻辑比较简单,变量也有注释进行说明。
其中
cursor:下个元素下标
lastRet:上个返回元素的下标,-1表示没有这个元素
- expectedModCount:记录ArrayList内modCount值,用于
next()
、remove()
、forEachRemaining()
内做检验
其中next()
、remove()
方法在执行时,会先调用checkForComodification()
进行判断modCount在此遍历期间是否有过改动,改动过(及ArrayList数据源有过结构性改动,比如增加、删除、元素排序等)就会抛出ConcurrentModificationException异常。
因为在for循环体内调用了了remove()
方法,所以在next() -> checkForComodification()时会抛出ConcurrentModificationException异常。
字节码分析
反编译字节码文件
Java内置了一个javap工具,通过该工具就能将 .class文件进行反汇编,得到具体的字节码指令。
通过javap -help
就能看到javap
工具的所有使用方式。
1 | ➜ ~ javap -help |
其中 `-v参数能导出了整个class文件的详情;
-c
参数,得到代码相关的JVM指令操作码。
1 | javap -v TestForEach > TestForEach.v |
得到TestForEach.v文件,其中.v后缀是为了和-v参数对应,方便辨别。文件内保存的是文本内容,文件后缀可以随意取。
TestForEach.v
1 | Classfile /Users/nht/WorkSpace/java/bytecode/TestForEach.class |
其中重点是Constant pool及其下方打括号{}内的方法表集合。
Constant pool
Constant pool
意为常量池。主要存放的是两大类常量:字面量(Literal)和符号引用(Symbolic References)。字面量类似于java中的常量概念,如文本字符串,final常量等,而符号引用则属于编译原理方面的概念,包括以下三种:
- 类和接口的全限定名(Fully Qualified Name)
- 字段的名称和描述符号(Descriptor)
- 方法的名称和描述符
查看第一个常量池数据:
1 | #1 = Methodref #17.#40 // |
这是一个方法定义,指向了第17和第40个常量,以此类推,最终组成了右边注释的内容
1 | java/lang/Object."<init>":()V |
表示的是实例构造器。
()内部是入参,这里为空,表示没有入参。
V表示void,代表没有返回类型。
关于字节码的类型对应如下:
标识字符 | 含义 |
---|---|
B | 基本类型byte |
C | 基本类型char |
D | 基本类型double |
F | 基本类型float |
I | 基本类型int |
J | 基本类型long |
S | 基本类型short |
Z | 基本类型boolean |
V | 特殊类型void |
L | 对象类型,以分号结尾,如Ljava/lang/Object; |
对于数组类型,每一位使用一个前置的”[“字符来描述,如定义一个java.lang.String[][]类型的维数组,将被记录为”[[Ljava/lang/String;” |
方法表集合分析
在常量池之后打括号{}内的是对类内部的方法描述。
1 | 0: new #2 // class java/util/ArrayList |
其中0、3、4表示 new ArrayList();
new: 创建ArrayList对象,并将其引用引用值压入栈顶(栈内:ref)
dup:复制栈顶数值并将复制值压入栈顶(栈内:ref ref)
invokespecial:调用了构造方法初始化实例并使用了一个引用(栈内:ref)
astore_1:弹出栈顶数据,并保存到局部变量表1处。
7:存储到局部变量表1处。
1 | 8: aload_1 |
本组指令表示将字符串“1”添加到列表内。
8:将局部变量表1处的列表压入栈内
9:将字符串“1”压入栈内
11:调用add方法,此时会消耗掉栈内的两个值(将栈顶字符串通过add方法添加到列表内),并压入add方法的Boolean类型的返回结果
14:出栈,将栈顶由11操作压入的值出栈(丢弃)
15~28操作与此一致:
15~21:将字符串“2”加入列表内;
22~28:将字符串“3”加入列表内;
1 | 29: aload_1 |
本组表示 Iterator
aload_1:将局部变量表1处的列表压入栈内
invokevirtual:调用ArrayList.iterator方法获取Iterator对象并压入栈内(ArrayList内获取到的是Itr对象)
astore_2:将栈顶的Itr对象保存到局部变量表2处
1 | 34: aload_2 |
本组表示 :iterator.hasNext()
aload_2:将局部变量表2处的Itr对象压入栈顶
invokeinterface:调用hasNext接口,并将boolean结果压入栈顶
1 | 40: ifeq 71 |
ifeq:if语句,栈顶内为true这执行下条指令(这里是43);反之,执行71处指令
43~52:表示调用iterator.next(),并将取到的结果强转成String类型后保存到局部变量表3处
1 | 53: ldc #4 // String 1 |
字符串“1”和局部变量表3处的值入栈,调用equals方法后判断栈顶值,true则执行下条指令(这里是62);反之,执行68处指令,goto 34,跳到34指令处执行,即再次开始判断hasNext。
62~67:将局部变量表3的值从列表内移除。
再执行68处goto指令。
而前面提到的71处指令如下:
1 | 71: getstatic #14 // Field java/lang/System.out:Ljava/io/PrintStream; |
表示调用System.out.println将列表数据打印。
return:结束程序。
综上分析,上面的分析过程是正确的。
1 | for (String number : list) { |
等同于
1 | for (Iterator<String> iterator = list.iterator();iterator.hasNext();){ |
特例
1 | for (String number : list) { |
在使用for( T : Iterable)方式时,有一种特殊用例,会使得遍历时能正常删除,就是删除的是list倒数第二项时。
经过上面的分析,我们只需回看下ArrayList.Itr的其中两个成员方法:ArrayList.Itr.hasNext()
和ArrayList.Itr.remove()
以及ArrayList.remove()
就可以知道原因了。
ArrayList.Itr
1 |
|
ArrayList
1 | public boolean remove(Object o) { |
当Itr.next()
取出的是list最后第二项后,成员变量cursor+1后的值等于size-1
。
而调用list.remove()(remove() -> fastRemove())方法后,可以看到–size。
所以此时Itr.cursor == size,Itr.hasNext()
返回的是false
,直接退出了循环,没有对list的最后一项进行判断。
但是不推荐怎么使用。
其他正确的删除方式
除了上面删除几种方式外,还有如下几种正确的方式:
显示使用迭代器
1 | String number; |
Collection.removeIf(Predicate<? super E> filter)
1 | list.removeIf(number -> number.equals("1")); |
Collection接口内给出了默认实现:
1 | /** |
也是使用迭代器进行删除。
但是Arraylist类重写了该实现,提高了删除效率:
1 |
|
总结
for( T : Iterable) 完全等同于
1
2
3
4for (Iterator<T> iterator = list.iterator();iterator.hasNext();){
T number = iterator.next();
//这里执行具体操作
}能够正常遍历时删除的方式有
- for(int i =0; i < list.size(); i ++)
- 迭代器方式
- Collection.removeIf(Predicate<? super E> filter)
for( T : Iterable) 或 迭代器内使用List.remove()时,若仅删除最后第二项时,不会抛出异常