java HashMap類
HashMap主要實現了Map介面,本文主要介紹HashMap的幾個方法,如果涉及到原始碼,都是基於jdk11的
行文結構
目錄
1雜湊表和連結串列
介紹幾個概念:
陣列:
採用一段連續的儲存單元來儲存資料.對給定下標的查詢,時間複雜度為O(1),對給定值的查詢,時間複雜度為O(n),n為陣列長度,因為要遍歷整個陣列,依次比較給定值和陣列中的各元素,陣列實現查詢和修改快,實現增加和刪除慢,比如除了在尾部增加和刪除,在其他地方的增加和刪除,其他元素都會響應移動,所以慢點
線性連結串列:
連結串列可以看成是一根斷了的自行車鏈條,連結串列上的每個節點的儲存地址是不連續的,連結串列的增加和刪除操作快,時間複雜度為O(1),而查詢慢,時間複雜度為O(n),原因是增加和刪除的時候,只需要將新節點加入到兩個連結串列的節點之間,或者將舊節點從兩個連結串列節點之間刪除,將兩個連結串列的指向關係重新寫下就好了
二叉樹
二叉樹有多種結構,比如平衡二叉樹,對當前節點來說,節點左邊的元素總比節點右邊的元素要大,對其插入,查詢,刪除操作的平均時間複雜度都是O(logn)
雜湊表
雜湊表` 是以一種容易找到它們的方式儲存的項的集合。雜湊表的每個位置,通常稱為一個槽,可以容納一個項,並且由從 0 開始的整數值命名。例如,我們有一個名為 0 的槽,名為 1 的槽,名為 2 的槽,以上。最初,雜湊表不包含項,因此每個槽都為空。我們可以通過使用列表來實現一個雜湊表,每個元素初始化為`null`.
hash的方式是通過雜湊的方式將元素均勻分佈到hash表中.即對給定關鍵字比如key值經過hash函式計算後,得到它要儲存的地址
給定項的集合,將每個項對映到唯一槽的雜湊函式被稱為完美雜湊函式。如果我們知道項和集合將永遠不會改變,那麼可以構造一個完美的雜湊函式。不幸的是,給定任意的項集合,沒有系統的方法來構建完美的雜湊函式。幸運的是,我們不需要雜湊函式是完美的,仍然可以提高效能。總是具有完美雜湊函式的一種方式是增加散列表的大小,使得可以容納項範圍中的每個可能值。這保證每個項將具有唯一的槽。雖然這對於小數目的項是實用的,但是當可能項的數目大時是不可行的。即便hash表中的位置沒有佔滿,雜湊函式還是可能計算出兩個不同的元素的hash值相同的,
每個元素要想放入到雜湊表,首先要通過hash函式計算它的hash值,當兩個項雜湊到同一個槽時,我們必須有一個系統的方法將第二個項放在散列表中。這個過程稱為衝突解決。
在不考慮雜湊衝突的情況下,新增,刪除,查詢等操作僅需要一次計算hash表中位置,即可定位完成操作,時間複雜度為O(1)
解決衝突的一種方法是查詢散列表,嘗試查詢到另一個空槽以儲存導致衝突的項。一個簡單的方法是從原始雜湊值位置開始,然後以順序方式移動槽,直到遇到第一個空槽。注意,我們可能需要回到第一個槽(迴圈)以查詢整個散列表。這種衝突解決過程被稱為開放定址,因為它試圖在散列表中找到下一個空槽或地址。通過系統地一次訪問每個槽,我們執行稱為線性探測的開放定址技術。
然而,在HashMap中,解決雜湊衝突採用的是陣列+連結串列/紅黑樹的方式,即如果hash值相同,那麼對應hash表的那個槽處成為一個連結串列,往連結串列上增加元素.,那位可能會說,既然hash表上可以有連結串列,連結串列的長度是無限的,初始化HashMap後,不需要再擴容了,理論上初始化的HashMap的雜湊表長度是16,是可以儲存無限多元素,實際上如果連結串列/紅黑樹過大,查詢,增加,刪除等操作需要遍歷連結串列/紅黑樹的話,效率就變低了,所以容量的使用達到一定量了,還是擴容吧
畫個圖,說明下HashMap的儲存樣例
2HashMap的實現原理 (原始碼角度)
開始前,先說明幾個名詞
陣列結構的每一個槽稱為桶
桶中存放的每一個數據稱為bin
size,Map中存放的鍵值對個數,包括陣列中的元素和每個桶中(包括連結串列或者紅黑樹中)元素的總和
capacity,HashMap中桶的數量
loadFactor,填充因子,也叫裝載因子,計算HashMap中實時裝載因子的公式為size/capacity(待驗證)
threshold: 當HashMap的size大於threshold時會執行resize擴容操作
2.1先看一些成員變數,即屬性:
* The default initial capacity - MUST be a power of two.
//預設的初始化容量即陣列長度必須是2的多少次冪,在這裡3效率要高是1左移4,即16,左移比直接乘效率高
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大容量1*(2^30)
static final int MAXIMUM_CAPACITY = 1 << 30;
//預設的填充因子是0.75,即達到現有容量的0.75倍即開始擴容
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 這是一個閾值,當桶(bucket)上的連結串列元素個數大於這個值時會轉成紅黑樹,put方法的程式碼裡有用到
static final int TREEIFY_THRESHOLD = 8;
// 擴容時,如果一個桶中的元素個數小於這個值,轉化為連結串列
static final int UNTREEIFY_THRESHOLD = 6;
// 樹形化和擴容的選擇閾值,只有陣列的大小超過這個閾值,單個桶才可以被轉換成樹而不是連結串列(
陣列長度小於這個值時,應該使用resize擴容,增加表的長度而不是增加桶的深度)
//這個值最少是TREEIFY_THRESHOLD的4倍,以避免擴容和樹化之間產生衝突
static final int MIN_TREEIFY_CAPACITY = 64;
//陣列的長度,即表的長度
transient Node<k,v>[] table;
transient Set<map.entry<k,v>> entrySet;
// 存放元素的個數,注意這個不等於陣列的長度。
transient int size;
// 每次擴容和更改map結構的計數器
transient int modCount;
// 臨界值 當實際大小(容量*填充因子)超過臨界值時,會進行擴容
int threshold;
// 填充因子
final float loadFactor;
2.2構造方法:
構造方法,初始化了一些屬性
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
//上述三個構造方法都呼叫下面這個構造方法
//下面這個構造方法大部分都在判斷傳入的值是否合法,看最後一行就行了
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
//此時table還未分配到記憶體,threshold就是將要分配的陣列大小
this.threshold = tableSizeFor(initialCapacity);
}
//這個函式的返回值是2的n次方,且返回值大於等於cap(構造方法中傳入的容量)
static final int tableSizeFor(int cap) {
int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
構造方法保證初始化的陣列長度比傳入值大且最接近2的n次冪的一個數,比如傳進去100,構造的陣列長度為128
HashMap的實現原理,
為什麼hashMap的長度一定要是2的冪?
我們知道往HashMap中新增一個元素時1呼叫元素自身的hashCode方法,獲取hashCode值,儘量將值雜湊開,再呼叫hash函式,
static final int hash(Object key) {
int h;
//使用hashCode的值與(hashCode的值無符號右移16位)做異或操作
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//hashCode是類繼承自Object的一個重寫的方法,返回的值儘量雜湊開
//(h>>>16)為了避免hash碰撞(hash collisons)將高位分散到低位上了,這是綜合考慮了速度,效能等各方面因素之後做出的
hash裡面做了一件事,判斷key是否為空,為空返回hash值為0,不為空返回hashCode值與右移16位的hashCode值的異或值,目的還是將hash值均勻雜湊,下一步是要查詢索引,用到了hash值,看下一行程式碼,
if ((p = tab[i = (n - 1) & hash]) == null) //計算索引,判斷索引處的桶是否為空
計算索引的方法為i=(n-1)&hash
我們知道n為hashMap的容量即桶的數量,如果n為2的冪,(n-1)&hash保證獲取的索引值在陣列範圍內,如下圖,hashCode值為一個大數值,經過hash運算後,再與n-1位與運算,得到的索引就在陣列長度範圍內
如果多個key計算出來的索引值一樣,那就都放入同一個位置的桶中,插入到連結串列或者紅黑樹中
查詢也是相似流程,定位到索引後,如果bucket的節點的key不是我們需要的(也就是發生了衝突),則通過keys.equals()在鏈中比較,時間複雜度為O(1)+O(n)。jdk8之後改為了利用紅黑樹替換連結串列,這樣複雜度就變成了O(1)+O(logn)了
3HashMap的put,get方法
put:
1、對key的hashCode()做hash,然後再計算index; 如果沒碰撞直接放到桶bucket裡;如果碰撞了,以連結串列的形式存在同buckets裡,如果索引處的桶的連結串列長度大於閾值,(預設為8),就將連結串列轉成紅黑樹,(轉成紅黑樹是在陣列長度大於64時才能轉),如果節點已經存在同一個key值就替換old value(保證key的唯一性);如果桶bucket的個數超過(load factor * current capacity),就擴容。
put方法原始碼:
public V put(K key, V value) {
// 對key的hashCode()做hash運算
return putVal(hash(key), key, value, false, true);
}
putVal方法原始碼:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
////判斷陣列表是否為空,如果為空進行表空間擴容初始化,第一次新增元素時用到
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
//key的hash值與(陣列table的長度-1)進行相與得出在陣列中的位置(與取餘一樣,但這樣效率高),如果為空說明陣列上這個位置沒被佔用,建立一個Node內部類節點賦值給陣列。
tab[i] = newNode(hash, key, value, null);
else {//如果在該陣列位置已經有值
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//判斷原陣列儲存的是否同一個key元素,如果是,通過變數e記錄下該位置,在下面把該元素的value替換為新值
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) {
//如果遍歷完連結串列後,還沒找到相同key的元素,說明該連結串列沒有原值,new一個node新增到連結串列
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//如果遍歷的連結串列長度大於=8,嘗試轉換為樹形結構
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
//如果遍歷過程中找到相同key的元素,e記錄位置後跳出迴圈
break;
p = e;
}
}
if (e != null) { // existing mapping for key
////如果e不為空說明找到原來的key,把原來node上的值賦予新值
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
////新增一個元素後size加1,如果目前存在元素數量大於threshold=capacity*loadFactor後進行擴容,
resize();
afterNodeInsertion(evict);
return null;
}
2 get方法:
呼叫key的hashCode()方法計算其hashcode值,再對key的hashCode值做hash運算,再計算index;如果該 index 對應的桶沒有元素,則直接返回 null;index對應的桶中有元素,判斷桶中第一個節點的key是否跟get(key)的key值相同,相同則返回此位置的元素
不同,則對該桶下的樹結構或者連結串列結構查詢,找到則返回元素的value值,找不到則返回null
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
getNode方法的原始碼:
final Node<K,V> getNode(int hash, Object key) {
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))))
// table陣列的這個桶的第一個節點正好是取的值
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
//如果是樹形結構通過樹形方法遍歷取node
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;
}
4HashMap的擴容
當HashMap中的元素個數超過陣列大小*負載因子時,就會進行陣列擴容,loadFactor的預設值為0.75.預設情況下,陣列大小為16,那麼當HashMap中元素個數超過16*0.75=12的時候,就把陣列的大小擴充套件為 2*16=32,即擴大一倍,然後重新計算每個元素在陣列中的位置,而這是一個非常消耗效能的操作,所以如果我們已經預知HashMap中元素的個數,那麼預設元素的個數能夠有效的提高HashMap的效能。
擴容時對原有元素的處理,桶中沒有元素的,不處理,桶中只有一個元素節點,重新計算index(計算方式為hash&(newCap-1)),桶中有多個節點,因為capcity總是2的冪,擴容變成原來的2倍,從二進位制來看,只是向左移動一位,此時不會重新計算所有index,而是看看原來的hash值新增的那個bit位是0還是1,(因為容量擴大了一倍,因此影響結果的是hash之前沒有參與運算的最右側位值,通過 hash & oldCap 便能得到),0的話索引沒變,1的話索引變為"原索引+oldCap".
resize函式原始碼:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//如果舊陣列為空,舊錶容量為0,舊錶不為空的話,舊錶容量為舊陣列的長度
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) { //如果舊錶容量大於0
if (oldCap >= MAXIMUM_CAPACITY) {
////判斷舊錶容量是否為最大容量,如果是最大容量,不擴容
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//舊錶容量*2以後還沒達到最大容量,加倍
newThr = oldThr << 1; // double threshold
} //如果舊錶容量等於0
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr; ////使用預設設定新增容量為16
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) { //如果舊的陣列不為空,要把就的陣列的值遍歷放入到新的陣列表
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) { //如果舊錶第j個位置上的桶不為空
oldTab[j] = null;
if (e.next == null)
//如果舊陣列這個位置儲存的桶裡是單個node物件沒有連結串列,
//直接把該node的hash值和新的table-1進行相與取得新的儲存位置
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
//如果舊陣列位置的這個桶裡不是單節點,如果為樹形結構,按照樹形結構遍歷
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order//否則是連結串列結構連結串列
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
//(e.hash & oldCap)判斷mask範圍在高位多1bit是否有值,如果有值新位置為(原索引+oldCap)否則為原索引位置
if (loTail == null)
loHead = e;//找連結串列頭
else
loTail.next = e;//指向下一個
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead; //新增到原索引位置,指向連結串列頭
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;//新增到(舊索引+舊錶長度)位置
}
}
}
}
}
return newTab;
}
resize後新索引位置圖解:根據原始碼分析
if ((e = oldTab[j]) != null) { //如果舊錶第j個位置上的桶不為空
oldTab[j] = null;
if (e.next == null)
//如果舊陣列這個位置儲存的桶裡是單個node物件沒有連結串列,
//直接把該node的hash值和新的table-1進行相與取得新的儲存位置
newTab[e.hash & (newCap - 1)] = e;
上面幾行程式碼(resize方法裡的)說了一件事,如果舊陣列中莫個桶中只有一個元素,那這個元素在新陣列中的索引值為e.hash&(newCap-1),
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
//(e.hash & oldCap)判斷mask範圍在高位多1bit是否有值,如果有值新位置為(原索引+oldCap)否則為原索引位置
if (loTail == null)
loHead = e;//找連結串列頭
else
loTail.next = e;//指向下一個
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead; //新增到原索引位置,指向連結串列頭
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;//新增到(舊索引+舊錶長度)位置
}
}
上面幾行程式碼(resize方法裡的)做了一件事,處理桶中非單個元素的事情,if ((e.hash & oldCap) == 0) 這行計算的是e.hash&(oldCap),而不是之前咱們計算索引的hash&(n-1),(注:n和oldCap都表示舊陣列的長度,把高位遮蔽),這行計算出來的就是hash值與原陣列長度相與的結果的高位,咱們知道地位的結果不會變,高位為1,重新計算出來的索引值比舊陣列中的索引值多了OldCap,如果為0,計算出來在新陣列的索引值也不會變的
樹化函式:
putVal函式中
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
//如果一個桶中的元素個數大於8,會呼叫此函式,判斷能否將連結串列轉換為紅黑樹,如不能樹化則進行resize擴容操作
final void treeifyBin(Node<K, V>[] tab, int hash) {
// e是hash值和陣列長度計算後,得到連結串列的首節點,
int n, index;
Node<K, V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 如果元素陣列長度已經大於等於了 MIN_TREEIFY_CAPACITY,這個桶bucket就要轉換成樹形結構
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K, V> hd = null, tl = null;
do {
TreeNode<K, V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
參考:https://blog.csdn.net/dhfzhishi/article/details/78173191
https://www.jianshu.com/p/f2361d06da82
https://blog.csdn.net/weixin_39220472/article/details/80459364
|
Options : History : Feedback : Donate | Close |