此次我和你們一塊兒學習HashMap
,HashMap
咱們在工做中常常會使用,並且面試中也很頻繁會問到,由於它裏面蘊含着不少知識點,能夠很好的考察我的基礎。但一個這麼重要的東西,我爲何沒有在一開始就去學習它呢,由於它是由多種基礎的數據結構和一些代碼設計思想組成的。咱們要學習了這些基礎,再學習HashMap
,這樣咱們才能更好的去理解它。古人云:無慾速,無見小利。欲速則不達,見小利則大事不成。html
HashMap
其實就是ArrayList
和LinkedList
的數據結構加上hashCode
和equals
方法的思想設計出來的。沒有理解上述說的知識點的同窗能夠翻開我過往的文章記錄。java
下面我就以面試問答的形式學習咱們的——HashMap
(源碼分析基於JDK8,輔以JDK7),問答內容只是對HashMap
的一個總結概括,由於現時已經有大牛把HashMap
通俗易懂的剖析了一遍,我學習HashMap
也是主要經過這篇文章學習的,強烈推薦:美團點評技術團隊的Java 8系列之從新認識HashMapnode
本文同步發佈於簡書:www.jianshu.com/p/32f67f9e7…程序員
問:HashMap
有用過嗎?您能給我說說他的主要用途嗎?面試
答:數組
HashMap
這種數據結構,HashMap
是基於Map
接口實現的一種鍵-值對<key,value>
的存儲結構,容許null
值,同時非有序,非同步(即線程不安全)。HashMap
的底層實現是數組 + 鏈表 + 紅黑樹(JDK1.8增長了紅黑樹部分)。它存儲和查找數據時,是根據鍵key
的hashCode
的值計算出具體的存儲位置。HashMap
最多隻容許一條記錄的鍵key
爲null
,HashMap
增刪改查等常規操做都有不錯的執行效率,是ArrayList
和LinkedList
等數據結構的一種折中實現。示例代碼:安全
// 建立一個HashMap,若是沒有指定初始大小,默認底層hash表數組的大小爲16
HashMap<String, String> hashMap = new HashMap<String, String>();
// 往容器裏面添加元素
hashMap.put("小明", "好帥");
hashMap.put("老王", "坑爹貨");
hashMap.put("老鐵", "沒毛病");
hashMap.put("掘金", "好地方");
hashMap.put("王五", "別搞事");
// 獲取key爲小明的元素 好帥
String element = hashMap.get("小明");
// value : 好帥
System.out.println(element);
// 移除key爲王五的元素
String removeElement = hashMap.remove("王五");
// value : 別搞事
System.out.println(removeElement);
// 修改key爲小明的元素的值value 爲 其實有點醜
hashMap.replace("小明", "其實有點醜");
// {老鐵=沒毛病, 小明=其實有點醜, 老王=坑爹貨, 掘金=好地方}
System.out.println(hashMap);
// 經過put方法也能夠達到修改對應元素的值的效果
hashMap.put("小明", "其實還能夠啦,開玩笑的");
// {老鐵=沒毛病, 小明=其實還能夠啦,開玩笑的, 老王=坑爹貨, 掘金=好地方}
System.out.println(hashMap);
// 判斷key爲老王的元素是否存在(捉姦老王)
boolean isExist = hashMap.containsKey("老王");
// true , 老王居然來搞事
System.out.println(isExist);
// 判斷是否有 value = "坑爹貨" 的人
boolean isHasSomeOne = hashMap.containsValue("坑爹貨");
// true 老王是坑爹貨
System.out.println(isHasSomeOne);
// 查看這個容器裏面還有幾個傢伙 value : 4
System.out.println(hashMap.size());複製代碼
HashMap
的底層實現是數組 + 鏈表 + 紅黑樹(JDK1.8增長了紅黑樹部分),核心組成元素有:int size;
用於記錄HashMap
實際存儲元素的個數;bash
float loadFactor;
負載因子(默認是0.75,此屬性後面詳細解釋)。數據結構
int threshold;
下一次擴容時的閾值,達到閾值便會觸發擴容機制resize
(閾值 threshold = 容器容量 capacity * 負載因子 load factor)。也就是說,在容器定義好容量以後,負載因子越大,所能容納的鍵值對元素個數就越多。多線程
Node<K,V>[] table;
底層數組,充當哈希表的做用,用於存儲對應hash位置的元素Node<K,V>
,此數組長度老是2的N次冪。(具體緣由後面詳細解釋)
示例代碼:
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
·····
/* ---------------- Fields -------------- */
/**
* 哈希表,在第一次使用到時進行初始化,重置大小是必要的操做,
* 當分配容量時,長度老是2的N次冪。
*/
transient Node<K,V>[] table;
/**
* 實際存儲的key - value 鍵值對 個數
*/
transient int size;
/**
* 下一次擴容時的閾值
* (閾值 threshold = 容器容量 capacity * 負載因子 load factor).
* @serial
*/
int threshold;
/**
* 哈希表的負載因子
*
* @serial
*/
final float loadFactor;
·····
}複製代碼
Node<K,V>[] table;
哈希表存儲的核心元素是Node<K,V>
,Node<K,V>
包含:final int hash;
元素的哈希值,決定元素存儲在Node<K,V>[] table;
哈希表中的位置。由final
修飾可知,當hash
的值肯定後,就不能再修改。
final K key;
鍵,由final
修飾可知,當key
的值肯定後,就不能再修改。
V value;
值
Node<K,V> next;
記錄下一個元素結點(單鏈表結構,用於解決hash衝突)
示例代碼:
/**
* 定義HashMap存儲元素結點的底層實現
*/
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;//元素的哈希值 由final修飾可知,當hash的值肯定後,就不能再修改
final K key;// 鍵,由final修飾可知,當key的值肯定後,就不能再修改
V value; // 值
Node<K,V> next; // 記錄下一個元素結點(單鏈表結構,用於解決hash衝突)
/**
* Node結點構造方法
*/
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;//元素的哈希值
this.key = key;// 鍵
this.value = value; // 值
this.next = next;// 記錄下一個元素結點
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
/**
* 爲Node重寫hashCode方法,值爲:key的hashCode 異或 value的hashCode
* 運算做用就是將2個hashCode的二進制中,同一位置相同的值爲0,不一樣的爲1。
*/
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
/**
* 修改某一元素的值
*/
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
/**
* 爲Node重寫equals方法
*/
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}複製代碼
問:您能說說HashMap
經常使用操做的底層實現原理嗎?如存儲put(K key, V value)
,查找get(Object key)
,刪除remove(Object key)
,修改replace(K key, V value)
等操做。
答:
put(K key, V value)
操做添加key-value
鍵值對時,進行了以下操做:判斷哈希表Node<K,V>[] table
是否爲空或者null
,是則執行resize()
方法進行擴容。
根據插入的鍵值key
的hash
值,經過(n - 1) & hash
當前元素的hash
值 & hash
表長度 - 1(實際就是 hash
值 % hash
表長度) 計算出存儲位置table[i]
。若是存儲位置沒有元素存放,則將新增結點存儲在此位置table[i]
。
若是存儲位置已經有鍵值對元素存在,則判斷該位置元素的hash
值和key
值是否和當前操做元素一致,一致則證實是修改value
操做,覆蓋value
便可。
當前存儲位置即有元素,又不和當前操做元素一致,則證實此位置table[i]
已經發生了hash衝突,則經過判斷頭結點是不是treeNode
,若是是treeNode
則證實此位置的結構是紅黑樹,已紅黑樹的方式新增結點。
若是不是紅黑樹,則證實是單鏈表,將新增結點插入至鏈表的最後位置,隨後判斷當前鏈表長度是否 大於等於 8,是則將當前存儲位置的鏈表轉化爲紅黑樹。遍歷過程當中若是發現key
已經存在,則直接覆蓋value
。
插入成功後,判斷當前存儲鍵值對的數量 大於 閾值threshold
是則擴容。
示例代碼:
/**
* 添加key-value鍵值對
*
* @param key 鍵
* @param value 值
* @return 若是本來存在此key,則返回舊的value值,若是是新增的key-
* value,則返回nulll
*/
public V put(K key, V value) {
//實際調用putVal方法進行添加 key-value 鍵值對操做
return putVal(hash(key), key, value, false, true);
}
/**
* 根據key 鍵 的 hashCode 經過 「擾動函數」 生成對應的 hash值
* 通過此操做後,使每個key對應的hash值生成的更均勻,
* 減小元素之間的碰撞概率(後面詳細說明)
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
/**
* 添加key-value鍵值對的實際調用方法(重點)
*
* @param hash key 鍵的hash值
* @param key 鍵
* @param value 值
* @param onlyIfAbsent 此值若是是true, 則若是此key已存在value,則不執
* 行修改操做
* @param evict 此值若是是false,哈希表是在初始化模式
* @return 返回本來的舊值, 若是是新增,則返回null
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// 用於記錄當前的hash表
Node<K,V>[] tab;
// 用於記錄當前的鏈表結點
Node<K,V> p;
// n用於記錄hash表的長度,i用於記錄當前操做索引index
int n, i;
// 當前hash表爲空
if ((tab = table) == null || (n = tab.length) == 0)
// 初始化hash表,並把初始化後的hash表長度值賦值給n
n = (tab = resize()).length;
// 1)經過 (n - 1) & hash 當前元素的hash值 & hash表長度 - 1
// 2)肯定當前元素的存儲位置,此運算等價於 當前元素的hash值 % hash表的長度
// 3)計算出的存儲位置沒有元素存在
if ((p = tab[i = (n - 1) & hash]) == null)
// 4) 則新建一個Node結點,在該位置存儲此元素
tab[i] = newNode(hash, key, value, null);
else { // 當前存儲位置已經有元素存在了(不考慮是修改的狀況的話,就表明發生hash衝突了)
// 用於存放新增結點
Node<K,V> e;
// 用於臨時存在某個key值
K k;
// 1)若是當前位置已存在元素的hash值和新增元素的hash值相等
// 2)而且key也相等,則證實是同一個key元素,想執行修改value操做
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;// 將當前結點引用賦值給e
else if (p instanceof TreeNode) // 若是當前結點是樹結點
// 則證實當前位置的鏈表已變成紅黑樹結構,則已紅黑樹結點結構新增元素
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {// 排除上述狀況,則證實已發生hash衝突,並hash衝突位置現時的結構是單鏈表結構
for (int binCount = 0; ; ++binCount) {
//遍歷單鏈表,將新元素結點放置此鏈表的最後一位
if ((e = p.next) == null) {
// 將新元素結點放在此鏈表的最後一位
p.next = newNode(hash, key, value, null);
// 新增結點後,當前結點數量是否大於等於 閾值 8
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 大於等於8則將鏈表轉換成紅黑樹
treeifyBin(tab, hash);
break;
}
// 若是鏈表中已經存在對應的key,則覆蓋value
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // 已存在對應key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null) //若是容許修改,則修改value爲新值
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 當前存儲鍵值對的數量 大於 閾值 是則擴容
if (++size > threshold)
// 重置hash大小,將舊hash表的數據逐一複製到新的hash表中(後面詳細講解)
resize();
afterNodeInsertion(evict);
// 返回null,則證實是新增操做,而不是修改操做
return null;
}複製代碼
get(Object key)
操做根據鍵key
查找對應的key-value
鍵值對時,進行了以下操做:1.先調用 hash(key)
方法計算出 key
的 hash
值
2.根據查找的鍵值key
的hash
值,經過(n - 1) & hash
當前元素的hash
值 & hash
表長度 - 1(實際就是 hash
值 % hash
表長度) 計算出存儲位置table[i]
,判斷存儲位置是否有元素存在 。
key
的hash
值 和 要獲取的key
的hash
值相等,而且 頭結點的key
自己 和要獲取的 key
相等,則返回該位置的頭結點。null
。3.若是存儲位置有元素存放,可是頭結點元素不是要查找的元素,則須要遍歷該位置進行查找。
4.先判斷頭結點是不是treeNode
,若是是treeNode
則證實此位置的結構是紅黑樹,以紅色樹的方式遍歷查找該結點,沒有則返回null
。
5.若是不是紅黑樹,則證實是單鏈表。遍歷單鏈表,逐一比較鏈表結點,鏈表結點的key
的hash
值 和 要獲取的key
的hash
值相等,而且 鏈表結點的key
自己 和要獲取的 key
相等,則返回該結點,遍歷結束仍未找到對應key
的結點,則返回null
。
示例代碼:
/**
* 返回指定 key 所映射的 value 值
* 或者 返回 null 若是容器裏不存在對應的key
*
* 更確切地講,若是此映射包含一個知足 (key==null ? k==null :key.equals(k))
* 的從 k 鍵到 v 值的映射關係,
* 則此方法返回 v;不然返回 null。(最多隻能有一個這樣的映射關係。)
*
* 返回 null 值並不必定 代表該映射不包含該鍵的映射關係;
* 也可能該映射將該鍵顯示地映射爲 null。可以使用containsKey操做來區分這兩種狀況。
*
* @see #put(Object, Object)
*/
public V get(Object key) {
Node<K,V> e;
// 1.先調用 hash(key)方法計算出 key 的 hash值
// 2.隨後調用getNode方法獲取對應key所映射的value值
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/**
* 獲取哈希表結點的方法實現
*
* @param hash key 鍵的hash值
* @param key 鍵
* @return 返回對應的結點,若是結點不存在,則返回null
*/
final Node<K,V> getNode(int hash, Object key) {
// 用於記錄當前的hash表
Node<K,V>[] tab;
// first用於記錄對應hash位置的第一個結點,e充當工做結點的做用
Node<K,V> first, e;
// n用於記錄hash表的長度
int n;
// 用於臨時存放Key
K k;
// 經過 (n - 1) & hash 當前元素的hash值 & hash表長度 - 1
// 判斷當前元素的存儲位置是否有元素存在
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {//元素存在的狀況
// 若是頭結點的key的hash值 和 要獲取的key的hash值相等
// 而且 頭結點的key自己 和要獲取的 key 相等
if (first.hash == hash && // always check first node 老是檢查頭結點
((k = first.key) == key || (key != null && key.equals(k))))
// 返回該位置的頭結點
return first;
if ((e = first.next) != null) {// 頭結點不相等
if (first instanceof TreeNode) // 若是當前結點是樹結點
// 則證實當前位置的鏈表已變成紅黑樹結構
// 經過紅黑樹結點的方式獲取對應key結點
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {// 當前位置不是紅黑樹,則證實是單鏈表
// 遍歷單鏈表,逐一比較鏈表結點
// 鏈表結點的key的hash值 和 要獲取的key的hash值相等
// 而且 鏈表結點的key自己 和要獲取的 key 相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 找到對應的結點則返回
return e;
} while ((e = e.next) != null);
}
}
// 經過上述查找均無找到,則返回null
return null;
}複製代碼
remove(Object key)
操做根據鍵key
刪除對應的key-value
鍵值對時,進行了以下操做:1.先調用 hash(key)
方法計算出 key
的 hash
值
2.根據查找的鍵值key
的hash
值,經過(n - 1) & hash
當前元素的hash
值 & hash
表長度 - 1(實際就是 hash
值 % hash
表長度) 計算出存儲位置table[i]
,判斷存儲位置是否有元素存在 。
若是存儲位置有元素存放,則首先比較頭結點元素,若是頭結點的key
的hash
值 和 要獲取的key
的hash
值相等,而且 頭結點的key
自己 和要獲取的 key
相等,則該位置的頭結點即爲要刪除的結點,記錄此結點至變量node
中。
若是存儲位置沒有元素存放,則沒有找到對應要刪除的結點,則返回null
。
3.若是存儲位置有元素存放,可是頭結點元素不是要刪除的元素,則須要遍歷該位置進行查找。
4.先判斷頭結點是不是treeNode
,若是是treeNode
則證實此位置的結構是紅黑樹,以紅色樹的方式遍歷查找並刪除該結點,沒有則返回null
。
5.若是不是紅黑樹,則證實是單鏈表。遍歷單鏈表,逐一比較鏈表結點,鏈表結點的key
的hash
值 和 要獲取的key
的hash
值相等,而且 鏈表結點的key
自己 和要獲取的 key
相等,則此爲要刪除的結點,記錄此結點至變量node
中,遍歷結束仍未找到對應key
的結點,則返回null
。
6.若是找到要刪除的結點node
,則判斷是否須要比較value
也是否一致,若是value
值一致或者不須要比較value
值,則執行刪除結點操做,刪除操做根據不一樣的狀況與結構進行不一樣的處理。
若是當前結點是樹結點,則證實當前位置的鏈表已變成紅黑樹結構,經過紅黑樹結點的方式刪除對應結點。
若是不是紅黑樹,則證實是單鏈表。若是要刪除的是頭結點,則當前存儲位置table[i]
的頭結點指向刪除結點的下一個結點。
若是要刪除的結點不是頭結點,則將要刪除的結點的後繼結點node.next
賦值給要刪除結點的前驅結點的next
域,即p.next = node.next;
。
7.HashMap
當前存儲鍵值對的數量 - 1,並返回刪除結點。
示例代碼:
/**
* 今後映射中移除指定鍵的映射關係(若是存在)。
*
* @param key 其映射關係要從映射中移除的鍵
* @return 與 key 關聯的舊值;若是 key 沒有任何映射關係,則返回 null。
* (返回 null 還可能表示該映射以前將 null 與 key 關聯。)
*/
public V remove(Object key) {
Node<K,V> e;
// 1.先調用 hash(key)方法計算出 key 的 hash值
// 2.隨後調用removeNode方法刪除對應key所映射的結點
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
/**
* 刪除哈希表結點的方法實現
*
* @param hash 鍵的hash值
* @param key 鍵
* @param value 用於比較的value值,當matchValue 是 true時纔有效, 不然忽略
* @param matchValue 若是是 true 只有當value相等時纔會移除
* @param movable 若是是 false當執行移除操做時,不刪除其餘結點
* @return 返回刪除結點node,不存在則返回null
*/
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
// 用於記錄當前的hash表
Node<K,V>[] tab;
// 用於記錄當前的鏈表結點
Node<K,V> p;
// n用於記錄hash表的長度,index用於記錄當前操做索引index
int n, index;
// 經過 (n - 1) & hash 當前元素的hash值 & hash表長度 - 1
// 判斷當前元素的存儲位置是否有元素存在
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {// 元素存在的狀況
// node 用於記錄找到的結點,e爲工做結點
Node<K,V> node = null, e;
K k; V v;
// 若是頭結點的key的hash值 和 要獲取的key的hash值相等
// 而且 頭結點的key自己 和要獲取的 key 相等
// 則證實此頭結點就是要刪除的結點
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 記錄要刪除的結點的引用地址至node中
node = p;
else if ((e = p.next) != null) {// 頭結點不相等
if (p instanceof TreeNode)// 若是當前結點是樹結點
// 則證實當前位置的鏈表已變成紅黑樹結構
// 經過紅黑樹結點的方式獲取對應key結點
// 記錄要刪除的結點的引用地址至node中
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {// 當前位置不是紅黑樹,則證實是單鏈表
do {
// 遍歷單鏈表,逐一比較鏈表結點
// 鏈表結點的key的hash值 和 要獲取的key的hash值相等
// 而且 鏈表結點的key自己 和要獲取的 key 相等
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
// 找到則記錄要刪除的結點的引用地址至node中,中斷遍歷
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
// 若是找到要刪除的結點,則判斷是否須要比較value也是否一致
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
// value值一致或者不須要比較value值,則執行刪除結點操做
if (node instanceof TreeNode) // 若是當前結點是樹結點
// 則證實當前位置的鏈表已變成紅黑樹結構
// 經過紅黑樹結點的方式刪除對應結點
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p) // node 和 p相等,則證實刪除的是頭結點
// 當前存儲位置的頭結點指向刪除結點的下一個結點
tab[index] = node.next;
else // 刪除的不是頭結點
// p是刪除結點node的前驅結點,p的next改成記錄要刪除結點node的後繼結點
p.next = node.next;
++modCount;
// 當前存儲鍵值對的數量 - 1
--size;
afterNodeRemoval(node);
// 返回刪除結點
return node;
}
}
// 不存在要刪除的結點,則返回null
return null;
}複製代碼
replace(K key, V value)
操做根據鍵key
查找對應的key-value
鍵值對,隨後替換對應的值value
,進行了以下操做:先調用 hash(key)
方法計算出 key
的 hash
值
隨後調用getNode
方法獲取對應key
所映射的value
值 。
記錄元素舊值,將新值賦值給元素,返回元素舊值,若是沒有找到元素,則返回null
。
示例代碼:
/**
* 替換指定 key 所映射的 value 值
*
* @param key 對應要替換value值元素的key鍵
* @param value 要替換對應元素的新value值
* @return 返回本來的舊值,若是沒有找到key對應的元素,則返回null
* @since 1.8 JDK1.8新增方法
*/
public V replace(K key, V value) {
Node<K,V> e;
// 1.先調用 hash(key)方法計算出 key 的 hash值
// 2.隨後調用getNode方法獲取對應key所映射的value值
if ((e = getNode(hash(key), key)) != null) {// 若是找到對應的元素
// 元素舊值
V oldValue = e.value;
// 將新值賦值給元素
e.value = value;
afterNodeAccess(e);
// 返回元素舊值
return oldValue;
}
// 沒有找到元素,則返回null
return null;
}複製代碼
問 1:您上面說,存放一個元素時,先計算它的hash值肯定它的存儲位置,而後再把這個元素放到對應的位置上,那萬一這個位置上面已經有元素存在呢,新增的這個元素怎麼辦?
問 2:hash
衝突(或者叫hash
碰撞)是什麼?爲何會出現這種現象,如何解決hash
衝突?
答:
hash
衝突: 當咱們調用put(K key, V value)
操做添加key-value
鍵值對,這個key-value
鍵值對存放在的位置是經過擾動函數(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16)
計算鍵key
的hash
值。隨後將 這個hash
值 % 模上 哈希表Node<K,V>[] table
的長度 獲得具體的存放位置。因此put(K key, V value)
多個元素,是有可能計算出相同的存放位置。此現象就是hash
衝突或者叫hash
碰撞。
例子以下:
元素 A 的hash
值 爲 9,元素 B 的hash
值 爲 17。哈希表Node<K,V>[] table
的長度爲8。則元素 A 的存放位置爲9 % 8 = 1
,元素 B 的存放位置爲17 % 8 = 1
。兩個元素的存放位置均爲table[1]
,發生了hash
衝突。
hash
衝突的避免:既然會發生hash
衝突,咱們就應該想辦法避免此現象的發生,解決這個問題最關鍵就是若是生成元素的hash
值。Java是使用「擾動函數」生成元素的hash
值。
示例代碼:
/**
* JDK 7 的 hash方法
*/
final int hash(int h) {
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
/**
* JDK 8 的 hash方法
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}複製代碼
Java7作了4次16位右位移異或混合,Java 8中這步已經簡化了,只作一次16位右位移異或混合,而不是四次,但原理是不變的。例子以下:
右位移16位,正好是32bit的一半,本身的高半區和低半區作異或,就是爲了混合原始哈希碼的高位和低位,以此來加大低位的隨機性。並且混合後的低位摻雜了高位的部分特徵,這樣高位的信息也被變相保留下來。
上述擾動函數的解釋參考自:JDK 源碼中 HashMap 的 hash 方法原理是什麼?
hash
衝突解決:解決hash
衝突的方法有不少,常見的有:開發定址法,HashMap
是使用鏈地址法解決hash
衝突的,當有衝突元素放進來時,會將此元素插入至此位置鏈表的最後一位,造成單鏈表。可是因爲是單鏈表的緣故,每當經過hash % length
找到該位置的元素時,均須要從頭遍歷鏈表,經過逐一比較hash
值,找到對應元素。若是此位置元素過多,形成鏈表過長,遍歷時間會大大增長,最壞狀況下的時間複雜度爲O(N)
,形成查找效率太低。因此當存在位置的鏈表長度 大於等於 8 時,HashMap
會將鏈表 轉變爲 紅黑樹,紅黑樹最壞狀況下的時間複雜度爲O(logn)
。以此提升查找效率。問:HashMap
的容量爲何必定要是2的n次方?
答:
由於調用put(K key, V value)
操做添加key-value
鍵值對時,具體肯定此元素的位置是經過 hash
值 % 模上 哈希表Node<K,V>[] table
的長度 hash % length
計算的。可是"模"運算的消耗相對較大,經過位運算h & (length-1)
也能夠獲得取模後的存放位置,而位運算的運行效率高,但只有length
的長度是2的n次方時,h & (length-1)
纔等價於 h % length
。
並且當數組長度爲2的n次冪的時候,不一樣的key算出的index相同的概率較小,那麼數據在數組上分佈就比較均勻,也就是說碰撞的概率小,相對的,查詢的時候就不用遍歷某個位置上的鏈表,這樣查詢效率也就較高了。
例子:
上圖中,左邊兩組的數組長度是16(2的4次方),右邊兩組的數組長度是15。兩組的hash
值均爲8和9。
當數組長度是15時,當它們和1110
進行&
與運算(相同爲1,不一樣爲0)時,計算的結果都是1000
,因此他們都會存放在相同的位置table[8]
中,這樣就發生了hash
衝突,那麼查詢時就要遍歷鏈表,逐一比較hash
值,下降了查詢的效率。
同時,咱們能夠發現,當數組長度爲15的時候,hash
值均會與14(1110)
進行&
與運算,那麼最後一位永遠是0,而0001
,0011
,0101
,1001
,1011
,0111
,1101
這幾個位置永遠都不能存放元素了,空間浪費至關大,更糟的是這種狀況中,數組可使用的位置比數組長度小了不少,這意味着進一步增長了碰撞的概率,減慢了查詢的效率。
HashMap
的容量是2的n次方,有利於提升計算元素存放位置時的效率,也下降了hash
衝突的概率。所以,咱們使用HashMap
存儲大量數據的時候,最好先預先指定容器的大小爲2的n次方,即便咱們不指定爲2的n次方,HashMap
也會把容器的大小設置成最接近設置數的2的n次方,如,設置HashMap
的大小爲 7 ,則HashMap
會將容器大小設置成最接近7的一個2的n次方數,此值爲 8 。上述回答參考自:深刻理解HashMap
示例代碼:
/**
* 返回一個比指定數cap大的,而且大小是2的n次方的數
* Returns a power of two size for the given target capacity.
*/
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}複製代碼
問:HashMap
的負載因子是什麼,有什麼做用?
答:負載因子表示哈希表空間的使用程度(或者說是哈希表空間的利用率)。
例子以下:
底層哈希表Node<K,V>[] table
的容量大小capacity
爲 16,負載因子load factor
爲 0.75,則當存儲的元素個數size = capacity 16 * load factor 0.75
等於 12 時,則會觸發HashMap
的擴容機制,調用resize()
方法進行擴容。
當負載因子越大,則HashMap
的裝載程度就越高。也就是能容納更多的元素,元素多了,發生hash
碰撞的概率就會加大,從而鏈表就會拉長,此時的查詢效率就會下降。
當負載因子越小,則鏈表中的數據量就越稀疏,此時會對空間形成浪費,可是此時查詢效率高。
咱們能夠在建立HashMap
時根據實際須要適當地調整load factor
的值;若是程序比較關心空間開銷、內存比較緊張,能夠適當地增長負載因子;若是程序比較關心時間開銷,內存比較寬裕則能夠適當的減小負載因子。一般狀況下,默認負載因子 (0.75) 在時間和空間成本上尋求一種折衷,程序員無需改變負載因子的值。
所以,若是咱們在初始化HashMap
時,就預估知道須要裝載key-value
鍵值對的容量size
,咱們能夠經過size / load factor
計算出咱們須要初始化的容量大小initialCapacity
,這樣就能夠避免HashMap
由於存放的元素達到閾值threshold
而頻繁調用resize()
方法進行擴容。從而保證了較好的性能。
問:您能說說HashMap
和HashTable
的區別嗎?
答:HashMap
和HashTable
有以下區別:
1)容器總體結構:
HashMap
的key
和value
都容許爲null
,HashMap
遇到key
爲null
的時候,調用putForNullKey
方法進行處理,而對value
沒有處理。
Hashtable
的key
和value
都不容許爲null
。Hashtable
遇到null
,直接返回NullPointerException
。
2) 容量設定與擴容機制:
HashMap
默認初始化容量爲 16,而且容器容量必定是2的n次方,擴容時,是以原容量 2倍 的方式 進行擴容。
Hashtable
默認初始化容量爲 11,擴容時,是以原容量 2倍 再加 1的方式進行擴容。即int newCapacity = (oldCapacity << 1) + 1;
。
3) 散列分佈方式(計算存儲位置):
HashMap
是先將key
鍵的hashCode
通過擾動函數擾動後獲得hash
值,而後再利用 hash & (length - 1)
的方式代替取模,獲得元素的存儲位置。
Hashtable
則是除留餘數法進行計算存儲位置的(由於其默認容量也不是2的n次方。因此也沒法用位運算替代模運算),int index = (hash & 0x7FFFFFFF) % tab.length;
。
因爲HashMap
的容器容量必定是2的n次方,因此能使用hash & (length - 1)
的方式代替取模的方式計算元素的位置提升運算效率,但Hashtable
的容器容量不必定是2的n次方,因此不能使用此運算方式代替。
4)線程安全(最重要):
HashMap
不是線程安全,若是想線程安全,能夠經過調用synchronizedMap(Map<K,V> m)
使其線程安全。可是使用時的運行效率會降低,因此建議使用ConcurrentHashMap
容器以此達到線程安全。
Hashtable
則是線程安全的,每一個操做方法前都有synchronized
修飾使其同步,但運行效率也不高,因此仍是建議使用ConcurrentHashMap
容器以此達到線程安全。
所以,Hashtable
是一個遺留容器,若是咱們不須要線程同步,則建議使用HashMap
,若是須要線程同步,則建議使用ConcurrentHashMap
。
此處再也不對Hashtable的源碼進行逐一分析了,若是想深刻了解的同窗,能夠參考此文章
Hashtable源碼剖析
問:您說HashMap
不是線程安全的,那若是多線程下,它是如何處理的?而且什麼狀況下會發生線程不安全的狀況?
答:
HashMap
不是線程安全的,若是多個線程同時對同一個HashMap
更改數據的話,會致使數據不一致或者數據污染。若是出現線程不安全的操做時,HashMap
會盡量的拋出ConcurrentModificationException
防止數據異常,當咱們在對一個HashMap
進行遍歷時,在遍歷期間,咱們是不能對HashMap
進行添加,刪除等更改數據的操做的,不然也會拋出ConcurrentModificationException
異常,此爲fail-fast(快速失敗)機制。從源碼上分析,咱們在put,remove
等更改HashMap
數據時,都會致使modCount的改變,當expectedModCount != modCount
時,則拋出ConcurrentModificationException
。若是想要線程安全,能夠考慮使用ConcurrentHashMap
。
並且,在多線程下操做HashMap
,因爲存在擴容機制,當HashMap
調用resize()
進行自動擴容時,可能會致使死循環的發生。
因爲時間關係,我暫不帶着你們一塊兒去分析resize()
方法致使死循環發生的現象形成緣由了,遲點有空我會再補充上去,請見諒,你們能夠參考以下文章:
問:咱們在使用HashMap
時,選取什麼對象做爲key
鍵比較好,爲何?
答:
可變對象:指建立後自身狀態能改變的對象。換句話說,可變對象是該對象在建立後它的哈希值可能被改變。
咱們在使用HashMap
時,最好選擇不可變對象做爲key
。例如String
,Integer
等不可變類型做爲key
是很是明智的。
若是key
對象是可變的,那麼key
的哈希值就可能改變。在HashMap
中可變對象做爲Key會形成數據丟失。由於咱們再進行hash & (length - 1)
取模運算計算位置查找對應元素時,位置可能已經發生改變,致使數據丟失。
詳細例子說明請參考:危險!在HashMap中將可變對象用做Key
HashMap
是基於Map
接口實現的一種鍵-值對<key,value>
的存儲結構,容許null
值,同時非有序,非同步(即線程不安全)。HashMap
的底層實現是數組 + 鏈表 + 紅黑樹(JDK1.8增長了紅黑樹部分)。
HashMap
定位元素位置是經過鍵key
通過擾動函數擾動後獲得hash
值,而後再經過hash & (length - 1)
代替取模的方式進行元素定位的。
HashMap
是使用鏈地址法解決hash
衝突的,當有衝突元素放進來時,會將此元素插入至此位置鏈表的最後一位,造成單鏈表。當存在位置的鏈表長度 大於等於 8 時,HashMap
會將鏈表 轉變爲 紅黑樹,以此提升查找效率。
HashMap
的容量是2的n次方,有利於提升計算元素存放位置時的效率,也下降了hash
衝突的概率。所以,咱們使用HashMap
存儲大量數據的時候,最好先預先指定容器的大小爲2的n次方,即便咱們不指定爲2的n次方,HashMap
也會把容器的大小設置成最接近設置數的2的n次方,如,設置HashMap
的大小爲 7 ,則HashMap
會將容器大小設置成最接近7的一個2的n次方數,此值爲 8 。
HashMap
的負載因子表示哈希表空間的使用程度(或者說是哈希表空間的利用率)。當負載因子越大,則HashMap
的裝載程度就越高。也就是能容納更多的元素,元素多了,發生hash
碰撞的概率就會加大,從而鏈表就會拉長,此時的查詢效率就會下降。當負載因子越小,則鏈表中的數據量就越稀疏,此時會對空間形成浪費,可是此時查詢效率高。
HashMap
不是線程安全的,Hashtable
則是線程安全的。但Hashtable
是一個遺留容器,若是咱們不須要線程同步,則建議使用HashMap
,若是須要線程同步,則建議使用ConcurrentHashMap
。
在多線程下操做HashMap
,因爲存在擴容機制,當HashMap
調用resize()
進行自動擴容時,可能會致使死循環的發生。
咱們在使用HashMap
時,最好選擇不可變對象做爲key
。例如String
,Integer
等不可變類型做爲key
是很是明智的。
Java 8系列之從新認識HashMap
JDK 源碼中 HashMap 的 hash 方法原理是什麼?
深刻理解HashMap
HashMap負載因子
Hashtable源碼剖析
危險!在HashMap中將可變對象用做Key
談談HashMap線程不安全的體現