高手不得不知的Java集合List的細節
高手不得不知的Java集合List的細節
進擊大叔 安卓巴士Android開發者門戶 今天
2018安卓巴士全球開發者論壇-北京站
寫在前面
作為Android開發者,Java集合可能是開發中最常使用的類之一了。但很多人可能跟我一樣,對Java集合只停留在“使用”的層面上,而對其的實現、原理如何只是略知一二,所以有時可能忽略了一些小細節。這些細節可能對專案的整體效能影響不大,但我覺得,要成為一個好的程式設計師,必須要精益求精,對程式碼效能“錙銖必較”。
舉個例子,各位在建立ArrayList例項時有沒有想過到底要不要指定其初始容量?指定了會怎樣?不指定又會怎樣?如果你跟博主我有同樣的困惑,那麼本文一定能給你個滿意的答案!
正文
這篇文章是關於Java集合之一的List的,但不妨先祭上一張經典的Java集合框架圖,先大概瞭解下Java集合整體的框架:
圖2.1 Java集合框架
如果之前沒見過圖2.1的童鞋緊張了,這麼多類呀!別慌,圖2.1很多是介面和抽象類,並且我們常使用的集合類也就那麼幾個,我們只關心我們經常使用的即可,不常用的就暫時忽略,等用到了再看就行了。
好了,上面關於Java集合List的類不多,我整理了下:
圖2.2 List繼承關係圖
從圖2.2可以看到,我們經常使用的Arrayist
、LinkedList
繼承的關係挺複雜的,但繼承的都是介面或抽象類。而Collection
List
是介面,Collection
介面定義了集合的通用方法,和List
介面是在Collection
基礎上補充了專屬於List
的通用方法。我們什麼時候使用抽象類?很多情況是為子類提供共同的方法實現或屬性時會使用抽象類。所以就不難理解AbstractColection
和AbstractList
的作用了,當然,你也可以繼承於它們實現自己的List
,而這是題外話了,這裡就不加討論了,下面我們進入正題吧。
本文將介紹下面List子類的一些細節:
-
ArrayList
-
Vector和Stack
-
LinkedList
-
SynchronizedList
ArrayLIst的細節
細節1:ArrayList
基於陣列實現,訪問元素效率快,插入刪除元素效率慢ArrayList
是基於陣列實現的,這個似乎不是什麼祕密了,但為了文章的完整性,還是要介紹下。ArrayList
內部維護一個數組elementData
,用於儲存列表元素,基於陣列的陣列這資料結構,我們知道,其索引元素是非常快的:
public E get(int index) {
if (index >= size)
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
return (E) elementData[index]; // 索引無需遍歷,效率非常高!
}
public E set(int index, E element) {
if (index >= size)
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
E oldValue = (E) elementData[index];
elementData[index] = element; // 索引無需遍歷,效率非常高!
return oldValue;
}
可以看到,get
、set
直接根據索引獲取了目標元素,中間不用做任何的遍歷操作,效率是非常快的。但是對於插入和刪除操作效率就不太理想了:
public void add(int index, E element) {
if (index > size || index < 0)
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
ensureCapacityInternal(size + 1); // 先判斷是否需要擴容
System.arraycopy(elementData, index, elementData, index + 1, // 把index後面的元素都向後偏移一位
size - index);
elementData[index] = element;
size++;
}
從插入操作的原始碼可以看到,插入前,要先判斷是否需要擴容(擴容後面會講,這裡先跳過),然後把Index後面的元素都偏移一位,這裡的偏移是需要把元素複製後,再賦值當前元素的後一索引的位置。顯然,這樣一來,插入一個元素,牽連到多個元素,效率自然就低了。再來看看刪除操作:
public E remove(int index) {
if (index >= size)
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
modCount++;
E oldValue = (E) elementData[index];
int numMoved = size - index - 1;
if (numMoved > 0) {
// 把index後面的元素向前偏移一位,填補刪除的元素
System.arraycopy(elementData, index + 1, elementData, index,
numMoved);
}
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}
同樣,刪除一個元素,需要把index後面的元素向前偏移一位,填補刪除的元素,也是牽連了多個元素。所以大家在使用時要謹慎了!
細節2:ArrayList
支援快速隨機訪問
什麼是隨機訪問?我們不防先來看看ArrayList
的類定義:
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
看到RandomAccess
了嗎,這個就是支援快速隨機訪問的標記,我們再點進去看看其原始碼:
/**
* ...
* <p>It is recognized that the distinction between random and sequential
* access is often fuzzy. For example, some <tt>List</tt> implementations
* provide asymptotically linear access times if they get huge, but constant
* access times in practice. Such a <tt>List</tt> implementation
* should generally implement this interface. As a rule of thumb, a
* <tt>List</tt> implementation should implement this interface if,
* for typical instances of the class, this loop:
* <pre>
* for (int i=0, n=list.size(); i &lt; n; i++)
* list.get(i);
* </pre>
* runs faster than this loop:
* <pre>
* for (Iterator i=list.iterator(); i.hasNext(); )
* i.next();
* </pre>
* ...
*/
public interface RandomAccess {
}
額,是一個介面,沒有任何的屬性或方法定義。其實它只是一個標記,繼承於它就相當於告訴別人,我支援快速隨機訪問,上面程式碼我特意留下部分的註釋說明,其中關鍵的部分在說,通常情況下,使用索引訪問的效率比使用迭代器訪問的效率快!
我們把目光暫時轉移到Collections
類下,其中有很多基於是否有繼承於RandomAccess
的List
做不同的演算法選擇判斷,我們來看其中的二分查詢演算法:
public static <T> int binarySearch(List<? extends Comparable<? super T>> list, T key) {
if (list instanceof RandomAccess || list.size()<BINARYSEARCH_THRESHOLD)
// 當List實現了RandomAccess或小於一定閥值時,使用索引二分查詢演算法
return Collections.indexedBinarySearch(list, key);
else
return Collections.iteratorBinarySearch(list, key);
}
所以快速隨機訪問是針對於Collections
中的方法而言的(其他類是否也有?歡迎大神們補充),支援快速隨機訪問時,就選擇索引訪問,效率會很快。
另外,從上面的二分查詢演算法我們又能得到一個提高效率的小細節:我們知道List
是提供了IndexOf
和lastIndexOf
方法來檢索元素的,它們分別是從頭和尾開始,一個一個比較的,那麼顯然,使用Collections#binarySearch
在大多數情況效率會比IndexOf
和lastIndexOf
更快~
細節3:大多數情況下,我們都應該指定ArrayList
的初始容量
如果說上面所介紹的細節大部分童鞋都知道,那這個細節相信很多人都不知道,包括在看原始碼之前的我。在講為什麼之前,我們需要先來了解ArrayList
的擴容機制。
ArrayList
每次擴容至少為原來容量大小的1.5倍,其預設容量是10,當你不為其指定初始容量時,它就會建立預設容量大小為10的陣列:
// 預設最小容量
private static final int DEFAULT_CAPACITY = 10;
// 空陣列
private static final Object[] EMPTY_ELEMENTDATA = {};
// 預設容量空陣列,可以理解為一個標記
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
// 指定最小容量建立列表
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);
}
}
// 建立預設空列表
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; // 預設容量空陣列
}
我們經常使用ArrayList
的預設建構函式來建立例項,等等,不是說不指定初始容量會建立預設容量大小為10的陣列嗎?但這裡只賦值了空陣列。是的,還記得我們上面分析的add
原始碼有個擴容操作嗎?如果使用預設建構函式來建立例項,在第一次新增元素時,就會進行擴容,擴容到預設容量10的陣列:
// 每次新增元素都會呼叫
private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
// 如果為預設容量空陣列的話,新增元素時,至少擴容到預設最小容量
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0) // 大於當前容量就擴容
grow(minCapacity);
}
// 擴容
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1); // 1.5倍原來大小
// 先嚐試擴容到1.5倍原來容量的大小,如果比使用者指定的大,那麼就擴容1.5倍
// 否則擴容使用者指定的
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
所謂“擴容”就是建立一個長度更大的陣列,再把舊陣列的元素全部賦值到新陣列。顯然,這個操作效率也是不理想的。雖然使用預設建構函式建立的例項,在第一次新增元素的擴容並沒有元素複製,但還是要另外建立一個數組,並且是大小為10的陣列,可能你並不需要這麼大的陣列,可能是3,可能是5,那麼我們為何不一開始就指定其容量呢?
指定初始容量的方法也很簡單,我們使用帶int
引數的建構函式就可以了:
// 指定最小容量建立列表
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);
}
}
或者有童鞋會說,使用ensureCapacity
指定容量也行,其實不然,為何ensureCapacity
對容量大小有限制:
// 指定最小容量
public void ensureCapacity(int minCapacity) {
int minExpand = (elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
// any size if not default element table
? 0
// larger than default for default empty table. It's already
// supposed to be at default size.
: DEFAULT_CAPACITY;
// 指定最小容量成功的情況
// 1.使用 new ArrayList() 建立例項並新增元素前,指定容量大小不能小於預設容量10
// 2.列表已存在元素,指定容量大小不能小於當前容量大小
if (minCapacity > minExpand) {
ensureExplicitCapacity(minCapacity);
}
}
所以講到這,相信大家有答案了,為什麼建立ArrayList
要指定其初始容量?顯然我們是不希望它進行耗時的擴容操作,並且能在我們預知的情況下儘量使用大小剛剛好的列表,而不浪費任何資源。那麼我們可以得到以下經驗:
-
都不應該使用預設建構函式建立例項,以免自動擴容到預設最小容量(10)
-
當列表容量確定,應該指定容量的方式建立例項
-
當列表容量不確定時,可以預估我們將有會多少元素,指定稍大於預估值的容量
Vector和Stack的細節
Vector
和Stack
我們幾乎是不使用的了,所以並不打算用大篇幅來介紹,我們大概瞭解下就可以了。但我們可以探索下他們為何不受待見,從而引以為戒。
細節1:Vector
也是基於陣列實現,同樣支援快速訪問,並且執行緒安全
因為跟ArrayList
一樣,都是基於陣列實現,所以ArrayList
具有的優勢和劣勢Vector
同樣也有,只是Vector
在每個方法都加了同步鎖,所以它是執行緒安全的。但我們知道,同步會大大影響效率的,所以在不需要同步的情況下,Vector
的效率就不如ArrayList
了。所以我們在不需要同步的情況下,優先選擇ArrayList
;而在需要同步的情況下,也不是使用Vector
,而是使用SynchronizedList
(後面講到)。你看,Vector
處於一個很尷尬的地步。但我個人覺得,Vector
被遺棄的最大原因不在於它執行緒同步影響效率——因為這畢竟能在多執行緒環境下使用——而在於它的擴容機制上。
細節2:Vector
的擴容機制不完善Vector
預設容量也是10,跟ArrayList
不同的是,Vector
每次擴容的大小是可以指定的,如果不指定,每次擴容原來容量大小的2倍:
protected Object[] elementData; // 元素陣列
protected int elementCount; // 元素數量
protected int capacityIncrement; // 擴容大小
public Vector(int initialCapacity, int capacityIncrement) {
super();
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
this.elementData = new Object[initialCapacity];
this.capacityIncrement = capacityIncrement;
}
public Vector(int initialCapacity) {
this(initialCapacity, 0); // 預設擴容大小為0,那麼擴容時會增大兩倍
}
public Vector() {
this(10); // 預設容量為10
}
public synchronized void ensureCapacity(int minCapacity) {
if (minCapacity > 0) {
modCount++;
ensureCapacityHelper(minCapacity);
}
}
private void ensureCapacityHelper(int minCapacity) {
// overflow-conscious code
if (minCapacity - elementData.length > 0) // 大於當前容量就擴容
grow(minCapacity);
}
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + ((capacityIncrement > 0) ?
capacityIncrement : oldCapacity); // 預設擴容兩倍
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
elementData = Arrays.copyOf(elementData, newCapacity);
}
另外需要提醒注意的是,不像ArrayList
,如果是用Vector
的預設建構函式建立例項,那麼第一次新增元素就需要擴容,但不會擴容到預設容量10,只會根據使用者指定或兩倍的大小擴容。所以使用Vector
時指不指定擴容大小都很尷尬:
-
如果容量大小和擴容大小都不指定,開始可能會頻繁地進行擴容
-
如果指定了容量大小不指定擴容大小,以2倍的大小擴容會浪費很多資源
-
如果指定了擴容大小,擴容大小就固定了,不管陣列多大,都按這大小來擴容,那麼這個擴容大小的取值總有不理想的時候
從Vector
我們也可以反觀ArrayList
設計巧妙的地方,這也許是Vector
存在的唯一價值了哈哈。
細節3:Stack
繼承於Vector
,在其基礎上擴充套件了棧的方法Stack
我們也不使用了,它只是新增多幾個棧常用的方法(這個LinkedList也有,後面討論),簡單來看下它們的實現吧:
// 進棧
public E push(E item) {
addElement(item);
return item;
}
// 出棧
public synchronized E pop() {
E obj;
int len = size();
obj = peek();
removeElementAt(len - 1);
return obj;
}
public synchronized E peek() {
int len = size();
if (len == 0)
throw new EmptyStackException();
return elementAt(len - 1);
}
LinkedList的細節
再來看看我們熟悉的LinkedList的細節~
細節1:LinkedList
基於連結串列實現,插入刪除元素效率快,訪問元素效率慢LinkedList
內部維護一個雙端連結串列,可以從頭開始檢索,也可以從尾開始檢索。同樣的,得益於連結串列這一資料結構,LinkedList
在插入和刪除元素效率非常快。
插入元素只需新建一個node
,再把前後指標指向對應的前後元素即可:
圖2.3.1 插入元素
// 鏈尾追加
void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
// 指定節點前插入
void linkBefore(E e, Node<E> succ) {
// assert succ != null;
// 插入節點,succ為Index的節點,可以看到,是插入到index節點的前一個節點
final Node<E> pred = succ.prev;
final Node<E> newNode = new Node<>(pred, e, succ);
succ.prev = newNode;
if (pred == null)
first = newNode;
else
pred.next = newNode;
size++;
modCount++;
}
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
同樣,刪除元素只要把刪除節點的鏈剪掉,再把前後節點連起來就搞定了:
圖2.3.2 刪除元素
E unlink(Node<E> x) {
// assert x != null;
final E element = x.item;
final Node<E> next = x.next;
final Node<E> prev = x.prev;
if (prev == null) {
// 鏈頭
first = next;
} else {
prev.next = next;
x.prev = null;
}
if (next == null) {
// 鏈尾
last = prev;
} else {
next.prev = prev;
x.next = null;
}
x.item = null;
size--;
modCount++;
return element;
}
public E remove(int index) {
checkElementIndex(index);
return unlink(node(index));
}
但由於連結串列我們只知道頭和尾,中間的元素要遍歷獲取的,所以導致了訪問元素時,效率就不好了:
Node<E> node(int index) {
// 使用了二分法
if (index < (size >> 1)) { // 如果索引小於二分之一,從first開始遍歷
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else { // 如果索引大於二分之一,從last開始遍歷
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}
所以,LinkedList
和ArrayList
剛好是互補的,所以具體場景,應考慮哪種操作最頻繁,從而選擇不同的List
來使用。
細節2:LinkedList
可以當作佇列和棧來使用
不知大家有沒注意到在圖2.2中,LinkedList
非常“特立獨行地”繼承了Deque
介面,而Deque
又繼承於Queue
介面,這佇列和棧的方法定義就是在這些介面中定義的,而LinkedList
實現其方法,使自身具備了佇列的棧的功能。
當作佇列(先進先出)使用:
// 進隊
public boolean offerFirst(E e) {
addFirst(e);
return true;
}
// 出隊
public E pollLast() {
final Node<E> l = last;
return (l == null) ? null : unlinkLast(l);
}
當作棧(後進又出)來使用:
// 進棧
public void push(E e) {
addFirst(e);
}
// 出棧,如果為空列表,會丟擲異常
public E pop() {
return removeFirst();
}
SynchronizedList的細節
在Collections
類中提供了很多執行緒執行緒的集合類,其實他們實現很簡單,只是在集合操作前,加一個鎖而已。
細節1:SynchronizedList
繼承於SynchronizedCollection
,使用裝飾者模式,為原來的List
加上鎖,從而使List
同步安全
先來看下SynchronizedCollection
的定義:
static class SynchronizedCollection<E> implements Collection<E>, Serializable {
private static final long serialVersionUID = 3053995032091335093L;
final Collection<E> c; // 裝飾的集合
final Object mutex; // 鎖
SynchronizedCollection(Collection<E> c) {
this.c = Objects.requireNonNull(c);
mutex = this;
}
SynchronizedCollection(Collection<E> c, Object mutex) {
this.c = Objects.requireNonNull(c);
this.mutex = Objects.requireNonNull(mutex);
}
}
可以看到,可以指定一個物件作為鎖,如果不指定,預設就鎖了集合了。
再來看下我們關注的SynchronizedList
:
static class SynchronizedList<E>
extends SynchronizedCollection<E>
implements List<E> {
final List<E> list;
SynchronizedList(List<E> list) {
super(list);
this.list = list;
}
SynchronizedList(List<E> list, Object mutex) {
super(list, mutex);
this.list = list;
}
...
public E get(int index) {
synchronized (mutex) {return list.get(index);}
}
public E set(int index, E element) {
synchronized (mutex) {return list.set(index, element);}
}
public void add(int index, E element) {
synchronized (mutex) {list.add(index, element);}
}
public E remove(int index) {
synchronized (mutex) {return list.remove(index);}
}
...
}
想不到SynchronizedList
的實現是如此簡單,上面的原始碼想必不用我多說了。
寫在最後
關於我們經常使用的List的細節到此就介紹完了,如果上面我有言論有誤或不嚴謹的,歡迎大家指正;如果有另外一些細節我沒談及到的,也歡迎大神們補充。
最後,我們來做一次總結:
-
ArrayList
和LinkedList
適用於不同使用場景,應根據具體場景從優選擇 -
根據
ArrayList
的擴容機制,我們應該開始就指定其初始容量,避免資源浪費 -
LinkedList
可以當作佇列和棧使用,當然我們也可以進一步封裝 -
儘量不使用
Vector
和Stack
,同步場景下,使用SynchronizedList
替代
大家都在看