HashMap多是咱們最常常用的Map接口的實現了。話很少說,咱們先看看HashMap類的註釋:html
基於哈希表的Map接口實現。java
這個實現提供了全部可選的映射操做,並容許空值和空鍵。(HashMap類與Hashtable大體至關,只是它是不一樣步的,而且容許爲null)node
這個類對映射的順序不作任何保證;特別是,它不保證順序將隨着時間的推移保持不變。
這個實現爲基本操做(get和put)提供了恆定的時間性能,假設hash函數在bucket中適當地分散了元素。集合視圖上的迭代所需的時間與HashMap實例的「容量」(bucket的數量)加上其大小(鍵值映射的數量)成比例。所以,若是迭代性能很重要,那麼不要將初始容量設置得過高(或者負載係數過低),這一點很是重要。
HashMap的實例有兩個影響其性能的參數:初始容量和負載因子。capacity是哈希表中的bucket數,初始容量就是建立哈希表時的容量。加載因子是一個度量哈希表在容量自動增長以前能夠達到的完整程度。當哈希表中的條目數超過加載因子與當前容量的乘積時,哈希表將從新哈希(即重建內部數據結構),使哈希表的存儲桶數大約爲原來的兩倍。
通常來講,默認的負載係數(.75)在時間和空間成本之間提供了很好的折衷。較高的值會減小空間開銷,但會增長查找開銷(反映在HashMap類的大多數操做中,包括get和put)。在設置初始容量時,應考慮地圖中的預期條目數及其荷載係數,以儘可能減小再灰化操做的次數。若是初始容量大於最大入口數除以負載係數,則不會發生再吹灰操做。
若是要在一個HashMap實例中存儲許多映射,那麼以足夠大的容量建立它將使映射的存儲效率更高,而不是讓它根據須要執行自動從新緩存以增長表。請注意,使用具備相同hashCode()的多個鍵確定會下降任何哈希表的性能。爲了改善影響,當鍵是可比較的時,這個類可使用鍵之間的比較順序來幫助打破聯繫。
請注意,此實現不是同步的。若是多個線程同時訪問一個哈希映射,而且至少有一個線程在結構上修改了該映射,則它必須在外部同步。(結構修改是指添加或刪除一個或多個映射的任何操做;僅更改與實例已包含的鍵相關聯的值不是結構修改。)這一般是經過對天然封裝映射的對象進行同步來完成的。若是不存在這樣的對象,則應該使用集合.synchronizedMap方法。最好在建立時執行此操做,以防止意外的不一樣步訪問映射:算法
Map m = Collections.synchronizedMap(new HashMap(...));
數組注意,迭代器的fail-fast行爲不能獲得保證,由於通常來講,在存在不一樣步的併發修改時,不可能作出任何明確保證。Fail fast迭代器在盡最大努力的基礎上拋出ConcurrentModificationException。所以,編寫一個依賴這個異常來保證其正確性的程序是錯誤的:迭代器的fail-fast行爲應該只用於檢測bug。緩存
如下是HashMap的類關係:安全
HashMap實現了Map接口,並繼承 AbstractMap 抽象類,其中 Map 接口定義了鍵值映射規則。和 AbstractCollection抽象類在 Collection 族的做用相似, AbstractMap 抽象類提供了 Map 接口的骨幹實現,以最大限度地減小實現Map接口所需的工做。數據結構
對於HashMap,咱們關注六個問題:多線程
既然HashMap叫這個名字,那他的實現必然是基於哈希表的,關於哈希表我在數據結構與算法(十):哈希表已有介紹。簡而言之,哈希表就是一種結合數組與鏈表的一種數據結構,藉助哈希算法快速獲取元素下標以實現高效查找。併發
關於HashMap的底層的數據結構,咱們須要瞭解兩個成員變量以及一個內部類:
transient Node<K,V>[] table;
:桶容器Node<K,V>
:entrySet
使用的,基於Map.Entry<K,V>
接口實現的節點類,也就是同容器中的鏈表畫圖描述一下就是:
咱們知道哈希表解決哈希衝突的方式有開放地址法和分離鏈表法,這裏很明顯使用的是分離鏈表法,也就是俗稱的拉鍊法。
當咱們存儲一個鍵值對的時候,會經過哈希算法得到key對應的哈希值,經過哈希值去找到在桶中要存放的位置的下標,而有時候不一樣的key會計算出相同的哈希值,也就是哈希碰撞,那麼節點就會接在第一個節點的身後造成一條鏈表。當查找的時候先經過key計算獲得哈希值找到鏈表,而後再遍歷鏈表找到key,所以若是哈希碰撞嚴重,會致使鏈表變的很長,會影響到查找效率。
按這角度思考,若是桶數組很大,那麼一樣的哈希算法能獲得的位置就更多,換句話說就是發生哈希碰撞的機率就越小,可是過大的桶數組又會浪費空間,因此就後面提到的擴容算法來動態的調整容量。
另外,咱們知道在JDK7中HashMap底層實現只是數組+鏈表,而到了JDK8就變成了數組+鏈表+紅黑樹。
紅黑樹是一種複雜的樹結構,這裏咱們簡單的理解爲一種具備二叉排序樹性質和必定平衡二叉樹性質(不要求絕對平衡以免頻繁旋轉)的二叉樹。
咱們知道發生哈希碰撞的節點會在桶中造成鏈表,而當鏈表上的元素超過8個的時候就會轉變成紅黑樹。這是由於一樣深度的狀況下,樹能夠儲存比鏈表更多的元素,而且同時能保證良好的插入刪除和查找效率。當元素小於6個的時候又會轉回鏈表。
那麼爲何會選擇8和6這兩個數字呢?
效率問題:
紅黑樹的平均查找長度是lg(n),而鏈表是n/2。按這個計算,lg(8)=3,6/2=3 -> lg(4)=2, 4/2=2,咱們能夠看見當越小於8的時候紅黑樹和鏈表查找效率就越差很少,加上轉化爲紅黑樹還須要消耗額外的時間和空間的狀況下,因此不如直接用鏈表。
防止頻繁的轉換:
8和6之間隔了一個7,若是轉換爲樹和轉換爲鏈表的閾值是直接相鄰,那麼極可能出現頻繁在樹和鏈表的結構件轉換的現象。
咱們先來看看有關HashMap構建中可能涉及的成員變量:
transient int size
:實際存儲的key-value鍵值對的個數;
int threshold
:要調整大小的下一個大小值。
通常是容量 * 負載係數,可是構造函數執行後大小等於初始化容量,只有第一次添加元素後纔會初始化;
final float loadFactor
:負載因子,表明了table的填充度有多少,默認是0.75。
加載因子存在的緣由,仍是由於減緩哈希衝突,若是初始桶爲16,等到滿16個元素才擴容,某些桶裏可能就有不止一個元素了。 因此加載因子默認爲0.75,也就是說大小爲16的HashMap,到了第13個元素,就會擴容成32;
transient int modCount
:HashMap被改變的次數。
因爲HashMap非線程安全,在對HashMap進行迭代時, 若是期間其餘線程的參與致使HashMap的結構發生變化了(好比put,remove等操做), 須要拋出異常ConcurrentModificationException
構造一個具備指定初始容量和負載因子的空HashMap。
這裏提到的負載因子,負載因子衡量的是一個散列表的空間的使用程度。
public HashMap(int initialCapacity, float loadFactor) { //初始容量必須大於0 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); }
這裏調用的tableSizeFor()
方法是個位運算,他的做用是:
對於給定的目標容量,返回2的冪
換而言之,初始化容量必須是2的n次方,這個地方與HashMap如何向集合高效添加元素的需求是直接相關的。
具體的分析能夠參考:HashMap源碼註解 之 靜態工具方法hash()、tableSizeFor()(四)。
接着咱們能夠看到初始容量處理後直接給了threshold
,不直接使用initialCapacity
而是這樣作的緣由是一開始的時候map的底層容器table還沒有初始化,這個操做被放到了第一次put上,因此當咱們第一次添加元素的時候,纔會根據指定的初始大小去初始化容器。
構造一個具備指定初始容量和默認負載因子(0.75)的空HashMap。
public HashMap(int initialCapacity) { //直接調用 HashMap(int initialCapacity, float loadFactor)構造方法 this(initialCapacity, DEFAULT_LOAD_FACTOR); }
構造一個具備指定初始容量(16)和默認負載因子(0.75)的空HashMap。
public HashMap() { //所有使用默認值 this.loadFactor = DEFAULT_LOAD_FACTOR; }
使用與指定Map相同的映射構造一個新的HashMap。使用默認的負載因子(0.75)和足以將映射保存在指定Map中的初始容量建立HashMap。
public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }
這裏調用了putMapEntries()
方法,咱們待會再細說,如今先簡單裏理解爲根據一個已經存在的Map集合去建立一個新Map集合,有點相似於Arrays.copyOf()
方法。
咱們從上文能夠知道,當構造函數執行完畢之後,並無真正的開闢HashMap的數據存儲空間,而是等到第一次put的時候纔會爲table分配空間。
HashMap中有一個put()
方法:
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
它的註釋是這樣描述的:
將指定值與該映射中的指定鍵相關聯。若是該映射先前包含該鍵的映射,則將替換舊值。
簡單的來講,就是兩個功能:
咱們能夠看到,實際上這個方法經過hash()
和putVal()
兩個方法來實現。
桶容器下標經過三個步驟來計算:獲取哈希值,異或運算混合高低位獲得新哈希,新哈希和長度與運算獲取下標。
咱們看看hash()
方法的源碼:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
這裏的hashCode()
方法是一個Native方法,原理是將對象的內存地址轉爲一個整數以獲取對象哈希值。
這一個方法先調用了一個 key.hashCode()
方法獲取了key的哈希值,而後將哈希值與哈希值的高16位作異或運算。
而在下面的putVal()
方法中,又經過相似下面三行代碼進行取模:
//n爲新桶數組長度 n = (tab = resize()).length; //進行與運算取模 (n - 1) & hash
從網上看到一張很形象的圖:
咱們來理解一下:
咱們先看與運算取模。一方面位與運算運算快;另外一方面因爲長度必然是2的冪,因此轉二進制有效位必然全是1,與運算的時候能夠充分散列表。
異或運算混合高低位:爲了將哈希值的高位和低位混合,以增長隨機性。
好比數組table的長度比較小的時候(好比圖中的長度就只有4),也能保證考慮到哈希值的高低位都參與計算中。
爲了更明確的說明長度取2的冪有助於充分散列避免哈希碰撞,這裏舉個特別明顯的例子:
當HashMap的容量是16時,它的二進制是10000,(n-1)的二進制是01111,與hash值得計算結果以下:
上面四種狀況咱們能夠看出,不一樣的hash值,和(n-1)進行位運算後,可以得出不一樣的值,使得添加的元素可以均勻分佈在集合中不一樣的位置上,避免hash碰撞。
下面就來看一下HashMap的容量不是2的n次冪的狀況,當容量爲10時,二進制爲01010,(n-1)的二進制是01001,向裏面添加一樣的元素,結果爲:
能夠看出,有三個不一樣的元素進過&運算得出了一樣的結果(01001),嚴重的hash碰撞了。
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[i]是否爲空或爲null,不然執行resize()進行擴容 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); //插入後鏈表長度大於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; } } //將新值覆蓋舊值,返回舊值 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()
方法的註釋和源碼:
返回指定鍵所映射到的值;若是此映射不包含鍵的映射關係,則返回null。更正式地講,若是此映射包含從鍵k到值v的映射,使得(key == null?k == null:key.equals(k)),則此方法返回v;不然,返回v。不然返回null。 (最多能夠有一個這樣的映射。)返回值null不必定表示該映射不包含該鍵的映射;它的返回值爲0。映射也可能將鍵顯式映射爲null。 containsKey操做可用於區分這兩種狀況。
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; //確保table不爲空,而且計算獲得的下標對應table的位置上有節點 if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { //判斷第一個節點是否是要找的key 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; }
首先,不被本來的的hashCode和equals是這樣的
咱們回顧一下上文,能夠看到不管put()
仍是get()
都會有相似這樣的語句:
p.hash == hash && (key != null && key.equals(k))
當咱們試圖添加或者找到一個key的時候,方法會去判斷哈希值是否相等和值是否相等,都相等的時候纔會判斷這個key就是要獲取的key。也就是說,嚴格意義上,一個HashMap裏是不容許出現相同的key的。
當咱們使用對象做爲key的時候,根據本來的hashCode和equals仍然能保證key的惟一性。可是當咱們重寫了equals方法而不重寫hashCode()方法時,可能出現值相等可是由於地址不相等致使哈希值不一樣,最後致使出現兩個相同的key的狀況。
咱們舉個例子:
咱們如今有一個類:
/** * @Author:CreateSequence * @Date:2020-08-14 16:15 * @Description:Student類,重寫了equals方法 */ public class Student { String name; Integer age; /** * 重寫了equals方法 * @param obj * @return */ @Override public boolean equals(Object obj) { Student student = (Student) obj; return (this.name == student.name) && (this.age == student.age); } public Student(String name, Integer age) { this.name = name; this.age = age; } @Override public String toString() { return "Student{" + "name='" + name + '\'' + ", age=" + age + '}'; } }
而後咱們試試看:
public static void main( String[] args ) { HashMap<Student,Integer> map = new HashMap(16); Student student1 = new Student("小明", 21); map.put(student1, 1); Student student2 = new Student("小明", 21); System.out.println("這個key已經存在了嗎?"+map.containsKey(student2)); System.out.println(map.get(student2)); } //輸出結果 這個key已經存在了嗎?false null
能夠看到,由於hashCode()
獲得的值不一樣,在map中他們被當成了不一樣的key。
而當咱們重寫了Student類的hashCode()
方法之後:
@Override public int hashCode() { return age; }
執行結果就變成:
這個key已經存在了嗎?true 1
可見重寫equals還要重寫hashcode的必要性。
參考:
HashMap初始容量爲何是2的n次冪及擴容爲何是2倍的形式;
](https://blog.csdn.net/qq_40574571/article/details/97612100)
話很少說,咱們直接源碼
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; //哈希值與原長度進行與運算,若是多出來那一位是0,就保持原下標 if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } //若是多出來那一位是1,就移動到(原下標+原長度)對應的新位置 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; }
咱們知道,若是桶數組擴容了,那麼數組長度也就變了,那麼put和get的時候根據長度與哈希進行與運算的時候計算出來的下標就不同。在JDK7擴容移動舊容器的數據的時候,會進行重哈希得到新索引,而在JDK8進行了優化。
由於桶數組長度老是2的冪,因此擴容之後翻倍,轉換爲二進制的時候就會比原來多一位,若是咱們假設桶數組爲n,則有:
n = 16 -> 10000; (n-1) - > 1111; n = 32 -> 100000; (n-1) - > 11111; n = 64 -> 1000000; (n-1) - > 111111; n = 128 -> 10000000; (n-1) - > 1111111;
咱們舉例子驗證一下,以下圖:
(a)是n=16時,key1與key2跟(n-1)與運算獲得的二進制下標;(b)是擴容後n=32時,key1與key2跟(n-1)與運算獲得的二進制下標。
咱們能夠看到key2進了一位,多出來這一位至關於多了10000,轉爲十進制就是在原基礎上加16,也就是加上了原桶數組的長度,反映到代碼裏,就是 newTab[j + oldCap] = hiHead;
這一句代碼;
如今在看看key1,咱們看到key1的索引並無移動,由於key多出來的那一位是0,因此與運算後仍是0,最後獲得的下標跟原來的同樣。
因此咱們能夠總結一下:
這樣作的好處除了不須要從新計算哈希值之外;因爲哈希值多處來的一位數多是0也多是1,這樣就讓本來在同一條鏈表的上元素有可能能夠在擴容後移動到新位置,有效緩解了哈希碰撞。
咱們知道HashMap是線程不安全的,線程安全的Map集合是ConcurrentHashMap。事實上,HashMap的線程不安全在JDK7和JDK8表現不一樣:
在JDK7中,錯誤出如今擴容方法transfer
中,其代碼以下:
void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry<K,V> e : table) { //遍歷鏈表,當前節點爲e while(null != e) { //獲取當前節點的下一個節點next 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; } } }
從代碼中咱們能夠看到,擴容後從新計算了元素的下標,並採用頭插法將表元素移插到新鏈表上。
舉個例子:
假設線程A線程B同時對下圖集合擴容:
1.A先執行,在newTable[i] = e
前時間片耗盡被掛起,此時e = 1,e.next = null,next = 2
2.線程B執行數組擴容,擴容完之後對於線程A就是如今這樣,此時next.next = 1,e.next = null,next = 2:
3.接着線程B掛起,線程A繼續執行 newTable[i] = e
之後的代碼,執行完畢後e = 2,next = 2,e.next = 1:
4.線程A接着下一次循環,因爲e.next = 1
,因而next = 1
,頭插法把2插入newTable[i]中,執行完畢之後e = 1,next = e.next = null:
5.線程A執行最後一次循環,此時因爲e.next = newTable[i]
,因此e.next = 2,而後接着 newTable[i] = e
,也就是說1又被插回newTable[i]的位置:
這個時候最危險的事情發生了:e = 1,e.next =2 ,e.next.next = 1,說明2和1已經造成了一個環形鏈表:
在此以後會無線循環1和2的頭插,形成死循環。
DK7中也有這個問題。
咱們知道put()
方法在插入時會對插入位置進行非空判斷,若是兩個線程都判斷同一個位置爲空,那麼先執行插入的數據就會被後一個覆蓋。