目錄
Java.util中的容器又被稱為Java Collections framework。雖然被稱為框架,但是其主要目的是提供一組接口盡量簡單而且相同、並且盡量高效、以便於開發人員按照場景選用,而不是自己重復實現的類。容器按接口可以分為兩大類:Collection和Map。本文主要關注Collection,以后會將Map這塊也進行研究。
一、Collection及子類/接口容器繼承關系
先從Collection說起。可以看出:
1.Collection接口並不是一個根接口,它的超級接口是Iterator,需要提供其遍歷、移除元素(可選操作)的能力。
2.Collection接口定義了基本的容器操作方法。
除此以外,
1.remove()和contains()判斷元素是否相等的依據是類似的。
對於remove(Object o),若Collection中包含的元素e,滿足(o==null ? e==null : o.equals(e)),移除其中的一個;
對於contains(Object o),若Collection中包含至少一個或多個元素e,滿足(o==null ? e==null : o.equals(e)),則返回true。
2.AbstractCollection抽象類實現了一部分Collection接口的方法,主要是基於iterator實現的,如remove()、toArray(),以及利用本身的屬性size實現的size()。如果讀一下源碼,可以發現雖然AbstractCollection利用add()實現了addAll(),但是add()本身的實現是直接拋UnsupportedOperationException異常的。實際上add()是一種“可選操作”,目的是延遲到需要時再實現。
二、List
了解了通用的Collection后,接下來,看看三大類的Collection:List、Set、Queue。首先從List說起。List中的元素是有序的,因而我們可以按序訪問List中的元素,以及訪問指定位置上的元素。對於“按順序遍歷訪問元素”的需求,使用List的超級接口Iterator即可以做到,這也是對應抽象類AbstractList中的實現;而訪問特定位置的元素(也即按索引訪問)、元素的增加和刪除涉及到了List中各個元素的連接關系,並沒有在AbstractList中提供。
2.1 ArrayList
ArrayList是最常用的List的實現,其包裝了一個用於存放元素的數組,並用size屬性來標識該容器里的元素個數,而非這個被包裝數組的大小。如果對數組有所了解,很容易理解ArrayList的元素是怎么編排的,各個數組的元素如何隨機訪問(通過索引)、元素之間如何跳轉(索引增減)。閱讀源碼可以發現,這個數組用transient關鍵字修飾,表示其不會被序列化。當然,ArrayList的元素最終還是會被序列化的,要不然,這個最常用的List之一,不能持久化、不能網絡傳輸,簡直不可想象。在序列化/反序列化時,會調用ArrayList的writeObject()/readObject()方法,將該ArrayList中的元素(即0...size-1下標對應的元素)寫入流/從流讀出。這樣做的好處是,只保存/傳輸有實際意義的元素,最大限度的節約了存儲、傳輸和處理的開銷。
2.1.1 序列化的探討
提到序列化,有個問題是,ArrayList的writeObject()/readObject()是如何被調用的?它們並不屬於ArrayList的任何一個接口,甚至是Serializabe!其實,序列化是ObjectOutputStream對象調用自身的writeObject()方法時,由它通過反射檢查入參——也即待序列化的對象——是否有writeObject()方法,並進行調用,這和接口無關,確實很古怪(可以參考《Java編程思想·第四版(中文)》第581頁)。
2.1.2 刪除元素
ArrayList在刪除元素時,不僅要將其他元素前移來占用被移除的元素並縮小size,對於原來位置的元素,如(size-1)位置的元素前移至(size-2)位,那么(size-1)位置是要設置為null的,這樣才能讓垃圾回收機制發揮作用。這種數據的用法在Java中比較常見,比如利用Vector實現的Stack,也是這樣。而在C語言中,一種利用數組實現的棧是可以在pop()后只修改當前棧對應的數組下標而不作清理的。
2.1.3 調整大小
利用Arrays.copyOf()方法做數組的調整。
2.2 Vector和Stack(不建議繼續使用)
雖然Vector經過了改造,但這么做只是為了兼容Java2之前的代碼,不建議繼續使用。
Java1.6的源碼中,和ArrayList類似,Vector底層也是數組,但是這個數組並沒有transient修飾,其序列化要低效不少。
Stack是繼承Vector實現的,而不是包裝一個Vector。這並不是一個很好的設計,如果要使用棧行為,應該使用LinkedList。Java1.6源碼中,Stack每次擴大都需要new新的數組並作拷貝,效率並不好。
新代碼中誤用這兩個容器的原因,可能是之前在C++中使用過STL的Vector和Stack。我剛接觸Java時,總以為這兩個類在Java中的地位類似C++。
2.3 抽象類AbstractSequentialList
滿足“連續訪問”數據存儲而非“隨機訪問”需求的List,對於指定index元素的操作,都需要利用抽象方法listIterator()獲得一個迭代器。其唯一實現是LinkedList,對其的討論放在Queue這部分。
三、Set
Set接口模仿了數學概念上的set,各個元素不要求重復。除了這一點,幾乎和Collection本身是一樣的。
Set接口有一個直接子接口SortedSet,該接口要求Set中所有元素有序。和通過Iterable接口依次訪問所有元素的“有序”不同,這個“有序”要求Set包括一個比較器,可以判斷兩個元素的大於、小於或等於關系。此外,該接口提供了訪問第一個元素、訪問最后一個元素、訪問一定范圍內元素的方法。SortedSet的子接口NavigableSet進一步擴展了這個系列的方法,提供了諸如返回大於/小於元素E的第一個元素的方法。
由於Set的操作與底層的實現關聯性很強,AbstractSet中實現的方法有限,在Java1.6中只有equals()、hashCode()、removeAll()進行了實現。
3.1 HashSet和LinkedHashSet
HashSet之所以命名中包含了“Hash”,是因為其底層是用HashMap實現的。Map有個特點,各個Key是唯一的,這和Set的元素唯一很類似。對HashSet的元素E進行的操作,實際上是對其包裝的HashMap中對應的<E,PRESENT>的操作,其中PRESENT是一個private static final的Object。因此,HashSet的原理,放到HashMap那一塊來研究。
HashSet有一個很特別的構造方法:HashSet(int initialCapacity, float loadFactor, boolean dummy)。這個方法第三個參數的唯一作用是,與其他兩個參數的構造方法相區分。使用這個構造方法,在底層使用的是HashMap的子類LinkedHashMap。而LinkedHashSet,正是使用了這個構造方法,在內部創建並封裝了一個LinkedHashMap而非一般的HashMap。
假如先有HashSet,后有HashMap,用HashSet實現HashMap,是否是一個好的主意?這也放在HashMap處研究。
HashSet的包裝的HashMap也使用transient關鍵字修飾,采用了和ArrayList一樣的序列化策略。
3.2 TreeSet
TreeSet是SortedSet的一個實現,也是其子接口NavigableSet的實現。
與HashSet/LinkedHashSet類似,TreeSet底層封裝了一個NavigableMap,同樣使用transient修飾,以及序列化策略。
四、Queue
Queue和List有兩個區別:前者有“隊頭”的概念,取元素、移除元素、均為對“隊頭”的操作(通常但不總是FIFO,即先進先出),而后者只有在插入時需要保證在尾部進行;前者對元素的一些同一種操作提供了兩種方法,在特定情況下拋異常/返回特殊值——add()/offer()、remove()/poll()、element()/peek()。不難想到,在所謂的兩種方法中,拋異常的方法完全可以通過包裝不拋異常的方法來實現,這也是AbstractQueue所做的。
Deque接口繼承了Queue,但是和AbstractQueue沒有關系。Deque同時提供了在隊頭和隊尾進行插入和刪除的操作。
4.1 PriorityQueue
PriorityQueue用於存放含有優先級的元素,插入的對象必須可以比較。該類內部同樣封裝了一個數組。與其抽象父類AbstractQueue不同,PriorityQueue的offer()方法在插入null時會拋空指針異常——null是無法與其他元素比較通常意義下的優先級的;此外,add()方法是直接包裝了offer(),沒有附加的行為。
由於其內部的數據結構是數組的緣故,很多操作都需要先把元素通過indexOf()轉化成對應的數組下標,再進行進一步的操作,如remove()、removeEq()、contains()等。其實這個數組保持優先級隊列的方式,是采用堆(Heap)的方式,具體可以參考任意一本算法書籍,比如《算法導論》等,這里就不展開解釋了。和堆的特性有關,在尋找指定元素時,必須從頭至尾遍歷,而不能使用二分查找。
4.2 LinkedList
很有趣的是,LinkedList既是List,也是Queue(Deque),其原因是它是雙向的,內部的元素(Entry)同時保留了上一個和下一個元素的引用。使用頭部的引用header,取其previous,就可以獲得尾部的引用。通過這一轉換,可以很容易實現Deque所需要的行為。也正因此,可以支持棧的行為,天生就有push()和pop()方法。簡而言之,是Java中的雙向鏈表,其支持的操作和普通的雙向鏈表一樣。
和數組不同,根據下標查找特定元素時,只能遍歷地獲取了,因而在隨機訪問時效率不如ArrayList。盡管如此,作者還是盡可能地利用了LinkedList的特性做了點優化,盡量減少了訪問次數:
private Entry<E> entry(int index) { if (index < 0 || index >= size) throw new IndexOutOfBoundsException("Index: "+index+ ", Size: "+size); Entry<E> e = header; if (index < (size >> 1)) { for (int i = 0; i <= index; i++) e = e.next; } else { for (int i = size; i > index; i--) e = e.previous; } return e; }
LinkedList對首部和尾部的插入都支持,但繼承自Collection接口的add()方法是在尾部進行插入。
五、一些瑣碎的話題
5.1 線程安全
ArrayList、HashSet/LinkedHashSet、PriorityQueue、LinkedList是線程不安全的,可以使用synchronized關鍵字,或者類似下面的方法解決:
List list = Collections.synchronizedList(new ArrayList(...));
5.2 clone()
ArrayList、LinkedList、HashMap/LinkedHashMap、TreeSet的clone()是淺拷貝,元素的引用和拷貝前相同;PriorityQueue的clone()繼承自Object。
5.3 foreach
在for(Element e : collection)中:
collection == null,直接拋異常;
容器內容為空,即剛剛被new出來,里面什么也沒有,直接跳過循環;
容器中放了null(如果允許的話),則將這個null取出並賦值給e,執行循環中的語句。
5.4 null對象
List可以放無限多個,set只能放一個。EnumSet、PriorityQueue是不能放null的。這個null也在計數中。所以放進去null用foreach取出來時需要判空。