HashMap實現原理及源碼分析
哈希表(hash table)也叫散列表,是一種非常重要的數據結構,應用場景及其豐富,許多緩存技術(比如memcached)的核心其實就是在內存中維護一張大的哈希表,而HashMap的實現原理也常常出現在各類的面試題中,重要性可見一斑。本文會對java集合框架中的對應實現HashMap的實現原理進行講解,然后會對JDK7的HashMap源碼進行分析。
目錄
一、什么是哈希表
在討論哈希表之前,我們先大概了解下其他數據結構在新增,查找等基礎操作執行性能
數組:采用一段連續的存儲單元來存儲數據。對於指定下標的查找,時間復雜度為O(1);通過給定值進行查找,需要遍歷數組,逐一比對給定關鍵字和數組元素,時間復雜度為O(n),當然,對於有序數組,則可采用二分查找,插值查找,斐波那契查找等方式,可將查找復雜度提高為O(logn);對於一般的插入刪除操作,涉及到數組元素的移動,其平均復雜度也為O(n)
線性鏈表:對於鏈表的新增,刪除等操作(在找到指定操作位置后),僅需處理結點間的引用即可,時間復雜度為O(1),而查找操作需要遍歷鏈表逐一進行比對,復雜度為O(n)
二叉樹:對一棵相對平衡的有序二叉樹,對其進行插入,查找,刪除等操作,平均復雜度均為O(logn)。
哈希表:相比上述幾種數據結構,在哈希表中進行添加,刪除,查找等操作,性能十分之高,不考慮哈希沖突的情況下,僅需一次定位即可完成,時間復雜度為O(1),接下來我們就來看看哈希表是如何實現達到驚艷的常數階O(1)的。
我們知道,數據結構的物理存儲結構只有兩種:順序存儲結構和鏈式存儲結構(像棧,隊列,樹,圖等是從邏輯結構去抽象的,映射到內存中,也這兩種物理組織形式),而在上面我們提到過,在數組中根據下標查找某個元素,一次定位就可以達到,哈希表利用了這種特性,哈希表的主干就是數組。
比如我們要新增或查找某個元素,我們通過把當前元素的關鍵字 通過某個函數映射到數組中的某個位置,通過數組下標一次定位就可完成操作。
存儲位置 = f(關鍵字)
其中,這個函數f一般稱為哈希函數,這個函數的設計好壞會直接影響到哈希表的優劣。舉個例子,比如我們要在哈希表中執行插入操作:
查找操作同理,先通過哈希函數計算出實際存儲地址,然后從數組中對應地址取出即可。
哈希沖突
然而萬事無完美,如果兩個不同的元素,通過哈希函數得出的實際存儲地址相同怎么辦?也就是說,當我們對某個元素進行哈希運算,得到一個存儲地址,然后要進行插入的時候,發現已經被其他元素占用了,其實這就是所謂的哈希沖突,也叫哈希碰撞。前面我們提到過,哈希函數的設計至關重要,好的哈希函數會盡可能地保證 計算簡單和散列地址分布均勻,但是,我們需要清楚的是,數組是一塊連續的固定長度的內存空間,再好的哈希函數也不能保證得到的存儲地址絕對不發生沖突。那么哈希沖突如何解決呢?哈希沖突的解決方案有多種:開放定址法(發生沖突,繼續尋找下一塊未被占用的存儲地址),再散列函數法,鏈地址法,而HashMap即是采用了鏈地址法,也就是數組+鏈表的方式,
二、HashMap實現原理
HashMap的主干是一個Entry數組。Entry是HashMap的基本組成單元,每一個Entry包含一個key-value鍵值對。
//HashMap的主干數組,可以看到就是一個Entry數組,初始值為空數組{},主干數組的長度一定是2的次冪,至於為什么這么做,后面會有詳細分析。 transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
Entry是HashMap中的一個靜態內部類。代碼如下
static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry<K,V> next;//存儲指向下一個Entry的引用,單鏈表結構 int hash;//對key的hashcode值進行hash運算后得到的值,存儲在Entry,避免重復計算 /** * Creates new entry. */ Entry(int h, K k, V v, Entry<K,V> n) { value = v; next = n; key = k; hash = h; } }
所以,HashMap的整體結構如下
簡單來說,HashMap由數組+鏈表組成的,數組是HashMap的主體,鏈表則是主要為了解決哈希沖突而存在的,如果定位到的數組位置不含鏈表(當前entry的next指向null),那么對於查找,添加等操作很快,僅需一次尋址即可;如果定位到的數組包含鏈表,對於添加操作,其時間復雜度依然為O(1),因為最新的Entry會插入鏈表頭部,急需要簡單改變引用鏈即可,而對於查找操作來講,此時就需要遍歷鏈表,然后通過key對象的equals方法逐一比對查找。所以,性能考慮,HashMap中的鏈表出現越少,性能才會越好。
其他幾個重要字段
//實際存儲的key-value鍵值對的個數 transient int size; //閾值,當table == {}時,該值為初始容量(初始容量默認為16);當table被填充了,也就是為table分配內存空間后,threshold一般為 capacity*loadFactory。HashMap在進行擴容時需要參考threshold,后面會詳細談到 int threshold; //負載因子,代表了table的填充度有多少,默認是0.75 final float loadFactor; //用於快速失敗,由於HashMap非線程安全,在對HashMap進行迭代時,如果期間其他線程的參與導致HashMap的結構發生變化了(比如put,remove等操作),需要拋出異常ConcurrentModificationException transient int modCount;
HashMap有4個構造器,其他構造器如果用戶沒有傳入initialCapacity 和loadFactor這兩個參數,會使用默認值
initialCapacity默認為16,loadFactory默認為0.75
我們看下其中一個
public HashMap(int initialCapacity, float loadFactor) { //此處對傳入的初始容量進行校驗,最大不能超過MAXIMUM_CAPACITY = 1<<30(2^30) 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();//init方法在HashMap中沒有實際實現,不過在其子類如 linkedHashMap中就會有對應實現 }
從上面這段代碼我們可以看出,在常規構造器中,沒有為數組table分配內存空間(有一個入參為指定Map的構造器例外),而是在執行put操作的時候才真正構建table數組
OK,接下來我們來看看put操作的實現吧
public V put(K key, V value) { //如果table數組為空數組{},進行數組填充(為table分配實際內存空間),入參為threshold,此時threshold為initialCapacity 默認是1<<4(2^4=16) if (table == EMPTY_TABLE) { inflateTable(threshold); } //如果key為null,存儲位置為table[0]或table[0]的沖突鏈上 if (key == null) return putForNullKey(value); int hash = hash(key);//對key的hashcode進一步計算,確保散列均勻 int i = indexFor(hash, table.length);//獲取在table中的實際位置 for (Entry<K,V> e = table[i]; e != null; e = e.next) { //如果該對應數據已存在,執行覆蓋操作。用新value替換舊value,並返回舊value Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++;//保證並發訪問時,若HashMap內部結構發生變化,快速響應失敗 addEntry(hash, key, value, i);//新增一個entry return null; }
先來看看inflateTable這個方法
private void inflateTable(int toSize) { int capacity = roundUpToPowerOf2(toSize);//capacity一定是2的次冪 threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//此處為threshold賦值,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值,capaticy一定不會超過MAXIMUM_CAPACITY,除非loadFactor大於1 table = new Entry[capacity]; initHashSeedAsNeeded(capacity); }
inflateTable這個方法用於為主干數組table在內存中分配存儲空間,通過roundUpToPowerOf2(toSize)可以確保capacity為大於或等於toSize的最接近toSize的二次冪,比如toSize=13,則capacity=16;to_size=16,capacity=16;to_size=17,capacity=32.
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; }
roundUpToPowerOf2中的這段處理使得數組長度一定為2的次冪,Integer.highestOneBit是用來獲取最左邊的bit(其他bit位為0)所代表的數值.
hash函數
//這是一個神奇的函數,用了很多的異或,移位等運算,對key的hashcode進一步進行計算以及二進制位的調整等來保證最終獲取的存儲位置盡量分布均勻 final int hash(Object k) { int h = hashSeed; if (0 != h && k instanceof String) { return sun.misc.Hashing.stringHash32((String) k); } h ^= k.hashCode(); h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
以上hash函數計算出的值,通過indexFor進一步處理來獲取實際的存儲位置
/** * 返回數組下標 */ static int indexFor(int h, int length) { return h & (length-1); }
h&(length-1)保證獲取的index一定在數組范圍內,舉個例子,默認容量16,length-1=15,h=18,轉換成二進制計算為
1 0 0 1 0 & 0 1 1 1 1 __________________ 0 0 0 1 0 = 2
最終計算出的index=2。有些版本的對於此處的計算會使用 取模運算,也能保證index一定在數組范圍內,不過位運算對計算機來說,性能更高一些(HashMap中有大量位運算)
所以最終存儲位置的確定流程是這樣的:
再來看看addEntry的實現:
void addEntry(int hash, K key, V value, int bucketIndex) { if ((size >= threshold) && (null != table[bucketIndex])) { resize(2 * table.length);//當size超過臨界閾值threshold,並且即將發生哈希沖突時進行擴容 hash = (null != key) ? hash(key) : 0; bucketIndex = indexFor(hash, table.length); } createEntry(hash, key, value, bucketIndex); }
通過以上代碼能夠得知,當發生哈希沖突並且size大於閾值的時候,需要進行數組擴容,擴容時,需要新建一個長度為之前數組2倍的新的數組,然后將當前的Entry數組中的元素全部傳輸過去,擴容后的新數組長度為之前的2倍,所以擴容相對來說是個耗資源的操作。
三、為何HashMap的數組長度一定是2的次冪?
我們來繼續看上面提到的resize方法
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); }
如果數組進行擴容,數組長度發生變化,而存儲位置 index = h&(length-1),index也可能會發生變化,需要重新計算index,我們先來看看transfer這個方法
void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; //for循環中的代碼,逐個遍歷鏈表,重新計算索引位置,將老數組數據復制到新數組中去(數組不存儲實際數據,所以僅僅是拷貝引用而已) 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); //將當前entry的next鏈指向新的索引位置,newTable[i]有可能為空,有可能也是個entry鏈,如果是entry鏈,直接在鏈表頭部插入。 e.next = newTable[i]; newTable[i] = e; e = next; } } }
這個方法將老數組中的數據逐個鏈表地遍歷,扔到新的擴容后的數組中,我們的數組索引位置的計算是通過 對key值的hashcode進行hash擾亂運算后,再通過和 length-1進行位運算得到最終數組索引位置。
hashMap的數組長度一定保持2的次冪,比如16的二進制表示為 10000,那么length-1就是15,二進制為01111,同理擴容后的數組長度為32,二進制表示為100000,length-1為31,二進制表示為011111。從下圖可以我們也能看到這樣會保證低位全為1,而擴容后只有一位差異,也就是多出了最左位的1,這樣在通過 h&(length-1)的時候,只要h對應的最左邊的那一個差異位為0,就能保證得到的新的數組索引和老數組索引一致(大大減少了之前已經散列良好的老數組的數據位置重新調換),個人理解。
還有,數組長度保持2的次冪,length-1的低位都為1,會使得獲得的數組索引index更加均勻,比如:
我們看到,上面的&運算,高位是不會對結果產生影響的(hash函數采用各種位運算可能也是為了使得低位更加散列),我們只關注低位bit,如果低位全部為1,那么對於h低位部分來說,任何一位的變化都會對結果產生影響,也就是說,要得到index=21這個存儲位置,h的低位只有這一種組合。這也是數組長度設計為必須為2的次冪的原因。
如果不是2的次冪,也就是低位不是全為1此時,要使得index=21,h的低位部分不再具有唯一性了,哈希沖突的幾率會變的更大,同時,index對應的這個bit位無論如何不會等於1了,而對應的那些數組位置也就被白白浪費了。
get方法
public V get(Object key) { //如果key為null,則直接去table[0]處去檢索即可。 if (key == null) return getForNullKey(); Entry<K,V> entry = getEntry(key); return null == entry ? null : entry.getValue(); }
get方法通過key值返回對應value,如果key為null,直接去table[0]處檢索。我們再看一下getEntry這個方法
final Entry<K,V> getEntry(Object key) { if (size == 0) { return null; } //通過key的hashcode值計算hash值 int hash = (key == null) ? 0 : hash(key); //indexFor (hash&length-1) 獲取最終數組索引,然后遍歷鏈表,通過equals方法比對找出對應記錄 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; }
可以看出,get方法的實現相對簡單,key(hashcode)-->hash-->indexFor-->最終索引位置,找到對應位置table[i],再查看是否有鏈表,遍歷鏈表,通過key的equals方法比對查找對應的記錄。要注意的是,有人覺得上面在定位到數組位置之后然后遍歷鏈表的時候,e.hash == hash這個判斷沒必要,僅通過equals判斷就可以。其實不然,試想一下,如果傳入的key對象重寫了equals方法卻沒有重寫hashCode,而恰巧此對象定位到這個數組位置,如果僅僅用equals判斷可能是相等的,但其hashCode和當前對象不一致,這種情況,根據Object的hashCode的約定,不能返回當前對象,而應該返回null,后面的例子會做出進一步解釋。
四、重寫equals方法需同時重寫hashCode方法
關於HashMap的源碼分析就介紹到這兒了,最后我們再聊聊老生常談的一個問題,各種資料上都會提到,“重寫equals時也要同時覆蓋hashcode”,我們舉個小例子來看看,如果重寫了equals而不重寫hashcode會發生什么樣的問題
/** * Created by chengxiao on 2016/11/15. */ public class MyTest { private static class Person{ int idCard; String name; public Person(int idCard, String name) { this.idCard = idCard; this.name = name; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()){ return false; } Person person = (Person) o; //兩個對象是否等值,通過idCard來確定 return this.idCard == person.idCard; } } public static void main(String []args){ HashMap<Person,String> map = new HashMap<Person, String>(); Person person = new Person(1234,"喬峰"); //put到hashmap中去 map.put(person,"天龍八部"); //get取出,從邏輯上講應該能輸出“天龍八部” System.out.println("結果:"+map.get(new Person(1234,"蕭峰"))); } }
實際輸出結果:
結果:null
如果我們已經對HashMap的原理有了一定了解,這個結果就不難理解了。盡管我們在進行get和put操作的時候,使用的key從邏輯上講是等值的(通過equals比較是相等的),但由於沒有重寫hashCode方法,所以put操作時,key(hashcode1)-->hash-->indexFor-->最終索引位置 ,而通過key取出value的時候 key(hashcode1)-->hash-->indexFor-->最終索引位置,由於hashcode1不等於hashcode2,導致沒有定位到一個數組位置而返回邏輯上錯誤的值null(也有可能碰巧定位到一個數組位置,但是也會判斷其entry的hash值是否相等,上面get方法中有提到。)
所以,在重寫equals的方法的時候,必須注意重寫hashCode方法,同時還要保證通過equals判斷相等的兩個對象,調用hashCode方法要返回同樣的整數值。而如果equals判斷不相等的兩個對象,其hashCode可以相同(只不過會發生哈希沖突,應盡量避免)。
五、總結
本文描述了HashMap的實現原理,並結合源碼做了進一步的分析,也涉及到一些源碼細節設計緣由,最后簡單介紹了為什么重寫equals的時候需要重寫hashCode方法。希望本篇文章能幫助到大家,同時也歡迎討論指正,謝謝支持!
HashMap的hashcode的作用?
- hashCode的存在主要是用於查找的快捷性,如Hashtable,HashMap等,hashCode是用來在散列存儲結構中確定對象的存儲地址的;
- 如果兩個對象相同,就是適用於equals(java.lang.Object) 方法,那么這兩個對象的hashCode一定要相同;
- 如果對象的equals方法被重寫,那么對象的hashCode也盡量重寫,並且產生hashCode使用的對象,一定要和equals方法中使用的一致,否則就會違反上面提到的第2點;
- 兩個對象的hashCode相同,並不一定表示兩個對象就相同,也就是不一定適用於equals(java.lang.Object) 方法,只能夠說明這兩個對象在散列存儲結構中,如Hashtable,他們“存放在同一個籃子里”。
什么時候需要重寫?
一般的地方不需要重載hashCode,只有當類需要放在HashTable、HashMap、HashSet等等hash結構的集合時才會重載hashCode,那么為什么要重載hashCode呢?就HashMap來說,好比HashMap就是一個大內存塊,里面有很多小內存塊,小內存塊里面是一系列的對象,可以利用hashCode來查找小內存塊hashCode%size(小內存塊數量),所以當equal相等時,hashCode必須相等,而且如果是object對象,必須重載hashCode和equal方法。
如何解決哈希沖突?
- 線性探查法(Linear Probing)
- 線性補償探測法
- 隨機探測
查找的時候流程是如何?
1.hashcode是用來查找的,如果你學過數據結構就應該知道,在查找和排序這一章有
例如內存中有這樣的位置
0 1 2 3 4 5 6 7
而我有個類,這個類有個字段叫ID,我要把這個類存放在以上8個位置之一,如果不用hashcode而任意存放,那么當查找時就需要到這八個位置里挨個去找,或者用二分法一類的算法。
但如果用hashcode那就會使效率提高很多。
我們這個類中有個字段叫ID,那么我們就定義我們的hashcode為ID%8,然后把我們的類存放在取得得余數那個位置。比如我們的ID為9,9除8的余數為1,那么我們就把該類存在1這個位置,如果ID是13,求得的余數是5,那么我們就把該類放在5這個位置。這樣,以后在查找該類時就可以通過ID除 8求余數直接找到存放的位置了。
2.但是如果兩個類有相同的hashcode怎么辦那(我們假設上面的類的ID不是唯一的),例如9除以8和17除以8的余數都是1,那么這是不是合法的,回答是:可以這樣。那么如何判斷呢?在這個時候就需要定義 equals了。
也就是說,我們先通過 hashcode來判斷兩個類是否存放某個桶里,但這個桶里可能有很多類,那么我們就需要再通過 equals 來在這個桶里找到我們要的類。
那么。重寫了equals(),為什么還要重寫hashCode()呢?
想想,你要在一個桶里找東西,你必須先要找到這個桶啊,你不通過重寫hashcode()來找到桶,光重寫equals()有什么用啊
總的來說,Java中的集合(Collection)有兩類,一類是List,再有一類是Set。你知道它們的區別嗎?前者集合內的元素是有序的,元素可以重復;后者元素無序,但元素不可重復。那么這里就有一個比較嚴重的問題了:要想保證元素不重復,可兩個元素是否重復應該依據什么來判斷呢?這就是 Object.equals方法了。但是,如果每增加一個元素就檢查一次,那么當元素很多時,后添加到集合中的元素比較的次數就非常多了。也就是說,如果集合中現在已經有1000個元素,那么第1001個元素加入集合時,它就要調用1000次equals方法。這顯然會大大降低效率。
於是,Java采用了哈希表的原理。哈希算法也稱為散列算法,是將數據依特定算法直接指定到一個地址上。關於哈希算法,這里就不詳細介紹。可以這樣簡單理解,hashCode方法實際上返回的就是對象存儲位置的映像。
這樣一來,當集合要添加新的元素時,先調用這個元素的hashCode方法,就能定位到它應該放置的存儲位置。如果這個位置上沒有元素,它就可以直接存儲在這個位置上,不用再進行任何比較了;如果這個位置上已經有元素了,就調用它的equals方法與新元素進行比較,相同的話就不存了,不相同就表示發生沖突了,散列表對於沖突有具體的解決辦法,但最終還會將新元素保存在適當的位置。這樣一來,實際調用equals方法的次數就大大降低了,幾乎只需要一兩次。
ConcurrentHashMap實現原理及源碼分析
ConcurrentHashMap是Java並發包中提供的一個線程安全且高效的HashMap實現,ConcurrentHashMap在並發編程的場景中使用頻率非常之高,本文就來分析下ConcurrentHashMap的實現原理,並對其實現原理進行分析(JDK1.7).
ConcurrentHashMap實現原理
眾所周知,哈希表是中非常高效,復雜度為O(1)的數據結構,在Java開發中,我們最常見到最頻繁使用的就是HashMap和HashTable,但是在線程競爭激烈的並發場景中使用都不夠合理。
HashMap :先說HashMap,HashMap是線程不安全的,在並發環境下,可能會形成環狀鏈表(擴容時可能造成,具體原因自行百度google或查看源碼分析),導致get操作時,cpu空轉,所以,在並發環境中使用HashMap是非常危險的。
HashTable : HashTable和HashMap的實現原理幾乎一樣,差別無非是1.HashTable不允許key和value為null;2.HashTable是線程安全的。但是HashTable線程安全的策略實現代價卻太大了,簡單粗暴,get/put所有相關操作都是synchronized的,這相當於給整個哈希表加了一把大鎖,多線程訪問時候,只要有一個線程訪問或操作該對象,那其他線程只能阻塞,相當於將所有的操作串行化,在競爭激烈的並發場景中性能就會非常差。
HashTable性能差主要是由於所有操作需要競爭同一把鎖,而如果容器中有多把鎖,每一把鎖鎖一段數據,這樣在多線程訪問時不同段的數據時,就不會存在鎖競爭了,這樣便可以有效地提高並發效率。這就是ConcurrentHashMap所采用的"分段鎖"思想。
ConcurrentHashMap源碼分析
ConcurrentHashMap采用了非常精妙的"分段鎖"策略,ConcurrentHashMap的主干是個Segment數組。
final Segment<K,V>[] segments;
Segment繼承了ReentrantLock,所以它就是一種可重入鎖(ReentrantLock)。在ConcurrentHashMap,一個Segment就是一個子哈希表,Segment里維護了一個HashEntry數組,並發環境下,對於不同Segment的數據進行操作是不用考慮鎖競爭的。(就按默認的ConcurrentLeve為16來講,理論上就允許16個線程並發執行,有木有很酷)
所以,對於同一個Segment的操作才需考慮線程同步,不同的Segment則無需考慮。
Segment類似於HashMap,一個Segment維護着一個HashEntry數組
transient volatile HashEntry<K,V>[] table;
HashEntry是目前我們提到的最小的邏輯處理單元了。一個ConcurrentHashMap維護一個Segment數組,一個Segment維護一個HashEntry數組。
static final class HashEntry<K,V> { final int hash; final K key; volatile V value; volatile HashEntry<K,V> next; //其他省略 }
我們說Segment類似哈希表,那么一些屬性就跟我們之前提到的HashMap差不離,比如負載因子loadFactor,比如閾值threshold等等,看下Segment的構造方法
Segment(float lf, int threshold, HashEntry<K,V>[] tab) { this.loadFactor = lf;//負載因子 this.threshold = threshold;//閾值 this.table = tab;//主干數組即HashEntry數組 }
我們來看下ConcurrentHashMap的構造方法
public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) { if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0) throw new IllegalArgumentException(); //MAX_SEGMENTS 為1<<16=65536,也就是最大並發數為65536 if (concurrencyLevel > MAX_SEGMENTS) concurrencyLevel = MAX_SEGMENTS; //2的sshif次方等於ssize,例:ssize=16,sshift=4;ssize=32,sshif=5 int sshift = 0; //ssize 為segments數組長度,根據concurrentLevel計算得出 int ssize = 1; while (ssize < concurrencyLevel) { ++sshift; ssize <<= 1; } //segmentShift和segmentMask這兩個變量在定位segment時會用到,后面會詳細講 this.segmentShift = 32 - sshift; this.segmentMask = ssize - 1; if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; //計算cap的大小,即Segment中HashEntry的數組長度,cap也一定為2的n次方. int c = initialCapacity / ssize; if (c * ssize < initialCapacity) ++c; int cap = MIN_SEGMENT_TABLE_CAPACITY; while (cap < c) cap <<= 1; //創建segments數組並初始化第一個Segment,其余的Segment延遲初始化 Segment<K,V> s0 = new Segment<K,V>(loadFactor, (int)(cap * loadFactor), (HashEntry<K,V>[])new HashEntry[cap]); Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize]; UNSAFE.putOrderedObject(ss, SBASE, s0); this.segments = ss; }
初始化方法有三個參數,如果用戶不指定則會使用默認值,initialCapacity為16,loadFactor為0.75(負載因子,擴容時需要參考),concurrentLevel為16。
從上面的代碼可以看出來,Segment數組的大小ssize是由concurrentLevel來決定的,但是卻不一定等於concurrentLevel,ssize一定是大於或等於concurrentLevel的最小的2的次冪。比如:默認情況下concurrentLevel是16,則ssize為16;若concurrentLevel為14,ssize為16;若concurrentLevel為17,則ssize為32。為什么Segment的數組大小一定是2的次冪?其實主要是便於通過按位與的散列算法來定位Segment的index。至於更詳細的原因,有興趣的話可以參考我的另一篇文章《HashMap實現原理及源碼分析》,其中對於數組長度為什么一定要是2的次冪有較為詳細的分析。
接下來,我們來看看put方法
public V put(K key, V value) { Segment<K,V> s; //concurrentHashMap不允許key/value為空 if (value == null) throw new NullPointerException(); //hash函數對key的hashCode重新散列,避免差勁的不合理的hashcode,保證散列均勻 int hash = hash(key); //返回的hash值無符號右移segmentShift位與段掩碼進行位運算,定位segment int j = (hash >>> segmentShift) & segmentMask; if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck (segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment s = ensureSegment(j); return s.put(key, hash, value, false); }
從源碼看出,put的主要邏輯也就兩步:1.定位segment並確保定位的Segment已初始化 2.調用Segment的put方法。
關於segmentShift和segmentMask
segmentShift和segmentMask這兩個全局變量的主要作用是用來定位Segment,int j =(hash >>> segmentShift) & segmentMask。
segmentMask:段掩碼,假如segments數組長度為16,則段掩碼為16-1=15;segments長度為32,段掩碼為32-1=31。這樣得到的所有bit位都為1,可以更好地保證散列的均勻性
segmentShift:2的sshift次方等於ssize,segmentShift=32-sshift。若segments長度為16,segmentShift=32-4=28;若segments長度為32,segmentShift=32-5=27。而計算得出的hash值最大為32位,無符號右移segmentShift,則意味着只保留高幾位(其余位是沒用的),然后與段掩碼segmentMask位運算來定位Segment。
get/put方法
get方法
public V get(Object key) { Segment<K,V> s; HashEntry<K,V>[] tab; int h = hash(key); long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE; //先定位Segment,再定位HashEntry if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null && (tab = s.table) != null) { for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE); e != null; e = e.next) { K k; if ((k = e.key) == key || (e.hash == h && key.equals(k))) return e.value; } } return null; }
get方法無需加鎖,由於其中涉及到的共享變量都使用volatile修飾,volatile可以保證內存可見性,所以不會讀取到過期數據。
來看下concurrentHashMap代理到Segment上的put方法,Segment中的put方法是要加鎖的。只不過是鎖粒度細了而已。
final V put(K key, int hash, V value, boolean onlyIfAbsent) { HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
//tryLock不成功時會遍歷定位到的HashEnry位置的鏈表(遍歷主要是為了使CPU緩存鏈表),若找不到,則創建HashEntry。
//tryLock一定次數后(MAX_SCAN_RETRIES變量決定),則lock。若遍歷過程中,由於其他線程的操作導致鏈表頭結點變化,則需要重新遍歷。 V oldValue; try { HashEntry<K,V>[] tab = table; int index = (tab.length - 1) & hash;
//定位HashEntry,可以看到,這個hash值在定位Segment時和在Segment中定位HashEntry都會用到,只不過定位Segment時只用到高幾位。 HashEntry<K,V> first = entryAt(tab, index); for (HashEntry<K,V> e = first;;) { if (e != null) { K k; if ((k = e.key) == key || (e.hash == hash && key.equals(k))) { oldValue = e.value; if (!onlyIfAbsent) { e.value = value; ++modCount; } break; } e = e.next; } else { if (node != null) node.setNext(first); else node = new HashEntry<K,V>(hash, key, value, first); int c = count + 1; //若c超出閾值threshold,需要擴容並rehash。擴容后的容量是當前容量的2倍。這樣可以最大程度避免之前散列好的entry重新散列,
//具體在另一篇文章中有詳細分析,不贅述。擴容並rehash的這個過程是比較消耗資源的。 if (c > threshold && tab.length < MAXIMUM_CAPACITY) rehash(node); else setEntryAt(tab, index, node); ++modCount; count = c; oldValue = null; break; } } } finally { unlock(); } return oldValue; }
總結
ConcurrentHashMap作為一種線程安全且高效的哈希表的解決方案,尤其其中的"分段鎖"的方案,相比HashTable的全表鎖在性能上的提升非常之大。本文對ConcurrentHashMap的實現原理進行了詳細分析,並解讀了部分源碼,希望能幫助到有需要的童鞋。