前言
哈希表(hash table)也叫散列表,是一種非常重要的數據結構
應用場景之一:緩存技術(比如memcached的核心其實就是在內存中維護一張大的哈希表)
目錄
一、哈希表
數據結構:
1、數組
用一段連續的存儲單元來存儲數據。
知道下標進行查找,時間復雜度為O(1)。
知道value值進行查找,時間復雜度為O(n),因為需要遍歷數組,逐一比對給定關鍵字和數組元素。
對於有序數組,則可采用二分查找,插值查找,斐波那契查找等方式,可將查找復雜度提高為O(logn)。
插入、刪除操作,涉及到數組元素的移動,其平均復雜度也為O(n)。
2、線性鏈表
新增,刪除等操作(在找到指定操作位置后),僅需處理結點間的引用即可,時間復雜度為O(1)。
查找操作需要遍歷鏈表逐一進行比對,復雜度為O(n)。
3、二叉樹
一棵相對平衡的有序二叉樹,對其進行插入,查找,刪除等操作,平均復雜度均為O(logn)。
4、哈希表
不考慮哈希沖突的情況下,添加,刪除,查找等操作,僅需一次定位即可完成,時間復雜度為O(1)。
數據結構的物理存儲結構:
1、順序存儲結構
2、 鏈式存儲結構
哈希表:
哈希表的主干就是數組。利用了數組的特性----根據下標查找某個元素一次定位就可以找到。
在新增或查找某個元素時,我們通過把當前元素的關鍵字傳給哈希函數,然后映射到數組中的某個位置,最后通過數組下標一次定位就可完成操作。
存儲位置 = f(關鍵字)
這個函數的設計好壞會直接影響到哈希表的優劣。
插入、查找操作,如圖:
哈希沖突:
如果兩個不同的元素,通過哈希函數得出的實際存儲地址相同怎么辦?
好的哈希函數會盡可能地保證 計算簡單和散列地址分布均勻,但是,我們需要清楚的是,數組是一塊連續的固定長度的內存空間,再好的哈希函數也不能保證得到的存儲地址絕對不發生沖突。
哈希沖突的解決方案:
1、開放定址法
2、再散列函數法
3、鏈地址法
HashMap即是采用了鏈地址法,也就是數組+鏈表的方式。
二、HashMap實現原理
JDK 8 中,HashMap的主干是一個Node數組。
//該table在第一次使用時初始化,並在必要時進行調整。當分配時,長度總是2的冪。
transient Node<K,V>[] table;
Node是HashMap中的一個靜態內部類
//HashMap.Node是LinkedHashMap.Entry的父類
//LinkedHashMap.Entry是HashMap.TreeNode的父類
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;//對key的hashcode值進行hash運算后得到的值,存儲在Entry,避免重復計算
final K key;
V value;
Node<K,V> next;//存儲指向下一個Node的引用,單鏈表結構
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
HashMap的整體結構如下:
HashMap由數組+鏈表組成的,數組是HashMap的主體,鏈表則是主要為了解決哈希沖突而存在的,如果定位到的數組位置不含鏈表(當前Node的next為null),那么對於查找,添加等操作很快,僅需一次尋址即可;如果定位到的數組位置包含鏈表,對於添加操作,其時間復雜度為O(n),首先遍歷鏈表,存在即覆蓋,否則新增;對於查找操作來講,仍需遍歷鏈表,然后通過key對象的equals方法逐一比對查找。所以,性能考慮,HashMap中的鏈表出現越少,性能才會越好。
HashMap的幾個重要屬性
//實際存儲的key-value鍵值對的個數
transient int size;
//閾值;
//當table分配內存空間后,threshold一般為 capacity*loadFactory
//HashMap在進行擴容時需要參考threshold
int threshold;
//負載因子,代表了table的填充度有多少,默認是0.75,超過了負載,就開始擴容
final float loadFactor;
//用於快速失敗,由於HashMap非線程安全,在對HashMap進行迭代時,如果期間其他線程的參與導致HashMap的結構發生變化了(比如put,remove等操作),需要拋出異常ConcurrentModificationException
transient int modCount;
HashMap有4個構造器,如果用戶沒有給構造器傳入initialCapacity 和loadFactor這兩個參數,會使用默認值 initialCapacity默認為16,loadFactory默認為0.75。
存
在常規構造器中,沒有為數組table分配內存空間(有一個入參為指定Map的構造器例外),而是在執行put操作的時候才真正構建table數組。
map.put("2","ljs");
public V put(K key, V value) {
return putVal(hash("2"), "2", "ljs", false, true);
}
hash("2")
static final int hash(Object key) {
int h;
//key.hashCode()該對象自己的hashcode
//HashMap的哈希函數:(hashcode) ^ (hashcode >>> 16)
//hashcode 與 向右無符號移動16位的自己 異或,一般都等於hashcode的值
// >>> 與 >> 都是右移,>>> 是會把符號位也一起移動,就是說負數用 >>> 后,會成為正數
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
putVal(hash("2"), "2", "ljs", false, true);
/**
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果table數組為空數組{},為table分配實際內存空間;----resize()
//在構造器中沒有指定threshold的話,就是默認的threshold,16
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// (n - 1) & hash key的哈希值 和 數組長度做 與運算,計算出在table數組中的具體下標位置
//該位置沒有數據,就直接插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//該位置有數據,遍歷該數組下標的單鏈表
//找到hash、key相同的,執行覆蓋操作。用新value替換舊value,並返回舊value
//沒有hash、key相同的,插入到鏈表尾部
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//覆蓋操作
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;//保證並發訪問時,若HashMap內部結構發生變化,快速響應失敗
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
resize()
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
//不是第一次resize(),擴容----Threshold * 2
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//雙倍
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//第一次resize(),為table分配內存空間,newCap = 16 ; newThreshold = 0.75*16 = 12
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
通過以上代碼能夠得知,當size大於閾值的時候,需要進行數組擴容,擴容時,需要新建一個長度為之前數組2倍的新的數組,然后將當前的Node數組中的元素全部傳輸過去,擴容后的新數組長度為之前的2倍,所以擴容相對來說是個耗資源的操作。
存儲位置的確定流程:
key.hashcode()-->hash()-->(length - 1) & hash-->最終索引位置,找到對應位置table[i]。
取
map.get("2")
public V get("2") {
Node<K,V> e;
return (e = getNode(hash("2"), "2")) == null ? null : e.value;
}
hash("2")
static final int hash("2") {
int h;
return ("2" == null) ? 0 : (h = "2".hashCode()) ^ (h >>> 16);
}
getNode(hash("2"), "2")
final Node<K,V> getNode(50, "2") {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
取值位置的確定流程:
key.hashcode()-->hash()-->(length - 1) & hash-->最終索引位置,找到對應位置table[i],再查看是否有鏈表,遍歷鏈表,通過key的equals方法比對查找對應的記錄。
注:存數據需要hashcode(),取數據需要equals();hashcode()、equals()是Object的方法,可以按照自己的需求,重寫對象的hashcode() 和 equals() 方法。
三、為何HashMap的數組長度一定是2的次冪?
數組進行擴容,數組長度發生變化,而存儲位置 index = h&(length-1),index也可能會發生變化,需要重新計算index:
將老數組中的數據逐個鏈表地遍歷,扔到新的擴容后的數組中,我們的數組索引位置的計算是通過 對key值的hashcode進行hash函數運算后,再通過和 length-1進行與運算。
1、保證得到的新的數組索引和老數組索引一致
16的二進制表示為 10000,那么length-1就是15,二進制為01111,同理擴容后的數組長度為32,二進制表示為100000,length-1為31,二進制表示為011111。從下圖可以我們也能看到這樣會保證低位全為1,而擴容后只有一位差異,也就是多出了最左位的1,這樣在通過 h & (length-1)的時候,只要h對應的最左邊的那一個差異位為0,就能保證得到的新的數組索引和老數組索引一致(大大減少了之前已經散列良好的老數組的數據位置重新調換)。
2、獲得的數組索引index更加均勻
數組長度保持2的次冪,length-1的低位都為1
3、唯一性
&運算,高位是不會對結果產生影響的,所以只關注低位,如果低位全部為1,那么對於h低位部分來說,任何一位的變化都會對結果產生影響,也就是說,要得到index=21這個存儲位置,h的低位只有這一種組合。
如果不是2的次冪,也就是低位不是全為1此時,要使得index=21,h的低位部分不再具有唯一性了,哈希沖突的幾率會變的更大,同時,index對應的這個bit位無論如何不會等於1了,而對應的那些數組位置也就被白白浪費了