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



作者:小傅哥
博客: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/ 大家可以參考學習。

六、系列文章


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM