作者:小傅哥
部落格: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.copyOf
到Object[]
集合中在賦值給屬性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 是父類,它可以是其他任意型別。- 子類實現和父類同名的方法,僅僅返回值不一致時,預設呼叫的是子類的實現方法。
造成這個結果的原因,如下;
- Arrays.asList 使用的是:
Arrays.copyOf(this.a, size,(Class<? extends T[]>) a.getClass());
- 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!類關係圖如下;
從以上的類圖關係可以看到;
- 這兩個List壓根不同一個東西,而且Arrasys下的List是一個私有類,只能通過asList使用,不能單獨建立。
- 另外還有這個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個長度的空間,如果超過這個長度則需要進行擴容,那麼它是怎麼擴容的呢?
從根本上分析來說,陣列是定長的,如果超過原來定長長度,擴容則需要申請新的陣列長度,並把原陣列元素拷貝到新陣列中,如下圖;
圖中介紹了當List結合可用空間長度不足時則需要擴容,這主要包括如下步驟;
- 判斷長度充足;
ensureCapacityInternal(size + 1);
- 當判斷長度不足時,則通過擴大函式,進行擴容;
grow(int minCapacity)
- 擴容的長度計算;
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 - 當擴容完以後,就需要進行把陣列中的資料拷貝到新陣列中,這個過程會用到
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 元素遷移
指定位置插入的核心步驟包括;
- 判斷size,是否可以插入。
- 判斷插入後是否需要擴容;
ensureCapacityInternal(size + 1);
。 - 資料元素遷移,把從待插入位置後的元素,順序往後遷移。
- 給陣列的指定位置賦值,也就是把待插入元素插入進來。
部分原始碼:
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. 刪除
有了指定位置插入元素的經驗,理解刪除的過長就比較容易了,如下圖;
這裡我們結合著程式碼:
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;
}
刪除的過程主要包括;
- 校驗是否越界;
rangeCheck(index);
- 計算刪除元素的移動長度
numMoved
,並通過System.arraycopy
自己把元素複製給自己。 - 把結尾元素清空,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),你怎麼處理?
想解決這個問題,就需要知道元素新增到集合中後知道它的位置,而這個位置呢,其實可以通過雜湊值與集合長度與運算,得出存放資料的下標,如下圖;
- 如圖就是計算出每一個元素應該存放的位置,這樣就可以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/ 大家可以參考學習。