一桩foreach引发的血案

>>强大,10k+点赞的 SpringBoot 后台管理系统竟然出了详细教程!

        最近在翻看阿里巴巴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 最后就能正确调试了。

同时我也在安卓手机(7.1.1)上试了一下第二段代码,发现把第二段代码中的判断修改为1的时候,程序抛出异常,而当改成2的时候,程序却可以正常运行,抛出异常的原因都是类似的,有兴趣的可以自己去研究android源码看看。

一桩foreach引发的血案

原文始发于微信公众号(九局下半大逆转):一桩foreach引发的血案