【Java集合学习系列】HashMap实现原理及源码分析
HashMap特性
hashMap是基于哈希表的Map接口的非同步實現,繼承自AbstractMap接口,實現了Map接口(HashTable跟HashMap很像,HashTable中的方法是線程安全的,也就是同步的,而HashMap是非同步的,這是唯一的區別),此實現提供所有可選的映射操作,并允許使用null值和null鍵。此類不保證映射的順序,特別是它不保證該順序恒久不變。
HashMap的數據結構
HashMap是基于hashing的原理,實際上就是一個“鏈表的數組”的數據結構,每個元素存放在鏈表頭結點的數組,即數組和鏈表的結合體—哈希表。
PS:數組的特點是尋址容易,但是插入和刪除困難;鏈表的特點是尋址困難,但是插入和刪除容易。哈希表就是綜合了這兩者特性的一種數據結構,尋址容易,插入和刪除也容易。
下面我們就結合哈希表的一種常用實現-拉鏈法來學習一下哈希表的使用:
拉鏈法:將具有同一散列地址的記錄存儲在一條線性鏈表中。在除留余數法中,取關鍵字被某個不大于哈希表長m的數p除后所得余數為我們需要的哈希地址。
H(key) = key MOD p(p <= m)
比如,以上length為16的數組,每個數組元素存儲的都是一個鏈表的頭結點。設關鍵字為(12,28,108,140),除數為16(小于等于16),散列地址為(12,12,12,12),所以(12,28,108,140)都存儲在數組下標為12的位置。
從以上我們可以看出HashMap存儲數據結構的容器就是一個線性數組。說到這里,我們知道HashMap是按鍵值對來存取數據的,但是線性數組怎么實現按鍵值對存取數據?
看HashMap的源碼我們知道,HashMap里面實現了一個靜態內部類Entry,Entry的主要屬性key、value、next。看到這里就明白了,原來HashMap按照鍵值對存取數據是通過Entry這個基礎bean來實現的。再回頭看我們上面說的線性數組實現按鍵值對存取數據,這個線性數組就是Entry[],Map中的數據都保存在線性數組Entry中。數組中的每一項又是一個鏈表,當新建一個HashMap的時候,就會初始化一個數組。其中Entry就是數組中的元素,每個Map.Entry其實就是一個key-value對,它持有一個指向下一個元素的引用next,這就構成了鏈表,如下圖。
數組是HashMap的主體,鏈表則是主要為了解決哈希沖突而存在的,如果定位到的數組位置不含鏈表(當前entry的next指向null),那么對于查找,添加等操作很快,僅需一次尋址即可;如果定位到的數組包含鏈表,對于添加操作,其時間復雜度依然為O(1),因為最新的Entry會插入鏈表頭部,急需要簡單改變引用鏈即可,而對于查找操作來講,此時就需要遍歷鏈表,然后通過key對象的equals方法逐一比對查找。所以,性能考慮,HashMap中的鏈表出現越少,性能才會越好。
HashMap的存取實現
我們上面說到HashMap是線性數組,因為我們知道HashMap可以隨機存取,線性數組實現隨機存取?HashMap是怎么實現的呢?我們看下下面的算法:
//存儲時 int hash = key.hashCode();// 每個key的hash是一個固定的int值 int index = hash % Entry[].length; Entry[index] = value; //取值時 int hash = key.hashCode(); int index = hash % Entry[].length; return Entry[index];這就是HashMap通過鍵值對實現存取的基本原理。
我們看下源碼:
存儲:
當我們向HashMap中put元素的時候,先根據key值計算hashcode,然后根據hash值得到這個元素在數組中的位置(下標)。如果數組的相應位置已經存放了元素,則在該位置上的元素以鏈表的形式存放,新加入的元素存放在鏈表的頭部,最先加入的元素存放在鏈表的尾部。如果數組的該位置上沒有元素,就直接將該元素放到此數組的該位置上。
void addEntry(int hash, K key, V value, int bucketIndex) {//如果Map中的key-value對的數量超過了極限或者bucketIndex索引不存在,將table對象的長度擴充到原來的2倍if ((size >= threshold) && (null != table[bucketIndex])) {resize(2 * table.length);hash = (null != key) ? hash(key) : 0;bucketIndex = indexFor(hash, table.length);}//添加EntrycreateEntry(hash, key, value, bucketIndex); }void createEntry(int hash, K key, V value, int bucketIndex) {//獲取指定bucketIndex所引處的EntryEntry<K,V> e = table[bucketIndex];//將新建的Entry放入bucketIndex索引處,并讓新的Entry指向原來的Entrytable[bucketIndex] = new Entry<>(hash, key, value, e);size++; }根據hash值調用addEntry(hash,key,value,i)方法將key、value值鍵值對存儲在數組table的i索引處。從源碼中我們可以看到,當系統決定存儲HashMap中的key、value鍵值對的時候,沒有有關value的操作,只是根據key來計算每個Entry的存儲位置。也就是說,當系統決定了key存儲的位置之后,value也就隨之保存了。
上面的hash(int h)方法根據key值重新計算了一次散列。此算法加入了高位計算,防止低位不變,高位變化時造成的hash沖突。
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); }我們可以看到,在上面我們是使用hash算法根據key來求得元素在對應數組中的位置。因為我們知道HashMap的數據結構是數組和鏈表的結合,如果HashMap里面的元素位置分布得比較均勻,每個位置上的元素數量只有一個,那么當我們用hash算法求位置的時候,馬上就可以知道對應位置的元素是不是我們需要的,而不需要再去遍歷鏈表,這樣能大大優化查詢效率。
對于任意對象。只要它的key值相同,
int hash = hash(key); int i = indexFor(hash, table.length);調用hash算法求得的hash值就是相同的。我們上面提到過的拉鏈法是值對表長度取模,在這里如果使用hash值對數組長度取模,元素的分配相對來說也會比較均勻。但是“模”運算的消耗比較大,HashMap是怎么解決這個問題的呢?
HashMap使用了indexFor(int h,int length)方法來計算該對象應該保存在table數組的哪個索引處,方法如下:
/*** 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); }我們注意到注釋里面有一行:table的長度length必須是2的非零冪。看到這里,我們需要提到另一個知識點,HashMap底層數組的長度總是2的n次方,這是HashMap在速度上的優化。
看下HashMap的putAll方法的源碼:
public void putAll(Map<? extends K, ? extends V> m) {int numKeysToBeAdded = m.size();if (numKeysToBeAdded == 0)return;if (table == EMPTY_TABLE) {inflateTable((int) Math.max(numKeysToBeAdded * loadFactor, threshold));}if (numKeysToBeAdded > threshold) {int targetCapacity = (int)(numKeysToBeAdded / loadFactor + 1);if (targetCapacity > MAXIMUM_CAPACITY)targetCapacity = MAXIMUM_CAPACITY;int newCapacity = table.length;while (newCapacity < targetCapacity)newCapacity <<= 1;if (newCapacity > table.length)resize(newCapacity);}for (Map.Entry<? extends K, ? extends V> e : m.entrySet())put(e.getKey(), e.getValue()); }我們注意到有這么一行:
while (newCapacity < targetCapacity)newCapacity <<= 1;PS:
int i = 1;
//類似于 i++就是 i = i+1;的這結構
i <<= 1;//i = i<<1 i等于i乘以2的1次方
i <<= 2;//i = i<<2 i等于i乘以2的2次方,>>就是相除了
這段代碼在JDK1.7之前出現在HashMap的構造函數中,保證了初始化HashMap的容量(即底層數組的長度)總是2的n次方。
擴容的時候也有一個方法:
private static int roundUpToPowerOf2(int number) {// assert number >= 0 : "number must be non-negative";return number >= MAXIMUM_CAPACITY? MAXIMUM_CAPACITY: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1; }當length總是 2 的n次方時,h& (length-1)運算等價于對length取模,也就是h%length,但是&比%具有更高的效率。
這看上去很簡單,其實比較有玄機的,我們舉個例子來說明:
假設數組長度分別為15和16,優化后的hash碼分別為8和9,那么&運算后的結果如下:
h & (table.length-1) | hash | table.length-1
8 & (15-1): | 0100 & 1110 = 0100
9 & (15-1): | 0101 & 1110 = 0100
8 & (16-1): | 0100 & 1111 = 0100
9 & (16-1): | 0101 & 1111 = 0101
從上面的例子中可以看出:當8、9兩個數和(15-1)2=(1110)進行“與運算&”的時候,產生了相同的結果,都為0100,也就是說它們會定位到數組中的同一個位置上去,這就產生了碰撞,8和9會被放到數組中的同一個位置上形成鏈表,那么查詢的時候就需要遍歷這個鏈 表,得到8或者9,這樣就降低了查詢的效率。同時,我們也可以發現,當數組長度為15的時候,hash值會與(15-1)2=(1110)進行“與運算&”,那么最后一位永遠是0,而0001,0011,0101,1001,1011,0111,1101這幾個位置永遠都不能存放元素了,空間浪費相當大,更糟的是這種情況中,數組可以使用的位置比數組長度小了很多,這意味著進一步增加了碰撞的幾率,減慢了查詢的效率!
而當數組長度為16時,即為2的n次方時,2n-1得到的二進制數的每個位上的值都為1(比如(24-1)2=1111),這使得在低位上&時,得到的和原hash的低位相同,加之hash(int h)方法對key的hashCode的進一步優化,加入了高位計算,就使得只有相同的hash值的兩個值才會被放到數組中的同一個位置上形成鏈表。
所以說,當數組長度為2的n次冪的時候,不同的key算得得index相同的幾率較小,那么數據在數組上分布就比較均勻,也就是說碰撞的幾率小,相對的,查詢的時候就不用遍歷某個位置上的鏈表,這樣查詢效率也就較高了。
讀取:
public V get(Object key) {if (key == null)return getForNullKey();Entry<K,V> entry = getEntry(key);return null == entry ? null : entry.getValue(); }private V getForNullKey() {if (size == 0) {return null;}for (Entry<K,V> e = table[0]; e != null; e = e.next) {if (e.key == null)return e.value;}return null; }final Entry<K,V> getEntry(Object key) {if (size == 0) {return null;}int hash = (key == null) ? 0 : hash(key);for (Entry<K,V> e = table[indexFor(hash, table.length)];e != null;e = e.next) {Object k;if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))return e;}return null; }總結起來,從HashMap中get元素的時候,會首先計算key的hash值,找到數組中對應位置的元素,然后通過equals比較key值從而找出需要的元素,
HashMap的存取歸納來說,就是HashMap 在底層將 key-value 當成一個整體進行處理,這個整體就是一個 Entry 對象。HashMap 底層采用一個 Entry[] 數組來保存所有的 key-value 對,當需要存儲一個 Entry 對象時,會根據hash算法來決定其在數組中的存儲位置,在根據equals方法決定其在該數組位置上的鏈表中的存儲位置;當需要取出一個Entry時,也會根據hash算法找到其在數組中的存儲位置,再根據equals方法從該位置上的鏈表中取出該Entry。
HashMap的resize(rehash):
當HashMap中的元素越來越多的時候,hash沖突的幾率也就越來越高,因為數組的長度是固定的。所以為了提高查詢的效率,就要對HashMap的數組進行擴容,數組擴容這個操作也會出現在ArrayList中,這是一個常用的操作,而在HashMap數組擴容之后,最消耗性能的點就出現了:原數組中的數據必須重新計算其在新數組中的位置,并放進去,這就是resize。
那么HashMap什么時候進行擴容呢?當HashMap中的元素個數超過數組大小*loadFactor時,就會進行數組擴容,loadFactor的默認值為0.75,這是一個折中的取值。也就是說,默認情況下,數組大小為16,那么當HashMap中元素個數超過16*0.75=12(這個值就是代碼中的threshold值,也叫做臨界值)的時候,就把數組的大小擴展為 2*16=32,即擴大一倍,然后重新計算每個元素在數組中的位置,而這是一個非常消耗性能的操作,所以如果我們已經預知HashMap中元素的個數,那么預設元素的個數能夠有效的提高HashMap的性能。
void resize(int newCapacity) {Entry[] oldTable = table;int oldCapacity = oldTable.length;//如果當前數組的長度已經達到最大值則不進行調整if (oldCapacity == MAXIMUM_CAPACITY) {threshold = Integer.MAX_VALUE;return;}//根據入參的長度定義新數組Entry[] newTable = new Entry[newCapacity];//按照最新的規則,將舊數組中的元素轉移到新數組transfer(newTable, initHashSeedAsNeeded(newCapacity));table = newTable;//更新臨界值threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); }/*** Transfers all entries from current table to newTable.*/ 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;}} }HashMap構造函數:
其中有兩個要注意的性能參數:initialCapacity(初始容量), loadFactor(負載因子)。
/*** 構建一個初始容量為16,,負載因子為0.75的HashMap** initialCapacity:HashMap的最大容量,即為底層數組的長度,默認為16.* loadFactor:負載因子loadFactor定義為:散列表的實際元素數目(n)/ 散列表的容量(m),默認為0.75.*/ public HashMap() {this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR); }/*** 構建一個初始容量為initialCapacity,,負載因子為0.75的HashMap*/ 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;threshold = initialCapacity;//臨界值init(); }負載因子衡量的是一個散列表的空間的使用程度,負載因子越大表示散列表的裝填程度越高,反之愈小。對于使用鏈表法的散列表來說,查找一個元素的平均時間是O(1+a),因此如果負載因子越大,對空間的利用更充分,然而后果是查找效率的降低;如果負載因子太小,那么散列表的數據將過于稀疏,對空間造成嚴重浪費。
threshold臨界值就是在此loadFactor和capacity對應下允許的最大元素數目,超過這個數目就重新resize,以降低實際的負載因子。當容量超出最大容量時,resize后的HashMap容量是現有容量的兩倍,addEntry方法中有這樣一段代碼:
if ((size >= threshold) && (null != table[bucketIndex])) {resize(2 * table.length);ConcurrentModificationException
java.util.HashMap不是線程安全的(為什么HashMap不是線程安全的),如果在使用迭代器的過程中有其它線程修改了map,就會拋出ConcurrentModificationException。
關于這個異常,我們需要學習HashMap源碼的一個抽象內部類HashIterator,這牽涉到fail-fast策略。這一策略在源碼中的實現是通過modCount。在上面的學習中我們知道modCount是記錄的修改次數,每次對HashMap內容的修改,這個值都會增加。在迭代器初始化的過程中會將這個值賦值給迭代器的expectedModCount。在迭代過程中,判斷modCount跟expectedModCount是否相等,如果不相等就表示已經有其他線程修改了Map。看了源碼我們就會知道,modCount聲明為volatile,保證線程之間修改的可見性。(volatile之所以線程安全是因為被volatile修飾的變量不保存緩存,直接在內存中修改,因此能夠保證線程之間修改的可見性)。
private abstract class HashIterator<E> implements Iterator<E> {Entry<K,V> next; // next entry to returnint expectedModCount; // For fast-failint index; // current slotEntry<K,V> current; // current entryHashIterator() {expectedModCount = modCount;if (size > 0) { // advance to first entryEntry[] t = table;while (index < t.length && (next = t[index++]) == null);}}public final boolean hasNext() {return next != null;}final Entry<K,V> nextEntry() {if (modCount != expectedModCount)throw new ConcurrentModificationException();Entry<K,V> e = next;if (e == null)throw new NoSuchElementException();if ((next = e.next) == null) {Entry[] t = table;while (index < t.length && (next = t[index++]) == null);}current = e;return e;}public void remove() {if (current == null)throw new IllegalStateException();if (modCount != expectedModCount)throw new ConcurrentModificationException();Object k = current.key;current = null;HashMap.this.removeEntryForKey(k);expectedModCount = modCount;} }重寫equals方法需同時重寫hashCode方法
在此之前我們先來看一下java中==和equals的區別:
String str1=new String("apple"); String str2=new String("apple");str1==str2;//false str1.equals(str2);//true結果解析:==比較的是兩個對象的地址,equals比較的是兩個對象的內容.
如果一個類沒有自己定義equals方法,那么它將繼承Object類的equals方法,Object類的equals方法的實現代碼如下:
boolean equals(Object o){return this==o; }這說明,如果一個類沒有自己定義equals方法,它默認的equals方法(從Object 類繼承的)就是使用==操作符,也是在比較兩個變量指向的對象是否是同一對象,這時候使用equals和使用==會得到同樣的結果,如果比較的是兩個獨立的對象則總返回false。
總結來說:
1)對于==,如果作用于基本數據類型的變量,則直接比較其存儲的 “值”是否相等;如果作用于引用類型的變量,則比較的是所指向的對象的地址
2)對于equals方法,注意:equals方法不能作用于基本數據類型的變量。如果沒有對equals方法進行重寫,則比較的是引用類型的變量所指向的對象的地址;諸如String、Date等類對equals方法進行了重寫的話,比較的是所指向的對象的內容。
下面我們來看,為什么重寫equals方法時必須要重寫hashCode方法:
在使用HashMap添加對象時,hashCode()方法會被調用,判斷與已經存儲在集合中對象的hashCode值是否一致。如果不一致則直接加進去(不用比較equals()提高效率);如果一致,則進行equals方法的比較,如果返回true,說明集合中已經存在這個對象,不能添加。如果返回false,表名集合中沒有這個對象,可以加進去。所以,在重寫hashCode()方法或者equals()方法的任何一個方法時,都必須重寫另外一個。
object對象中默認的public boolean equals(Object obj),對于任何非空引用值 x 和 y,當且僅當 x 和 y 引用同一個對象時,此方法才返回 true。Object默認的hashCode散列碼是對象的存儲地址,對象不同其所調用的hashCode方法所得到的值也不同
equals()方法和hashCode()方法有一個常規協定,如下:
(1)當obj1.equals(obj2)為true時,obj1.hashCode() == obj2.hashCode()必須為true【同一個對象的hashCode必須相等】
(2)當obj1.hashCode() == obj2.hashCode()為false時,obj1.equals(obj2)必須為false【hashCode不相等的兩個對象一定不是同一個對象】
如果不重寫equals,就相當于使用==,那么比較的是對象的引用是否指向同一塊內存地址。重寫之后的目的是為了區別于==,去比較兩個對象的value值是否相等。
通過上面的學習,我們知道,hashCode是用于散列數據的快速存取,如利用HashSet/HashMap/HashTable類來存儲數據時,都是根據存儲對象的hashCode值來進行判斷是否相同。
這樣,如果我們對一個對象重寫了equals,意思是只要對象的成員變量的值都相等那么equals就等于true,但不重寫hashCode,那么當我們再重新new一個新對象,當原對象.equals(新對象)等于true的時候,兩者的hashCode是不一樣的,由此產生了理解的不一致。如果在存儲散列集合時(如set時),將會存儲兩個值一樣的對象,導致混淆,看下面的測試代碼。因此,重寫equals方法時也必須要重寫hashCode方法。
測試代碼:
import java.util.Collection; import java.util.HashSet;public class HelloWord {public static void main(String[] args) {Person n1 = new Person("Tom");Person n2 = new Person("Tom");Collection c = new HashSet();System.out.println("n1放入collection集合");c.add(n1);System.out.println("n2放入collection集合");c.add(n2);System.out.println("equals比較");System.out.println("n1.equals(n2) : " + n1.equals(n2));System.out.println("計算hashCode");System.out.println("n1.hashCode() : " + n1.hashCode());System.out.println("n2.hashCode() :" + n2.hashCode());System.out.println("顯示集合列表");System.out.println("c : " + c);} } public class Person {private String name;public Person(String name) {this.name = name;}public String toString(){return this.name;}public boolean equals(Object obj) {if (obj instanceof Person) {Person person = (Person) obj;System.out.println("[equals:name]"+ name);System.out.println("[equals:person.name]"+ person.name);return (name.equals(person.name));}return super.equals(obj);}/*public int hashCode() {Person person = (Person) this;System.out.println("[hashCode:person.name]" + person.name);System.out.println("[hashCode:name.hashCode()]" + name.hashCode());return name.hashCode(); }*/ }重寫了hashCode方法的運行結果:
n1放入collection集合
[hashCode:person.name]Tom
[hashCode:name.hashCode()]84274
n2放入collection集合
[hashCode:person.name]Tom
[hashCode:name.hashCode()]84274
[equals:name]Tom
[equals:person.name]Tom
equals比較
[equals:name]Tom
[equals:person.name]Tom
n1.equals(n2) : true
計算hashCode
[hashCode:person.name]Tom
[hashCode:name.hashCode()]84274
n1.hashCode() : 84274
[hashCode:person.name]Tom
[hashCode:name.hashCode()]84274
n2.hashCode() :84274
顯示集合列表
c : [Tom]
不重寫hashCode方法的運行結果:
n1放入collection集合
n2放入collection集合
equals比較
[equals:name]Tom
[equals:person.name]Tom
n1.equals(n2) : true
計算hashCode
n1.hashCode() : 1326101490
n2.hashCode() :1202453864
顯示集合列表
c : [Tom, Tom]
HashMap在應用中遇到的問題:
HashMap在并發下可能出現的問題分析
總結
以上是生活随笔為你收集整理的【Java集合学习系列】HashMap实现原理及源码分析的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 【HTTPS、HTTP】网易新闻首页ht
- 下一篇: 【Spring学习】ring的core模