Map在開發過程當中使用頻率很高的數據結構,Map是Key-value
鍵值對映射的抽象接口,該映射不包括重複的鍵,既一個鍵對應一個值。HashMap
、HashTable
、ConcurrentHashMap
都是Java Collection Framework的重要成員。Map接口提供三種collection視圖,容許以鍵集(keySet())、值集(values())或鍵-值映射關係集(entrySet())的形式查看某個映射的內容。 node
咱們知道數組的儲存方式是在內存上分配固定的連續的空間,尋址速度快(查詢速度快),時間複雜度爲O(1)
,可是在插入、刪除元素時候須要移動數組的元素,因此插入、刪除時候速度慢,時間複雜度爲O(n)
。鏈表的存儲方式在內存上是不連續的,每一個元素都保存着下個元素的內存地址,經過這個地址找到下個元素,因此鏈表在查詢的時候速度慢,時間複雜度爲O(n)
,在插入和刪除的時候速度快,時間複雜度爲O(1)
。
若是咱們想要一個數據結構既查詢速度快,插入和刪除速度也要快,那咱們應該怎麼作呢?這時哈希(Hash)表就應時而生了,經過哈希函數計算出鍵在哈希表中指定的儲存位置(注意這裏的儲存位置是在表中的位置,並非內存的地址),稱爲哈希地址,而後將值儲存在這個哈希地址上,而後經過鍵就能夠直接操做到值,查詢、插入、刪除等操做時間複雜度都是O(1)
。
既然是鍵經過哈希函數計算出儲存位置,那麼哈希函數的好壞直接影響到哈希表的操做效率,如會出現浪費儲存空間、出現大量衝突(即不一樣的鍵計算出來的儲存位置同樣)。數組
哈希函數能夠將任意長度的輸入映射成固定長度的輸出,也就是哈希地址 哈希衝突是不可避免的,經常使用的哈希衝突解決辦法有如下2種方法。安全
負載因子 = 填入哈希表中的元素個數 / 哈希表的數組長度bash
HashMap
採用上述的拉鍊法解決哈希衝突.HashMap是非線程安全的,容許鍵、值爲null
,不保證有序(好比插入的順序),也不保證順序不隨時間變化(哈希表加倍擴容後,數據會有遷移)。
咱們建立個HashMap運行看看數據結構
HashMap<String, Integer> map = new HashMap();
map.put("語文", 1);
map.put("數學", 2);
map.put("英語", 3);
map.put("歷史", 4);
map.put("政治", 5);
map.put("地理", 6);
map.put("生物", 7);
map.put("化學", 8);
複製代碼
table
,
size
,
threshold
,
loadFactor
,
modCount
。
Entry[]
數組類型,而Entry
其實是一個單向鏈表,哈希表的鍵值對都是儲存在Entry
數組中,每一個Entry
對應一個哈希地址,這裏的Entry
即常說的桶快速失敗機制:對於線程不安全(注意是線程不安全的集合纔有這個機制)的集合對象的迭代器,若是在使用迭代器的過程當中有其餘的線程修改了集合對象的結構或者元素數量,那麼迭代馬上結束,迭代器將拋出
ConcurrentModificationException
。app
HashMap有4個構造函數,以下:函數
//無參構造函數,負載因子爲默認的0.75,HashMap的容量(數組大小)默認容量爲16
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
//指定HashMap容量大小的構造函數 負載因子爲默認的0.75
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//指定HashMap容量大小和負載因子的構造函數
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);
}
//包含子Map的構造函數,負載因子爲默認的0.75
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
複製代碼
爲何負載因子默認是0.75?按照官方給出的解釋是,當負載因子爲0.75時候,
Entry
單鏈表的長度幾乎不可能超過8
(到達8的機率是0.00000006),做用就是讓Entry
單鏈表的長度儘可能小,讓HashMap的查詢效率儘量高。ui
因爲當HashMap的大小(即size)大於初始容量(capacity)時候,HashMap就會擴大一倍,因爲不少時候並不須要擴大這麼多,因此當咱們知道咱們的數據的大小的時候,就能夠在HashMap初始化的時候指定容量(數組大小)。
須要注意的是,咱們指定的容量必須是2的冪次方,即便咱們傳入的容量不是2的冪次方,源碼中也會將容量轉成2的冪次方,好比咱們傳入的是5,最終的容量是8。this
爲何容量必定要是2的冪次方?由於HashMap是數組+單鏈表的結構,咱們但願元素的存放的更均勻,最理想的狀態是每一個
Entry
中只存放一個元素,這樣在查詢的時候效率最高。那怎麼才能均勻的存放呢?咱們首先想到的是取模運算 哈希地址%容量大小,SUN的大師們的想法和咱們的也同樣,只不過他們使用位運算來實現這個運算(位運算效率高),爲了使位運算和取模運算結果同樣,即hash & (capacity - 1) == hash % capacity
,容量(Capacity)的大小就必須爲2的冪次方。spa
在JDK1.8以前hashMap的插入是在鏈表的頭部插入的,本文分析的是JDK1.8源碼,是在鏈表的尾部插入的。
hashCode()
計算出當前鍵值對的哈希地址,用於定位鍵值對在HashMap數組中存儲的下標table
是否初始化,沒有初始化則調用resize()
爲table
初始化容量,以及threshold的值table
數組索引,若是對應的數組索引位置沒有值,則調用newNode(hash, key, value, null)
方法,爲該鍵值對建立節點。這裏思考個問題,當table數組長度變化後,是否是取到的值就不正確了?後面給出分析。這裏簡單分析下爲何不是直接按照哈希地址作數組下標,而是用table數組長度和哈希地址作&運算(i = (n - 1) & hash)(由於數組的大小是2的冪次方,因此這個運算等效於mod 數組大小的運算)計算數組下標,由於哈希地址可能超過數組大小,還有就是爲了讓鍵值對更均勻的分佈的在各個桶(鏈表)中,也由於容量會變因此各個桶(鏈表)中的節點的哈希地址並非相同的,相同的哈希地址也可能分到不一樣的下標。
table
數組索引有節點,且節點的鍵key
和傳入的鍵key
相等,哈希地址和傳入的哈希地址也相等,則將對應的節點引用賦值給e。table
數組索引有節點,且節點的哈希地址和傳入的哈希地址同樣,可是節點的鍵key
和傳入的鍵key
不相等,則遍歷鏈表,若是遍歷過程當中找到節點的鍵key
和傳入的鍵key
相等,哈希地址和傳入的哈希地址也相等,則將對應的value
值更新。不然調用newNode(hash, key, value, null)
方法,爲該鍵值對建立節點添加到鏈表尾部,若是追加節點後的鏈表長度 >= 8,則轉爲紅黑樹onlyIfAbsent
爲true
則不會覆蓋相同key
和相同哈希地址的value
。public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
//若是參數onlyIfAbsent是true,那麼不會覆蓋相同key的值value。若是evict是false。那麼表示是在初始化時調用的
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//tab存放 當前的哈希桶, p用做臨時鏈表節點
Node<K,V>[] tab; Node<K,V> p; int n, i;
//若是當前哈希表是空的,表明是初始化
if ((tab = table) == null || (n = tab.length) == 0)
//那麼直接去擴容哈希表,而且將擴容後的哈希桶長度賦值給n
n = (tab = resize()).length;
//若是當前index的節點是空的,表示沒有發生哈希碰撞。 直接構建一個新節點Node,掛載在index處便可。
//這裏再囉嗦一下,數組下標index 是利用 哈希地址 & 哈希桶的長度-1,替代模運算
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {//不然 發生了哈希衝突。
//e
Node<K,V> e; K k;
//若是哈希值相等,key也相等,則是覆蓋value操做
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;//將當前節點引用賦值給e
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);
//若是追加節點後,鏈表數量》=8,則轉化爲紅黑樹
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;
}
}
//若是e不是null,說明有須要覆蓋的節點,
if (e != null) { // existing mapping for key
//則覆蓋節點值,並返回原oldValue
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//這是一個空實現的函數,用做LinkedHashMap重寫使用。
afterNodeAccess(e);
return oldValue;
}
}
//若是執行到了這裏,說明插入了一個新的節點,因此會修改modCount,以及返回null。
//修改modCount
++modCount;
//更新size,並判斷是否須要擴容。
if (++size > threshold)
resize();
//這是一個空實現的函數,用做LinkedHashMap重寫使用。
afterNodeInsertion(evict);
return null;
}
複製代碼
hashCode()是Object類的一個方法,hashCode()方法返回對象的hash code,這個方法是爲了更好的支持hash表,好比Set、HashTable、HashMap等。hashCode()的做用:若是用equals去比較的話,若是存在1000個元素,你new一個新的元素出來,須要去調用1000次equals去逐個和它們比較是不是同一個對象,這樣會大大下降效率。ashcode其實是返回對象的存儲地址,若是這個位置上沒有元素,就把元素直接存儲在上面,若是這個位置上已經存在元素,這個時候纔去調用equal方法與新元素進行比較,相同的話就不存了,散列到其餘地址上。
table
不爲空,且table
的長度大於0,且根據鍵key
的hashCode()
計算出哈希地址,再根據桶的數量-1和哈希地址作&運算計算出數組的下標,該下標下不爲空(即存有鏈表頭指針)則繼續往下進行,不然返回null
。key
都相同,則返回第一個節點。getTreeNode(hash, key)
,在樹中尋找節點,而且返回。不然遍歷鏈表,找到鍵key
、哈希地址同樣的則返回此節點。public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
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 && // 若是索引到的第一個Node,key 和 hash值都和傳遞進來的參數相等,則返回該Node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) { //若是索引到的第一個Node 不符合要求,循環變量它的下一個節點。
if (first instanceof TreeNode) // 在樹中get
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {// 在鏈表中get
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
複製代碼
table
不爲空,且table
的長度大於0,且根據鍵key
的hashCode()
計算出哈希地址,再根據哈希地址計算出數組的下標,該下標下不爲空(即存有鏈表頭指針)則繼續往下進行,不然執行6`。key
同樣,則將對應的節點引用賦值給node,而後執行4。不然執行3。getTreeNode(hash, key)
在樹中尋找節點而且返回,不然遍歷鏈表,找到鍵key
、哈希地址同樣的節點而後將對應的節點引用賦值給node,而後執行4,不然執行6。node
不爲空(即查詢到鍵key
對應的節點),且當matchValue
爲false
的時候或者value
也相等的時候,則執行5,不然執行6。removeTreeNode(this, tab, movable)
移除相應的節點。不然在鏈表中移除相應的節點,null
。public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
// p 是待刪除節點的前置節點
Node<K,V>[] tab; Node<K,V> p; int n, index;
//若是哈希表不爲空,則根據hash值算出的index下 有節點的話。
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
//node是待刪除節點
Node<K,V> node = null, e; K k; V v;
//若是鏈表頭的就是須要刪除的節點
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;//將待刪除節點引用賦給node
else if ((e = p.next) != null) {//不然循環遍歷 找到待刪除節點,賦值給node
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
//若是有待刪除節點node, 且 matchValue爲false,或者值也相等
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)//若是node == p,說明是鏈表頭是待刪除節點
tab[index] = node.next;
else//不然待刪除節點在表中間
p.next = node.next;
++modCount;//修改modCount
--size;//修改size
afterNodeRemoval(node);//LinkedHashMap回調函數
return node;
}
}
return null;
}
複製代碼
若是存在指定的鍵key
,返回true,不然返回false。
containsKey方法調用的get調用的方法同樣的方法,參考get方法的解析。
public boolean containsKey(Object key) {
return getNode(hash(key), key) != null;
}
複製代碼
分析resize方法,咱們就能夠知道爲何哈希表的容量變化後,仍然能取到正確的值
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
//若是哈希表是空的 則將舊容量置爲0,不然置爲舊哈希表的容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//舊的哈希表的閾值
int oldThr = threshold;
//新的哈希表的容量和閾值 都置爲0
int newCap, newThr = 0;
//若是舊的容量大於0 即不是第一次初始化 是擴容操做
if (oldCap > 0) {
//舊的容量是否大於2的30次冪方(容量的最大值)
if (oldCap >= MAXIMUM_CAPACITY) {
//閾值設置爲Integer的最大值
threshold = Integer.MAX_VALUE;
//返回舊的哈希表(舊的哈希表已經到最大的容量了,不能繼續擴容 因此返回)
return oldTab;
}
//新的哈希表容量的=舊的容量<<1,即新的容量=舊的2倍,若是新的容量小於2的30次冪方(容量的最大值) 且 舊的容量大於等於默認的容量(16)
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//新的哈希表的閾值=舊的哈希表的閾值<<1,既即新的閾值=舊的2倍 擴容table
newThr = oldThr << 1; // double threshold
}
//第一次初始化,若是舊的閾值>0
即HashMap是以傳入容量大小或者傳入容量大小、負載因子的構造函數進行初始化的,閾值thr
eshlod已經在構造函數初始化過了,因此閾值在這裏大於0
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);
}
//新的閾值=0,即執行的是上面的else if (oldThr >
0)(使用帶參數的構造函數初始化),是使用帶參數的構造函數進行的初始化,而且計算出新的
閾值
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
//將舊的哈希表的節點所有從新定位,好比舊的哈希表容量是16,有一個
值a放在數組下標爲0上,如今新的哈希表容量是32,從新定位後值a就被重
新定位到下標爲32上,即新的哈希表的下標爲32儲存值a,簡單來講就是新
的下標=舊的哈希表的下標+新的哈希表的容量,正是由於這個節點的遷移,
因此咱們在hashMapputget操做的時候,在哈希表容量變化後仍讓取到正確
的值,可是也由於這個遷移操做,會消耗不少資源,因此儘可能在建立HashMa
p的時候就估計哈希表的容量,儘可能不要讓他加倍擴容。這裏的遷移也都是
運用的位運算,因此在初始化的時候,桶的數量必須是2冪次方,才能保證
位運算和取模運算結果同樣。
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;
}
複製代碼
咱們能夠運行個例子,調試看看。
HashMap<String, Integer> map = new HashMap();
for (int i = 1; i <= 24; i ++) {
map.put(String.valueOf(i), i);
}
for (int i = 25; i <= 80; i ++) {
map.put(String.valueOf(i), i);
}
複製代碼
咱們以無參構造函數(即哈希表容量默認是16,負載因子默認是0.75)new
一個HashMap,而後調試看看
for
循環,看到
11
保存的下標爲0,
12
保存的下標是1
for
,發現下標爲0的變成了44,下標爲1的變成了45
for
的時候哈希表發生了擴容,而後節點都遷移了,新的下標=舊的下標+新的哈希表的容量
Java HashMap工做原理及實現
Map 綜述(一):徹頭徹尾理解 HashMap
原文地址:https://ddnd.cn/2019/03/07/jdk1.8-hashmap/