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


做者:小傅哥
博客:https://bugstack.cnhtml

沉澱、分享、成長,讓本身和他人都能有所收穫!😄

1、前言

數據結構是寫好代碼的基礎!java

說到數據結構基本包括;數組、鏈表、隊列、紅黑樹等,但當你看到這些數據結構以及想到本身平時的開發,彷佛並無用到過。那麼爲何還要學習數據結構?程序員

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

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

那麼,你何時會用到這樣的技術呢?就是,當你考慮體量、夯實服務、琢磨性能時,就會逐漸的深刻到數據結構以及核心的基本原理當中,那裏的每一分深刻,都會讓整個服務性能成指數的提高。數組

2、面試題

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

你看下面這段代碼輸出結果是什麼?app

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

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

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函數

3、數據結構

Array + List = 數組 + 列表 = ArrayList = 數組列表

ArrayList的數據結構是基於數組實現的,只不過這個數組不像咱們普通定義的數組,它能夠在ArrayList的管理下插入數據時按需動態擴容、數據拷貝等操做。

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

4、源碼分析

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

    1. 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中的桶結構。

5、總結

  • 就像咱們開頭說的同樣,數據結構是你寫出代碼的基礎,更是寫出高級代碼的核心。只有瞭解好數據結構,才能更透徹的理解程序設計。並非全部的邏輯都是for循環
  • 面試題只是引導你學習的點,但不能爲了面試題而忽略更重要的核心知識學習,背一兩道題是不可能抗住深度問的。由於任何一個考點,都不僅是一種問法,每每能夠從不少方面進行提問和考查。就像你看完整篇文章,是否理解了沒有說到的知識,當你固定位置插入數據時會進行數據遷移,那麼在擁有大量數據的ArrayList中是不適合這麼作的,很是影響性能。
  • 在本章的內容編寫的時候也參考到一些優秀的資料,尤爲發現這份外文文檔;https://beginnersbook.com/ 你們能夠參考學習。

6、系列文章

相關文章
相關標籤/搜索