jdk1.7的HashMap採用數組+單鏈表實現,儘管定義了hash函數來避免衝突,但由於數組長度有限,仍是會出現兩個不一樣的Key通過計算後在數組中的位置同樣,1.7版本中採用了鏈表來解決。java
從上面的簡易示圖中也能發現,若是位於鏈表中的結點過多,那麼很顯然經過key值依次查找效率過低,因此在1.8中對其進行了改良,採用數組+鏈表+紅黑樹來實現,當鏈表長度超過閾值8時,將鏈表轉換爲紅黑樹.具體細節參考我上一篇總結的 深刻理解jdk8中的HashMap算法
從上面圖中也知道實際上每一個元素都是Entry類型,因此下面再來看看Entry中有哪些屬性(在1.8中Entry更名爲Node,一樣實現了Map.Entry)。數組
//hash標中的結點Node,實現了Map.Entry
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
//Entry構造器,須要key的hash,key,value和next指向的結點
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
//equals方法
public final boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry e = (Map.Entry)o;
Object k1 = getKey();
Object k2 = e.getKey();
if (k1 == k2 || (k1 != null && k1.equals(k2))) {
Object v1 = getValue();
Object v2 = e.getValue();
if (v1 == v2 || (v1 != null && v1.equals(v2)))
return true;
}
return false;
}
//重寫Object的hashCode
public final int hashCode() {
return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
}
public final String toString() {
return getKey() + "=" + getValue();
}
//調用put(k,v)方法時候,若是key相同即Entry數組中的值會被覆蓋,就會調用此方法。
void recordAccess(HashMap<K,V> m) {
}
//只要從表中刪除entry,就會調用此方法
void recordRemoval(HashMap<K,V> m) {
}
}
複製代碼
//默認初始化容量初始化=16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大容量 = 1 << 30
static final int MAXIMUM_CAPACITY = 1 << 30;
//默認加載因子.通常HashMap的擴容的臨界點是當前HashMap的大小 > DEFAULT_LOAD_FACTOR *
//DEFAULT_INITIAL_CAPACITY = 0.75F * 16
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//默認是空的table數組
static final Entry<?,?>[] EMPTY_TABLE = {};
//table[]默認也是上面給的EMPTY_TABLE空數組,因此在使用put的時候必須resize長度爲2的冪次方值
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
//map中的實際元素個數 != table.length
transient int size;
//擴容閾值,當size大於等於其值,會執行resize操做
//通常狀況下threshold=capacity*loadFactor
int threshold;
//hashTable的加載因子
final float loadFactor;
/** * The number of times this HashMap has been structurally modified * Structural modifications are those that change the number of mappings in * the HashMap or otherwise modify its internal structure (e.g., * rehash). This field is used to make iterators on Collection-views of * the HashMap fail-fast. (See ConcurrentModificationException). */
transient int modCount;
//hashSeed用於計算key的hash值,它與key的hashCode進行按位異或運算
//hashSeed是一個與實例相關的隨機值,用於解決hash衝突
//若是爲0則禁用備用哈希算法
transient int hashSeed = 0;
複製代碼
咱們看看HashMap源碼中爲咱們提供的四個構造方法。安全
//(1)無參構造器:
//構造一個空的table,其中初始化容量爲DEFAULT_INITIAL_CAPACITY=16。加載因子爲DEFAULT_LOAD_FACTOR=0.75F
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
複製代碼
//(2)指定初始化容量的構造器
//構造一個空的table,其中初始化容量爲傳入的參數initialCapacity。加載因子爲DEFAULT_LOAD_FACTOR=0.75F
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
複製代碼
//(3)指定初始化容量和加載因子的構造器
//構造一個空的table,初始化容量爲傳入參數initialCapacity,加載因子爲loadFactor
public HashMap(int initialCapacity, float loadFactor) {
//對傳入初始化參數進行合法性檢驗,<0就拋出異常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//若是initialCapacity大於最大容量,那麼容量=MAXIMUM_CAPACITY
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//對傳入加載因子參數進行合法檢驗,
if (loadFactor <= 0 || Float.isNaN(loadFactor))
//<0或者不是Float類型的數值,拋出異常
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//兩個參數檢驗完了,就給本map實例的屬性賦值
this.loadFactor = loadFactor;
threshold = initialCapacity;
//init是一個空的方法,模板方法,若是有子類須要擴展能夠自行實現
init();
}
複製代碼
從上面的這3個構造方法中咱們能夠發現雖然指定了初始化容量大小,但此時的table仍是空,是一個空數組,且擴容閾值threshold爲給定的容量或者默認容量(前兩個構造方法實際上都是經過調用第三個來完成的)。在其put操做前,會建立數組(跟jdk8中使用無參構造時候相似).多線程
//(4)參數爲一個map映射集合
//構造一個新的map映射,使用默認加載因子,容量爲參數map大小除以默認負載因子+1與默認容量的最大值
public HashMap(Map<? extends K, ? extends V> m) {
//容量:map.size()/0.75+1 和 16二者中更大的一個
this(Math.max(
(int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY),
DEFAULT_LOAD_FACTOR);
inflateTable(threshold);
//把傳入的map裏的全部元素放入當前已構造的HashMap中
putAllForCreate(m);
}
複製代碼
這個構造方法即是在put操做前調用inflateTable方法,這個方法具體的做用就是建立一個新的table用之後面使用putAllForCreate裝入傳入的map中的元素,這個方法咱們來看下,注意剛也提到了此時的threshold擴容閾值是初始容量。下面對其中的一些方法進行說明併發
這個方法比較重要,在第四種構造器中調用了這個方法。而若是建立集合對象的時候使用的是前三種構造器的話會在調用put方法的時候調用該方法對table進行初始化app
private void inflateTable(int toSize) {
//返回不小於number的最小的2的冪數,最大爲MAXIMUM_CAPACITY,類比jdk8的實現中的tabSizeFor的做用
int capacity = roundUpToPowerOf2(toSize);
//擴容閾值爲:(容量*加載因子)和(最大容量+1)中較小的一個
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
//建立table數組
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
複製代碼
private static int roundUpToPowerOf2(int number) {
//number >= 0,不能爲負數,
//(1)number >= 最大容量:就返回最大容量
//(2)0 =< number <= 1:返回1
//(3)1 < number < 最大容量:
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}
//該方法和jdk8中的tabSizeFor實現基本差很少
public static int highestOneBit(int i) {
//由於傳入的i>0,因此i的高位仍是0,這樣使用>>運算符就至關於>>>了,高位0。
//仍是舉個例子,假設i=5=0101
i |= (i >> 1); //(1)i>>1=0010;(2)i= 0101 | 0010 = 0111
i |= (i >> 2); //(1)i>>2=0011;(2)i= 0111 | 0011 = 0111
i |= (i >> 4); //(1)i>>4=0000;(2)i= 0111 | 0000 = 0111
i |= (i >> 8); //(1)i>>8=0000;(2)i= 0111 | 0000 = 0111
i |= (i >> 16); //(1)i>>16=0000;(2)i= 0111 | 0000 = 0111
return i - (i >>> 1); //(1)0111>>>1=0011(2)0111-0011=0100=4
//因此這裏返回4。
//而在上面的roundUpToPowerOf2方法中,最後會將highestOneBit的返回值進行 << 1 操做,即最後的結果爲4<<1=8.就是返回大於number的最小2次冪
}
複製代碼
該方法就是遍歷傳入的map集合中的元素,而後加入本map實例中。下面咱們來看看該方法的實現細節函數
private void putAllForCreate(Map<? extends K, ? extends V> m) {
//實際上就是遍歷傳入的map,將其中的元素添加到本map實例中(putForCreate方法實現)
for (Map.Entry<? extends K, ? extends V> e : m.entrySet())
putForCreate(e.getKey(), e.getValue());
}
複製代碼
putForCreate方法原理實現post
private void putForCreate(K key, V value) {
//判斷key是否爲null,若是爲null那麼對應的hash爲0,不然調用剛剛上面說到的hash()方法計算hash值
int hash = null == key ? 0 : hash(key);
//根據剛剛計算獲得的hash值計算在table數組中的下標
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//hash相同,key也相同,直接用舊的值替換新的值
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {
e.value = value;
return;
}
}
//這裏就是:要插入的元素的key與前面的鏈表中的key都不相同,因此須要新加一個結點加入鏈表中
createEntry(hash, key, value, i);
}
複製代碼
void createEntry(int hash, K key, V value, int bucketIndex) {
//這裏說的是,前面的鏈表中不存在相同的key,因此調用這個方法建立一個新的結點,而且結點所在的桶
//bucket的下標指定好了
Entry<K,V> e = table[bucketIndex];
/*Entry(int h, K k, V v, Entry<K,V> n) {value = v;next = n;key = k;hash = h;}*/
table[bucketIndex] = new Entry<>(hash, key, value, e);//Entry的構造器,建立一個新的結點做爲頭節點(頭插法)
size++;//將當前hash表中的數量加1
}
複製代碼
1.7中的計算hash值的算法和1.8的實現是不同的,而hash值又關係到咱們put新元素的位置、get查找元素、remove刪除元素的時候去經過indexFor查找下標。因此咱們來看看這兩個方法this
final int hash(Object k) {
int h = hashSeed;
//默認是0,不是0那麼須要key是String類型才使用stringHash32這種hash方法
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
//這段代碼是爲了對key的hashCode進行擾動計算,防止不一樣hashCode的高位不一樣但低位相同致使的hash衝突。簡單點
//說,就是爲了把高位的特徵和低位的特徵組合起來,下降哈希衝突的機率,也就是說,儘可能作到任何一位的變化都能對
//最終獲得的結果產生影響
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
複製代碼
咱們經過下面的例子來講明對於key的hashCode進行擾動處理的重要性,咱們如今想向一個map中put一個Key-Value對,Key的值爲「fsmly」,不進行任何的擾動處理知識單純的通過簡單的獲取hashcode後,獲得的值爲「0000_0000_0011_0110_0100_0100_1001_0010」,若是當前map的中的table數組長度爲16,最終獲得的index結果值爲10。因爲15的二進制擴展到32位爲「00000000000000000000000000001111」,因此,一個數字在和他進行按位與操做的時候,前28位不管是什麼,計算結果都同樣(由於0和任何數作與,結果都爲0,那這樣的話一個put的Entry結點就太過依賴於key的hashCode的低位值,產生衝突的機率也會大大增長)。以下圖所示
由於map的數組長度是有限的,這樣衝突機率大的方法是不適合使用的,因此須要對hashCode進行擾動處理下降衝突機率,而JDK7中對於這個處理使用了四次位運算,仍是經過下面的簡單例子看一下這個過程.能夠看到,剛剛不進行擾動處理的hashCode在進行處理後就沒有產生hash衝突了。
總結一下:咱們會首先計算傳入的key的hash值而後經過下面的indexFor方法肯定在table中的位置,具體實現就是經過一個計算出來的hash值和length-1作位運算,那麼對於2^n來講,長度減一轉換成二進制以後就是低位全一(長度16,len-1=15,二進制就是1111)。上面四次擾動的這種設定的好處就是,對於獲得的hashCode的每一位都會影響到咱們索引位置的肯定,其目的就是爲了能讓數據更好的散列到不一樣的桶中,下降hash衝突的發生。關於Java集合中存在hash方法的更多原理和細節,請參考這篇hash()方法分析
static int indexFor(int h, int length) {
//仍是使用hash & (n - 1)計算獲得下標
return h & (length-1);
}
複製代碼
主要實現就是將計算的key的hash值與map中數組長度length-1進行按位與運算,獲得put的Entry在table中的數組下標。具體的計算過程在上面hash方法介紹的時候也有示例,這裏就不贅述了。
public V put(K key, V value) {
//咱們知道Hash Map有四中構造器,而只有一種(參數爲map的)初始化了table數組,其他三個構造器只
//是賦值了閾值和加載因子,因此使用這三種構造器建立的map對象,在調用put方法的時候table爲{},
//其中沒有元素,因此須要對table進行初始化
if (table == EMPTY_TABLE) {
//調用inflateTable方法,對table進行初始化,table的長度爲:
//不小於threshold的最小的2的冪數,最大爲MAXIMUM_CAPACITY
inflateTable(threshold);
}
//若是key爲null,表示插入一個鍵爲null的K-V對,須要調用putForNullKey方法
if (key == null)
return putForNullKey(value);
//計算put傳入的key的hash值
int hash = hash(key);
//根據hash值和table的長度計算所在的下標
int i = indexFor(hash, table.length);
//從數組中下標爲indexFor(hash, table.length)處開始(1.7中是用鏈表解決hash衝突的,這裏就
//是遍歷鏈表),實際上就是已經定位到了下標i,這時候就須要處理可能出現hash衝突的問題
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//hash值相同,key相同,替換該位置的oldValue爲value
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
//空方法,讓其子類重寫
e.recordAccess(this);
return oldValue;
}
}
//若是key不相同,即在鏈表中沒有找到相同的key,那麼須要將這個結點加入table[i]這個鏈表中
//修改modCount值(後續總結文章會說到這個問題)
modCount++;
//遍歷沒有找到該key,就調用該方法添加新的結點
addEntry(hash, key, value, i);
return null;
}
複製代碼
這個方法是處理key爲null的狀況的,當傳入的key爲null的時候,會在table[0]位置開始遍歷,遍歷的其實是當前以table[0]爲head結點的鏈表,若是找到鏈表中結點的key爲null,那麼就直接替換掉舊值爲傳入的value。不然建立一個新的結點而且加入的位置爲table[0]位置處。
//找到table數組中key爲null的那個Entry對象,而後將其value進行替換
private V putForNullKey(V value) {
//從table[0]開始遍歷
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
//key爲null
if (e.key == null) {
//將value替換爲傳遞進來的value
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue; //返回舊值
}
}
modCount++;
//若不存在,0位置桶上的鏈表中添加新結點
addEntry(0, null, value, 0);
return null;
}
複製代碼
addEntry方法的主要做用就是判斷當前的size是否大於閾值,而後根據結果判斷是否擴容,最終建立一個新的結點插入在鏈表的頭部(實際上就是table數組中的那個指定下標位置處)
/* hashmap採用頭插法插入結點,爲何要頭插而不是尾插,由於後插入的數據被使用的頻次更高,而單鏈表沒法隨機訪問只能從頭開始遍歷查詢,因此採用頭插.忽然又想爲何不採用二維數組的形式利用線性探查法來處理衝突,數組末尾插入也是O(1),可數組其最大缺陷就是在於若不是末尾插入刪除效率很低,其次若添加的數據分佈均勻那麼每一個桶上的數組都須要預留內存. */
void addEntry(int hash, K key, V value, int bucketIndex) {
//這裏有兩個條件
//①size是否大於閾值
//②當前傳入的下標在table中的位置不爲null
if ((size >= threshold) && (null != table[bucketIndex])) {
//若是超過閾值須要進行擴容
resize(2 * table.length);
//下面是擴容以後的操做
//計算不爲null的key的hash值,爲null就是0
hash = (null != key) ? hash(key) : 0;
//根據hash計算下標
bucketIndex = indexFor(hash, table.length);
}
//執行到這裏表示(可能已經擴容也可能沒有擴容),建立一個新的Entry結點
createEntry(hash, key, value, bucketIndex);
}
複製代碼
void resize(int newCapacity) {
//獲取map中的舊table數組暫存起來
Entry[] oldTable = table;
//獲取原table數組的長度暫存起來
int oldCapacity = oldTable.length;
//若是原table的容量已經超過了最大值,舊直接將閾值設置爲最大值
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//以傳入的新的容量長度爲新的哈希表的長度,建立新的數組
Entry[] newTable = new Entry[newCapacity];
//調用transfer
transfer(newTable, initHashSeedAsNeeded(newCapacity));
//table指向新的數組
table = newTable;
//更新閾值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
複製代碼
transfer方法遍歷舊數組全部Entry,根據新的容量逐個從新計算索引頭插保存在新數組中。
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) {
//從新計算hash值
e.hash = null == e.key ? 0 : hash(e.key);
}
//這裏根據剛剛獲得的新hash從新調用indexFor方法計算下標索引
int i = indexFor(e.hash, newCapacity);
//假設當前數組中某個位置的鏈表結構爲a->b->c;women
//(1)當爲原鏈表中的第一個結點的時候:e.next=null;newTable[i]=e;e=e.next
//(2)當遍歷到原鏈表中的後續節點的時候:e.next=head;newTable[i]=e(這裏將頭節點設置爲新插入的結點,即頭插法);e=e.next
//(3)這裏也是致使擴容後,鏈表順序反轉的原理(代碼就是這樣寫的,鏈表反轉,固然前提是計算的新下標仍是相同的)
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
複製代碼
這個方法的主要部分就是,在從新計算hash以後對於原鏈表和新table中的鏈表結構的差別,咱們經過下面這個簡單的圖理解一下,假設原table中位置爲4處爲一個鏈表entry1->entry2->entry3,三個結點在新數組中的下標計算仍是4,那麼這個流程大概以下圖所示
//get方法,其中調用的是getEntry方法沒若是不爲null就返回對應entry的value
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
複製代碼
能夠看到,get方法中是調用getEntry查詢到Entry對象,而後返回Entry的value的。因此下面看看getEntry方法的實現
//這是getEntry的實現
final Entry<K,V> getEntry(Object key) {
//沒有元素天然返回null
if (size == 0) {
return null;
}
//經過傳入的key值調用hash方法計算哈希值
int hash = (key == null) ? 0 : hash(key);
//計算好索引以後,從對應的鏈表中遍歷查找Entry
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
//hash相同,key相同就返回
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
複製代碼
//這個方法是直接查找key爲null的
private V getForNullKey() {
if (size == 0) {
return null;
}
//直接從table中下標爲0的位置處的鏈表(只有一個key爲null的)開始查找
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
//key爲null,直接返回對應的value
if (e.key == null)
return e.value;
}
return null;
}
複製代碼
(1)由於其put操做對key爲null場景使用putForNullKey方法作了單獨處理,HashMap容許null做爲Key
(2)在計算table的下標的時候,是根據key的hashcode值調用hash()方法以後獲取hash值與數組length-1進行&運算,length-1的二進制位全爲1,這是爲了可以均勻分佈,避免衝突(長度要求爲2的整數冪次方) (3)不論是get仍是put以及resize,執行過程當中都會對key的hashcode進行hash計算,而可變對象其hashcode很容易變化,因此HashMap建議用不可變對象(如String類型)做爲Key. (4)HashMap是線程不安全的,在多線程環境下擴容時候可能會致使環形鏈表死循環,因此若須要多線程場景下操做可使用ConcurrentHashMap(下面咱們經過圖示簡單演示一下這個狀況) (5)當發生衝突時,HashMap採用鏈地址法處理衝突 (6)HashMap初始容量定爲16,簡單認爲是8的話擴容閾值爲6,閾值過小致使擴容頻繁;而32的話可能空間利用率低。
上面在說到resize方法的時候,咱們也經過圖示實例講解了一個resize的過程,因此這裏咱們就再也不演示單線程下面的執行流程了。咱們首先記住resize方法中的幾行核心代碼
Entry<K,V> next = e.next;
//省略從新計算hash和index的兩個過程...
e.next = newTable[i];
newTable[i] = e;
e = next;
複製代碼
resize方法中調用的transfer方法的主要幾行代碼就是上面的這四行,下來簡單模擬一下假設兩個線程thread1和thread2執行了resize的過程.
(1)resize以前,假設table長度爲2,假設如今再添加一個entry4,就須要擴容了
(2)假設如今thread1執行到了 **Entry<K,V> next = e.next;**這行代碼處,那麼根據上面幾行代碼,咱們簡單作個註釋
(3)而後因爲線程調度輪到thread2執行,假設thread2執行完transfer方法(假設entry3和entry4在擴容後到了以下圖所示的位置,這裏咱們主要關注entry1和entry2兩個結點),那麼獲得的結果爲
(4)此時thread1被調度繼續執行,將entry1插入到新數組中去,而後e爲Entry2,輪到下次循環時next因爲Thread2的操做變爲了Entry1
以下圖所示
(5)thread1繼續執行,將entry2拿下來,放在newTable[1]這個桶的第一個位置,而後移動e和next
(6)e.next = newTable[1] 致使 entry1.next 指向了 entry2,也要注意,此時的entry2.next 已經指向了entry1(thread2執行的結果就是entry2->entry1,看上面的thread2執行完的示意圖), 環形鏈表就這樣出現了。