對於 Map ,最直觀就是理解就是鍵值對,映射,key-value 形式。一個映射不能包含重復的鍵,一個鍵只能有一個值。平常我們使用的時候,最常用的無非就是 HashMap。
HashMap 實現了 Map 接口,允許使用 null 值 和 null 鍵,並且不保證映射順序。
HashMap 有兩個參數影響性能:
初始容量:表示哈希表在其容量自動增加之前可以達到多滿的一種尺度
加載因子:當哈希表中的條目超過了容量和加載因子的乘積的時候,就會進行重哈希操作。
如下成員變量源碼:
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
transient Node<K,V>[] table;
可以看到,默認加載因子為 0.75, 默認容量為 1 << 4,也就是 16。加載因子過高,容易產生哈希沖突,加載因子過小,容易浪費空間,0.75是一種折中。
另外,整個 HashMap 的實現原理可以簡單的理解成:當我們 put 的時候,首先根據 key 算出一個數值 x,然后在 table[x] 中存放我們的值。這樣有一個好處是,以后的 get 等操作的時間復雜度直接就是O(1),因為 HashMap 內部就是基於數組的一個實現。
put 方法的實現 與 哈希沖突
下面再結合代碼重點分析下 HashMap 的 put 方法的內部實現 和 哈希沖突的解決辦法:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
首先我們看到 hash(key) 這個就是表示要根據 key 值算出一個數值,以此來決定在 table 數組的哪一個位置存放我們的數值。(Ps:這個 hash(key) 方法 也是大有講究的,會嚴重影響性能,實現得不好會讓 HashMap 的 O(1) 時間復雜度降到 O(n),在JDK8以下的版本中帶來災難性影響。它需要保證得出的數在哈希表中的均勻分布,目的就是要減少哈希沖突)
重要說明一下:
**JDK8 中哈希沖突過多,鏈表會轉紅黑樹,時間復雜度是O(logn),不會是O(n) **
**JDK8 中哈希沖突過多,鏈表會轉紅黑樹,時間復雜度是O(logn),不會是O(n) **
**JDK8 中哈希沖突過多,鏈表會轉紅黑樹,時間復雜度是O(logn),不會是O(n) **
然后,我們再看到:
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
......
這就表示,如果沒有 哈希沖突,那么就可以放入數據 tab[i] = newNode(hash, key, value, null); 如果有哈希沖突,那么就執行 else 需要解決哈希沖突。
那么放入數據 其實就是 建立一個 Node 節點,該 Node節點有屬性 key,value,分別保存我們的 key 值 和 value 值,然后再把這個 Node 節點放入到 table 數組中,並沒有什么神秘的地方。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
上述可以看到 Node 節點中 有一個 Node<K,V> next; ,其實仔細思考下就應該知道這個是用來解決哈希沖突的。下面再看看是如何解決哈希沖突的:
哈希沖突:通俗的講就是首先我們進行一次 put 操作,算出了我們要在 table 數組的 x 位置放入這個值。那么下次再進行一個 put 操作的時候,又算出了我們要在 table 數組的 x 位置放入這個值,那之前已經放入過值了,那現在怎么處理呢?
其實就是通過鏈表法進行解決。
首先,如果有哈希沖突,那么:
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
需要判斷 兩者的 key 是否一樣的,因為 HashMap 不能加入重復的鍵。如果一樣,那么就覆蓋,如果不一樣,那么就先判斷是不是 TreeNode 類型的:
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
這里表示 是不是現在已經轉紅黑樹了(在大量哈希沖突的情況下,鏈表會轉紅黑樹),一般我們小數據的情況下,是不會轉的,所以這里暫時不考慮這種情況(Ps:本人也沒太深入研究紅黑樹,所以就不說這個了)。
如果是正常情況下,會執行下面的語句來解決哈希沖突:
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
這里其實就是用鏈表法來解決。並且:
沖突的節點放在鏈表的最下面。
沖突的節點放在鏈表的最下面。
沖突的節點放在鏈表的最下面。
因為 首先有:p = tab[i = (n - 1) & hash] ,再 for 循環,然后有 if ((e = p.next) == null) { ,並且如果 當前節點的下一個節點有值的話,那么就 p = e;,這就說明了放在最下面。
強烈建議自己拿筆拿紙畫畫。
總結
一個映射不能包含重復的鍵,一個鍵只能有一個值。允許使用 null 值 和 null 鍵,並且不保證映射順序。
HashMap 解決沖突的辦法先是使用鏈表法,然后如果哈希沖突過多,那么會把鏈表轉換成紅黑樹,以此來保證效率。
如果出現了哈希沖突,那么新加入的節點放在鏈表的最后面。
參考
強烈建議看一下:
Java HashMap工作原理及實現
Java 8:HashMap的性能提升
HashTable
HashTable 是 HashMap 的線程安全版本。 內部的實現幾乎和 HashMap 一模一樣。例如:
同樣的有一個數組:
private transient Entry<?,?>[] table;
對於 put 方法:
public synchronized V put(K key, V value) {
......
// Makes sure the key is not already in the hashtable.
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
Entry<K,V> entry = (Entry<K,V>)tab[index];
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
addEntry(hash, key, value, index);
return null;
}
這里可以看到, for 循環表示如果出現了哈希沖突,那么就放在最后一位。因為不斷的進行 entry = entry.next,直到 entry != null。需要注意的是,JDK8 中的 HashMap 如果有很多哈希沖突的話,那么是可能會把鏈表變成紅黑樹以此來提高效率。但是這里 HashTable 並沒有這樣做。
另外,從這里也可以看出,HashTable 實現多線程同步的主要方式是通過加 synchronized 關鍵字。
另外,對於 get 方法:
@SuppressWarnings("unchecked")
public synchronized V get(Object key) {
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
return (V)e.value;
}
}
return null;
}
這里最明顯的就是 synchronized,其實還有很多其他的方法用的也是 synchronized。get 方法的處理也是先根據 key 定位到 table 的某一個位置,最后再 for 循環拿到該值(因為可能出現了哈希沖突,所以要 for 循環)。
總結
- Hashtable的方法是同步的,HashMap則是非同步的,所以在多線程場合要手動同步HashMap,這個區別就像Vector和ArrayList一樣。
- Hashtable不允許null值(key和value都不可以),HashMap允許null值(key和value都可以)。
- Hashtable比HashMap多一個elements方法用於遍歷。
- Hashtable使用Enumeration,HashMap使用Iterator。
- 哈希值的使用不同,Hashtable直接使用對象的hashCode,而HashMap重新計算hash值,而且用與代替求模。
- Hashtable中hash數組默認大小是11,增加的方式是 old*2+1。HashMap中hash數組的默認大小是16,而且一定是2的指數。