本文主要討論jdk1.7下hashMap的源碼實現,其中主要是在擴容時容易出現死循環的問題,以及put元素的整個過程。java
數組+鏈表
示例圖以下:算法
常量屬性數組
/** * The default initial capacity - MUST be a power of two. * 默認初始容量大小 */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 /** * MUST be a power of two <= 1<<30. * hashMap最大容量,可裝元素個數 */ static final int MAXIMUM_CAPACITY = 1 << 30; /** * The load factor used when none specified in constructor. * 加載因子,如容量爲16,默認閾值即爲16*0.75=12,元素個數超過(包含)12且,擴容 */ static final float DEFAULT_LOAD_FACTOR = 0.75f; /** * 空數組,默認數組爲空,初始化後才纔有內存地址,第一次put元素時判斷,延遲初始化 */ static final Entry<?,?>[] EMPTY_TABLE = {};
擴容致使的死循環,jdk1.7中在多線程高併發環境容易出死循環,致使cpu使用率太高問題,問題出在擴容方法resize()中,更具體內部的transfer方法:將舊數組元素轉移到新數組過程當中,源碼以下:安全
void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; //1.若是原來數組容量等於最大值了,2^30,設置擴容閾值爲Integer最大值,不須要再擴容 if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; } //2.建立新數組對象 Entry[] newTable = new Entry[newCapacity]; //3.將舊數組元素轉移到新數組中,分析一 transfer(newTable, initHashSeedAsNeeded(newCapacity)); //4.從新引用新數組對象和計算新的閾值 table = newTable; threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); }
transfer方法多線程
/** * Transfers all entries from current table to newTable. * 從當前數組中轉移全部的節點到新數組中 */ void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; //遍歷舊數組 for (Entry<K,V> e : table) { //1,首先獲取數組下標元素 while(null != e) { //2.獲取數組該桶位置鏈表中下一個元素 Entry<K,V> next = e.next; //3.是否須要從新該元素key的hash值 if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } //4,從新肯定在新數組中下標位置 int i = indexFor(e.hash, newCapacity); //5.頭插法:插入新鏈表該桶位置,如有元素,就造成鏈表,每次新加入的節點都插在第一位,就數組下標位置 e.next = newTable[i]; newTable[i] = e; //6.繼續獲取鏈表下一個元素 e = next; } } } //傳入容量值返回是否須要對key從新Hash final boolean initHashSeedAsNeeded(int capacity) { //1.hashSeed默認爲0,所以currentAltHashing爲false boolean currentAltHashing = hashSeed != 0; //2,sun.misc.VM.isBooted()在類加載啓動成功後,狀態會修改成true // 所以變數在於,capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD,debug發現正常狀況ALTERNATIVE_HASHING_THRESHOLD是一個很大的值,使用的是Integer的最大值 boolean useAltHashing = sun.misc.VM.isBooted() && (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD); //3,二者異或,只有不相同時才爲true,即useAltHashing =true時,dubug代碼發現useAltHashing =false, boolean switching = currentAltHashing ^ useAltHashing; if (switching) { hashSeed = useAltHashing ? sun.misc.Hashing.randomHashSeed(this) : 0; } //正常狀況下是返回false,即不須要從新對key哈希 return switching; }
上面源碼展現轉移元素過程:併發
如下模擬2個線程併發操做hashMap 在put元素時形成的死循環過程:app
鏈表死循環圖例:dom
1.7的put方法,因沒有紅黑樹結構,相比較1.8簡單, 容易理解,流程圖以下所示:函數
代碼以下:高併發
public V put(K key, V value) { //1,若當前數組爲空,初始化 if (table == EMPTY_TABLE) { //分析1 inflateTable(threshold); } //2,若put的key爲null,在放置在數組下標第一位,索引爲0位置,從該源碼可知 // hashMap容許 鍵值對 key=null,可是隻能有惟一一個 if (key == null) // 分析2 return putForNullKey(value); //3,計算key的hash,這裏與1.8有區別 //分析3 int hash = hash(key); // 4,肯定在數組下標位置,與1.8相同 int i = indexFor(hash, table.length); // 5,遍歷該數組位置,即該桶處遍歷 for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { // 找到相同的key,則覆蓋原value值,返回舊值 V oldValue = e.value; e.value = value; //該方法爲空,不用看 e.recordAccess(this); return oldValue; } } //由於hashMap線程不安全,修改操做沒有同步鎖, //該字段值用於記錄修改次數,用於快速失敗機制 fail-fast,防止其餘線程同時作了修改,拋出併發修改異常 modCount++; // 6,原數組中沒有相同的key,以頭插法插入新的元素 //分析4 addEntry(hash, key, value, i); return null; }
分析1: HashMap如何初始化數組的,延遲初始化有什麼好處?
結論: 一、1.7,1.8都是延遲初始化,在put第一個元素時建立數組,目的是爲了節省內存。
初始化代碼:
private void inflateTable(int toSize) { // Find a power of 2 >= toSize //1.該方法很是重要,目的爲了獲得一個比toSize最接近的2的冪次方的數, // 且該數要>=toSize,這個2的冪次方方便後面各類位運算 // 如:new HashMap(15),指定15大小集合,內部實際 建立數組大小爲2^4=16 // 分析見下 int capacity = roundUpToPowerOf2(toSize); //2,肯定擴容閾值 threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1); //3,初始化數組對象 table = new Entry[capacity]; initHashSeedAsNeeded(capacity); }
Q:如何確保獲取到比toSize 最接近且大於等於它的2的冪次方的數?
深刻理解roundUpToPowerOf2方法:
private static int roundUpToPowerOf2(int number) { // assert number >= 0 : "number must be non-negative"; //若是number大於等於最大值 2^30,賦值爲最大,主要是防止傳參越界,number必定是否非負的 return number >= MAXIMUM_CAPACITY ? MAXIMUM_CAPACITY : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1; //核心在於Integer.highestOneBit((number - 1) << 1) 此處 }
先拋出2個問題:
1:這個 (number - 1) << 1 的做用是什麼?
2:這個方法highestOneBit確定是爲了獲取到知足條件的2的冪次方的數,背後的原理呢?
結論: Integer的方法highestOneBit(i) 這個方法是經過位運算,獲取到i的二進制位最左邊(最高位)的1,其他位都抹去,置爲0,即獲取的是小於等於i的2的冪次方的數.
若是直接傳入number,那麼獲取到的是2的冪次方的數,可是該數必定小於等於number,但這不是咱們的目的;
如highestOneBit(15)=8highestOneBit(21)=16而咱們是想要獲取一個剛剛大於等於number的2次方的數,(number-1)<<1 所以須要先將number 擴大二倍number <<1 , 爲何須要number-1,是考慮到臨界值問題,剛好number自己就是2的冪次方,如 number=16,擴大2倍後爲32, highestOneBit方法計算後結果仍是32,這不符合需求。
public static int highestOneBit(int i) { // HD, Figure 3-1 i |= (i >> 1); i |= (i >> 2); i |= (i >> 4); i |= (i >> 8); i |= (i >> 16); return i - (i >>> 1); }
2的冪次方二進值特色:只有最高位爲1,其餘位全爲0
目的:將傳入i的二進制最左邊的1保留,其他低位的1全變爲0
原理:某數二進制: 0001 ,不關心其低位是什麼,以*代替,進行運算
i |= (i >> 1); 0001**** | 00001*** ---------- 00011*** #保證左邊2位是1
i |= (i >> 2); 00011*** | 0000011* ---------- 0001111* #保證左邊4位是1
i |= (i >> 4); 0001111* | 00000001 ---------- 00011111 #把高位如下全部位變爲1了,該數仍是隻有5位,該計算可將8位下全部的置爲1
Q:爲何要再執行右移8位,16位?
因int類型 4個字節,32位,這樣能夠必定能夠保證將低位全置爲1;
i - (i >>> 1); #此時 i= 00011111 00011111 - 00001111 #無符號右移1位 --------- 00010000 #拿到值
分析2: HashMap如何處理key 爲null狀況,value呢?
結論:
private V putForNullKey(V value) { //1.直接table[0] 位置獲取,先遍歷鏈表(這裏對該數組位置統稱爲鏈表,可能沒有元素,或者只有一個元素,或者鏈表)查找是否存在相同的key,存在覆蓋原值 for (Entry<K,V> e = table[0]; e != null; e = e.next) { if (e.key == null) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; //此時注意添加節點時,第一個0即表明數組下標位置,後面會分析該方法 addEntry(0, null, value, 0); return null; }
分析3:如何實現hash算法,保證key的hash值均勻分散,減小hash衝突?
jdk1.7中爲了儘量的對key的hash後均勻分散,擾動函數實現採用了 5次異或+4次位移
final int hash(Object k) { int h = hashSeed; if (0 != h && k instanceof String) { return sun.misc.Hashing.stringHash32((String) k); } //k的hashCode值 與hashSeed 異或 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); }
分析4:插入新的節點到map中,若是原數組總元素個數超過閾值,先擴容再插入節點
void addEntry(int hash, K key, V value, int bucketIndex) { //總元素個數大於等於閾值 且 當前數組下標已存在元素了: 擴容 if ((size >= threshold) && (null != table[bucketIndex])) { //1,擴容,上面已分析過代碼 resize(2 * table.length); //2,計算新加key的hash值,key爲null的hash值爲0 hash = (null != key) ? hash(key) : 0; //3,確保計算的數組下標必定在數組有效索引內,見分析5 bucketIndex = indexFor(hash, table.length); } // 4,擴容後再插入新數組中 createEntry(hash, key, value, bucketIndex); } //分析5 static int indexFor(int h, int length) { // 與數組長度-1與運算,必定能夠確保結果值在數組有效索引內,且均勻分散 return h & (length-1); } // 進一步分析插入節點方法 void createEntry(int hash, K key, V value, int bucketIndex) { //1,首先獲取新數組索引位置元素 Entry<K,V> e = table[bucketIndex]; //2,頭插法插入新節點, Entry構造方法第4個參數e表示指定當前新增節點的next指針指向該節點,造成鏈表 table[bucketIndex] = new Entry<>(hash, key, value, e); //3,map元素個數+1 size++; }
參考: