ArrayList這篇就夠了
提起ArrayList,相信很多小夥伴都用過,而且還不少用。但在幾年之前,我在一場面試中,面試官要求說出ArrayList的擴容機制。很顯然,那個時候的我並沒有關注這些,從而錯過了一次機會。不過好在我還算比較喜歡搞事情的,所以今天這篇文章也算是填坑吧。
看完這邊文章你將了解到:
ArrayList
底層實現ArrayList
為什麼允許null值ArrayList
為什麼可重複ArrayList
查詢效率和插入效率對比
類圖
下圖是ArrayList
的類圖結構
ArrayList
繼承於 AbstractList
,實現了 List
, RandomAccess
, Cloneable
, java.io.Serializable
這些接口。
這裡逐個分析一下這裡接口的意義:
RandomAccess
是一個標誌接口,表明實現這個這個接口的List
集合是支持快速隨機訪問的。有興趣可以看看Collections
類中哪個方法用到了這個標誌性接口。- 實現
Cloneable
接口並覆蓋了方法clone()
,能被克隆。 - 實現了java.io.Serializable 接口,這意味着
ArrayList
支持序列化,能通過序列化去傳輸(請注意,ArrayList
的序列化是有點小特殊的,後面會講解)。
源碼解析
成員變量
在正式進入源碼分析之前,我們有必要先看看它的成員變量都有哪些,這裡列舉比較重要的成員變量:
private int size; // 實際元素個數
transient Object[] elementData; //真正保存元素的數組
private static final int DEFAULT_CAPACITY = 10;//默認的初始容量大小
構造方法
我們有三種初始化辦法:無參數直接初始化、指定大小初始化、指定初始數據初始化,源碼如下:
//1、無參數直接初始化,數組大小為空
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
//2、指定初始數據初始化
public ArrayList(Collection<? extends E> c){
//elementData是保存數組的容器,默認為null
elementData=c.toArray();
//如果給定的集合(c)數據有值
if((size=elementData.length)!=0){
//c.toArray might(incorrectly)not return Object[](see 6260652)
//如果集合元素類型不是Object類型,我們會轉成Object
if(elementData.getClass()!=Object[].class){
elementData=Arrays.copyOf(elementData,size,Object].class);
}
}else{
//給定集合(c)無值,則默認空數組
this.elementData=EMPTY_ELEMENTDATA
}
}
//3、指定初始容量
public ArrayList(int initialCapacity) {
//指定的初始容量大於0,將elementData初始化為指定大小的數組
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
//否則初始化成一個空數組
this.elementData = EMPTY_ELEMENTDATA;
}
}
除過源碼中注釋外,補充幾點:
ArrayList
無參構造器初始化時,默認大小是空數組,並不是大家常說的10,10是在第一次add
的時候擴容的數組值。- 使用方式二進行創建對象時,如果入參容器保存的對象不是
Object
,則轉換為Object
。
DEFAULTCAPACITY_EMPTY_ELEMENTDATA
和EMPTY_ELEMENTDATA
又是什麼鬼?它其實是定義在成員變量的兩個空數組,
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
private static final Object[] EMPTY_ELEMENTDATA = {};
很明顯問題來了,既然都是空數組,為什麼要聲明兩個?一個不行嗎?讀者請先思考一下,帶着疑問往下看。
新增和擴容實現
通過構造方法可以很清楚的看到,ArrayList
的確是基於數組的,但動態
又從何說起?
新增時就是給數組中添加元素,主要分為兩步走:
- 判斷是否需要擴容,如果需要擴容執行擴容操作;
- 直接賦值。
對應源碼如下:
public boolean add(E e) {
//確保數組大小是否足夠,不夠執行擴容,size為當前數組元素個數,判斷size+1是因為後面還要size++
ensureCapacityInternal(size + 1); //1
elementData[size++] = e;//2
return true;
}
我們先來看一下擴容部分的源碼:
private void ensureCapacityInternal(int minCapacity) {
//先調用calculateCapacity計算容量
ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}
private static int calculateCapacity(Object[] elementData, int minCapacity) {
//如果當前數組還是個空數組,也就是他用過無參構造去初始化的
//那麼直接返回DEFAULT_CAPACITY,即10
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
return minCapacity;
}
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// 如果當前容量已經大於當前數組的長度了,說明需要去擴容了
if (minCapacity - elementData.length > 0)
//擴容
grow(minCapacity);
}
private void grow(int minCapacity){
int oldCapacity = elementData.length;
//oldCapacity>>1是把oldCapacity除以2的意思
int newCapacity=oldCapacity+(oldCapacity>>1);
//如果擴容後的值<我們的期望值,擴容後的值就等於我們的期望值
if(newCapacity-minCapacity<0)
newCapacity = minCapacity;
//如果擴容後的值>jvm所能分配的數組的最大值,那麼就用Integer的最大值
if(newCapacity-MAX_ARRAY_SIZE>0)
elementData=Arrays.copyOf(elementData,newCapacity);
}
注釋相對來說已經比較詳細了,這裡需要注意以下幾點:
- 上面有個問題是為什麼需要聲明兩個空數組。我們在看到上面源碼的時候有一個方法為
calculateCapacity
,這個方法內部邏輯只有在通過無參構造初始化ArrayList
的時候才會改變將要返回的minCapacity
。而返回的這個值將會決定下面的數組是否需要擴容。如果我們通過指定大小的方式初始化ArrayList
並指定大小為0,這說明我們需要的就是一個空的ArrayList
,不需要去擴容,你細品; - 新增時,沒有對值進行校驗,所以新增值可以為
null
,且沒有做重複值判斷,所以元素可以重複
; - ArrayList中的數組的最大值是
Integer.MAX_VALUE
,超過這個值,JVM
就不會給數組分配
內存空間了; - 擴容是原來容量大小+容量大小的一半,簡單說就是擴容後的大小是原來容量的1.5倍。
擴容完成之後,就是簡單的賦值了,賦值時並沒有加鎖,所以是線程不安全
的。
擴容的本質
在grow
方法的最後,擴容是通過Arrays.copyOf(elementData,newCapacity);
這行代碼實現的。這個方法實際上調用的方法是我們經常使用的System.arraycopy
:
/**
*@param src 被拷貝的數組
*@param srcPos 從數組那裡開始
*@param dest 目標數組
*@param destPos從目標數組那個索引位置開始拷貝
*@param length 拷貝的長度
*此方法是沒有返回值的,通過dest的引用進行傳值
*/
public static native void arraycopy(Object src, int srcPos,Object dest, int destPos,int length);
這個方法是一個native
方法,雖然不能看到方法內部的具體實現,但通過參數也可以管中窺豹。這個方法會移動元素。所以說數組如果要擴容,需要重新分配一塊更大的空間,再把數據全部複製過去,時間複雜度 O(N);而且你如果想在數組中間進行插入和刪除,每次必須搬移後面的所有數據以保持連續,時間複雜度 O(N)。由於數組又是一塊連續的內存空間,能夠根據索引快速訪問元素。
上面也就解釋了一開始那個問題:ArrayList
為什麼插入慢,查詢快。
刪除
ArrayList
有多種刪除方法,這裡以根據值刪除的方式進行說明(其他原理類似):
public boolean remove(Object o) {
//如果要刪除的值是null,刪除第一個是null的值
if(o==null){
for(int index=0;index<size;index++)
if(elementData[index]==null){
fastRemove(index)
return true;
}
}else{
//如果要刪除的值不為null,找到第一個和要刪除的值相等的刪除
for(int index=0;index<size;index++)
//這裡是根據 equals來判斷值相等的,相等後再根據索引位置進行刪除
//所以根據對象刪除時,一般來說,如果你確定要刪除的是某一類的業務對象,則需要重寫equals
if(o.equals(elementData[index]){
fastRemove(index)
return true;
}
}
return false
}
核心其實是fastRemove
方法:
private void fastRemove(int index){
//記錄數組的結構要發生變動了
nodCount++;
//numMoved表示刪除index位置的元素後,需要從index後移動多少個元素到前面去
//減1的原因,是因為size從1開始算起,index從0開始算起
int numMoved=size-index-1;
if(numMoved>0)
//從index+1位置開始被拷貝,拷貝的起始位置是index,長度是numMoved
System.arraycopy(elementData, index+1, elementData, index, numMoved);
//數組最後一個位置賦值null,幫助GC(沒有引用則自動回收了)
elementData[--size] = null;
}
從源碼中,我們可以看出,某一個元素被刪除後,為了維護數組結構,我們都會把數組後面的元素往前移動,同時釋放最後一個引用,便於回收。
總結
本文主要從ArrayList的源碼入手,分別從初始化、新增、擴容、刪除四個方面展開學習。我們發現ArrayList內部其實就是圍繞了一個數組,在數組容量不足時將數組擴容至更大,所以也就自然被稱作基於動態數組
。
微信搜索Java成神之路或掃描下方二維碼,發現更多Java有趣知識,讓我們一起成長!