- title: java集合框架學習總結
- tags:集合框架
- categories:總結
- date: 2017-03-23 19:24:21
好久都想找出個時間來分析分析,總結總結java中的集合容器問題了。趁今天有時間也有興趣就來看看。不過,網上也有很多碼友們各抒己見地對java集合的分析,實踐。這都是他們根據自己的理解分析總結過來的,不過也很是值得我借鑒。不過最終還是要根據自己的思考與動手操作來跟深入的了解java的集合框架吧。畢竟在日常開發中像List,Map等非常常見且核心的框架類我們都會經常使用,有時候我們若是更深入的了解這些集合,根據實際情況分析,什么時候使用什么類型的集合,對程序運行,效率,可拓展性等等會有更清楚的認識。在一些不注意的基礎細節,其實也是相當重要的。
Java中的集合框架和分類
在java中,集合就想讓與一組類型相同或者異同的對象或者基礎數據的集合。那總是要有容器來容納這些數據吧。就像用一個籃子將散落在地上的雞蛋盛放起來,或者是用一個格子布局的盒子將石頭什么的存放起來,又或者想我們的書架將不同類別的書放置好是一個道理。
將數據保存或者是放入某種容器,那必定是有一套規則的,至于怎么放,是一個一個放,放在那里,還是一次多個存放在指定位置。這些規則都可以通過在設計容器的時候進行設置,就像java中的List,Map等一樣,都是用來保存數據的。至于如何將集合中的數據取出,那么就要看list,Map等容器的方法函數設置了。
當將"容器"這個概念簡單闡述后,下面就來看看java中的容器有哪些,每種容器在存放哪些數據類型?如何存放一個或者多個數據,如何取出一個或者多個數據?容器的不同的適用場景有哪些?有哪些利弊等方面都可以根據自己認識去分析分析,探討探討,當然了,最后還可以從JDKSE源代碼中去looklook......
集合類圖和分類
[1] Java的容器類圖
剛開始自己可以通過對jdk集合類圖的層級結構,結合OOP的概念將java中的容器集合大致梳理出來,包括Collection線性集合和KEY-VALUE鍵值對的MAP圖,分別如下:
A. 從上述的Collection線性集合可以看到,Collection
是所有集合層級結構的根,也就是最頂層的接口。一個集合代表了一組可以被稱為"元素"的對象。接口Collection中聲明的接口是所有集合子類所擁有的通用共有操作,其實就是定義了作為一個集合,應當有什么功能
.
Collection中聲明的通用操作包括以下幾個:
int size(); /*獲取集合中元素個數*/
boolean isEmpty(); /*判斷集合容器是否為空*/
boolean contains(Object o); /*判斷集合中是否有對象o存在*/
Iterator<E> iterator(); /*實現了Iterator接口返回迭代器對象*/
Object[] toArray(); /*將集合元素轉換為數組對象*/
<T> T[] toArray(T[] a); /*根據類型T轉換成數組元素*/
boolean add(E e); /*一次添加一個對象到集合容器中*/
boolean remove(Object o);/*一次移除集合中的某一個元素*/
boolean containsAll(Collection<?> c);/*判斷集合元素中是否包含參數c集合中的所有元素*/
boolean addAll(Collection<? extends E> c);/*一次添加多個元素*/
boolean removeAll(Collection<?> c);/*一次移除多個元素*/
boolean retainAll(Collection<?> c);/*篩選元素*/
void clear();/*清除集合中所有元素*/
boolean equals(Object o);/*定義判斷對象是否相等的邏輯*/
int hashCode();/*自定義hash值*/
從上面方法就可以看出,集合基礎方法就包括這些:獲取集合容器數量,集合轉換為數組,單個元素添加,多個元素添加,單個元素移除,多個元素移除等等。另一方面,因為這是根最頂層接口,我們若是自定義集合類,要自己實現全部基礎方法的話,也有些太麻煩了。所以,根據上面的圖,我們可以看到一個抽象類AbstractCollection。
AbstractCollection是一個抽象類,該類實現了Collection接口,除了將size(),iterator兩個方法抽象化,其他的接口方法都有了基礎的實現。這就不僅僅給我們提供了便利,其他內部的集合類都有繼承了該抽象類,也就具有集合的基礎功能。另一方面,若是我們想定義自己的集合類,當然最佳方法就是通過extends來繼承該抽象類了。根據OOP的繼承理念,我們的集合類也就繼承了所有集合特性的基礎操作功能。同理,像集合框架中的抽象類,AbstractList,AbstractSet一般都是提供了其實現的接口中方法的基本實現。
然后再可以根據集合內部對象元素是否可以重復,或者說相同。又將集合分支為另外兩個方向,不同方向的集合功能通常都是通過Interface接口將功能或者集合特性區分開,以適用于不同的場景:
-
<interface>java.util.List:
List容器內部包含有序的元素集合,可以通過內部元素的索引快速訪問和查找每一個元素。容器內部可以包含相同的元素。 -
<interface>java.util.Set:
Set集合容器內部包含了可重復相同的元素。
至于更詳細的兩者差別和適用放到下一節做總結。
B. java集合的KEY-VALUE鍵值對模式的Map集合類圖如上圖。可以看到Map<K,V>類中的類泛型K,V,就是分別代表Map集合中每個Entry中的key和value對應的類型。Map<K,V>是所有鍵值對集合的根類,可以從該接口的方法中看看,該類型的集合對內部的鍵值對有什么通用的操作,與Collection集合接口一樣,抽象的AbstractMap類則提供了KEY-VALUE鍵值對集合基本功能方法。
int size();
boolean isEmpty();
boolean containsKey(Object obj); /*元素中是否包含某個key*/
boolean containsValue(Object obj);/*元素中是否包含某個value值*/
V get(Object key); /*根據鍵值取到對應的值*/
V put(K key, V value);/*放置新的鍵值對到集合中*/
void putAll(Map<? extends K, ? extends V> m);
Set<K> keySet(); /*拿到map集合元素所有的key值-不可重復*/
Collection<V> values();/*拿到集合中所有的value值集合*/
Set<Map.Entry<K, V>> entrySet(); /*拿到集合中所有的鍵值對(key-value)集合*/
//還有一個內部接口Entry<K,V>代表容器中一個鍵值對對象
interface Entry<K,V> {
K getKey();
V getValue();
V setValue(V value);
boolean equals(Object o);
int hashCode();
}
鍵值對類型的集合與線性單個元素集合就有很大不同了,就是屬于兩種不同的結構。Map顧名思義就是根據某個KEY,去拿到對應的VALUE,鍵值對集合其實在開發中也是非常常用的。其中,Map<K,V>中的Entry<K,V>也是非常有用,也是map中不同分支區分的重要依據,因為可以從上圖看到,其實每個map子類中都會有自己自定義的Entry類,比如HashMap.Entry,TreeMap.Entry。根據自定義的map元素key-value實體,就能根據該類的職責對內部的節點元素進行操作。就拿LinkedHashMap來說,它內部的Entry類是繼承HashMap的,自身Entry類中定義了before,after屬性,就可以用來維持內部鍵值對的位置關系,就能達到LinkedHashMap實現的:出去元素順序與放置鍵值對順序是相同的。
Map集合的分類,也就是根據內部Entry子元素是否需要排序分成HashMap和SortedMap兩大分支。至于更細的部分放到后面再說。
[2]容器集合其他相關類:
下面羅列的幾個類,都是與集合容器密切相關的接口或者類。代表著不同的功能或者特性。
- 集合迭代:
<Interface>java.lang.Iterable<T>: 實現該接口的對象,可以使用foreach增強循環迭代獲取對象內部元素。由上面Collection類圖可知,Collection繼承了該接口,所以,所有Collection下子類都能使用增強for循環遍歷集合元素。該類中包含一個返回java.util.Iterator<E>對象的方法:
Iterator<T> iterator();
<Interface>java.util.Iterator<E>: 該接口用于替代之前集合框架中使用枚舉Enumeration來遍歷容器元素。該接口與枚舉類不同的地方包括兩點:
1.跟原先Enumeration接口的方法名比起來,Iterator接口的方法名語義更規范和明確。
2.Iterators對象允許集合容器在遍歷元素過程中,將某個元素從元素移除。
@Test
public void tt(){
List<String> tt = new ArrayList<String>(Arrays.asList("aa","bb"));
System.out.println(tt.size());
Iterator<String> iterator = tt.iterator();
while(iterator.hasNext()){
String next = iterator.next();
if("aa".equals(next)){
iterator.remove();
}
}
System.out.println(tt.size());
}
//output 2 1
迭代器對象中方法包括以下方法:
boolean hasNext(); /*判斷容器中是否還有元素*/
E next(); /*在循環中用于獲取當次的元素*/
void remove(); /*用于移除當次循環中的元素*/
- 對象克隆:
<Interface>java.lang.Cloneable: 該接口內部沒有定義方法,只是用來標識:所有實現該接口的類實例化的對象都可以被克隆。若是調用Object中的clone()方法的對象類沒有實現該接口,則會拋出CloneNotSupportedException異常。當然了,對象克隆包括了淺克隆和深度克隆。 - 對象序列化:
<Interface>java.io.Serializable: 該接口沒有屬性和方法。該接口用于標識:實現該接口的類對象可以被序列化和反序列化。 - 集合隨機訪問:
<Interface>java.util.RandomAccess: 通常是用于標記List接口子類的接口,實現該接口表明了該集合在獲取元素時候,可以隨機訪問容器中任一個位置元素。與順序訪問概念相對。 - 線性隊列:
<Interface>java.util.Deque<E>: 該接口是代表一種獲取線性集合元素方式的功能集合。該接口繼承了Queue隊列集合接口。可以在線性集合的兩端獲取和添加元素,同時具備了隊列先進先出
和棧后進先出
的數據結構模式。
泛型相關內容
因為在集合中元素的多種多樣,不可能每種數據類型都定義一種專門盛放該類型元素的集合,所以,就可以使用泛型這個類參數概念來解決,通過泛型類參數來標識,那么容器內的類型就會推遲到運行期間去進行類型判斷。泛型的本質就是參數化類型,就是將容器內的元素對應的java.lang.Class也可以在運行期間作為一個變量傳遞到容器中,這個Class可以是java的所有類型。
泛型的使用,在java中,可以聲明在類或者接口,還有方法上。如下:
public class MathOp<E,K>{
public static <E extends Number,K extends Comparable<? super K >> E find(E[] src,E obj){
E target = null;
K ret = null;
for(int i=0;i<src.length;i++){
if(src[i] == obj || src[i].equals(obj)){
target = src[i];
break;
}
}
return target;
}
}
//interface eg: public interface tt<K,V>{}
在簡單說說通常會遇見的泛型范圍界定和泛型通配符?
結合上面代碼可以看到有使用<E extends Number>
和<? super K>
。<E extends Number>: 這意味著當在實際編碼過程中,傳入容器的類型E必須是Number的子類,這里實際上就是規定了類型參數E的上界。即編譯器會根據你傳入的參數類型判斷該類型是不是Number的子類,包括byte, double, float, int, long, and short等類型都行。另一方面,當定義了參數類型上界為Number,那么方法里的對象就可以調用Number類的方法。
<? super K >: 對extends相對,這里使用通配符,可以動態代表類型參數,這里使用了super來定義參數類型的下界:在實際調用傳入類型參數時候,類型參數必須是類型K,或者K類型的父類。
在使用泛型界定的時候,extends和super會對容器取出或者放置元素有影響。
想看更多關于java泛型例子,可以看這篇文章: <u>Java 泛型 <? super T> 中 super 怎么 理解</u>
Collection系列
在了解了Collection集合的結構和主要分類后,那么就可以根據這些分類來進行延伸,看看這些重要的經常使用的子類如何創建,使用?在什么需求下應該選擇哪一種集合容器。集合與集合之間,還能進行并集,交集等操作處理元素。下面就通過List和Set分支分別進行了解。
List
java.util.List系列的集合容器,意味著容器內部能存放相同的元素(eg:List內部兩個元素e1,e2。e1.equals(e2))。List接口中除了繼承自Collection接口的方法外,還為了自身結構而設計的幾種方法:
/*List位置操作方法*/
E get(int index); /*根據索引位置返回位置內保存的元素*/
E set(int index, E element);/*將指定位置的元素替換成指定的元素*/
void add(int index, E element);/*在指定位置內添加新的元素,后面的元素要向后移動*/
E remove(int index);/*將指定位置元素移除,并返回移除的元素*/
/*List查找元素方法*/
int indexOf(Object o);/*返回集合內部第一次出現指定元素索引位置*/
int lastIndexOf(Object o);/*返回集合內部最后一次出現指定元素索引位置*/
/*集合子集集合*/
List<E> subList(int fromIndex, int toIndex);
可以看到List接口根據自身特定,對自己集合容器的元素的獲取和設置動作進行定義。包括根據集合元素的位置索引,可以快速定義元素,對元素進行增刪改查操作。至于為什么可以通過位置索引快速定位元素這樣隨機訪問集合容器,那它內部的如何實現的?后面通過具體的ArrayList實現來說明。
List還有一個特別的迭代器,是List類自定義的一個迭代器ListIterator<E>。這個迭代器是專門用于List服務的,有什么特點呢?可以在遍歷迭代List集合過程中,可以雙向移動光標位置,或向前調用previous(),或向后next()。根據光標向前或者向后移動來獲取集合內元素并進行修改刪除元素等操作,看看該接口內定義的方法:
boolean hasNext();/*根據光標向后移動,next()方法是否返回元素,就表面后面還有元素*/
E next();/*獲取后一位元素,并且光標向后移動*/
boolean hasPrevious();/*反向移動光標獲取元素,判斷前一位是否還有元素*/
E previous();/*向前移動光標,返回前一位元素*/
int nextIndex();/*返回調用next方法后光標索引位置*/
int previousIndex();
void remove();/*移除在調用next,previous方法后返回的元素*/
void set(E e);/*替換在調用next,previous方法后返回的元素*/
void add(E e);/*添加新元素,注意調用時序*/
那么就用個例子來調用上面方法熟悉熟悉這個接口的使用過程:
@Test
public void ListIteratorTest(){
List<String> tt = new ArrayList<String>(Arrays.asList("one","two","three","four"));
ListIterator<String> listIterator = tt.listIterator();
System.out.println("原始集合元素:"+tt);
//向后遍歷
while(listIterator.hasNext()){
String t = listIterator.next();
System.out.println("下一個元素:"+t);
int index = listIterator.nextIndex();
if(index == 2 && listIterator.hasPrevious()){
System.out.println("index 為"+index+"的前一個元素:"+listIterator.previous());
System.out.println("修改index為"+index+"前一個元素為 update_two");
listIterator.set("update_two");
break;
}
}
System.out.println("修改后的集合元素:"+tt);
}
//輸出
原始集合元素:[one, two, three, four]
下一個元素:one
下一個元素:two
index 為2的前一個元素:two
修改index為2前一個元素為 update_two
修改后的集合元素:[one, update_two, three, four]
每個List子類包括:AbstractList,ArrayList,LinkedList內部都有個私有類來實現ListIterator<E>這個接口,以滿足不同集合特征的元素遍歷方式。
其實從源代碼部分List接口中聲明的Iterator<E> iterator()
方法在AbstractList和List多數子類中的實現,內部是通過私有類Itr來實現的。
List接口中聲明的ListIterator<E> listIterator()
方法則是
public Iterator<E> iterator() {
return new Itr();
}
private class Itr implements Iterator<E> {..}
##############
public ListIterator<E> listIterator() {
return listIterator(0);
}
public ListIterator<E> listIterator(final int index) {
rangeCheckForAdd(index);
return new ListItr(index);
}
private class ListItr extends Itr implements ListIterator<E> {...}
所以,List子類中的迭代器都可以有兩種方式來獲取即:iterator()和listIterator()。兩者大的區別也就是listIterator可以雙向移動獲取和操作元素了。
根據類圖,可以看到List主要的三種實現:ArrayList,Vector,LinkedList。下面分別說說:
-
ArrayList
ArrayList是一個可動態調整大小的實現List的集合。該集合可以容納任何類型的對象,包括null。ArrayList的方法根據時間復雜度的不同可以大致分為以下幾種:
constant time:即不管你集合內部有多少數據量,調用這幾個方法花費的時間都是基本相等的:size(),isEmpty(),get(),set(),iterator(),listIterator()。
amortized constant time: 就是add()方法,添加n個元素需要時間是O(n)。
linear time : 其他操作基本上算是線性時間階。
ArrayList內部是由一個Object[]類型的elementData字段來存儲數據。也就是說該集合底層的操作都是由數組維持的,無論是增刪改都是與數組的結構特性相關,即可以隨機存取,但是在n位置插入一個新元素,n+1后面的所有元素都要后移一位等。
elementData數組的長度默認是DEFAULT_CAPACITY = 10;
。當我們在創建ArrayList,調用的無參構造函數,內部就是初始化elementData數組的長度為10。ArrayList的所有新增,刪除,初始化等操作都是與elementData,size變量有關。而elementData數組中的capacity又是很重要的概念,表示數組最多能容納的元素數量,size變量則是表示當前elementData數組中存放元素的個數。
通過下面的方法來看看內部的ArrayList操作:
//java.util.ArrayList
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
/**
* Default initial capacity.
*/
private static final int DEFAULT_CAPACITY = 10;
/**
* Shared empty array instance used for empty instances.
*/
private static final Object[] EMPTY_ELEMENTDATA = {};
/**
* The array buffer into which the elements of the ArrayList are stored.
* The capacity of the ArrayList is the length of this array buffer. Any
* empty ArrayList with elementData == EMPTY_ELEMENTDATA will be expanded to
* DEFAULT_CAPACITY when the first element is added.
*/
private transient Object[] elementData;
/**
* The size of the ArrayList (the number of elements it contains).
*
* @serial
*/
private int size;
...
}
//end ArrayList
//當我們創建一個ArrayList對象若是傳入了初始化數組的個數的話,就直接this.elementData = new Object[initialCapacity];
//elementData數組就初始化完成。
//若是我們常用的調用無參數的構造器,那么內部數組是如何初始化的呢?
//List<String> tt = new ArrayList<String>();
//1. 首先將一個空的數組賦值給elementData
public ArrayList() {
super();
this.elementData = EMPTY_ELEMENTDATA;
}
//2.當調用add("sptok")時候,add方法就會對elementData進行容量拓展
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
//3. ensureCapacityInternal方法,從代碼可以看到,根據上面1的無參構造的調用,
//第一個if判斷為真,然后將默認的DEFAULT_CAPACITY=10容量值,與當前數組個數size
//進行比較,因為是第一次添加,size必定小于DEFAULT_CAPACITY,所以,
//傳入ensureExplicitCapacity的參數就是10.
private void ensureCapacityInternal(int minCapacity) {
if (elementData == EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
//4. ensureExplicitCapacity
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
//5. grow(10) ,可以看到最后是調用Arrays的copyOf方法對elementData進行初始化
//Arrays.copyOf(elementData,10); 最后ArrayList的elementData數組容量大小就為10.
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
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);
}
特別的,在ArrayList中,elementData的capacity是一個特別重要的地方,因為ArrayList內部所有數據元素存儲都是在elementData中,而elementData最多能存放多少元素個數就是與capacity有關,所以在每次使用add一次添加一個元素,或者addAll一次添加多個元素,這些對存儲新元素有關的操作,該ArrayList內部都會使用ensureCapacity,ensureCapacityInternal,ensureExplicitCapacity,hugeCapacity等方法對capacity重新處理,若是數組容量不夠大,就要擴容。
在elementData容量修改成功之后,所有元素的添加add,修改set,刪除remove,查詢get等方法都是與常規操作數組一樣了。ArrayList是線程不安全的,意味著在處理容器元素時候,在多線程環境下是要進行人為同步的,無論是通過共享內存,添加synchronized關鍵字等。相對的,與ArrayList結構完全差不多的線程安全的集合就是Vector了。
-
B. LinkedList
與ArrayList內部是由數組存儲元素,可隨機讀取元素不同,LinkedList是鏈式的數據結構即為鏈式存儲,ArrayList則是順序存儲的線性表。所以存儲結構不同,當然就會有不同的操作特性與具體實現。鏈式的LinkedList類中有first
和last
連個節點屬性變量來維持鏈式存儲信息和操作內部的鏈式存儲元素。因為LinkedList也實現了Deque接口,那么這個鏈式集合的存取都可以從任意一段進行操作。
看看LinkedList內部用于維護鏈式存儲信息的結構:內部有個節點Node私有類。
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{
transient int size = 0;
/**
* Pointer to first node.
*/
transient Node<E> first;
/**
* Pointer to last node.
*/
transient Node<E> last;
....
//元素節點,分別存儲當前節點前面和后面的節點信息。因為是雙向的。
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
...
}
因為LinkedList是鏈式存儲,那么對內部元素的操作都是使用Node來操作,就那添加節點來舉個例子:
// List<String> tt = new LinkedList<String>();
public boolean add(E e) {
linkLast(e); //調用鏈式方法,在鏈尾添加一個信息的Node.
return true;
}
//linkLast()
/**
* Links e as last element.
*/
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++;
}
所以,LinkedList中繼承自List中的公共方法底層實現,都是經過鏈式操作包裝的。這樣就能達到LinkedList類使用的目的,遍歷讀取集合內部元素的順序與添加元素的順序是相同的。這都是因為內部Node的next,prev保存者每個節點前后節點的信息來支持的。所以呢,對于線性鏈式存儲的優勢弊端同樣也會在LinkedList中體現出來,那就是在相同的環境下,鏈式結構在指定位置添加新的元素速度會比順序結構塊(不是最后一個),因為沒有順序表要將插入點后的所有元素移位的花銷,當然了,這也是通常的境況下。
-
C.Vector
與ArrayList差不多等價,只是Vector是線程同步的。內部的方法等都有synchronized修飾而已。
Set
Set是Collection集合的另一分支,與List相對,Set內部不能存放相同的元素。其他的功能方法與AbstractCollection中相差不大,主要就看看如何處理這個"相同元素"的問題以及元素排序TreeSet的內容。
public class HashSet<E>
extends AbstractSet<E>
implements Set<E>, Cloneable, java.io.Serializable
{
private transient HashMap<E,Object> map;
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
/**
* Constructs a new, empty set; the backing <tt>HashMap</tt> instance has
* default initial capacity (16) and load factor (0.75).
*/
public HashSet() {
map = new HashMap<>();
}
...
}
HashSet集合內部實際上是HashMap來實現的,這個KEY-VALUE的map所有value值都是同一個PRESENT對象。所以,HashSet實際上也就是一個key值不同,value值全部都是一個相同Object的Map集合,HashSet僅僅是關注不可重復的key值集合而已。
那重點當然看看這個不可添加重復的遠的的Set內部的add方法是如何實現的?
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
map的put方法就是根據key的hash值進行比較,發現若是有相同的key的話,就替換對應的value值,返回被替換的value值。返回時候,不為null,所以返回false。也就是說明HashSet沒有添加成功,有相同的元素。若是沒有相同的元素,map的put方法會返回null,則HashSet的add方法返回true,表示添加新元素成功。
再來看看需要將元素排序的TreeSet的一些實現和用法。從源代碼可以看到,與HashSet內部由HashMap實現相似,TreeSet內部底層也由Map系列的具有排序功能的NavigableMap接口的實現類實現。
public class TreeSet<E> extends AbstractSet<E>
implements NavigableSet<E>, Cloneable, java.io.Serializable
{
/**
* The backing map.
*/
private transient NavigableMap<E,Object> m;
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
//通常我們使用的無參數構造器內部其實傳遞了一個TreeMap實現。
public TreeSet() {
this(new TreeMap<E,Object>());
}
//若是要自定義排序規則
public TreeSet(Comparator<? super E> comparator) {
this(new TreeMap<>(comparator));
}
...
}
看看如何使用TreeSet,定義一個實現Comparator接口的類,用于定義在集合容器中排序規則,這個是最主要的:
//Bottle pojo
public class Bottle {
private int height = 0;
public Bottle(){}
public Bottle(int height){
this.height = height;
}
@Override
public String toString() {
return "Bottle [height=" + height + "]";
}
//getter setter
}
//排序規則
public class BottleComparator implements Comparator<Bottle>{
//定義排序規則,按照瓶子高度升序
@Override
public int compare(Bottle o1, Bottle o2) {
return o1.getHeight() - o2.getHeight();
}
}
//treeSet使用
@Test
public void CompaTest(){
//根據Bottle瓶子的高度排序
TreeSet<Bottle> tr = new TreeSet<Bottle>(new BottleComparator());
Bottle b1 = new Bottle(15);
Bottle b2 = new Bottle(20);
Bottle b3 = new Bottle(10);
tr.add(b1);
tr.add(b2);
tr.add(b3);
System.out.println(tr);
}
//輸出,可以看到已經按照瓶子高度從低到高排序
[Bottle [height=10], Bottle [height=15], Bottle [height=20]]
Set的底層實現大多都是依賴Map實現的,具體的細節還是要到Map中的查看。
Map系列
Map<K,V>是鍵值對的集合抽象,內部接口定義的方法都是圍繞KEY-VALUE來進行操作的。如何獲取和設置KEY,VALUE,若是KEY值重復如何處理?如何判斷KEY的重復,在內部Map是如何實現每個KEY-VALUE存儲的?每個KEY-VALUE是如何具體表示的?等等都可以進行思考與實踐。下面就按照HashMap和TreeMap兩大分支分別細說。
HashMap
因為HashMap的存儲等核心都會與Hash有關,那先看看hash是什么,有什么作用,與java有什么關系?
Hash定義:就是把任意長度的輸入(預映射),通過散列算法(eg:md5,sha1..),變換成固定的長度的輸出。更多請看百度-hash
在java中呢,所有的對象的頂層對象Object有一個hashCode()方法,就是根據一定的自定義規則,將與對象相關的信息(比如對象的內存地址,對象字段,屬性等)映射成一個固定長度的數值,這個數值稱為散列值。這樣我們就可以根據自己定義的散列算法規則,得到想要的散列值,得到這個散列值,可以用來標識對象唯一性,或者對比兩個對象是否相等。在java中對比兩個對象是否相等,通常不都是重寫hashCode和equals兩個方法么。
關于java中hashCode更多內容,可以看看這篇文章,說得很好:淺談Java中的hashcode方法
//Object,注釋上說這個方法主要是可以用來標識對象唯一性且可以為Hash Table提供支持。
//兩個對象通過e1.equals(e2)==true后,還不能判定兩個對象相等,還要分別調用
//hashCode方法進行對比,若是兩個對象的hashCode值相等,則兩個對象相等。
* <li>If two objects are equal according to the {@code equals(Object)}
* method, then calling the {@code hashCode} method on each of
* the two objects must produce the same integer result.
public native int hashCode();
特別的,在HashMap底層的hash表更是hash散列函數的主要應用,Hash Table的操作都是需要依賴散列函數來操作的,HashMap自定義hash映射規則,然后根據規則添加節點元素。HashMap和HashTable大體上是相同的,不同點在于:HashMap是線程不安全的,并且可以放置的KEY-VALUE值分別均可為null;HashTable反之。好了,下面具體看看HashMap的內容。
從java.util.HashMap的注釋中可以知道:影響HashMap性能的兩大參數是capacity和load factor:
capacity:表示hash表內部的buckets(桶)的數量。也就是hash表的數組容量長度。
initial capacity: 表示hash表在創建的時候表中的capacity的初始值大小。
load factor:加載因子,就是用來檢測當hash表中存放buckets占總的capacity的比例,達到某個指定的閾值,就將hash表的capacity擴容。
HashMap內部底層是由Entry<K,V>數組table這個hash表來實現對元素的添加,修改,刪除,查詢操作的。下面就根據自己對HashMap的理解,將內部的hash表在put操作的過程畫出來,并對這個put過程進行簡單說明:
//HashMap.java
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
{
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* An empty table instance to share when the table is not inflated.
*/
static final Entry<?,?>[] EMPTY_TABLE = {};
/**
* The table, resized as necessary. Length MUST Always be a power of two.
*/
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
/**
* The number of key-value mappings contained in this map.
*/
transient int size;
...
public V put(K key, V value) {
//1.如果key為null,那么將此value放置到table[0],即第一個桶中
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
//2.根據key值得到hash值
int hash = hash(key);
//3. 根據hash值得到對象所要被放置的table表索引槽
int i = indexFor(hash, table.length);
//4. 遍歷索引槽上的鏈式節點
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//4-1. 查看是否有相同的key對象,注意這里對比相等的條件
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
//5. 不存在,則根據鍵值對<Key,Value> 創建一個新的Entry<Key,Value>對象,
//然后添加到這個桶的Entry<Key,Value>鏈表的頭部。
addEntry(hash, key, value, i);
return null;
}
...}
就拿上述代碼中的put方法進行解說,這個放置新的對象到hashmap對象中的大致過程也就像下圖所示:(最左邊的Object對象即為要放進hashMap對象元素,每個對象對應的key-value值都有在圖中描述出來了)
1.先根據對象的key值,調用hash函數hash(key)得到hash值。
2.在根據這個hash值調用indexFor方法得到table數組的索引值,就決定將元素放在那個槽。
3.然后遍歷索引所在槽對應的鏈表,看看是否有相同的key值對象,若是存在相同key值,則替換原來key值對應value值,返回被替換的value值。
4.若是沒有相同的key值,則調用addEntry方法添加新的節點。具體如何添加的,在后面再細說。
從圖看出,java對hashMap的hash映射規則有兩步:第一步通過hash方法得到一個根據key值拿到的hash值;第二步再根據第一步得到的hash值映射到內部hash表table中具體的數組索引槽。這樣就能大致定位元素所要存放的位置。之后,在槽的內部在進行鏈式結構的節點存儲和對比查詢。
這里的節點Entry<K,V>節點概念也是非常重要的,它是每個hash表table字段指定索引對應的槽中鏈式節點的基礎。只有通過這個節點結構,才能形成鏈式節點元素的存儲,看看該節點類是如何存儲的:
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next; //指向下一個節點指針
int hash; //hash值 : hash(key)
...
}
結合上圖中,Object1,Object2,Object3分別對應的e1,e2,e3節點對象。每個節點中都保存著節點的key值,value值,和hash值,還有指向下一個節點的節點指針。這樣其實就能形成鏈式節點了。再來看看每個存入對象Entry節點的hash值是如何計算的:
/*
* 這個hash函數就是自定義的hash映射函數,將對象根據自定義規則得到定長一個hash值返回。
* A. 如果輸入是String類型,那么可以直接使用sun公司提供的stringHash32函數,得到32位的hash值。
* B. 如果不是String類型,會首先調用輸入對象的hashCode方法得到一個hash值,但是為了避免hash值沖突,
* 為什么要避免hash值沖突,就是應為,若是假設沖突概率大,10000個元素有9999個元素都在一個table
* 索引槽中(最壞打算),那么當通過get(key)查找元素時候,就會遍歷索引槽的鏈式節點,順序查詢,
* 非常影響性能。
* 所以,javase設計人員通過設計的一系列位運算,就是為了平衡hash值沖突情況,旨在盡量不影響hash表的性能。
*/
final int hash(Object k) {
//隨機的hashSeed,來降低沖突發生的幾率
int h = hashSeed;
//如果是字符串,用了sun.misc.Hashing.stringHash32((String) k);來獲取hash值。
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
/**
* Returns index for hash code h.
* 該函數是用來根據對象的hash值定位到hash表的索引槽位置
* 這里剛開始默認的capacity=16,即length=16
* 這里無論h為多少, h & (16 -1) = h & 0xffff < 16
* 得到的都是后四位二進制,最大值也就是15,就對應table的索引值0~15.
* 這樣就能找到索引槽。
*/
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
}
上面重要的hash函數說明也解釋了,然后再來看看當調用indexFor找到索引槽后,是如何比較兩個元素相等的:
if (e.hash == hash && ((k = e.key) == key || key.equals(k))){}
可以看到,因為每個元素節點都有hash值屬性,這個hash值都是根據HashMap.hash(key)方法算出來。首先比較兩個Entry對象的hash值是否相等,相等的條件是兩個對象的hash值相等,并且在未進行hash計算的兩個對象的key值也相等(==相等或者equals相等)。若是相等的話,就直接將舊的value值替換成新的value值,并返回被替換的value值。若是不相同,則創建新的節點,鏈接到索引槽的鏈表上。
再看看hash表是如何添加新的節點Entry的:
從put方法中可以看到,若是在循環Entry鏈表中,找不到相同的key值,那么就調用addEntry方法,將hash值,key,value,index值都傳遞下去,從源代碼看看,是如何創建新的節點的:
/*
* 先判斷table這個hash表中元素(buckets)數量是否大于等于閾值(閾值=capacity * (load factor)),并且
* bucketIndex的索引值出的元素不為null,就調用resize方法進行2倍擴容,這時候的table.length是原來的兩倍。
*
* 然后在根據hash(key)得到的值和新的table.length得到新節點所在的索引槽,定位到索引槽之后,就可以添加新的Entry節點了。
*/
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length); //擴容2倍
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length); //定位擴容后的元素所在索引槽
}
//定位到新元素所在的索引槽,后添加節點
createEntry(hash, key, value, bucketIndex);
}
/*
* 可以看到,首先將索引槽處的節點賦值給e,然后再將新的節點放置在索引槽table[index]處,
* 最后將索引槽處節點指向e: 達到的效果就是,每次添加新的Entry節點都是放在鏈表的頭部
* 也就是索引槽的位置。
*/
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
其實,在了解了hashMap內部的hash表結構和hash(),indexFor()兩個函數,就大概知道內部是如何操作節點的了。hash表內部就是數組和鏈表的組合操作。至于每個數組索引槽的鏈表節點數量的控制,就是hash()函數來直接影響的。最大差異化hash值,盡量少碰到hash碰撞的節點情況,這樣鏈表的索引的數量就會少。其實,table數組的長度和每個索引槽的鏈表的長度兩者的關系直接影響到HashMap的性能了,至于如何協調,還要多看看了。
TreeMap
TreeMap內部主要的難點就是底層紅黑二叉樹的理解和實現。其實自己對這個算法也不是太了解,再次就不對這個進行過多的描述了。就簡單看看treeMap對象的put方法,大致是如何放置元素的,以及內部的二叉樹結構是如何形成的。
public class TreeMap<K,V>
extends AbstractMap<K,V>
implements NavigableMap<K,V>, Cloneable, java.io.Serializable
{
/**
* The comparator used to maintain order in this tree map, or
* null if it uses the natural ordering of its keys.
*
* @serial
*/
private final Comparator<? super K> comparator;
private transient Entry<K,V> root = null;
/**
* The number of entries in the tree
*/
private transient int size = 0;
...
}
可以看到,內部有兩個個重要的屬性就是comparator比較器和root根節點元素。從注釋也可以看到,若是在構造器中傳入比較器實例,那么就會按照每個對象的key值進行自然排序。也就是使用java自己實現的每個對象的比較規則對treeMap內的元素進行排序。那么TreeMap這個樹形結構是如何形成的呢?主要還是因為TreeMap中每個節點Entry的定義:
static final class Entry<K,V> implements Map.Entry<K,V> {
K key;
V value;
Entry<K,V> left = null;
Entry<K,V> right = null;
Entry<K,V> parent;
boolean color = BLACK;
}
這樣每個節點有左右子節點,還有父節點,這樣一個樹形層級的二叉樹就出來了。然后再來看看當將一個元素放置入map中,內部的樹是如何進行處理的?
public V put(K key, V value) {
Entry<K,V> t = root;
if (t == null) {
/*若是第一次添加元素,root根節點為null
* 然后在判斷是否傳入自定義的比較器comparator.若是沒有傳入
* 則調用java內構的數據類型的比較器,然后創建根節點
*/
compare(key, key); // type (and possibly null) check
root = new Entry<>(key, value, null);
size = 1;
modCount++;
return null;
}
int cmp; //用于判斷最后是添加左葉子還是右葉子節點
Entry<K,V> parent;
// split comparator and comparable paths
Comparator<? super K> cpr = comparator;
//若是傳入了自定義元素比較器,則內部二叉樹節點添加將會根據這個比較器進行
if (cpr != null) {
/*
* 不斷的從根節點到左右子節點進行遞歸比較每個節點的key值。
* 若是當前樹節點的key值小于新節點key值,那么就往右字數迭代,
* 反之,則往左字數迭代比較,直到遍歷到葉子節點后 t= null,
* 跳出遞歸循環,添加新的葉子節點。
*/
do {
parent = t;//保存父節點信息
//根據自定義比較器,判斷parent節點的key值與添加的Entry節點key值。
cmp = cpr.compare(key, t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
//若是找到相同的key,則拿新值,替換舊的值,并返回舊值。
return t.setValue(value);
} while (t != null);
}
else { //自然排序,過程與上面的流程一樣
if (key == null)
throw new NullPointerException();
Comparable<? super K> k = (Comparable<? super K>) key;
do {
parent = t;
cmp = k.compareTo(t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t != null);
}
//遍歷二叉樹后,找到合適位置,添加新的樹節點
Entry<K,V> e = new Entry<>(key, value, parent);
//根據cmp變量保存的最后key比較信息,來決定是添加右葉子節點還是左葉子節點。
if (cmp < 0)
parent.left = e;
else
parent.right = e;
fixAfterInsertion(e);
size++;
modCount++;
return null;
}
從上面代碼的注解中我們就可以直到TreeMap內部二叉樹是如何添加新節點的了,都是根據comparator比較器來迭代循環二叉樹節點,將每個樹節點的key值與新添加的節點的key值進行比較,最后決定是在右葉添加還是左葉添加新節點而已。主要決定節點是左還是右邊就是依賴comparator比較器。
集合容器工具類
在將JAVA SE中大多數經常使用的集合框架說明完后,最后,再看看集合容器的工具類,還有數組工具類。因為集合和數組都是形影不離的,兩種類型的容器密不可分。就從上面的集合源代碼也可以直到,某些內部集合存儲元素都是使用數組來實現的。兩者還能相互轉換。
Collections:
所有集合框架的工具類,內部集成了為集合框架服務的工具類:包括集合內部元素排序,查找,拷貝,最大值,最小值,隨機亂序,替換,同步等等方法。
-
Arrays:
數組工具方法,集成了為數組服務的工具類:包括數組排序,查找,hash值,拷貝等等方法。
在這里就重點看看兩類容器的拷貝方法和相互轉換:
集合拷貝,Collections集合工具類定義的方法:
其實內部就是通過遍歷src集合,然后將每個元素設置到dest之中,也沒什么技術含量。若是想實現自己的集合拷貝方法,也是非常不錯的。
public static <T> void copy(List<? super T> dest, List<? extends T> src){..}
數組拷貝,可以通過Arrays工具類的copyOf方法,也可以通過System.arraycopy方法:
//Arrays.copyOf
public static <T> T[] copyOf(T[] original, int newLength) {
return (T[]) copyOf(original, newLength, original.getClass());
}
public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
T[] copy = ((Object)newType == (Object)Object[].class)
? (T[]) new Object[newLength]
: (T[]) Array.newInstance(newType.getComponentType(), newLength);
System.arraycopy(original, 0, copy, 0,
Math.min(original.length, newLength));
return copy;
}
//System.arraycopy
public static native void arraycopy(Object src, int srcPos,
Object dest, int destPos,
int length);
其實從實現可以看到,實質上Arrays.copyOf底層還是通過反射和system.arraycopy實現的。因為arraycopy方法是native的,本地代碼庫,更貼近機器底層,所以效率那肯定比copyOf方法高了。在數組拷貝需求中,優先考慮arraycopy方法了。
兩者的相互轉換:
集合轉換數組:
toArray() 或者 toArray(T[] a)(優先)
數組轉換成集合:
Arrays.asList(..)
參考:
Java HashMap 源碼解析
Java集合框架源碼剖析:HashSet 和 HashMap
HashMap的設計原理和實現分析