HashMap是一種Java開發過程當中使用頻率很是高的容器,本文將對HashMap底層存儲結構和源代碼進行解讀和分析,源代碼依據的JDK的版本是JDK7,小版本是80,JDK7中各個小版本的HashMap源代碼多是不一樣的,這一點要注意。java
一般咱們說的哈希函數(英語:Hash function)又稱散列算法、散列函數,是一種從任何一種數據中建立小的數字「指紋」的方法。散列函數把消息或數據壓縮成【摘要】,使得數據量變小,將數據的格式固定下來。該函數將數據打亂混合,從新建立一個叫作散列值的指紋。散列值一般用一個短的隨機字母和數字組成的字符串來表明。算法
哈希表是一種能實現關聯數組的抽象數據結構,能把不少【value】映射到不少【key】上。哈希函數的一個使用場景就是哈希表,哈希表被普遍用於快速搜索數據,它的時間複雜度是O(1)。編程
哈希函數的構造方法包括:除留餘數法、隨機數法、平方取中法、摺疊法、直接定址法和數字分析法。這裏就再也不對哈希函數進行展開解讀了,有空會專門寫一篇介紹哈希函數的總結。數組
有一種現象叫作哈希衝突,指的是,當不一樣的數據用同一哈希函數計算出的值相同的場景。好的哈希函數在輸入域中不多出現哈希衝突。在哈希表和數據處理中,不抑制衝突來區別數據,會使得數據記錄更難找到。解決哈希衝突的方法一般有下面幾種:安全
方法
|
方法描述
|
備註
|
|
開
放
定
址
法
|
線性探查法
|
當產生哈希衝突時,則去尋找下一個空位。從當前位置開始搜索,當搜索到最後一個位置時,再從哈希表表首開始依次搜索,直到搜索到空位爲止。只要哈希表足夠大,而且有空位確定能搜索到位置。
|
fi(key) = (f(key) + di) mod m,m表明哈希表的長度,di = m-1,
di的取值範圍能夠保證搜索完整個哈希表
|
平方探查法
|
當產生哈希衝突時,則去尋找下一個空位位置。從當前位置增長平方項,再對哈希表的長度取模。增長平方項的目的是不讓關鍵字集中在同一個區域,避免不一樣的關鍵字爭奪同一位置。該方法並不能搜索全部的位置,一般能搜索哈希表一半的位置,若是在一半的位置都沒有找到合適的空位,則表明此哈希表須要重建。
|
fi(key) = (f(key) + di) mod m,m表明哈希表的長度,di=1^2,-1^2,2^2,
-2^2....p^2,-p^2, p<=m/2
|
|
雙散列函數探查法
|
當產生哈希衝突時,則去尋找下一個空位。在當前的位置基礎上,增長一個由隨機函數產生的數值。
|
fi(key) =(f(key) + di) mod m,m表明哈希表的長度,di由一個隨機函數產生。
|
|
鏈地址法
|
基礎是哈希表,哈希表的每個元素均可能加掛一個鏈表,也便是同義詞存儲在同一個列表中。
|
鏈地址法是HashMap解決哈希衝突使用的方法之一。JDK7徹底使用此方法,
在JDK8中使用混合的方式解決哈希衝突,當同一個鏈表的元素大於8的時候,
自動轉化爲紅黑樹,也防止HashMap查詢元素時出現O(n)的可能。
|
|
再哈希法
|
同時準備多個哈希函數,當一個哈希函數得出的值出現衝突時,使用其餘的哈希函數,直到獲取到空位爲止。
|
優勢:不容易產生彙集,缺點時:增長了計算時間。
|
|
創建公共溢出區
|
取兩個哈希表,例如表a和表b,當出現表a的下標衝突時,把該元素都移動到表b中。
|
package java.lang; public class Object { 。。。 public native int hashCode(); 。。。 }
hashCode方法是Java中全部類共有的方法,是一個原生態方法,參考源代碼中的註釋數據結構
This is typically implemented by converting the internal address of the object into an integer, but this implementation technique is not required by the Java programming language。併發
翻譯過來是:這一般經過將對象的內部地址轉換爲整數來實現,但Java編程語言不須要此實現技術。app
也就是說java中的類不復寫Object中的hashCode方法的話,是調用本地系統的方法生成的一個整數值,hash值和內存地址有關係,可是它們並不相等。編程語言
常見的存儲結構有順序存儲(數組)、鏈式存儲(鏈表)、索引存儲以及散列存儲(哈希表),咱們介紹一下幾類常見存儲結構對於新增、刪除和查找的性能狀況。函數
一、數組
數組Java中最高效的數據物理存儲結構了,它採用一段連續的存儲單元存儲數據。對於指定的下標,查找元素的時間複雜度是O(1);對於指定的值,查找元素須要遍歷整個數組,逐一比較數組元素和給定值,因此時間複雜度是O(n),對於有序數組,能夠採起二分查等方式,可將時間複雜度提高爲O(logn)。通常的新增或者刪除操做,涉及到數組元素的挪動,時間複雜度是O(n)。
二、鏈表
一種鏈式存儲方式,不保證順序性,邏輯上相鄰的元素之間用指針所指定,它不是用一塊連續的內存存儲,邏輯上相連的物理位置不必定相鄰。對於新增和刪除操做,只處理節點的引用便可,時間複雜度是O(1);查找指定的節點,則須要循環整個鏈表,逐一比較節點的值和給定的值,時間複雜度是O(n)。
三、哈希表
新增 | 刪除 | 查找 | |
數組 | O(1) | O(n) | O(n) |
鏈表 | O(1) | O(1) | O(n) |
哈希表 | O(1) | O(1) | O(1) |
哈希表爲何具有如此高效的性能呢?
上面咱們已經介紹了,數組是最高效的數據存儲物理結構,根據下標查找元素的時間複雜度是O(1)。哈希表的基礎就是數組,在不考慮哈希衝突的狀況下,經過一個特定的函數計算出要存儲的元素在數組中的下標,只需一步便可實現新增、刪除和查找操做。這個特定的函數就是哈希函數,哈希函數的好壞直接影響建立的哈希表的性能。一個優秀的哈希函數須要具有以下幾個特性:
HashMap採用鏈地址法解決哈希衝突,極端狀況下hashMap的查找元素的時間複雜度是O(n),也便是採用一個返回固定值的哈希算法,這樣不一樣的元素返回的哈希值是同樣的,在某一個固定位置上,引入一個鏈表,存儲全部的元素。
HashMap內部結構是由數組和鏈表組成,如圖:
size hashmap中的kv組合的總數量,拿上圖舉例,size = 4(數組元素)+4(鏈表節點) = 9。
capacity 容量,hashmap中數組的長度,也稱做桶的數量,默認值是DEFAULT_INITIAL_CAPACITY=16。拿上圖舉例,capacity=10。
loadFactor 裝載因子,默認是0.75,此數值能夠衡量hashmap滿的程度。
threshold 擴容閥指,threshold = capacity * loadFactor ,當hashmap的size大於或者等於 threshold 時,hashmap將進行擴容。
MAXIMUM_CAPACITY HashMap的最大容量,1 << 30 = 230
HashMap有4個構造函數,下面這個函數是其餘三個函數的基礎。
/** * Constructs an empty <tt>HashMap</tt> with the specified initial * capacity and load factor. * * @param initialCapacity the initial capacity * @param loadFactor the load factor * @throws IllegalArgumentException if the initial capacity is negative * or the load factor is nonpositive */ public HashMap(int initialCapacity, float loadFactor) { //容量小於0,拋出異常 if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); //最大容量是2的30次冪 if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; //裝載因子參數大於零 if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; threshold = initialCapacity; //HashMap中這是一個空方法,LinkedHashMap則有邏輯 init(); }
從構造方法中能夠看出,HashMap並未在new的時候就初始化數組,初始化數組是在put方法中進行的。
public V put(K key, V value) { if (table == EMPTY_TABLE) { //第一次存儲數據時,進行數組的擴容 inflateTable(threshold); } if (key == null) //k=null時,放置在數組的第一個位置 return putForNullKey(value); //計算key的hashcode int hash = hash(key); //哈希表的祕密之所在,根據hashcode,計算此key在table中的存儲位置 int i = indexFor(hash, table.length); for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; //若是key是同樣,則覆蓋原的值 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; //鉤子函數,hashmap並未實現 e.recordAccess(this); return oldValue; } } //迭代hashmap時,fast-fail依據此值是否拋出ConcurrentModificationException異常 modCount++; //新增元素 addEntry(hash, key, value, i); return null; }
HashMap非線程安全,就是說的這個方法未加鎖。當覆蓋原值的時候,會把原值返回;當是新增一個元素時,則返回null。
咱們接下來看一些inflateTable函數
private void inflateTable(int toSize) { // capacity大於等於toSize,而且是2的n次冪 int capacity = roundUpToPowerOf2(toSize); threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1); //初始化數組 table = new Entry[capacity]; initHashSeedAsNeeded(capacity); }
roundUpToPowerOf2函數的目的就是找到一個是2的次冪,而且是大於toSize,最接近toSize的正整數。它是怎麼作到的呢?
private static int roundUpToPowerOf2(int number) { // assert number >= 0 : "number must be non-negative"; //number非負數 //而且最大是2的30次冪 return number >= MAXIMUM_CAPACITY ? MAXIMUM_CAPACITY : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1; }
就是經過Integer.highestOneBit函數作到的,此方法的用意就是取正整數二進制的左邊最高位的數字,而後再用這個數字的右邊所有補0組成一個新的二進制數,這個二進制數的就是Integer.highestOneBit的結果。
例如number=15,
第一步:(15-1) << 1 = 14 * 21 = 28
第二步:28的二進制表示是 00011100
第三步:取 00011100,右邊補0,組成新的二進制數 00010000
則Integer.highestOneBit((15-1) << 1) = 16,也便是大於15,而且最接近15的2的n次冪的整數是16。
final int hash(Object k) { int h = hashSeed; if (0 != h && k instanceof String) { return sun.misc.Hashing.stringHash32((String) k); } h ^= k.hashCode(); // This function ensures that hashCodes that differ only by // constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor). h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
此版本的HashMap哈希函數應用了大量的位運算,目的就是使得hashcode很是分散。
良好的哈希算法結合indexFor方法,使得存儲的元素能均勻的分散在數組中。由於能直接索引到數組的下標值,因此HashMap的平均時間複雜度O(1)。
/** * Returns index for hash code h. */ static int indexFor(int h, int length) { // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2"; return h & (length-1); }
此方法至關於對h取模,可是經過位運算比取模運算效率更高。
void addEntry(int hash, K key, V value, int bucketIndex) { //數量大於等於閥值,而且發生了哈希碰撞 if ((size >= threshold) && (null != table[bucketIndex])) { //擴容hash表 resize(2 * table.length); hash = (null != key) ? hash(key) : 0; //從新計算hash表的索引 bucketIndex = indexFor(hash, table.length); } //建立Entry,若是此數組位置上已經有數據了,則在此位置上產生鏈表。最新的元素存儲在數組中,最新生成的Entry的next指向原來的Entry。 createEntry(hash, key, value, bucketIndex); }
上面介紹了HashMap第一次初始化數組的時候,經過roundUpToPowerOf2函數計算出數組的大小是2的n次冪,在addEntry的時候運行resize函數,將數組擴容到 2*table.length,這就決定了數組的大小必定是2的n次冪。
這樣的設計的目的是減小哈希碰撞,使得要存儲的元素能均勻的分佈在數組中。下面咱們經過比較來證實這樣設計的好處。
咱們假設要存儲的元素的哈希值是[0,1,2...9]這10個數,當table.length = 16時,經過idnexFor函數計算出的索引值以下圖所示:
咱們能夠看到,要存儲的元素的存儲下標值很是均勻,而且沒有產生任何哈希碰撞,此哈希表的時間複雜度是O(1)。下面咱們把table.length=15,示意圖以下:
總共發生了5次碰撞,造成了5個鏈表,而且形成了table數組的空間浪費。
public V get(Object key) { if (key == null) //key是null的時候,直接在數組第一個位置取 return getForNullKey(); Entry<K,V> entry = getEntry(key); return null == entry ? null : entry.getValue(); }
final Entry<K,V> getEntry(Object key) { if (size == 0) { return null; } //取key的hashcode int hash = (key == null) ? 0 : hash(key); //根據key的hashcode,定位索引下標 for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) { Object k; //首先哈希值必須相等 //其次要不內存地址同樣,要不就是equals if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } return null; }