java集合類深入分析之HashSet, HashMap
Map和Set是比較常用的兩種資料結構。我們在平常的程式設計中經常會用到他們。只是他們的內部實現機制到底是怎麼樣的呢?瞭解他們的具體實現對於我們如何有效的去使用他們也是很有幫助的。這裡主要是針對Map, Set這兩種型別的資料結構規約和典型的HashMap,HashSet實現做一個討論。
Map
Map是一種典型的名值對型別,它提供一種Key-Value對應儲存的資料結構。我們通過Key值來訪問對應的Value。和Java集合類裡頭其他的類不太一樣,這個介面並沒有繼承Collection這介面。而其他的類或者介面不管是List, Set, Stack等都繼承了Collection。從這一點來說,它有點像一個異類。
從前面的這部分討論,我們可以簡單的歸類一下Map接口裡面定義的常用操作。最常見的兩種操作方法是get, put方法。get方法用於根據Key來取得所需要的Value值,而put方法用於根據特定的Key來放置對應的Value。除了這兩個方法以外還有判斷Key,Value是否存在的containsKey, containsValue方法。
Map型別的資料結構有一個比較好的地方就是在存取元素的時候都能夠有比較高的效率。 因為每次存取元素的時候都是通過計算Key的hash值再通過一定的對映規則來實現,在理想的情況下可以達到一個常量值。
下面這部分是Map裡面主要方法的列表:
方法名 | 方法詳細定義 | 說明 |
containsKey | boolean containsKey(Object key); | 判斷名是否存在 |
containsValue | boolean containsValue(Object value); | 判斷值是否存在 |
get | V get(Object key); | 讀取元素 |
put | V put(K key, V value); | 設定元素 |
keySet | Set<K> keySet(); | 所有key值合集 |
values | Collection<V> values(); | 所有value的集合 |
entrySet | Set<Map.Entry<K, V>> entrySet(); | 鍵值對集合 |
掌握了以上這些主要的方法介紹,對於其他部分也就很好理解。
HashMap
我們從書本上看到的hash表根據不同的需要可以有不同的實現方式,比如有的直接用線性表,有的用連結串列陣列。在hash值的對映規則上也各不相同。在jdk的實現裡,HashMap是採用連結串列陣列形式的結構:
有了這部分的闡述,我們後面來理解它具體實現步驟就容易了很多。
內部結構
我們根據這種連結串列陣列的型別,可以推斷它內部肯定是有一個連結串列的結構。在HashMap內部,有一個transient Entry[] table;這樣的結構陣列,它儲存所有Entry的一個列表。而Entry的定義是一個典型的連結串列結構,不過由於既要有Key也要有Value,所以包含了Key, Value兩個值。他們的定義如下:
Java程式碼- static class Entry<K,V> implements Map.Entry<K,V> {
- final K key;
- V value;
- Entry<K,V> next;
- final int hash;
- /**
- * Creates new entry.
- */
- Entry(int h, K k, V v, Entry<K,V> n) {
- value = v;
- next = n;
- key = k;
- hash = h;
- }
- //...
- }
這裡省略了其他部分,主要把他們這個連結串列結構部分突出來。這部分就相當於連結串列裡一個個的Node節點。ok,這樣我們至少已經清楚了它裡面是怎麼組成的了。
陣列增長調整
現在再來看一個地方,我們實際中設計HashMap的時候,這裡面數組的長度該多少合適呢?是否需要進行動態調整呢?如果是固定死的話,如果我們需要放置的元素少了,豈不是浪費空間?如果我們要放的元素太多了,這樣也會導致更大程度的hash碰撞,會帶來效能方面的損失。在HashMap裡面儲存元素的table是可以動態增長的,它有一個預設的長度16,
Java程式碼- static final int DEFAULT_INITIAL_CAPACITY = 16;
- static final int MAXIMUM_CAPACITY = 1 << 30;
在HashMap的建構函式中,可以指定初始陣列的長度。通過這個初始長度值,構造一個長度為2的若干次方的陣列:
Java程式碼- // Find a power of 2 >= initialCapacity
- int capacity = 1;
- while (capacity < initialCapacity)
- capacity <<= 1;
在我們需要調整陣列長度的時候,它的過程和前面討論過的List, Queue有些類似,但是又有不同的地方。相同的地方在於,它每次也是將原來的陣列長度翻倍,同時將元素拷貝過去。但是由於HashMap本身的獨特性質,它需要重新做一次對映。實現這個過程的方法如下:
Java程式碼- void resize(int newCapacity) {
- Entry[] oldTable = table;
- int oldCapacity = oldTable.length;
- if (oldCapacity == MAXIMUM_CAPACITY) {
- threshold = Integer.MAX_VALUE;
- return;
- }
- Entry[] newTable = new Entry[newCapacity];
- transfer(newTable);
- table = newTable;
- threshold = (int)(newCapacity * loadFactor);
- }
- /**
- * Transfers all entries from current table to newTable.
- */
- void transfer(Entry[] newTable) {
- Entry[] src = table;
- int newCapacity = newTable.length;
- for (int j = 0; j < src.length; j++) { //遍歷原來的陣列table
- Entry<K,V> e = src[j];
- if (e != null) {
- src[j] = null;
- do { //對該連結串列元素裡面所有連結的<key, value>對做重新的對映
- Entry<K,V> next = e.next;
- int i = indexFor(e.hash, newCapacity);
- e.next = newTable[i];
- newTable[i] = e;
- e = next;
- } while (e != null);
- }
- }
- }
前面這部分的程式碼看起來比較長,實際上就是將舊的陣列的元素挪到新的陣列中來。因為新陣列的長度不一樣了,再對映的時候要對連結串列裡面所有的元素根據新的長度進行重新對映來對應到不同的位置。
那麼,我們可以看出來,元素存放的位置是和陣列長度相關的。而這其中具體對映的過程和怎麼放置元素的呢?我們在這裡就可以找到一個入口點了。就是indexFor方法。
詳細對映過程
我們要把一個<K, V>Entry放到table中間的某個位置,首先是通過計算key的hashCode值,我們都知道。在java裡每個物件都有一個hashCode的方法,返回它對應的hash值。HashMap這邊通過這個hash值再進行一次hash()方法的計算,得到一個int的結果。再通過indexFor將它對映到陣列的某個索引。
Java程式碼- static int indexFor(int h, int length) {
- return h & (length-1);
- }
- static int hash(int h) {
- // 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);
- }
hash方法就是對傳進來的key的hashCode()值再進行一次運算。indexFor方法則是具體對映的方法。因為最後得到的這個值將走為儲存Entry的索引。這裡採用h & (length - 1)的手法比較有意思。因為我們定義的陣列長度為2的若干次方,這意味著如果我們取長度減一的值時,它的二進位制數字是最高位以下的所有位為1.經過與運算之後它的結果肯定在0~2**x之間。就算前面hash方法計算出來的結果比陣列長度大也沒關係,因為這麼一與運算,前面長出來的部分都變成0了。它這一步運算的效果相當於h % length;
有了這部分對陣列長度調整和對映關係的理解,我們再來看具體的get, put方法就很容易了。
get
get方法的定義如下:
Java程式碼- public V get(Object key) {
- if (key == null)
- return getForNullKey();
- int hash = hash(key.hashCode());
- for (Entry<K,V> e = table[indexFor(hash, table.length)];
- // table[indexFor(hash, table.length)] 就是將indexFor運算得到的值直接對映到陣列的索引
- e != null;
- e = e.next) {
- Object k;
- if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
- //找到hash值相同的情況下可能出現hash碰撞,所以需要呼叫equals方法來比較是否相等
- return e.value;
- }
- return null;
- }
它這裡就是一個對映,查詢的過程。找到對映的點之後再和連結串列裡的元素逐個比較,保證找到目標值。因為是hash表,會存在多個值對映到同一個index裡面,所以這裡還要和連結串列裡的元素做對比。
put
put元素就是一個放置元素的過程,首先也是找到對應的索引,然後再把元素放到連結串列裡面去。如果連結串列裡有和元素相同的,則更新對應的value,否則就放到連結串列頭。
Java程式碼- public V put(K key, V value) {
- if (key == null)
- return putForNullKey(value);
- int hash = hash(key.hashCode());
- int i = indexFor(hash, table.length);
- for (Entry<K,V> e = table[i]; e != null; e = e.next) {
- Object k;
- if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
- //如果找到相同的值,更新,然後返回。
- V oldValue = e.value;
- e.value = value;
- e.recordAccess(this);
- return oldValue;
- }
- }
- //在前面的迴圈裡面沒有找到,則新建一個Entry物件,加入到連結串列頭。
- modCount++;
- addEntry(hash, key, value, i);
- return null;
- }
addEntry方法會判斷表長度,如果達到一定的閥值則調整陣列的長度,將其翻倍:
Java程式碼- void addEntry(int hash, K key, V value, int bucketIndex) {
- Entry<K,V> e = table[bucketIndex];
- table[bucketIndex] = new Entry<>(hash, key, value, e);
- if (size++ >= threshold)
- resize(2 * table.length);
- }
Set
Set接口裡面主要定義了常用的集合操作方法,包括新增元素,判斷元素是否在裡面和對元素過濾。常用的幾個方法如下:
方法名 | 方法詳細定義 | 說明 |
contains | boolean contains(Object o); | 判斷元素是否存在 |
add | boolean add(E e); | 新增元素 |
remove | boolean remove(Object o); | 刪除元素 |
retainAll | boolean retainAll(Collection<?> c); | 過濾元素 |
我們知道,集合裡面要求儲存的元素是不能重複的,所以它裡面所有的元素都是唯一的。它的定義就有點不太一樣。
HashSet
HashSet是基於HashMap實現的,在它內部有如下的定義:
Java程式碼- private transient HashMap<E,Object> map;
- // Dummy value to associate with an Object in the backing Map
- private static final Object PRESENT = new Object();
在它裡面放置的元素都應到map裡面的key部分,而在map中與key對應的value用一個Object()物件儲存。因為內部是大量借用HashMap的實現,它本身不過是呼叫HashMap的一個代理,這些基本方法的實現就顯得很簡單:
Java程式碼- public boolean add(E e) {
- return map.put(e, PRESENT)==null;
- }
- public boolean remove(Object o) {
- return map.remove(o)==PRESENT;
- }
- public boolean contains(Object o) {
- return map.containsKey(o);
- }
總結
在前面的參考資料裡已經對HashMap做了一個很深入透徹的解析。這裡在前人的基礎上加入一點自己個人的理解體會。希望對以後使用類似的結構有一個更好的利用,也能夠充分利用裡面的設計思想。