一桩foreach引发的血案
最近在翻看阿里巴巴Java开发手册,对照自己的代码规范,发现了代码中存在的不少缺陷。比如手册强制规定所有的POJO类属性必须使用包装类型。因为使用包装类型在使用起来会比较麻烦,有时候需要多加一些判断,我很好奇是否这些规定都落地实施了,于是到阿里云下载了几个SDK查看了一下,这个规范确实是都做到了,但也发现了有些强制要求的规范没有实施,比如不允许任何魔法值(即未经预先定义的常量)直接出现在代码中。
反例:
String key = "Id#taobao_" + tradeId;
cache.put(key, value);
手册里面强制规定不允许此类代码的出现,但是我发现阿里云的SDK中也还是出现这种字符串未定义就直接拼接的情况。下面先看一些平时需要多加注意的地方,后面再讲一下foreach导致的问题。
强制所有的相同类型的包装对象之间值的比较,全部使用equals方法的比较。在-128至127范围内赋值,Integer对象是在IntegerCache.cache产生,会复用已有对象,在这个区间内的Integer值可以直接使用==进行判断,但是这个区间之外的所有数据,都会在堆上产生,并不会复用已有对象,这是一个大坑,推荐使用equals方法进行判断。
最好不要一个常量类维护所有常量,而要按常量功能进行归类,分开维护。比如在constants包下面,缓存相关的常量放在CacheConsts下;系统配置的相关常量放在类ConfigConsts等等。
使用工具类Arrays.asList()把数组转化成集合时,不能使用其修改集合相关的方法,它的add/remove/clear方法会抛出UnsupportedOperationException异常。原因是asList返回对象是一个Arrays内部类,并没有实现集合的修改方法。Arrays.asList体现的是适配器模式,只是转化接口,后台的数据仍是数组。改变一个另一个也会改变。比如str[0] = "jc",那么list.get(0)也会改变。
推荐使用entrySet集合遍历Map类集合KV,而不是KeySet方式进行遍历。KeySet其实是遍历了两次,一次是转为Iterator对象,另一次是从hashMap中取出key所对应的value。而entrySey只是遍历了一次就把key和value都放到了entry中,效率更高。如果是JDK8,使用Map.foreach方法。value()是返回V值集合,是一个list集合对象;keySet()返回的是K值集合,是一个Set集合对象;entrySet()返回的是K-V值组合集合。
正则表达式相关。使用正则表达式的预编译编译功能,可以有效加快正则匹配速度。Pattern要定义为staic final静态变量。以避免多次预编译。
private static final Pattern pattern = Pattern.compile(regexRule);
private void func(...) {
Matcher m = pattern.matcher(content);
if (m.matches()) {
...
}
}
foreach引发的血案
终于开始讲正题了,手册中规定不要在foreach循环里进行元素的remove/add操作,remove元素请使用Iterator方式,如果是并发操作,需要对Iterator对象加锁。先来看下面这两段代码
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("1");
list.add("2");
// 正确的删除方式
// Iterator<String> iterator = list.iterator();
// while (iterator.hasNext()){
// String item = iterator.next();
// if (item.equals("2")){
// System.out.println(item);
// iterator.remove();
// }
// }
// 错误的删除方式
for (String item : list) {
if (item.equals("1")){
System.out.println(item);
list.remove(item);
}
}
}
其实你运行这两段代码,都不会出错,第一种使用手册推荐的方式,没什么问题。第二段代码的循环其实只执行了一次,但是没有报错;如果将其中的判断的数字1改成2,程序就会抛出异常了。
下面我们来仔细分析一些第二段代码,在终端中切换到该类的目录下,输入命令javac xxx.java,就会在同一个包下生成一个名字相同的xxx.class,javac是一种编译器,将代码编写成class文件的工具,在AndroidStudio上打开xxx.class,可以看到第二段代码直接变成
ArrayList var1 = new ArrayList();
var1.add("1");
var1.add("2");
Iterator var2 = var1.iterator();
while(var2.hasNext()) {
String var3 = (String)var2.next();
if (var3.equals("1")) {
System.out.println(var3);
var1.remove(var3);
}
}
可以看到foreach遍历集合,实际上内部使用的是Iterator。代码先判断是否hasNext(),然后再去调用next()方法,这两个函数是引起问题的关键。remove()调用的还是list的remove()方法。下面来一起来看一下remove中的fastRemove()方法。
private void fastRemove(int var1) {
++this.modCount;
int var2 = this.size - var1 - 1;
if (var2 > 0) {
System.arraycopy(this.elementData, var1 + 1, this.elementData, var1, var2);
}
this.elementData[--this.size] = null;
}
我们可以看到第二行处有个modCount,这里我们先记住fail-fast机制是Java集合中的一种错误检测机制。通过记录modCount参数来实现。
然后我们也可以看到add方法中也有
public void add(E var1) {
this.checkForComodification();
try {
int var2 = this.cursor;
ArrayList.this.add(var2, var1);
this.cursor = var2 + 1;
this.lastRet = -1;
this.expectedModCount = ArrayList.this.modCount;
} catch (IndexOutOfBoundsException var3) {
throw new ConcurrentModificationException();
}
}
private void checkForComodification() {
if (ArrayList.this.modCount != this.modCount) {
throw new ConcurrentModificationException();
}
}
下面再来看一下next,hasNext方法,在内部类Itr类当中
private class Itr implements Iterator<E> {
int cursor;
int lastRet = -1;
int expectedModCount;
Itr() {
this.expectedModCount = ArrayList.this.modCount;
}
public boolean hasNext() {
return this.cursor != ArrayList.this.size;
}
public E next() {
this.checkForComodification();
int var1 = this.cursor;
if (var1 >= ArrayList.this.size) {
throw new NoSuchElementException();
} else {
Object[] var2 = ArrayList.this.elementData;
if (var1 >= var2.length) {
throw new ConcurrentModificationException();
} else {
this.cursor = var1 + 1;
return var2[this.lastRet = var1];
}
}
}
final void checkForComodification() {
if (ArrayList.this.modCount != this.expectedModCount) {
throw new ConcurrentModificationException();
}
}
在next方法第二行处可以看到checkForComodification方法中的modCount != expectedModCount,就会抛出ConcurrentModificationException异常,一开始modCount和expectedModCount是相等的,就是list内部的元素个数。在hasNext方法中,cursor != list.size()的时候,hasNext返回true。next方法中checkForComodification()就是函数抛出异常的原因。
下面再仔细分析一下把第二段代码为什么不会报错(当判断的字符串为1的时候),该代码执行完第一次循环以后:
-
modCount = 3,因为执行了一次remove(),调用了里面的fastRemove()方法。
-
expectedModCount = 2,因为一开始的list.size()大小为2。
-
cursor = 1,执行了一次next()。
-
size = 1,移除了字符串1。
所以程序在执行hasNext方法的时候返回(cursor != size) false,相当于总共就循环了一次,程序也不会报错。
当把判断的字符串改成2的时候,执行完第二层循环后:
-
modCount = 3,因为执行了一次remove(),调用了里面的fastRemove()方法。
-
expectedModCount = 2,因为一开始的list.size()大小为2。
-
cursor = 2,因为执行了两次next()
-
size = 1
此时hasNext方法返回(cursor != size) true,接着就会继续执行next方法,然后就会检查modCount != expectedModCount,如果不相等就抛出ConcurrentModificationException异常,此时两个值不相等,抛出了异常,相当于执行第三次循环的时候,在next方法中抛出了异常。
到此为止就很清楚了,foreach循环实际上使用的还是Iteartor迭代器,但是移除的时候通过list的remove方法去便很有可能抛出ConcurrentModificationException异常,如果使用迭代器的remove()方法就不会有什么问题,当然多线程情况下也是有可能出问题,所以要添加并发锁。
fail-fast机制
fail-fast 机制,即快速失败机制,是java集合(Collection)中的一种错误检测机制。通过记录modCount参数来实现,当在迭代集合的过程中该集合在结构上发生改变的时候,就有可能会发生fail-fast,即抛出ConcurrentModificationException异常。fail-fast机制并不保证在不同步的修改下一定会抛出异常,它只是尽最大努力去抛出,所以这种机制一般仅用于检测bug。
碰到的其他问题
在调试的过程中,也碰到一下其他小问题。我是在Android Studio上调试的,一开始调试的时候,查看源码的时候查看ArrayList的源码是Android API 27 Platform上的。但是调试的时候显示源码不匹配,但是程序确实是运行下去了,该报bug的也报bug。
而且如果把上面的第二段代码中的判断改成2,程序运行报错,抛出异常,但是在API 27的时候显示的源码却怎么也推理不出抛异常的结果,它的hasNext方法如下,这样是不会抛出异常的,但是事实是程序的确抛出异常了。
后面问题总于找到了,我虽然在Android Studio上写代码,但是我新建普通Java类,写了一个main方法来验证程序的运行过程,所以实际上程序并未运行在任何安卓平台上,所以ArrayLIst类虽然是Android API 27 上的,但是最终运行的源码是rt.jar中的ArrayList。在这里选择1.8 rt.jar 最后就能正确调试了。
原文始发于微信公众号(九局下半大逆转):一桩foreach引发的血案