起因
最近接到一个需求(假的),从一个list
中的member
对象中,将年龄小于18岁的对象去掉。不然展示出去之后就要出问题。
拿到需求后一顿操作,很快就写好了。逻辑也很清晰明了,大致代码如下:
void removeMember(List<Member> list) {
for (int i = 0; i < list.size(); i++) {
if(list.get(i).getAge()<18){
list.remove(i);
}
}
}
看起来是不是一点问题都没有,然而不是。在实际运行中,总是存在部分漏网之鱼没有被去掉。
原因分析
下面我们对这个问题进行详细的分析。
先上代码:
public static void main(String[] args) {
//构造测试数据
List<Member> list = new ArrayList<>();
list.add(new Member("zhangsan",17));
list.add(new Member("lisi",16));
list.add(new Member("wangwu",17));
list.add(new Member("aaa",19));
list.add(new Member("bbb",18));
list.add(new Member("ccc",17));
list.add(new Member("ddd",19));
list.add(new Member("eee",20));
list.add(new Member("fff",22));
list.add(new Member("ggg",17));
list.add(new Member("hhh",17));
list.add(new Member("iii",17));
removeMember(list);
print(list);
}
/**
* 打印Member List
* @param list
*/
private static void print(List<Member> list) {
for (int i = 0; i < list.size(); i++) {
System.out.println(list.get(i));
}
System.out.println("-----------------------------");
}
/**
* 删除年龄小于18岁的Member对象
* @param list
*/
private static void removeMember(List<Member> list) {
for (int i = 0; i < list.size(); i++) {
if(list.get(i).getAge()<18){
list.remove(i);
}
}
}
根据上述代码,应该得到的结果是所有大于等于18岁的member对象。
然而实际上输出结果是这样的:
Member{name='lisi', age=16}
Member{name='aaa', age=19}
Member{name='bbb', age=18}
Member{name='ddd', age=19}
Member{name='eee', age=20}
Member{name='fff', age=22}
Member{name='hhh', age=17}
lisi
,hhh
成了漏网之鱼,这放在生产环境中就成了大事故了。
原理解析
这里面涉及到一个list
的操作问题,看过源码就知道是怎么回事了。
ArrayList
实现类中的remove
方法如下:
public E remove(int index) {
rangeCheck(index);
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}
重点是这一句:System.arraycopy(elementData, index+1, elementData, index, numMoved);
ArrayList
底层是数组来实现的,我们都知道(也许吧),数组是不支持在已有数组上进行扩容或缩容的,数组的扩容是通过数组的拷贝来实现的,也就是新创建一个更大长度的数组,将原来的数组内容拷贝到新数组中。
ArrayList
中移除元素,就涉及到了数组的缩容。缩容也就只能将数组拷贝到一个新的数组中。就会涉及到数组中元素下标的移位。一张图可以展示上面代码的执行逻辑。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pRQtXw81-1648794262807)(F:\jskfb\技术文档库\开发技术\附件\image-20220401102439011.png)]
- 解释一下,i=0时,对zhangsan这个家伙进行判断,发现age小于18,直接干掉。
- 划重点!此时数组将变为i=1时对应的数组,zhangsan没了,这个list不能没有头啊,所以lisi赶紧补上去,后面的家伙们也赶紧往前冲,就形成了一个新的数组。但是,此时i=1了,已经指向了wangwu了。所以这一次的判断结束后,将会移除wangwu,得到的就是i=2时的数组。
- 发现了吗?lisi就这么逃过了一劫。
结论分析
由此得出结论:
使用
for
循环遍历list
来删除某个元素时,可能会存在漏网之鱼,也就是存在漏删的情况。(只删除一个元素是可行的)
其他方案分析
换一种方式,聊聊增强for
循环。用增强for
循环,会存在什么问题呢?
增强for循环删除元素
我们将removeMember
方法改造一下
private static void removeMember(List<Member> list) {
for (Member member : list) {
if(member.getAge()<18){
list.remove(member);
}
}
}
运行报错了。
Exception in thread "main" java.util.ConcurrentModificationException
at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909)
at java.util.ArrayList$Itr.next(ArrayList.java:859)
at Test1.removeMember(Test1.java:48)
at Test1.main(Test1.java:26)
这是什么原因呢?还得看看源码:
ArrayList
实现的remove(Object o)
方法如下:
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;
}
看不出来问题,看看fastRemove(index)
这个方法
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; // clear to let GC do its work
}
也没啥毛病,那问题出在哪里呢?
跟一下异常堆栈信息,at java.util.ArrayList$Itr.next(ArrayList.java:859)
看来是在循环的时候出的问题。
跟进代码发现,这个next()
方法是在list
循环的时候调用的
public E next() {
checkForComodification();
int i = cursor;
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
调用前先进行了校验修改
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
我们给这打个断点:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GBDsx0YT-1648794262808)(F:\jskfb\技术文档库\开发技术\附件\image-20220401110828682.png)]、
可以看到是这个地方modCount
与expectedModCount
值不一致导致抛出的异常。这事就麻烦大了。。。
再看看上面的next
()方法,这个方法是一个Iterator
迭代器的实现类来实现的。也就是说,增强for
循环,实际上是迭代器的简化写法?
事实的确如此,在增强for循环中,集合遍历是通过iterator
进行的。
但是元素的add/remove却是直接使用的集合类自己的方法(没有使用迭代器中的remove方法,迭代器中有单独的remove方法,后面再讲)。这就导致iterator在遍历的时候,会发现有一个元素在自己不知不觉的情况下就被删除/添加了,就会抛出一个异常,用来提示用户,可能发生了并发修改。当然,本次实际上倒是没有并发修改,只是循环和删除,是在两个不同的来源中处理的,也可以理解为并发修改了。
所以,增强for循环删除元素没问题,但是删除了还要继续遍历,那就不行了。
那差不多意思就是,在for循环和增强for循环中,删除一个元素是没问题的。删除多个元素,可能就会出大问题。
那到底要用什么样的方式来对循环对list中的元素进行操作呢?正确的姿势就是,使用迭代器Iterator
使用迭代器Iterator删除元素
我们再改造一下removeMember()
方法
private static void removeMember(List<Member> list) {
Iterator<Member> iterator = list.iterator();
while (iterator.hasNext()){
Member next = iterator.next();
if(next.getAge()<18){
iterator.remove();
}
}
}
输出结果:
Member{name='aaa', age=19}
Member{name='bbb', age=18}
Member{name='ddd', age=19}
Member{name='eee', age=20}
Member{name='fff', age=22}
这一波很稳,没有任何问题。
实际上,在阿里巴巴java开发手册中就已经有规定了。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-inLnkbI1-1648794262808)(F:\jskfb\技术文档库\开发技术\附件\image-20220401112410646.png)]
关于迭代器的实现,也是可以根据源码来分析的,下次咱可以再详细去分析分析。
结论
总之,通过这个案例,我们可以得出一个结论:
- 尽量不要在for循环或增强for循环中取删除list中的元素。
- 对元素的删除,统一使用Iterator迭代器。
- 开发这件事,不要停留在粗略的表面,一定要尽量弄清楚原理,不然出现错误都找不到错在哪里!