面經手冊 · 第7篇《ArrayList也這麼多知識?一個指定位置插入就把謝飛機面暈了!》

小傅哥發表於2020-08-28


作者:小傅哥
部落格:https://bugstack.cn

沉澱、分享、成長,讓自己和他人都能有所收穫!?

一、前言

資料結構是寫好程式碼的基礎!

說到資料結構基本包括;陣列、連結串列、佇列、紅黑樹等,但當你看到這些資料結構以及想到自己平時的開發,似乎並沒有用到過。那麼為什麼還要學習資料結構?

其實這些知識點你並不是沒有用到的,而是Java中的API已經將各個資料結構封裝成對應的工具類,例如ArrayList、LinkedList、HashMap等,就像在前面的章節中,小傅哥寫了5篇文章將近2萬字來分析HashMap,從而學習它的核心設計邏輯。

可能有人覺得這類知識就像八股文,學習只是為了應付面試。如果你真的把這些用於支撐其整個語言的根基當八股文學習,那麼硬背下來不會有多少收穫。理科學習更在乎邏輯,重在是理解基本原理,有了原理基礎就複用這樣的技術運用到實際的業務開發。

那麼,你什麼時候會用到這樣的技術呢?就是,當你考慮體量、夯實服務、琢磨效能時,就會逐漸的深入到資料結構以及核心的基本原理當中,那裡的每一分深入,都會讓整個服務效能成指數的提升。

二、面試題

謝飛機,聽說你最近在家很努力學習HashMap?那考你個ArrayList知識點?

你看下面這段程式碼輸出結果是什麼?

public static void main(String[] args) {
    List<String> list = new ArrayList<String>(10);
    list.add(2, "1");
    System.out.println(list.get(0));
}

嗯?不知道!?眼睛看題,看我臉幹啥?好好好,告訴你吧,這樣會報錯!至於為什麼,回家看看書吧。

Exception in thread "main" java.lang.IndexOutOfBoundsException: Index: 2, Size: 0
	at java.util.ArrayList.rangeCheckForAdd(ArrayList.java:665)
	at java.util.ArrayList.add(ArrayList.java:477)
	at org.itstack.interview.test.ApiTest.main(ApiTest.java:13)

Process finished with exit code 1

?謝飛機是懵了,我們們一點點分析ArrayList

三、資料結構

Array + List = 陣列 + 列表 = ArrayList = 陣列列表

ArrayList的資料結構是基於陣列實現的,只不過這個陣列不像我們普通定義的陣列,它可以在ArrayList的管理下插入資料時按需動態擴容、資料拷貝等操作。

接下來,我們就逐步分析ArrayList的原始碼,也同時解答謝飛機的疑問。

四、原始碼分析

1. 初始化

List<String> list = new ArrayList<String>(10);

public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

 /**
  * Constructs an empty list with the specified initial capacity.
  *
  * @param  initialCapacity  the initial capacity of the list
  * @throws IllegalArgumentException if the specified initial capacity
  *         is negative
  */
 public ArrayList(int initialCapacity) {
     if (initialCapacity > 0) {
         this.elementData = new Object[initialCapacity];
     } else if (initialCapacity == 0) {
         this.elementData = EMPTY_ELEMENTDATA;
     } else {
         throw new IllegalArgumentException("Illegal Capacity: "+
                                            initialCapacity);
     }
 }
  • 通常情況空建構函式初始化ArrayList更常用,這種方式陣列的長度會在第一次插入資料時候進行設定。
  • 當我們已經知道要填充多少個元素到ArrayList中,比如500個、1000個,那麼為了提供效能,減少ArrayList中的拷貝操作,這個時候會直接初始化一個預先設定好的長度。
  • 另外,EMPTY_ELEMENTDATA 是一個定義好的空物件;private static final Object[] EMPTY_ELEMENTDATA = {}

1.1 方式01;普通方式

ArrayList<String> list = new ArrayList<String>();
list.add("aaa");
list.add("bbb");
list.add("ccc");
  • 這個方式很簡單也是我們最常用的方式。

1.2 方式02;內部類方式

ArrayList<String> list = new ArrayList<String>() \\{
    add("aaa");
    add("bbb");
    add("ccc");
\\};
  • 這種方式也是比較常用的,而且省去了多餘的程式碼量。

1.3 方式03;Arrays.asList

 ArrayList<String> list = new ArrayList<String>(Arrays.asList("aaa", "bbb", "ccc"));

以上是通過Arrays.asList傳遞給ArrayList建構函式的方式進行初始化,這裡有幾個知識點;

1.3.1 ArrayList建構函式
public ArrayList(Collection<? extends E> c) {
    elementData = c.toArray();
    if ((size = elementData.length) != 0) {
        // c.toArray might (incorrectly) not return Object[] (see 6260652)
        if (elementData.getClass() != Object[].class)
            elementData = Arrays.copyOf(elementData, size, Object[].class);
    } else {
        // replace with empty array.
        this.elementData = EMPTY_ELEMENTDATA;
    }
}
  • 通過建構函式可以看到,只要實現Collection類的都可以作為入參。
  • 在通過轉為陣列以及拷貝Arrays.copyOfObject[]集合中在賦值給屬性elementData

注意:c.toArray might (incorrectly) not return Object[] (see 6260652)

see 6260652 是JDK bug庫的編號,有點像商品sku,bug地址:https://bugs.java.com/bugdatabase/view_bug.do?bug_id=6260652

那這是個什麼bug呢,我們來測試下面這段程式碼;

@Test
public void t(){
    List<Integer> list1 = Arrays.asList(1, 2, 3);
    System.out.println("通過陣列轉換:" + (list1.toArray().getClass() == Object[].class));
    
    ArrayList<Integer> list2 = new ArrayList<Integer>(Arrays.asList(1, 2, 3));
    System.out.println("通過集合轉換:" + (list2.toArray().getClass() == Object[].class));
}

測試結果:

通過陣列轉換:false
通過集合轉換:true

Process finished with exit code 0
  • public Object[] toArray() 返回的型別不一定就是 Object[],其型別取決於其返回的實際型別,畢竟 Object 是父類,它可以是其他任意型別。
  • 子類實現和父類同名的方法,僅僅返回值不一致時,預設呼叫的是子類的實現方法。

造成這個結果的原因,如下;

  1. Arrays.asList 使用的是:Arrays.copyOf(this.a, size,(Class<? extends T[]>) a.getClass());
  2. ArrayList 建構函式使用的是:Arrays.copyOf(elementData, size, Object[].class);
1.3.2 Arrays.asList

你知道嗎?

  • Arrays.asList 構建的集合,不能賦值給 ArrayList
  • Arrays.asList 構建的集合,不能再新增元素
  • Arrays.asList 構建的集合,不能再刪除元素

那這到底為什麼呢,因為Arrays.asList構建出來的List與new ArrayList得到的List,壓根就不是一個List!類關係圖如下;

小傅哥 bugstack.cn & List類關係圖

從以上的類圖關係可以看到;

  1. 這兩個List壓根不同一個東西,而且Arrasys下的List是一個私有類,只能通過asList使用,不能單獨建立。
  2. 另外還有這個ArrayList不能新增和刪除,主要是因為它的實現方式,可以參考Arrays類中,這部分原始碼;private static class ArrayList<E> extends AbstractList<E> implements RandomAccess, java.io.Serializable

此外,Arrays是一個工具包,裡面還有一些非常好用的方法,例如;二分查詢Arrays.binarySearch、排序Arrays.sort

1.4 方式04;Collections.ncopies

Collections.nCopies 是集合方法中用於生成多少份某個指定元素的方法,接下來就用它來初始化ArrayList,如下;

ArrayList<Integer> list = new ArrayList<Integer>(Collections.nCopies(10, 0));
  • 這會初始化一個由10個0組成的集合。

2. 插入

ArrayList對元素的插入,其實就是對陣列的操作,只不過需要特定時候擴容。

2.1 普通插入

List<String> list = new ArrayList<String>();
list.add("aaa");
list.add("bbb");
list.add("ccc");

當我們依次插入新增元素時,ArrayList.add方法只是把元素記錄到陣列的各個位置上了,原始碼如下;

/**
 * Appends the specified element to the end of this list.
 *
 * @param e element to be appended to this list
 * @return <tt>true</tt> (as specified by {@link Collection#add})
 */
public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}
  • 這是插入元素時候的原始碼,size++自增,把對應元素新增進去。

2.2 插入時擴容

在前面初始化部分講到,ArrayList預設初始化時會申請10個長度的空間,如果超過這個長度則需要進行擴容,那麼它是怎麼擴容的呢?

從根本上分析來說,陣列是定長的,如果超過原來定長長度,擴容則需要申請新的陣列長度,並把原陣列元素拷貝到新陣列中,如下圖;

小傅哥 bugstack.cn & 陣列擴容

圖中介紹了當List結合可用空間長度不足時則需要擴容,這主要包括如下步驟;

  1. 判斷長度充足;ensureCapacityInternal(size + 1);
  2. 當判斷長度不足時,則通過擴大函式,進行擴容;grow(int minCapacity)
  3. 擴容的長度計算;int newCapacity = oldCapacity + (oldCapacity >> 1);,舊容量 + 舊容量右移1位,這相當於擴容了原來容量的(int)3/2
    4. 10,擴容時:1010 + 1010 >> 1 = 1010 + 0101 = 10 + 5 = 15
    2. 7,擴容時:0111 + 0111 >> 1 = 0111 + 0011 = 7 + 3 = 10
  4. 當擴容完以後,就需要進行把陣列中的資料拷貝到新陣列中,這個過程會用到 Arrays.copyOf(elementData, newCapacity);,但他的底層用到的是;System.arraycopy

System.arraycopy;

@Test
public void test_arraycopy() {
    int[] oldArr = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    int[] newArr = new int[oldArr.length + (oldArr.length >> 1)];
    System.arraycopy(oldArr, 0, newArr, 0, oldArr.length);
    
    newArr[11] = 11;
    newArr[12] = 12;
    newArr[13] = 13;
    newArr[14] = 14;
    
    System.out.println("陣列元素:" + JSON.toJSONString(newArr));
    System.out.println("陣列長度:" + newArr.length);
    
    /**
     * 測試結果
     * 
     * 陣列元素:[1,2,3,4,5,6,7,8,9,10,0,11,12,13,14]
     * 陣列長度:15
     */
}
  • 拷貝陣列的過程並不複雜,主要是對System.arraycopy的操作。
  • 上面就是把陣列oldArr拷貝到newArr,同時新陣列的長度,採用和ArrayList一樣的計算邏輯;oldArr.length + (oldArr.length >> 1)

2.3 指定位置插入

list.add(2, "1");

到這,終於可以說說謝飛機的面試題,這段程式碼輸出結果是什麼,如下;

Exception in thread "main" java.lang.IndexOutOfBoundsException: Index: 2, Size: 0
	at java.util.ArrayList.rangeCheckForAdd(ArrayList.java:665)
	at java.util.ArrayList.add(ArrayList.java:477)
	at org.itstack.interview.test.ApiTest.main(ApiTest.java:14)

其實,一段報錯提示,為什麼呢?我們翻開下原始碼學習下。

2.3.1 容量驗證
public void add(int index, E element) {
    rangeCheckForAdd(index);
    
    ...
}

private void rangeCheckForAdd(int index) {
    if (index > size || index < 0)
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
  • 指定位置插入首先要判斷rangeCheckForAdd,size的長度。
  • 通過上面的元素插入我們知道,每插入一個元素,size自增一次size++
  • 所以即使我們申請了10個容量長度的ArrayList,但是指定位置插入會依賴於size進行判斷,所以會丟擲IndexOutOfBoundsException異常。
2.3.2 元素遷移

小傅哥 bugstack.cn & 插入元素遷移

指定位置插入的核心步驟包括;

  1. 判斷size,是否可以插入。
  2. 判斷插入後是否需要擴容;ensureCapacityInternal(size + 1);
  3. 資料元素遷移,把從待插入位置後的元素,順序往後遷移。
  4. 給陣列的指定位置賦值,也就是把待插入元素插入進來。

部分原始碼:

public void add(int index, E element) {
	...
	// 判斷是否需要擴容以及擴容操作
	ensureCapacityInternal(size + 1);
    // 資料拷貝遷移,把待插入位置空出來
    System.arraycopy(elementData, index, elementData, index + 1,
                     size - index);
    // 資料插入操作                  
    elementData[index] = element;
    size++;
}
  • 這部分原始碼的主要核心是在,System.arraycopy,上面我們已經演示過相應的操作方式。
  • 這裡只是設定了指定位置的遷移,可以把上面的案例程式碼複製下來做測試驗證。

實踐:

List<String> list = new ArrayList<String>(Collections.nCopies(9, "a"));
System.out.println("初始化:" + list);

list.add(2, "b");
System.out.println("插入後:" + list);

測試結果:

初始化:[a, a, a, a, a, a, a, a, a]
插入後:[a, a, 1, a, a, a, a, a, a, a]

Process finished with exit code 0
  • 指定位置已經插入元素1,後面的資料向後遷移完成。

3. 刪除

有了指定位置插入元素的經驗,理解刪除的過長就比較容易了,如下圖;
小傅哥 bugstack.cn & 刪除元素

這裡我們結合著程式碼:

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;
}

刪除的過程主要包括;

  1. 校驗是否越界;rangeCheck(index);
  2. 計算刪除元素的移動長度numMoved,並通過System.arraycopy自己把元素複製給自己。
  3. 把結尾元素清空,null。

這裡我們做個例子:

@Test
public void test_copy_remove() {
    int[] oldArr = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    int index = 2;
    int numMoved = 10 - index - 1;
    System.arraycopy(oldArr, index + 1, oldArr, index, numMoved);
    System.out.println("陣列元素:" + JSON.toJSONString(oldArr));
}
  • 設定一個擁有10個元素的陣列,同樣按照ArrayList的規則進行移動元素。
  • 注意,為了方便觀察結果,這裡沒有把結尾元素設定為null。

測試結果:

陣列元素:[1,2,4,5,6,7,8,9,10,10]

Process finished with exit code 0
  • 可以看到指定位置 index = 2,元素已經被刪掉。
  • 同時陣列已經移動用元素4佔據了原來元素3的位置,同時結尾的10還等待刪除。這就是為什麼ArrayList中有這麼一句程式碼;elementData[--size] = null

4. 擴充套件

如果給你一組元素;a、b、c、d、e、f、g,需要你放到ArrayList中,但是要求獲取一個元素的時間複雜度都是O(1),你怎麼處理?

想解決這個問題,就需要知道元素新增到集合中後知道它的位置,而這個位置呢,其實可以通過雜湊值與集合長度與運算,得出存放資料的下標,如下圖;

小傅哥 bugstack.cn & 下標計算

  • 如圖就是計算出每一個元素應該存放的位置,這樣就可以O(1)複雜度獲取元素。

4.1 程式碼操作(新增元素)

List<String> list = new ArrayList<String>(Collections.<String>nCopies(8, "0"));

list.set("a".hashCode() & 8 - 1, "a");
list.set("b".hashCode() & 8 - 1, "b");
list.set("c".hashCode() & 8 - 1, "c");
list.set("d".hashCode() & 8 - 1, "d");
list.set("e".hashCode() & 8 - 1, "e");
list.set("f".hashCode() & 8 - 1, "f");
list.set("g".hashCode() & 8 - 1, "g");
  • 以上是初始化ArrayList,並存放相應的元素。存放時候計算出每個元素的下標值。

4.2 程式碼操作(獲取元素)

System.out.println("元素集合:" + list);
System.out.println("獲取元素f [\"f\".hashCode() & 8 - 1)] Idx:" + ("f".hashCode() & (8 - 1)) + " 元素:" + list.get("f".hashCode() & 8 - 1));
System.out.println("獲取元素e [\"e\".hashCode() & 8 - 1)] Idx:" + ("e".hashCode() & (8 - 1)) + " 元素:" + list.get("e".hashCode() & 8 - 1));
System.out.println("獲取元素d [\"d\".hashCode() & 8 - 1)] Idx:" + ("d".hashCode() & (8 - 1)) + " 元素:" + list.get("d".hashCode() & 8 - 1));

4.3 測試結果

元素集合:[0, a, b, c, d, e, f, g]

獲取元素f ["f".hashCode() & 8 - 1)] Idx:6 元素:f
獲取元素e ["e".hashCode() & 8 - 1)] Idx:5 元素:e
獲取元素d ["d".hashCode() & 8 - 1)] Idx:4 元素:d

Process finished with exit code 0
  • 通過測試結果可以看到,下標位置0是初始元素,元素是按照指定的下標進行插入的。
  • 那麼現在獲取元素的時間複雜度就是O(1),是不有點像HashMap中的桶結構。

五、總結

  • 就像我們開頭說的一樣,資料結構是你寫出程式碼的基礎,更是寫出高階程式碼的核心。只有瞭解好資料結構,才能更透徹的理解程式設計。並不是所有的邏輯都是for迴圈
  • 面試題只是引導你學習的點,但不能為了面試題而忽略更重要的核心知識學習,背一兩道題是不可能抗住深度問的。因為任何一個考點,都不只是一種問法,往往可以從很多方面進行提問和考查。就像你看完整篇文章,是否理解了沒有說到的知識,當你固定位置插入資料時會進行資料遷移,那麼在擁有大量資料的ArrayList中是不適合這麼做的,非常影響效能。
  • 在本章的內容編寫的時候也參考到一些優秀的資料,尤其發現這份外文文件;https://beginnersbook.com/ 大家可以參考學習。

六、系列文章

相關文章