ArrayList分析1-迴圈、擴容、版本

funnyZpC發表於2022-06-30

ArrayList分析1-迴圈、擴容、版本

轉載請註明出處 https://www.cnblogs.com/funnyzpc/p/16407733.html

前段時間抽空看了下ArrayList的原始碼,發現了一些有意思的東東,真的是大受裨益哈,尤其是版本問題?
所以,本篇部落格開始我將大概分三篇講講ArrayList裡面一些有意思的點哈,由於原始碼大概一千八百逾行,裡面大多程式碼都很通俗,也有些部分存在重複的(Itr以及SubList的內部方法),因為大多通俗遂這裡不會逐行的分析哈,好了,現在開始~?

一.關於迴圈的一個問題

首先,我給出一個很easy的迴圈

    public static void main(String[] args) {
      for(int i = 0;i<8;i++){
          System.out.print(i+"\t"); // 0	1	2	3	4	5	6	7	
      }
  }

看起來很簡單吧,哈哈,這時我會問:各位有沒試過將i提到for迴圈外邊呢,像下面這樣:

    public static void main(String[] args) {
      int i;
      for(i = 0;i<8;i++){
          System.out.println(i);
      }
      System.out.println(i);// ?
  }

上面第六行的i會輸出什麼呢?真是個有意思的問題,這真是一個微小而有意思的問題,我們經常使用,卻很少利用for去做一些別樣的事兒,ArrayList就有一騷操作,
原本我是準備臆測出來,卻發現怎麼也理解不了,當然啦,這個問題接下來我會說到:探究這個問題我們先看看一個普通的for迴圈的結構

for(定義1;定義2;定義3){
  //定義4 :迴圈內的語句塊
}

個人文采拙劣,這裡就用定義一詞哈?
定義1: 這個地方我們經常會用int i=0; 這樣一個語句,其實這個地方是對迴圈的變數做一次定義,這個地方的定義是一次性的,而且是第一次迴圈的時候會執行。
定義2: 這裡一般是個判斷性的表示式,而且這個地方的整體必須返回一個boolean,這個很重要,既然這個地方只需要返回一個布林的結果,那麼你想過沒有,如果這個地方 我寫的是 (i<10 && i>=0) 會不會拋錯呢 ?
定義4: 這是迴圈內語句塊,通常我們會取到當前迴圈到的i進行某些邏輯處理,這裡不是重點哈。
定義3: 這個地方是重點,一般我們會說每次迴圈後我們會將i--或者i++, 這種迴圈變數變化我們一般都會寫在這個位置,這是_very very normal_的,但問題是每次執行完定義4的部分 就一定會執行定義3這個地方嘛? 答案是:一定會的!,為什麼呢,看看生成的位元組碼指令就知道了哈? :

0 iconst_0
1 istore_1
2 iload_1
3 bipush 8
5 if_icmpge 21 (+16)
8 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
11 iload_1
12 invokevirtual #3 <java/io/PrintStream.println : (I)V> //列印i
15 iinc 1 by 1
18 goto 2 (-16)
21 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
24 iload_1
25 invokevirtual #3 <java/io/PrintStream.println : (I)V> //列印i
28 return

以上是main函式內的完整位元組碼內容(jdk=java8), 可以看到指令內有兩處println,自然第一個println即是for迴圈內的(標號12處的),下面一行就很重要了,官方描述是:將區域性棧幀的索引+1(see: https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.iinc),說明白些也就是將i加一,然後就到了標號18這個位置,goto是將當前語句指向標號2這個位置 將storei載入...到這裡也就很明白了, goto指令是在i自增1之後,可以完全確認迴圈外的println列印的就一定是 8
看似簡單的操作 ArrayList 則時常使用,比如可以用i迴圈,迴圈完成後,陣列的大小不就是這個i了?以下ArrayList->Itr內的一段程式碼:

        // 迴圈每個剩餘操作
      // 這是java8提供給iterator的函式式迴圈介面,其使用方式如下
      //        ArrayList arr = new ArrayList();
      //        arr.add("a");
      //        arr.add("b");
      //        arr.add("c");
      //        System.out.println(arr);
      //        Iterator iterator = arr.iterator();
      //        iterator.next(); // a
      //        iterator.forEachRemaining(item-> System.out.println(item)); // b c
      @Override
      @SuppressWarnings("unchecked")
      public void forEachRemaining(Consumer<? super E> consumer) {
          // 檢查是否為null,否則丟擲錯誤
          Objects.requireNonNull(consumer);
          // 獲取當前陣列大小並檢查迭代器的遊標位置是否大於陣列大小
          final int size = ArrayList.this.size;
          int i = cursor;
          if (i >= size) {
              return;
          }
          // 老實說 elementData.length 與 ArrayList.this.size 是一對一關聯的,這裡這樣做似乎多餘
          final Object[] elementData = ArrayList.this.elementData;
          if (i >= elementData.length) {
              throw new ConcurrentModificationException();
          }
          while (i != size && modCount == expectedModCount) {
              // 消費這個元素,同時將遊標位置+1
              consumer.accept((E) elementData[i++]);
          }
          // update once at end of iteration to reduce heap write traffic 在迭代結束時更新一次以減少堆寫入流量
          // 因為i在以上已經+1了,所以這裡直接賦值以及重置當前迭代的索引位置(lastRet)
          cursor = i;
          lastRet = i - 1;
          checkForComodification();
      }

上面這是迭代器內的部分迭代方法的定義,可以看到有句 cursor = i; 這個地方的i其實也就是size哈,稍不注意就理解錯了,小問題讓大家見笑了...?

二.ArrayList擴容

ArrayList內部其實也就是維護了一個 Object 型別的陣列,它具體是這樣定義的transient Object[] elementData; ,看起來是不是超簡單呢?呵呵呵,如果將ArrayList看作成一個大水缸的話,這個elementData就是水缸的本體,ArrayList可以看做一個使用者態的介面,簡單理解它其實就是是水缸外層刷的油漆或蓋子抑或是漏斗。
打比方,如果這個水缸 (elementData )能裝四桶水,那麼這四桶水我們用 initialCapacity 這個變數來表示,當前實際上如果只有兩桶水,則這個水缸實際儲水容量(兩桶水)我們用size來表示,這樣就好理解了吧?
好了,如果預先知道將有8桶水倒入缸內,那我們就要準備一個能容納8桶水的水缸,對於程式碼就是這樣的:public ArrayList(int initialCapacity) {....}
當然,如果我們只允許用一個水缸來儲存水的話,這個水缸當前如果是滿載(8桶水),第9桶水的時候就需要準備一個至少能容納9桶水的水缸,對於ArrayList來說這時候就需要擴容了,程式碼是這樣的:

    // 確保顯式容量(官方直譯,不懂直接看程式碼)
   private void ensureExplicitCapacity(int minCapacity) {
       // 這個變數記錄的是當前活動陣列被修改的次數
       // 每新增一個(準確的說是一次)元素修改次數+1,如果是addAll也算做是+1
       modCount++;
       // overflow-conscious code 判斷是否溢位
       // 可以簡單的理解 minCapacity 為當前需要保證的最小容量(具體大小為當前容量+1:這是對於當前add元素的個數而定的),elementData.length則為當前活動陣列的容量
       // minCapacity 也為新增元素後所需陣列容量大小,如果(所需容量)大於當前(新增前)陣列容量即需要<b>擴容</b>
       if (minCapacity - elementData.length > 0)
           grow(minCapacity); //增長(擴容)
   }

當然這時本著少騰挪多儲水的原則,我們一般不會準備一個只能容納9桶水的水缸,水缸太大也不好,容易浪費缸的容量維護也麻煩?,所以對於ArrayList這個水缸,我們每次增長為現有容量的1.5倍(多了50%左右,如果當前Capacity是10->增長到15,9->增長到13),具體對應到ArrayList的程式碼就是:

    /**
     * Increases the capacity to ensure that it can hold at least the
     * number of elements specified by the minimum capacity argument.
     * 增加容量以確保它至少可以容納最小容量引數指定的元素數量。
     * @param minCapacity the desired minimum capacity 所需的最小容量(也即當前需要的容量大小)
     */
    private void grow(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;
        // 這裡的增長策略是 oldCapacity=10 -> newCapacity=15 oldCapacity=9 -> newCapacity=14
        // 即 每一次增長的為上一次的一半
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        // 這裡個人覺得只是一個保險,對於類似addAll這樣的操作 newCapacity 可能小於一次add的數量
        // 比如當前容量是10[oldCapacity:10->newCapacity:15],addAll(100)後所需的容量還是不夠 這時就會出現[newCapacity:100]
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        // 這裡也是一個保險,對於待擴容後的大小比陣列最大(MAX_ARRAY_SIZE)還要大的時候啟用hugeCapacity(minCapacity)
        // 這裡呼叫 hugeCapacity 後頂多擴容8個大小 MAX_ARRAY_SIZE=2147483639(Integer.MAX_VALUE-8)
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        // minCapacity is usually close to size, so this is a win:
        // 翻譯: minCapacity 通常接近 size,所以這是一個勝利
        // Arrays.copyOf:
        //  複製指定的陣列,截斷或填充空值(如有必要),使副本具有指定的長度。對於在原始陣列和副本中都有效的所有索引,
        //  這兩個陣列將包含相同的值。對於在副本中有效但在原始副本中無效的任何索引,副本將包含 null。
        //  當且僅當指定長度大於原始陣列的長度時,此類索引才會存在。結果陣列與原始陣列的類完全相同。
        elementData = Arrays.copyOf(elementData, newCapacity);
    }

上面有一句很重要 : int newCapacity = oldCapacity + (oldCapacity >> 1); ,每次換用更大的水缸時都會將之前缸內的水兌過來,對應ArrayList就是這句:elementData = Arrays.copyOf(elementData, newCapacity);
算是很形象吧?,這只是簡單的理解,擴容也還有很多內容,比如什麼時候擴容,擴容一個單位不夠怎麼辦,滿了怎麼辦???等等問題....

三.ArrayList中的版本管理

一開始大家會覺得這是個奇怪的問題,ArrayList中為啥會有版本,版本做什麼用?

首先,我詳細解答第一個問題:ArrayList中為什麼有版本?,首先先看一段ArrayList的原始碼,關於Iterator的:
(以下為第一段程式碼)

    public Iterator<E> iterator() {
      // 雖然與都會返回一個迭代器,但是iterator只能單向迴圈,且不能實現增刪改查
      // 詳見: https://www.cnblogs.com/tjudzj/p/4459443.html
      return new Itr();
  }

(以下為第二段程式碼)

       public E next() {
          // 版本檢查
          checkForComodification();
          // 這個將遊標賦予i,然後檢查是否i是否超出當前陣列索引位置(size)
          // 我暫時沒看出以下三行跟 hasNext() 有多少區別。。。,而且checkForComodification內也是做了安全檢查了的
          // 總結就是:十分沒必要啊...
          int i = cursor;
          if (i >= size)
              throw new NoSuchElementException();
          // 不大明白為啥要再整個引用 ,通過這個新引用索引返回陣列值
          Object[] elementData = ArrayList.this.elementData;
          if (i >= elementData.length)
              throw new ConcurrentModificationException();
          // 因為迭代器每次迴圈前都會呼叫 hasNext ,故此推測這裡也應該將遊標+1
          cursor = i + 1;
          // 需要說明的是這個 lastRet 是個成員變數,而i只是個方法內臨時變數而已
          // 所以每迴圈一次這個 lastRet 需要記錄為當前返值前的當前索引位置
          return (E) elementData[lastRet = i];
      }

(以下為第三段程式碼)

      //// example:
      //  ArrayList arr = new ArrayList();
      //  arr.add("a");
      //  arr.add("b");
      //  arr.add("c");
      //  ListIterator listIterator = arr.listIterator();
      //  arr.remove("a");// throw error
      //  Object previous = listIterator.previous();
      final void checkForComodification() {
          if (modCount != expectedModCount)
              throw new ConcurrentModificationException();
      }

額,由於只是擷取了部分程式碼,我先簡單講講上面三段程式碼,第一段程式碼很明顯,我們使用迭代器的時候首先呼叫的就是集合類的 iterator() 方法,而 iterator() 內部 只做了一件事兒:new Itr() ,現在知道迭代器是一個類物件,這點很重要。
繼續看第二段next()方法,這個方法內部第一行程式碼 是 checkForComodification(), 這是一個較為特殊的存在,點進去會看到以上第三段程式碼,if判斷內有兩個引數 ,一個是 modCount (直譯為修改次數),第二個引數為 expectedModCount (預期修改的次數),點到這個引數定義,它只有一句很簡單的定義int expectedModCount = modCount; ,是不是很迷糊??
next()內還有一句也很重要 Object[] elementData = ArrayList.this.elementData; ,這句估計很好懂了,Itr迭代器內使用的陣列其實也就是ArrayList中維護的陣列物件(elementData),倒退一步,再往回思考下 checkForComodification()看...
不知讀者老爺有沒恍然大悟,其實很簡單啦: Itr物件不希望你在使用Itr迭代器的過程中修改(主要是增刪)ArrayList中的(elementData)元素,不然在迭代的時候源陣列少了個元素會直接拋錯的,Itr內的expectedModCount只會在 new Itr() 時被賦值一次,這就是很好的證明啦~
ItrIterator的實現,裡面只有迭代的操作,如果有更復雜的操作,比如ListItr(是Itr以及ListIterator的繼承實現) 裡面更是對迭代器增加了增刪改查方法,以及SubList這個物件內部也是,內部均是對ArrayList維護elementData直接操作(他們並未拷貝elementData),所以裡面的增刪操作不僅僅要比較ArrayListelementData版本,也要在操作(增刪)之後同步ArrayListmodCount版本,以下是ListItr內的add(E e)函式的原始碼:

public void add(E e) {
            checkForComodification();
            try {
                // ArrayList的新增方法,在遊標位置新增元素,遊標及之後的元素往後移動,
                int i = cursor;
                // 這裡還需要注意的是這個插入是在當前元素之後插入元素,ArrayList則是在元素之前,這主要是遊標是當前位置+1
                ArrayList.this.add(i, e);
                // 因為增加了個元素,所以遊標的位置要+1,當前位置lastRet會在下一次呼叫next或previous時會被重置
                cursor = i + 1;
                lastRet = -1;
                // 這個其實類似於版本的概念,主要由於ArrayList與Iterator修改不同步,expectedModCount,這個會在 checkForComodification() 中進行校驗
                expectedModCount = modCount;
            } catch (IndexOutOfBoundsException ex) {
                throw new ConcurrentModificationException();
            }
        }

以上程式碼中有這樣一句 expectedModCount = modCount; 不知讀者們明白否。。。?

ArrayList中為啥有版本管理,版本管理怎麼用?

好了,我總結下本小節開頭的的兩個問題
首先版本管理就是在增刪元素的時候對 modCount 自增1
因為對ArrayList的迭代器 ItrListItr以及SubList(擷取類) 他們是單獨類物件同時內部也是直接操作的ArrayList的源elementData陣列物件,所以在ArrayList新增元素時這三個類內部方法均不知道陣列元素個數已發生變化,所以在操作elementData時候均需要判讀版本是否一致,這就是為啥有版本;
他解決的是:這幾個類在操作 elementData (ArrayList的)時 ArrayList可能對其的增刪導致的版本不一致的問題,總結似乎臭長了些,但就是這麼個意思?
理解這個很重要,不然你在讀 ArrayListaddremove這類方法時不知modCount作甚云云,哈哈。。。

相關文章