文章目錄
- HashMap基礎
- HashMap減少哈希衝突與解決哈希衝突的方法
- put與get方法
- 擴容
- HashSet
- 總結
- 1.HashMap內部解決哈希衝突總結
- 2.構造函數總結
- 3.hash函數與哈希桶數組對應下標的計算
- 4.put函數總結
- 5.get函數總結
- 6.resize函數總結
- 6.重寫hashcode與equals方法的原因
- 7.HashMap中的key可以是null,value也可以是null。 HashMap最多隻允許一條記錄的鍵爲null,允許多條記錄的值爲null。
- 8.HashMap爲什麼線程不安全
- 9.HashTable與HashMap的區別
- 參考鏈接
HashMap基礎
HashMap的成員變量
靜態變量
1.哈希桶數組的默認長度爲16,同時其長度一定要爲2^n,默認負載係數爲0.75,其最大長度爲2的30次方。
2.鏈表樹化的條件是:哈希桶數組的長度大於等於64且鏈表中節點的個數大於等於8。
3.紅黑樹鏈表化的條件是:樹中節點數小於等於6。
//哈希桶數組的默認長度(16)二進制:10000。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//哈希桶數組的最大長度(2^30)
static final int MAXIMUM_CAPACITY = 1 << 30;
//默認的負載係數
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//樹化的最小鏈表節點數(鏈表節點數要大於等於8且哈希桶數組的長度大於等於64)
static final int TREEIFY_THRESHOLD = 8;
//由樹轉爲鏈表的節點數,當樹中節點數小於該值會轉換爲鏈表。
static final int UNTREEIFY_THRESHOLD = 6;
//樹化的最小哈希桶數組值
static final int MIN_TREEIFY_CAPACITY = 64;
實例變量
1.哈希桶數組存放的數據類型可以是鏈式節點(Node),也可以是樹式節點(TreeNode繼承自Node)。
2.threshold是桶擴充的閾值,這個閾值等於capacity * loadfactor,當鍵值對的數量超過這個閾值時會擴容。
//哈希桶數組
transient Node<K,V>[] table;
//存儲鍵值對的Set,存儲的類爲Map中的內部類
transient Set<Map.Entry<K,V>> entrySet;
//鍵值對的數量
transient int size;
//HashMap結構修改的次數
transient int modCount;
//擴容的閥值,當鍵值對的數量超過這個閥值會產生擴容
int threshold;
//負載因子
final float loadFactor;
鏈式節點
這是鏈式節點的存儲結構,其實是實現了內部接口Entry<K,V>。樹式節點繼承自鏈式節點,這裏不寫出來了。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
HashMap的構造函數
無參數構造函數:構造一個默認容量(16)與默認負載係數(0.75)的HashMap。
/**
* Constructs an empty <tt>HashMap</tt> with the default initial capacity
* (16) and the default load factor (0.75).
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
傳入容量的構造函數,與無參構造函數類似,會將傳入的容量轉化爲大於該值的最小的2的冪次方,並賦值給擴充閾值。
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
傳入容量和負載係數的構造函數,這裏的tableSizeFor方法可以理解爲將傳入的容量轉化爲大於該值的最小的2的冪次方,比如傳入6,就會返回8。可以看到HashMap沒有在構造函數中初始化hash桶數組。
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;
this.threshold = tableSizeFor(initialCapacity);
}
HashMap減少哈希衝突與解決哈希衝突的方法
哈希衝突
比如現在有這幾個key值:5,28,19,15,20,33,12,17,10,而我們的哈希桶數組大小是9,哈希函數爲了簡便起見爲:
H(key)=key%9
會發現H(28)=1,而H(19)=1,這樣就產生了哈希衝突。解決哈希衝突就是HashMap中要做的事情。
HashMap的hash函數與哈希桶數組下標的計算(重要)
HashMap是如何計算key的hash值呢,是通過hash函數。這裏還可以知道HashMap存儲的鍵值對允許key值爲null。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
之後計算對應哈希桶數組的下標是通過這行代碼,其中hash是通過上面的hash函數求出來的哈希值。
&是按位與
i=(n - 1) & hash
爲什麼要無符號右移16位後做異或運算(減少哈希衝突)
第一次看到這裏我也是很奇怪爲什麼hash函數不直接使用key的hashCode,而是使用下面的這行代碼。
>>>是無符號右移
^是異或
(h = key.hashCode()) ^ (h >>> 16);
可以理解爲設計者想要將高低二進制特徵混合,防止哈希桶數組長度較小時,哈希桶數組下標的計算結果只與哈希值的低16位有關,而造成哈希衝突。
以圖中例子可知,h右移16位,相當於把高16位的數移動到了後11位,而在於h自身做異或操作後,原本高16位沒有變化,低16位就變成了低16位與高16位的異或結果,這樣可以將高低位二進制特徵混合起來。
而之所以要在低16位的結果改爲高低位混合的原因在於,哈希桶數組的長度往往不會很大,也就是說除非長度大於2^16+1,纔會用到高16位。這樣會造成的結果是:如果兩個hash值在低16位沒有差別,而差別在高16位,如果低16位結果沒有改變的話,他們計算出的哈希桶數組下標就相同了,很容易出現哈希衝突。而HashMap這種設計方法就是爲了減少哈希衝突。
計算下標的方法:
i=(n - 1) & hash
這就是一個n值爲16的例子,可以看到高16位的二進制特徵都丟失了。
爲什麼桶數組的長度是2^n(減少哈希衝突)
是爲了讓結果更加均勻。比如哈希桶數組的長度爲17,那樣17-1的結果就是16(00010000),可以看到,因爲是與運算,最後計算出的下標值就只與hash值的第五位有關係了,其他值不管爲0或1與運算之後都是0,這樣會造成更大的哈希衝突。
而如果桶數組的長度爲2^n,做完減1操作後,其二進制就有多個1(16-1=15(00001111)),相當於結果與hash值多個位置有關(所有二進制爲1的位置),可以有效地減少哈希衝突。
爲什麼使用&而非%(節省時間)
其實理論上來說兩種方式結果相同,不過按位與的操作會更快。因爲%是算術運算,最終還是會轉換爲位運算、
HashMap解決哈希衝突的方法(重要)
HashMap的常規存儲方式是數組,數組中存放 Node<K,V>的節點,但是爲了防止出現哈希衝突,HashMap使用數組+鏈表+紅黑樹的存儲方式。
HashMap理想的情況是不出現哈希衝突,一個桶中裝一個值。但不幸的是,即使HashMap已經通過上述很多種方式減少哈希衝突,可是哈希衝突還是會出現。HashMap中使用鏈表和紅黑樹來解決哈希衝突。
HashMap會在鏈表的長度大於等於8且哈希桶數組的長度大於等於64時,會將鏈表樹化。紅黑樹是一個自平衡的二叉查找樹,因此查找效率就會從O(n)變成O(logn)。
static final int TREEIFY_THRESHOLD = 8;
static final int MIN_TREEIFY_CAPACITY = 64;
爲什麼不將所有鏈表全部轉化爲紅黑樹呢?
答案在於:
(1)鏈表結構簡單,而紅黑樹結構複雜,在數量少的情況下,未必數組+鏈表的性能比數組+鏈表+紅黑樹差。
(2)第二個是HashMap頻繁的resize(擴容),擴容的時候需要重新計算節點的索引位置,也就是會將紅黑樹進行拆分和重組,這裏涉及到紅黑樹的着色和旋轉,這又是一個比鏈表結構耗時的操作,所以爲鏈表樹化設置一個閥值是非常有必要的。
put與get方法
put方法(重要)
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
這裏會先調用hash方法計算key的哈希值,具體邏輯上文已經講完了,繼續看putVal方法。
putval方法
1.會判斷哈希桶數組是否爲空,爲空就會初始化,哈希桶數組table的初始化不是在構造函數中進行的,而是在第一次put時進行的。
//看似是在給tab賦值,其實主要是判斷table是否爲空,爲空可以去初始化。
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
2.接下來會通過(n-1)&hash計算出傳入的key對應的哈希桶數組下標,具體邏輯上文已經講過了,這裏不再贅述。
i = (n - 1) & hash
接下來將p指向當前下標的哈希桶中的節點。p=null代表當前下標的哈希桶中沒有存Node,也就是沒有發生哈希衝突,因此直接創建一個鏈式節點,並存入對應下標的哈希桶中。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
3.接下來是發生哈希衝突的情況:
注意這裏聲明瞭一個新的節點e,這個節點很重要,後續代碼會根據它是否爲空來判斷本次操作是新增了節點還是替換了原有節點的value值。
Node<K,V> e;
(1)如果哈希桶數組索引處的節點與新加入的節點具有重複的key則直接覆蓋該處節點的value值,同時將e指向p。
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
(2)如果原本存儲在桶中的節點是樹節點,就交由紅黑樹去解決這個哈希衝突,如果插入過程中發現新節點的key與已有的樹中節點衝突則覆蓋該處value。覆蓋的方法是賦值給節點e,在後面判斷節點e是否爲null,不是null則說明需要覆蓋。
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
(3)如果存儲在桶中的節點是鏈式節點。就遍歷整個鏈表去尋找鏈表中是否有和新加入的節點具有重複的key的節點,如果發現key重複的節點的話,就直接將該原節點的value值更改爲新加入節點的value,並結束循環;如果不存在的話,就將新節點插在鏈表的尾部。
注意:如果p.next==null,說明已經找完了鏈表,就要在鏈表的尾部插入新加入的節點。插入後要判斷鏈表長度是否超過樹化的閾值,如果超過,就要將鏈表樹化。(這裏比較的是TREEIFY_THRESHOLD - 1就是將新加入的節點也計算在內)
與紅黑樹中解決哈希衝突類似:如果是插入新的節點,e會爲null,如果是替換原節點的value,則e會指向那個節點。
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;
}
}
(4)如果e!=null,說明只是替換了原節點中的value值,因此,HashMap長度沒有發生改變,因此不需要判斷是否擴充,直接 return oldvalue 即可。
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
4.走到這一步說明,HashMap中添加了新的鍵值對。
如果當前的鍵值對個數已經超過閾值了,就需要去擴容。
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
簡易流程圖
完整代碼:
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)
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))))
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;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
get方法
返回getNode的結果,如果爲空則說明哈希桶數組中不存在與所查找的key相同的節點,否則返回那個節點的值。
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
getNode方法
(1)判斷哈希桶數組是否爲空,爲空直接返回null。
(2)通過tab[(n - 1) & hash]定位到哈希桶數組的下標,判斷對應下標處哈希桶是否沒有存儲節點,沒有則返回null。
(3)有存儲節點就要去對應結構(鏈表或樹)中按順序尋找相同key值的節點,如果有的話就會返回該節點,否則返回null。
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))))
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;
}
擴容
resize函數
1.創建一個oldtab數組指向table,oldcap爲table的長度(table爲空則爲0),oldThr則爲當前的閾值。newCap和newThr是我們將要擴充後的容量與閾值。
2 .首先判斷oldcap是否大於0,大於0則代表本次resize是擴充,否則則說明是初始化。
(1) 擴充的情況:如果原本哈希桶數組的長度已經大於等於允許長度的最大值,則將擴充閾值賦值爲Integer.MAX_VALUE,並直接返回,不進行擴充。否則,就將數組的新容量擴充爲從前容量的兩倍(在擴充後的長度不大於MAXIMUM_CAPACITY的情況),同時將新擴充閾值也擴大兩倍。(通過公式threshold = cap * loadFactor,容量閾值也擴容爲兩倍)。
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
}
(2).初始化的情況:
初始化包括以下兩種情況,
(1)設置了容量的情況
public HashMap(int initialCapacity)
(2)空構造方法的情況
public HashMap()
(2.1)如果設置了容量的話,就會直接讓數組的新容量等於舊閾值。
newCap = oldThr;
(2.2)空構造方法:數組的新容量賦值爲默認大小16,數組的新擴容閾值賦值爲12(16*0.75)。
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
接下來的代碼是用於沒有對新擴容閾值賦值的情況下,要給newThr賦值。
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
3.更新當前閾值,以及以新容量創建新的哈希桶數組。
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
4 .如果舊哈希桶不爲空的話,將舊哈希桶數組中的值映射到新哈希桶數組。具體做法是遍歷整個原哈希桶數組,如果當前下標的哈希桶中存放的節點爲空則繼續尋找,否則就要映射到新哈希桶數組。
這裏主要講哈希桶中存放有節點的情況:
(1)如果是單個節點,直接放入新哈希桶數組對應下標下即可。
//e是當前節點
newTab[e.hash & (newCap - 1)] = e;
(2)如果是樹形結構,則需要進行樹拆分,並映射。
(3)如果是多個節點的鏈表結構,會將鏈表拆分爲兩段,並映射到新數組中。具體映射的下標分別爲:原數組下標,原數組下標+原數組長度。
爲什麼是兩段呢,先舉下面這個例子,假如原始哈希桶數組長度爲16,而擴容後哈希桶數組長度爲32。可以看到對應兩個不同的hash值,但計算出的結果都爲2,因此都會存入下標爲2的哈希桶中,當然他們的結構肯定是鏈表。
當哈希桶數組擴容後,長度爲32,計算結果改變。可以看到hash1對應結果爲18,而hash2對應結果仍然爲2。因此需要將鏈表拆分爲兩段,一段放入新哈希桶數組的2下標處,另一段放入18下標處。
//由於n爲16以及32,所以hash值我們省略至6位(原本爲32位)
hash1: 010010
&
n-1(15):001111
i=2
hash2: 000010
&
n-1(15):001111
i=2
hash1: 010010
&
n-1(31):011111
i=2+16=18
hash2: 000010
&
n-1(31):011111
i=2
那麼HashMap是如何區分究竟將哪個節點放入鏈表1,哪個節點放入鏈表2呢,它是通過如下代碼。可以通過我們上邊的例子看出,hash1的新下標爲 18,而hash2的新下標仍然爲2的原因在於他們的第五位是否爲1。而oldcap恰恰爲010000,可以比較不同hash值的第五位。
e.hash & oldCap
如果e.hash & oldCap==0,則代表第五位不爲0,則該節點應放置在新數組的原下標位置,故加入鏈表1;
e.hash & oldCap==1,則代表第五位爲1,則該節點應放置在新數組的原下標+原數組長度位置,故加入鏈表2。
最後的工作很簡單,就將兩個鏈表分別放置到新數組對應下標中即可。
完整代碼
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
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;
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) {
oldTab[j] = null;
if (e.next == null)
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) {
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函數流程圖
HashSet
HashSet內部是通過一個HashMap來實現的,與HashMap相似,內部的HashMap默認容量爲16,負載因子爲0.75。當然HashSet也可以通過傳入參數來構造。
private transient HashMap<E,Object> map;
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
我們來看幾個常用的函數:
add函數
上文提到的PRESENT是一個工具人,Hashset在add(E e)
的時候,其實調用的就是HashMap的put方法,key是e,而value就是PRESENT。
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
我們知道HashMap的put方法是有返回值的,如果當前key所對應的哈希桶下標處沒有存節點或沒有相同的key的節點,那麼就會插入新的節點。插入新節點會返回null,而替換節點就會返回節點的舊value。
HashSet<Integer>set = new HashSet<>();
System.out.println(set.add(1));%false
System.out.println(set.add(1));%true
HashMap<Integer,Integer>map = new HashMap<>();
System.out.println(map.put(1,2));%null
System.out.println(map.put(1,3));%2
contains方法
HahSet方法也是依賴於HashMap的containsKey方法,該方法最後會通過getNode的返回值來判斷。
public boolean contains(Object o) {
return map.containsKey(o);
}
HashMap的containsKey。
public boolean containsKey(Object key) {
return getNode(hash(key), key) != null;
}
總結
1.HashMap內部解決哈希衝突總結
HashMap內部通過數組+鏈表或數組+紅黑樹解決哈希衝突。HashMap的哈希桶數組存放的是Node<K, V>節點對象,哈希桶數組長度要求必須是2的冪次方。
正常情況下是一個桶中存放一個節點,而發生哈希衝突時,一個桶中存放多個節點,節點可以是鏈表也可以是紅黑樹。鏈表結構簡單,但查找效率低:O(n),紅黑樹結構複雜,查找效率高O(logn),因爲紅黑樹是自平衡的二叉搜索樹。但由於要多次調用resize方法,將哈希桶中的紅黑樹移動到新的哈希桶數組中比較耗時,因此不能當發生哈希衝突時,就存儲爲紅黑樹結構。
因此HashMap中設置了閾值來判斷存儲爲鏈表還是存儲爲紅黑樹。HashMap中鏈表樹化的條件是:鏈表中節點大於等於8且哈希桶數組長度大於等於64;樹化鏈表的條件是:樹中節點小於等於6。
2.構造函數總結
HashMap無參數構造時,默認容量爲16,負載因子爲0.75,擴充閾值爲12(哈希桶數組長度超過該閾值會擴容);
HashMap傳入容量構造時,默認負載因子爲0.75,但擴充閾值則會通過tableSizeFor(int cap)計算(該方法會返回大於傳入值的最小的2次冪,比如傳入6,會返回8)**。與無參數構造不同的是,當第一次構造時,容量 = 舊的擴充閾值,擴充閾值 = 容量*負載因子。
HashMap傳入容量與負載因子時,與傳入容量構造類似,只不過會將負載因子賦值爲傳入的負載因子,傳入容量構造內部也是調用的這個方法。
注意:HashMap在構造函數處不會進行初始化操作。
3.hash函數與哈希桶數組對應下標的計算
首先明確一點:給定一個key值,需要計算其hashcode以便找到其對應哈希桶數組的下標。HashMap通過hash函數返回key值對應的hashcode,並計算其對應哈希桶數組的下標。
//hash函數
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//計算下標
i=hash&(n-1)
(1)key的hashCode值是32位的,而一般哈希桶數組的長度較小(小於等於2^16),在進行與計算時,計算結果不會與hashCode值的高16位有關(兩個不同的hash值,低16位沒有區別,高16位有區別,而當數組長度較小時,計算結果相同,產生哈希衝突)。爲了防止這種情況,hash函數返回的是hashCode值與hashCode值右移16位的異或結果。這樣做後,高16位不變,低16位變爲高16位與低16位的異或值,這樣可以有效地避免上述的哈希衝突。
(2)i=hash&n-1計算下標的原因:n爲2的n次方,以n=16舉例,n-1=15,15的二進制爲01111,而16的二進制爲10000,與計算只與1位有關,那麼如果是hash&n,結果只與hash值的第五位有關,而hash&n-1,結果就與第一、二、三、四位有關,因此,可以減少一些哈希衝突。
4.put函數總結
HashMap在構造函數中不會初始化,而是在第一次put時初始化。
put函數調用putVal方法,傳入key與value的同時,也傳入通過hash函數計算出來key的hash值。
put函數首先會判斷哈希桶數組是否爲空,爲空會調用resize進行初始化;接下來會通過hash&(n-1) 計算當前hash值對應的哈希桶數組下標。
如果沒有出現哈希衝突(當前下標處哈希桶沒有存儲節點),則直接創建新節點並把新節點放入哈希桶中即可。
如果發生哈希衝突,則需要遍歷樹狀結構/鏈表結構,如果其中存在與當前傳入key值的hash相同且通過equals比較相等的節點,則覆蓋該節點的value,並結束遍歷,否則,將該節點插入到鏈表/樹狀結構的末尾。
最後判斷是否新增了節點,如果新增節點,要判斷是否需要擴容;沒有新增節點(只是替換了value值),就不需要判斷,直接返回。
5.get函數總結
get函數調用getNode函數,查找過程與put過程類似,getNode函數會返回節點或返回null,最終由get函數返回節點的value或null。
首先通過hash&n-1計算出哈希桶數組的下標,之後去判斷當前桶中是否存有節點,沒有存則直接返回null,否則繼續尋找。
如果當前哈希桶存儲的只有一個節點,直接比較key是否相同,如果相同則直接返回該節點;如果存儲的是一個鏈表或紅黑樹,則遍歷整個結構,去判斷是否有key相同的節點,有則返回該節點,否則返回null。
6.resize函數總結
調用該函數有兩種情況:(1)初始化;(2)擴容
(1)初始化:分爲HashMap是設置容量構造和HashMap是無參數構造。設置容量構造,已經設置了閾值,直接將數組新容量賦值爲該閾值,新擴充閾值設爲新容量*負載因子。無參數構造,將數組新容量賦值爲16,新擴充閾值設置爲12。
(2)擴容:如果舊數組容量已經超過當前允許最大長度,則將擴充閾值設置爲Integer.MAX_VAULE,並結束。否則,將數組新容量擴充至舊數組容量的2倍,並將數組新擴充閾值也擴充至2倍。
接下來會創建新哈希桶數組,需要將舊數組中的節點映射至新數組。
遍歷舊數組,對應每個位置的哈希桶,如果桶中存在節點,則根據節點類型進行處理。
如果只有一個節點,則直接把該節點放置到新數組的[hash&(newcap-1)]位置;
如果是樹形結構,則將樹拆分並映射到新數組中;
如果是多個節點的鏈表,則將鏈表拆分爲2個鏈表,分別放置在新數組的[原下標]處與[原下標+原數組長度]處。具體節點放置在哪裏根據hash&oldcap是否等於0決定,等於0的放置在原下標,大於0的放置在原下標+原數組長度處。
分爲兩個鏈表的原因如下:
//由於n爲16以及32,所以hash值我們省略至6位(原本爲32位)
hash1: 010010
&
n-1(15):001111
i=2
hash2: 000010
&
n-1(15):001111
i=2
//而n擴充至32後,計算結果就改變了,變爲:2和2+16(原數組長度)
hash1: 010010
&
n-1(31):011111
i=2+16=18
hash2: 000010
&
n-1(31):011111
i=2
6.重寫hashcode與equals方法的原因
如果HashMap中的使用自定義的類作爲key的話,就需要重寫該類的hashCode方法與equals方法。
原因如下:HashMap在判斷key是否相同,會通過hashCode方法與equals方法共同判斷。
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
但equals方法和hashCode方法都是默認調用Object類的。Object中的equals方法是對比兩個對象的引用。Object中的hashCode則會默認返回對象的內存地址。
由於是兩個對象,因此引用(內存地址)肯定不同,因此HashMap就會默認這是兩個key。
Student stu1 = new ArrayListDemo().new Student("Mrlj", "B");
Student stu2 = new ArrayListDemo().new Student("Mrlj", "B");
HashMap<Student, String> hMap = new HashMap<>();
所以我們自定義的類必須重寫equals與hashcode方法,定義自己的規則。
@Override
public boolean equals(Object o) {
if (o instanceof Student) {
Student stu = (Student)o;
return (name+sex).equalsIgnoreCase(stu.getName().trim()+stu.getSex().trim());
}
return false;
}
@Override
public int hashCode() {
return (name+sex).hashCode();
}
7.HashMap中的key可以是null,value也可以是null。 HashMap最多隻允許一條記錄的鍵爲null,允許多條記錄的值爲null。
當key是null的話,會返回0,那麼也就是默認存儲到哈希桶數組的首個位置。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
8.HashMap爲什麼線程不安全
1.7的resize
簡單來說,由於之前的resize考慮將將原哈希桶數組中的節點逆序存放在新哈希桶數組中,所以當兩個線程同時進行resize操作時,會導致產生環形鏈路。
具體代碼如下:
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
單線程情況下:
多線程情況下,同時resize就會出現環路,當我們使用get操作時就會出現死循環。
假如當前情況:線程1在執行resize操作時,只執行到e指向3,而next指向7。之後就到線程二操作,線程二將鏈表逆序存儲(7->3)。
然後切換回線程1,首先將e插入線程1的哈希桶數組下標3處,然後指向e=next
,(next是7)也就是繼續去處理節點7。
處理節點7時,節點7的next是節點3,首先將節點7插入哈希桶數組下標3處(7.next = newTable[3]; newTable[3] = 7;
),之後讓e指向next(e=next
),代表接着處理節點3。
處理節點3,首先3.next=null
,也就是本次會結束循環,然後將節點3插入到3的位置處,並將3.next=7
。**這樣就形成環路,當使用get操作時會進入死循環。
1.8的put
當兩個線程同時調用put,且key所計算出的hash值相等時,會出現數據丟失的情況,後執行線程所創建的節點會覆蓋掉前一個線程創建的節點。
例子:以當前hash值對應的哈希桶下標沒有存儲節點,可以直接放入,也就是調用newNode創建新節點。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
那麼假設我們線程1和線程2都進入當前的判斷,並都在創建新節點。那麼後執行線程所創建的節點就會覆蓋前一個線程創建的節點,而造成數據丟失的問題。
9.HashTable與HashMap的區別
首先HashTable是線程安全的,而HashMap是線程不安全的。HashTable幾乎所有的public方法都是synchronized。
其次HashMap允許key和value爲null,而HashTable不允許。HashTable遇到爲null的時候會直接報NullPointerException