一個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 最後就能正確調試了。

 

同時我也在安卓手機(7.1.1)上試了一下第二段代碼,發現把第二段代碼中的判斷修改爲1的時候,程序拋出異常,而當改成2的時候,程序卻可以正常運行,拋出異常的原因都是類似的,有興趣的可以自己去研究android源碼看看。

高質量編程視頻shangyepingtai.xin

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章