Java中常用的數據結構封裝在java.util包下的一些類中,util包中有這樣兩個接口:
這裏面都是日常開發所常用的容器,下面以Java7中HashMap源碼爲參考,分析HashMap的實現過程。
一、HashMap介紹
Hashmap是基於哈希表的Map接口的實現,以key-value的形式存在。在HashMap中,key-value總是會當做一個整體來處理,系統會根據hash算法來來計算key-value的存儲位置,我們可以通過key快速地存、取value。
二、JDK中Hashmap的定義:
publicclass HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>,Cloneable, Serializable
{
...
}
HashMap實現了Map接口,繼承AbstractMap。其中Map接口定義了鍵映射到值的規則,而AbstractMap類提供 Map 接口的骨幹實現,以最大限度地減少實現此接口所需的工作,其實AbstractMap類已經實現了Map。
三、HashMap的構造函數
HashMap提供了三個構造函數:
HashMap():構造一個具有默認初始容量 (16) 和默認加載因子 (0.75) 的空 HashMap。
HashMap(int initialCapacity):構造一個帶指定初始容量和默認加載因子 (0.75) 的空 HashMap。
HashMap(int initialCapacity, floatloadFactor):構造一個帶指定初始容量和加載因子的空HashMap。
這裏提到了兩個參數:初始容量,加載因子。這兩個參數是影響HashMap性能的重要參數,其中容量表示哈希表中桶的數量,初始容量是創建哈希表時的容量,加載因子是哈希表在其容量自動增加之前可以達到多滿的一種尺度,它衡量的是一個散列表的空間的使用程度,負載因子越大表示散列表的裝填程度越高,反之愈小。對於使用鏈表法的散列表來說,查找一個元素的平均時間是O(1+a),因此如果負載因子越大,對空間的利用更充分,然而後果是查找效率的降低;如果負載因子太小,那麼散列表的數據將過於稀疏,對空間造成嚴重浪費。系統默認負載因子爲0.75,一般情況下我們是無需修改的。
四、HashMap的數據結構
我們知道在Java中最常用的兩種結構是數組和模擬指針(引用),幾乎所有的數據結構都可以利用這兩種來組合實現,HashMap也是如此。實際上HashMap是基於Hash table來實現了,而此Hash Table的數據結構由“數組+鏈表”來組成。
從圖中可以看出HashMap底層實現還是數組,只是數組的每一項都是一條鏈。其中構造函數中第一個參數initialCapacity就代表了該數組的長度。下面爲HashMap構造函數的源碼:
public HashMap(intinitialCapacity, floatloadFactor) {
//初始容量不能小於0
if (initialCapacity < 0)
thrownew IllegalArgumentException("Illegal initialcapacity: " + initialCapacity);
//初始容量不能大於最大容量(2^30)
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity= MAXIMUM_CAPACITY;
//負載因子不能小於0
if (loadFactor <= 0 || Float.isNaN(loadFactor))
thrownew IllegalArgumentException("Illegal loadfactor: " + loadFactor);
//算出一個最小的大於initialCapacity的2次冪,HashMap容量爲這個2的冪
//initialCapacity默認值是16 2^4
//爲什麼要這樣做下面再探討
intcapacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
this.loadFactor = loadFactor;
intthreshold = (int)(capacity * loadFactor);
table = new Entry[capacity];
init();
}
從這段代碼中可以看出,每次新建一個HashMap時,都會初始化一個table數組。table數組的元素爲Entry節點。
Entry定義:
staticclass Entry<K,V>implements Map.Entry<K,V>{
finalK key;
V value;
Entry<K,V> next;
finalinthash;
/**
* Creates new entry.
*/
Entry(inth, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
.......
}
其中Entry爲HashMap的內部類,它不僅包含了鍵key、值value還有下一個節點next,以及hash值,這是非常重要的,Entry才構成了table數組的鏈表。
五、存儲的實現
Hashmap剛創建出來是空的,往hashmap中存值用到put(key,vlaue)方法。
public V put(K key, V value) {
/**
* 當key爲null,調用putForNullKey方法,保存null在table第一個位置
* 中,這是HashMap允許爲null的原因
*/
if (key == null)
return putForNullKey(value);
//計算key的hash值
inthash = hash(key.hashCode());
//計算key的hash值在table數組中的位置
inti = indexFor(hash, table.length);
//循環鏈表,找到key的保存位置
for(Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//判斷該條鏈上是否有hash值相同的(key相同)
//若存在相同,則直接覆蓋value,返回舊value
if (e.hash == hash && ((k = e.key) ==key || key.equals(k))) {
VoldValue = e.value;
e.value= value;
e.recordAccess(this);
return oldValue;
}
}
//修改次數增加1
modCount++;
//將key、value添加至i位置處
addEntry(hash, key, value, i);
returnnull;
}
首先判斷key是否爲null,若爲null,則直接調用putForNullKey方法。通過這段源碼我們可以清晰看到HashMap保存數據的過程:
若key不爲空則先計算key的hash值,然後根據hash值搜索在table數組中的索引位置。
如果table數組在該位置處有元素,則比較是否存在相同的key,若存在則覆蓋原來key的value,否則將該元素保存在鏈頭。
若table數組在該處沒有元素,則直接保存。
效率問題:
我們知道對於HashMap的table而言,數據分佈的越均勻越好(最好每項都只有一個元素,這樣就可以直接找到),太緊會導致查詢速度慢,太鬆則浪費空間。
來看一下Key在hashmap的table中怎麼實現定位的
int hash = hash(key.hashCode());
inti = indexFor(hash, table.length);
staticintindexFor(inth, intlength) {
returnh & (length-1);
}
拿到hash值後是跟數組長度-1做了與運算操作,這是因爲在計算機中做二進制位元算效率最高。沒毛病。首先將key的hashcode經過一次散列運算得到一個hash值,得到hash值後調用indexFor方法,拿到的h&(length - 1)就是key的位置。
其次考慮爲什麼返回的這個值會跟其他數返回來值衝突的情況少。
假設初始長度爲默認的16,也就是hash()後的值跟15做與運算,會發現永遠是h的低四位在進行與操作,很容易發生hash衝突。
如果返回的h散列的跟均勻些,那麼衝突的概率就會變小,所以hash方法應該是會使得hash值的位值在高低位上儘量均勻分佈的算法。
通過一個例子來做說明:
兩個key,調用Object的hashCode方法後值分別爲:32,64,然後entry數組大小依舊爲:16,不調hash()方法直接返回hashCode,即在調用indexFor時參數分別爲[32,15],[64,15],這時分別對它們調用indexFor方法:
32計算過程:
100000 & 1111 => 000000 =>0
64計算過程如下:
1000000 & 1111 => 000000 =>0
可以看到indexFor在Entry數組大小不是很大時只會對低位進行與運算操作,高位值不參與運算,很容易發生hash衝突。
現在讓32和64經過hash()算法:
原始h爲32的二進制:100000
h>>>20:
000000
h>>>12:
000000
接着運算 h^(h>>>20)^(h>>>12):
結果:100000
然後運算: h^(h>>>7)^(h>>>4),
過程如下:
h>>>7: 000000
h>>>4: 000010
最後運算: h^(h>>>7)^(h>>>4),
結果: 100010,即十進制34
再調用indexFor方法:
100010 & 1111 => 2,即存放在Entry數組下標2的位置上
同樣,64進過hash運算結果爲:1000100,十進制值爲68
再調用indexfor方法:
1000100 & 1111 => 4,即存放在Entry數組下標4的位置上
由此可見hash()的作用。
除此之外我們再假設h得到的爲5、6、7,length爲16(2^n)和15
length=16 | |||
h | length-1 | h&(length - 1) | |
5 | 15 | 0101&1111=00101 | 5 |
6 | 15 | 0110&1111=00110 | 6 |
7 | 15 | 0111&1111=00111 | 7 |
length=15 | |||
5 | 14 | 0101&1110=00101 | 5 |
6 | 14 | 0110&1110=00110 | 6 |
7 | 14 | 0111&1110=00110 | 6 |
這裏做了個對比,因爲在構造器有這樣一步:
//算出一個最小的大於initialCapacity的2次冪,HashMap容量爲2的冪
intcapacity = 1;
while (capacity < initialCapacity)
capacity<<= 1;
它的意義就是當length =2^n時,不同的hash值發生碰撞的概率比較小,這樣就會使得數據在table數組中分佈較均勻,查詢速度相應較快。
另外,找到在table中的位置後,會迭代這個Entry,利用key.equals()方法判斷是否存在hash值(key)衝突,如果衝突,用新value替換舊value,這裏並沒有處理key,這就解釋了HashMap中沒有兩個相同的key。
這裏也強調重寫equals方法後要重寫hashCode方法。
六、讀取的實現
相對於HashMap的存而言,取就顯得比較簡單了。通過key的hash值找到在table數組中的索引處的Entry,然後返回該key對應的value即可。
public V get(Object key) {
// 若爲null,調用getForNullKey方法返回相對應的value
if (key == null)
return getForNullKey();
// 根據該 key 的 hashCode 值計算它的 hash 碼
inthash = hash(key.hashCode());
// 取出 table 數組中指定索引處的值
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next){
Object k;
//若搜索的key與查找的key相同,則返回相對應的value
if (e.hash== hash&& ((k = e.key) == key || key.equals(k)))
returne.value;
}
returnnull;
}
七、HashMap在Java1.7與1.8中的區別
在JDK1.7之前
使用一個Entry數組來存儲數據,用hash()和indexFor(int h, intlength)來決定key會被放到數組裏的位置,如果hashcode相同,或者hashcode取模後的結果相同(hash collision),那麼這些key會被定位到Entry數組的同一個格子裏,這些key會形成一個鏈表。
在hashcode特別差的情況下,比方說所有key的hashcode都相同,這個鏈表可能會很長,那麼put/get操作都可能需要遍歷這個鏈表,也就是說時間複雜度在最差情況下會退化到O(n)。
在JDK1.8中
使用一個Node數組來存儲數據,但這個Node可能是鏈表結構,也可能是紅黑樹結構。如果插入的key的hashcode相同,那麼這些key也會被定位到Node數組的同一個格子裏。
如果同一個格子裏的key不超過8個,使用鏈表結構存儲。
如果超過了8個,那麼會調用treeifyBin函數,將鏈表轉換爲紅黑樹。
那麼即使hashcode完全相同,由於紅黑樹的特點,查找某個特定元素,也只需要O(log n)的開銷,也就是說put/get的操作的時間複雜度最差只有O(log n)
可參考:http://blog.csdn.net/xs521860/article/details/59484291詳解