JAVA集合-Map集合分析-HashMap
HashMap的特點:
1.HashMap 是一個散列表,它儲存的內容是鍵值對(key-value)對映。
2.HashMap 繼承於AbstractMap,實現了Map、Cloneable、java.io.Serializable介面。
3.HashMap 的實現不是同步的,這意味著它不是執行緒安全的。它的key、value都可以為null。此外,HashMap中的對映不是有序的。
1.類中的關鍵屬性
// 預設的初始容量是16,必須是2的冪。 static final int DEFAULT_INITIAL_CAPACITY = 16; // 最大容量(必須是2的冪且小於2的30次方,傳入容量過大將被這個值替換) static final int MAXIMUM_CAPACITY = 1 << 30; // 預設載入因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; // 儲存資料的Entry陣列,長度是2的冪。 // HashMap是採用拉鍊法實現的,每一個Entry本質上是一個單向連結串列 transient Entry[] table; // HashMap的大小,它是HashMap儲存的鍵值對的數量 transient int size; // HashMap的閾值,用於判斷是否需要調整HashMap的容量(threshold = 容量*載入因子) int threshold; // 載入因子實際大小 final float loadFactor; // HashMap被改變的次數 transient volatile int modCount;
2.構造方法
2.1無參建構函式
// 預設建構函式。 public HashMap() { // 設定“載入因子” this.loadFactor = DEFAULT_LOAD_FACTOR; // 設定“HashMap閾值”,當HashMap中儲存資料的數量達到threshold時,就需要將HashMap的容量加倍。 threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR); // 建立Entry陣列,用來儲存資料 table = new Entry[DEFAULT_INITIAL_CAPACITY]; init(); }
2.2指定容量大小的建構函式
// 指定“容量大小”的建構函式
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
2.3 指定“容量大小”和“載入因子”的建構函式
// 指定“容量大小”和“載入因子”的建構函式 public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); // HashMap的最大容量只能是MAXIMUM_CAPACITY if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); // 找出“大於initialCapacity”的最小的2的冪 int capacity = 1; while (capacity < initialCapacity) capacity <<= 1; // 設定“載入因子” this.loadFactor = loadFactor; // 設定“HashMap閾值”,當HashMap中儲存資料的數量達到threshold時,就需要將HashMap的容量加倍。 threshold = (int)(capacity * loadFactor); // 建立Entry陣列,用來儲存資料 table = new Entry[capacity]; init(); }
2.4包含“子Map”的建構函式
// 包含“子Map”的建構函式
public HashMap(Map<? extends K, ? extends V> m) {
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
// 將m中的全部元素逐個新增到HashMap中
putAllForCreate(m);
}
3.儲存資料
3.1put()
若要新增到HashMap中的鍵值對對應的key已經存在HashMap中,則找到該鍵值對;然後新的value取代舊的value,並退出!
若要新增到HashMap中的鍵值對對應的key不在HashMap中,則將其新增到該雜湊值對應的連結串列中,並呼叫addEntry()。
public V put(K key, V value) {
// 若“key為null”,則將該鍵值對新增到table[0]中。
if (key == null)
return putForNullKey(value);
// 若“key不為null”,則計算該key的雜湊值,然後將其新增到該雜湊值對應的連結串列中。
int hash = hash(key.hashCode());
//搜尋指定hash值在對應table中的索引
int i = indexFor(hash, table.length);
// 迴圈遍歷Entry陣列,若“該key”對應的鍵值對已經存在,則用新的value取代舊的value。然後退出!
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相同則覆蓋並返回舊值
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
//修改次數+1
modCount++;
//將key-value新增到table[i]處
addEntry(hash, key, value, i);
return null;
}
3.2Entry的資料結構,Entry實際上是一個單向連結串列
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
// 指向下一個節點
Entry<K,V> next;
final int hash;
// 建構函式。
// 輸入引數包括"雜湊值(h)", "鍵(k)", "值(v)", "下一節點(n)"
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;
}
// 判斷兩個Entry是否相等
// 若兩個Entry的“key”和“value”都相等,則返回true。
// 否則,返回false
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;
}
// 實現hashCode()
public final int hashCode() {
return (key==null ? 0 : key.hashCode()) ^
(value==null ? 0 : value.hashCode());
}
public final String toString() {
return getKey() + "=" + getValue();
}
// 當向HashMap中新增元素時,繪呼叫recordAccess()。
// 這裡不做任何處理
void recordAccess(HashMap<K,V> m) {
}
// 當從HashMap中刪除元素時,繪呼叫recordRemoval()。
// 這裡不做任何處理
void recordRemoval(HashMap<K,V> m) {
}
}
3.3 addEntry()的作用是新增Entry。將“key-value”插入指定位置,bucketIndex是位置索引。
void addEntry(int hash, K key, V value, int bucketIndex) {
// 儲存“bucketIndex”位置的值到“e”中
Entry<K,V> e = table[bucketIndex];
// 設定“bucketIndex”位置的元素為“新Entry”,
// 設定“e”為“新Entry的下一個節點”
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
// 若HashMap的實際大小 不小於 “閾值”,則調整HashMap的大小
if (size++ >= threshold)//相比creatEntry多的兩句
resize(2 * table.length);
}
與addEntry()相似的另一個函式createEntry()
void createEntry(int hash, K key, V value, int bucketIndex) {
// 儲存“bucketIndex”位置的值到“e”中
Entry<K,V> e = table[bucketIndex];
// 設定“bucketIndex”位置的元素為“新Entry”,
// 設定“e”為“新Entry的下一個節點”
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
size++;
}
addEntry()與CreatEntry的區別
(01) addEntry()一般用在 新增Entry可能導致“HashMap的實際容量”超過“閾值”的情況下。
例如,我們新建一個HashMap,然後不斷通過put()向HashMap中新增元素;put()是通過addEntry()新增Entry的。
在這種情況下,我們不知道何時“HashMap的實際容量”會超過“閾值”;
因此,需要呼叫addEntry()
(02) createEntry() 一般用在 新增Entry不會導致“HashMap的實際容量”超過“閾值”的情況下。
例如,我們呼叫HashMap“帶有Map”的建構函式,它繪將Map的全部元素新增到HashMap中;
但在新增之前,我們已經計算好“HashMap的容量和閾值”。也就是,可以確定“即使將Map中的全部元素新增到HashMap中,都不會超過HashMap的閾值”。
此時,呼叫createEntry()即可。
3.4putForNullKey(V value)
private V putForNullKey(V value) {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) { //如果有key為null的物件存在,則覆蓋掉
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0); //如果鍵為null的話,則hash值為0
return null;
}
注意:如果key為null的話,hash值為0,物件儲存在陣列中索引為0的位置。即table[0]
它是通過key的hashCode值計算hash碼(具體函式檢視3.1Entry的資料結構)
3.5 為什麼雜湊表的容量一定要是2的整數次冪?
一般對雜湊表的雜湊很自然地會想到用hash值對length取模(即除法雜湊法),Hashtable中也是這樣實現的,這種方法基本能保證元素在雜湊表中雜湊的比較均勻,但取模會用到除法運算,效率很低,HashMap中則通過h&(length-1)的方法來代替取模,同樣實現了均勻的雜湊,但效率要高很多。(& 按位與 快速取模方法)
得到hash碼之後就會通過hash碼去計算出應該儲存在陣列中的索引,計算索引的函式如下:
static int indexFor(int h, int length) { //根據hash值和陣列長度算出索引值
return h & (length-1); //這裡不能隨便算取,用hash&(length-1)是有原因的,這樣可以確保算出來的索引是在陣列大小範圍內,不會超出
}
接下來,我們分析下為什麼雜湊表的容量一定要是2的整數次冪
首先,length為2的整數次冪的話,h&(length-1)就相當於對length取模,這樣便保證了雜湊的均勻,同時也提升了效率;
其次,length為2的整數次冪的話,為偶數,這樣length-1為奇數,奇數的最後一位是1,這樣便保證了h&(length-1)的最後一位可能為0,也可能為1(這取決於h的值),即與後的結果可能為偶數,也可能為奇數,這樣便可以保證雜湊的均勻性,而如果length為奇數的話,很明顯length-1為偶數,它的最後一位是0,這樣h&(length-1)的最後一位肯定為0,即只能為偶數,這樣任何hash值都只會被雜湊到陣列的偶數下標位置上,這便浪費了近一半的空間,因此,length取2的整數次冪,是為了使不同hash值發生碰撞的概率較小,這樣就能使元素在雜湊表中均勻地雜湊。
我們舉個例子來說明:
假設陣列長度分別為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
從上面的例子中可以看出:當它們和15-1(1110)“與”的時候,產生了相同的結果,也就是說它們會定位到陣列中的同一個位置上去,這就產生了碰撞,8和9會被放到陣列中的同一個位置上形成連結串列,那麼查詢的時候就需要遍歷這個鏈 表,得到8或者9,這樣就降低了查詢的效率。同時,我們也可以發現,當陣列長度為15的時候,hash值會與15-1(1110)進行“與”,那麼 最後一位永遠是0,而0001,0011,0101,1001,1011,0111,1101這幾個位置永遠都不能存放元素了,空間浪費相當大,更糟的是這種情況中,陣列可以使用的位置比陣列長度小了很多,這意味著進一步增加了碰撞的機率,減慢了查詢的效率!而當陣列長度為16時,即為2的n次方時,2n-1得到的二進位制數的每個位上的值都為1,這使得在低位上&時,得到的和原hash的低位相同,加之hash(int h)方法對key的hashCode的進一步優化,加入了高位計算,就使得只有相同的hash值的兩個值才會被放到陣列中的同一個位置上形成連結串列。
所以說,當陣列長度為2的n次冪的時候,不同的key算得得index相同的機率較小,那麼資料在陣列上分佈就比較均勻,也就是說碰撞的機率小,相對的,查詢的時候就不用遍歷某個位置上的連結串列,這樣查詢效率也就較高了。
3.6 putAll()的作用是將"m"的全部元素都新增到HashMap中
public void putAll(Map<? extends K, ? extends V> m) {
// 有效性判斷
int numKeysToBeAdded = m.size();
if (numKeysToBeAdded == 0)
return;
// 計算容量是否足夠,
// 若“當前實際容量 < 需要的容量”,則將容量x2。
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);
}
// 通過迭代器,將“m”中的元素逐個新增到HashMap中。
for (Iterator<? extends Map.Entry<? extends K, ? extends V>> i = m.entrySet().iterator(); i.hasNext(); ) {
Map.Entry<? extends K, ? extends V> e = i.next();
put(e.getKey(), e.getValue());
}
}
3.7resize() 重新調整HashMap的大小,newCapacity是調整後的單位
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);//用來將原先table的元素全部移到newTable裡面
table = newTable; //再將newTable賦值給table
threshold = (int)(newCapacity * loadFactor);//重新計算臨界值
}
新建了一個HashMap的底層陣列,上面程式碼中第10行為呼叫transfer方法,將HashMap的全部元素新增到新的HashMap中,並重新計算元素在新的陣列中的索引位置
當HashMap中的元素越來越多的時候,hash衝突的機率也就越來越高,因為陣列的長度是固定的。所以為了提高查詢的效率,就要對HashMap的陣列進行擴容,陣列擴容這個操作也會出現在ArrayList中,這是一個常用的操作,而在HashMap陣列擴容之後,最消耗效能的點就出現了:原陣列中的資料必須重新計算其在新陣列中的位置,並放進去,這就是resize。
那麼HashMap什麼時候進行擴容呢?
當HashMap中的元素個數超過陣列大小*loadFactor時,就會進行陣列擴容,loadFactor的預設值為0.75,這是一個折中的取值。也就是說,預設情況下,陣列大小為16,那麼當HashMap中元素個數超過16*0.75=12的時候,就把陣列的大小擴充套件為 2*16=32,即擴大一倍,然後重新計算每個元素在陣列中的位置,擴容是需要進行陣列複製的,複製陣列是非常消耗效能的操作,所以如果我們已經預知HashMap中元素的個數,那麼預設元素的個數能夠有效的提高HashMap的效能。
4. remove()刪除“鍵為key”元素
public V remove(Object key) {
Entry<K,V> e = removeEntryForKey(key);
return (e == null ? null : e.value);
}
// 刪除“鍵為key”的元素
final Entry<K,V> removeEntryForKey(Object key) {
// 獲取雜湊值。若key為null,則雜湊值為0;否則呼叫hash()進行計算
int hash = (key == null) ? 0 : hash(key.hashCode());
int i = indexFor(hash, table.length);
Entry<K,V> prev = table[i];
Entry<K,V> e = prev;
// 刪除連結串列中“鍵為key”的元素
// 本質是“刪除單向連結串列中的節點”
while (e != null) {
Entry<K,V> next = e.next;
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {
modCount++;
size--;
if (prev == e)
table[i] = next;
else
prev.next = next;
e.recordRemoval(this);
return e;
}
prev = e;
e = next;
}
return e;
}
5.get() 的作用是獲取key對應的value
從HashMap中get元素時,首先計算key的hashCode,找到陣列中對應位置的某一元素,然後通過key的equals方法在對應位置的連結串列中找到需要的元素。
public V get(Object key) {
if (key == null)
return getForNullKey();
// 獲取key的hash值
int hash = hash(key.hashCode());
// 在“該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.equals(k)))
return e.value;
}
return null;
}
這裡需要強調的是:HashMap將“key為null”的元素都放在table的位置0處,即table[0]中;“key不為null”的放在table的其餘位置!
6.clear()
clear() 的作用是清空HashMap。它是通過將所有的元素設為null來實現的。
public void clear() {
modCount++;
Entry[] tab = table;
for (int i = 0; i < tab.length; i++)
tab[i] = null;
size = 0;
}
7. containsValue()
containsValue() 的作用是判斷HashMap是否包含“值為value”的元素。
containsNullValue()分為兩步進行處理:第一,若“value為null”,則呼叫containsNullValue()。第二,若“value不為null”,則查詢HashMap中是否有值為value的節點。
public boolean containsValue(Object value) {
// 若“value為null”,則呼叫containsNullValue()查詢
if (value == null)
return containsNullValue();
// 若“value不為null”,則查詢HashMap中是否有值為value的節點。
Entry[] tab = table;
for (int i = 0; i < tab.length ; i++)
for (Entry e = tab[i] ; e != null ; e = e.next)
if (value.equals(e.value))
return true;
return false;
}
//如果value為null 呼叫的函式
//作用判斷HashMap中是否包含“值為null”的元素。
private boolean containsNullValue() {
Entry[] tab = table;
for (int i = 0; i < tab.length ; i++)
for (Entry e = tab[i] ; e != null ; e = e.next)
if (e.value == null)
return true;
return false;
}
8.總結
1.要知道hashMap在JDK1.8以前是一個連結串列雜湊這樣一個數據結構,而在JDK1.8以後是一個數組加連結串列加紅黑樹的資料結構。
2.為什麼HashMap是非執行緒安全?
①通過Entry內部的next變數可以知道使用的是連結串列,這時候我們可以知道,如果多個執行緒,在某一時刻同時操作HashMap並執行put操作,而有大於兩個key的hash值相同,如圖中a1、a2,這個時候需要解決碰撞衝突,而解決衝突的辦法上面已經說過,對於連結串列的結構在這裡不再贅述,暫且不討論是從連結串列頭部插入還是從尾部初入,這個時候兩個執行緒如果恰好都取到了對應位置的頭結點e1,而最終的結果可想而知,a1、a2兩個資料中勢必會有一個會丟失
②擴容方法也不是同步的,通過程式碼我們知道在擴容過程中,會新生成一個新的容量的陣列,然後對原陣列的所有鍵值對重新進行計算和寫入新的陣列,之後指向新生成的陣列。
當多個執行緒同時檢測到總數量超過門限值的時候就會同時呼叫resize操作,各自生成新的陣列並rehash後賦給該map底層的陣列table,結果最終只有最後一個執行緒生成的新陣列被賦給table變數,其他執行緒的均會丟失。而且當某些執行緒已經完成賦值而其他執行緒剛開始的時候,就會用已經被賦值的table作為原始陣列,這樣也會有問題。