上一篇文章講了Java的常見容器,相信大家對Java中容器的繼承關係有了大致瞭解了。今天我們將聚焦HashMap,從Java中HashMap源代碼實現開始,來對HashMap進行剖析(媽媽再也不用擔心我的面試)。本文將回答下列幾個問題:什麼是HashMap?有哪些應用?Hash碰撞是什麼?如果我們自己寫一個MyHashMap,應該怎樣去實現?
一、HashMap的概念
哈希表(hash table)也叫散列表,是一種非常重要的數據結構(說了和沒說好像沒什麼區別)。在具體介紹HashMap之前,我們先來探討一下爲什麼需要HashMap。
數據結構 | 優點 | 缺點 |
數組 | 查找方便,索引訪問時間複雜度爲O(1) | 增、刪性能較慢 |
列表 | 增、刪元素性能較快 | 訪問鏈表元素需要從頭遍歷,速度較慢 |
樹 | 對於相對平衡的有序二叉樹,對其進行插入,查找,刪除等操作,平均複雜度均爲O(logn) | 佔用的空間較大 |
經過對比上述三種結構,單純地用某種數據結構來進行存儲數據都不能應對大多數的數據存儲情景,能否有一種結構將它們的優點都結合起來,成爲一種比較完美的結構呢?HashMap誕生了。
HashMap是基於哈希表的Map接口的非同步實現,存入HashMap的數據以key-value的形式進行存儲。
(圖片出自:https://www.cnblogs.com/chengxiao/p/6059914.html#t1)
由於數據結構的物理存儲結構只有兩種:順序存儲結構和鏈式存儲結構(像棧,隊列,樹,圖等是從邏輯結構去抽象的,映射到內存中,也這兩種物理組織形式),而在上面我們提到過,在數組中根據下標查找某個元素,一次定位就可以達到,哈希表利用了這種特性,哈希表的主幹就是數組。
哈希表的主幹就是數組!
哈希表的主幹就是數組!
比如我們要新增或查找某個元素,我們通過把當前元素的關鍵字 通過某個函數映射到數組中的某個位置,通過數組下標一次定位就可完成操作。
存儲位置 = f(關鍵字)
其中,這個函數f一般稱爲哈希函數,這個函數的設計好壞會直接影響到哈希表的優劣。如果不同的元素都映射在同一個位置,我們稱之爲“哈希衝突”,此時則在此位置上會生出一條鏈表(也叫“拉鍊法”)。由於一個好的哈希函數會讓衝突發生的可能性降低,所以生成鏈表後需要完全遍歷的機率就會小很多,此時HashMap訪問的時間複雜度僅僅比數組的略高,遠遠低於訪問數組的複雜度。
在JDK1.7以前,HashMap是數組+鏈表實現的;在JDK1.8以後,如果發生哈希衝突的次數>8次,則會調用HashMap的方法自動將列表轉化爲“紅黑樹”。紅黑樹是平衡二叉樹的一種,能夠確保任何一個節點的左右子樹的高度差不會超過二者中較低那個的一倍,因此能在數據較多時,也能有高於鏈表的訪問性能(O(logn))。
二、源碼分析
(一)繼承的接口、類
可以看到,HashMap繼承了一個類和兩個接口:
AbstractMap<K,V>:作爲Map接口的抽象類,實現了Map接口的大部分方法
Cloneable:表示可以拷貝
Serializable:序列化(表示可以把對象保存在本地)
上述三個類或者接口更詳細的作用不是本文的重點,請大家自行百度。
(二)關鍵屬性:
1. static final int DEFAULT_INITIAL_CAPACITY = 1<<4 ;//初始容量爲16,採用位運算,速度更快
注意:(1)如果我們自己要存的數據量很大,必須把這裏的數字改大,否則HashMap性能會因爲一直做擴容操作而下降;
(2)自定義最大容量時,一定要是2的整數次冪。(具體原因讀下去你就明白了)
最大容量:1<<30 //即可以存儲2的30次個K-V對
2.static int DEFAULT_LOAD_FACTOR = 0.75f; //擴容因子
即:當hashmap中的元素個數/hashmap的容量 = 0.75 時,將發生擴容操作
3.static final int TREEIFY_THRESHOLD = 8 ; 當同個hash桶中的元素個數>8時,鏈表->RB tree;
static final intUNTREEFY_THRESHOLD = 3 ; 當同個hash桶中的元素個數>8時,RB tree -> 鏈表;
4.1 transient Node <K,V> [ ] table ; //hash桶,存儲元素的數組
當必要的時候回進行擴容(即hashmap中的元素個數/hashmap的容量==擴容因子時);而且每次的擴容總是讓數組的長度增長兩倍。
4.2 transient SET<Map,Entry> entrySet ; // 存儲實際元素的集合
HashMap將數據轉換成set的另一種存儲形式,這個變量主要用於迭代功能
(二)幾個關鍵方法
1.構造方法:上文提過,initialCapacity可隨着自己的需要自行更改(否則會多次擴容而減慢速度),loadFactor也可自己預設。
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
2.hash運算:爲即將存入HashMap的元素的Key計算一個hash值後,以此hash值爲索引放入哈希桶中。
請注意返回語句中的(h = key.hashCode()) ^ (h >>> 16),正因爲這裏需要進行hashcode()和位運算,纔要求自己設置的Capacity才必須得是2的整數次冪,否則就不能有最快的運行速度。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
3.Put方法
putVal(hash,key,value,onlyIfabsent,evict) —— 此方法是一系列Put操作進行時調用的方法,非常長,不過我們可以將它可大致分爲幾個板塊進行解讀,就不會覺得很長了。具體見註釋。
put(key,value)——開放給用戶使用的方法,直接輸入鍵值對即可。
注:put中的key和hash桶中的key不同,前者是用戶的原數據,而後者是原數據經過哈希計算後的值。
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;
//p即將要存放的hash桶位置上是空的,直接存入即可
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//p要放的位置在哈希桶中已經被佔了,拉鍊法
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//p即將要存放的位置是二叉樹上的結點
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//p即將要存放的位置是在鏈表上的結點
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//如果達到了轉化爲二叉樹的數量,調用treeifyBin方法
if (binCount >= TREEIFY_THRESHOLD - 1)
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;
}
}
//元素增加後,,若HashMap內部結構發生變化,快速響應失敗
++modCount;
//size+1,如果>threshold,進行擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
4.Get方法:get()同樣是開放給用戶使用的方法,其實它內部是調用了GetNode()方法的。從上文我們知道,在HashMap中可能會有數組+鏈表+樹存在的情況,所以Get方法自然也會根據不同的數據結構來進行操作。
如果是樹的結點,調用getTreeNode()方法繼續找,由於getTreeNode()涉及到的代碼過多,此處不一一列出。
源代碼具體分析如下:
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//總是從hash桶開始找,如果第一個結點就是所要的結點,則直接返回
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//如果第一個結點不是,且還有後繼,則分情況繼續找
if ((e = first.next) != null) {
//如果是樹節點,調用getTreeNode()方法
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//如果是鏈表,則對鏈表進行遍歷查找
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
//沒找到,返回null
return null;
}
注:1.請注意源代碼中的“==”和“equals”何處使用的:
==:用於算key是否相等,涉及到在內存中的位置;
equals:用於算value是否相等,涉及到和值的比較有關的操作(如果相等則返回或者存儲等等等)。
2.hash桶中存的是key的hash值而不是key本身。
(三)MyHashMap的實現
分析了HashMap的實現原理後,我們自己也可以動手來寫自己的HashMap,只是我們的性能並沒有原版那麼快而已。
作爲第一個版本,實現是最重要的爲了簡明扼要:1.我們的hash運算直接照搬原版,性能比直接模運算快一些;2.hash衝突>8時我們也不進行鏈表轉紅黑樹的變化。以後我們可以繼續進行優化,讓它更像一個真正的哈希表。
package MyHash;
public class MyHashMap<K,V> {
private static final int DEFAULT_SIZE=1<<4; //默認大小爲16
private Entry<K,V> data[]; //hash桶,這裏存的就是table表
private int capacity; //必須是2的整數次冪
private int size; //HashMap存放的數據個數
public MyHashMap(){
this(DEFAULT_SIZE);
}
public MyHashMap(int cap){
data = new Entry[cap];
size=0;
this.capacity=cap;
}
private int hash(K key){ //照搬源碼
int h;
h = (key==null) ? 0 : (h=key.hashCode())^(h >>> 16); //直接return可能會越界,mod
return h % capacity; //此處是防止越界
}
public void put(K key,V value){
int hash = hash(key);
Entry<K,V> newE = new Entry<K,V>(key,value,null);
Entry<K,V> hasM = data[hash];
//!!! 注:此處還有擴容 >8時鏈表轉紅黑樹沒寫
while(hasM != null){ //當hasM的位置有元素時,即發生了hash衝突,遍歷到鏈表的末尾
if(hasM.key.equals(key)){ //怕有一樣的
hasM.value = value;
}
hasM = hasM.next;
}
newE.next = data[hash];
data[hash] = newE;
size++; //表示成功插入一個數據
}
public V get(K key){
int hash = hash(key);
Entry<K,V> entry = data[hash];
while (entry != null ){ //如果data[hash]處有值,開始遍歷
if(entry.key.equals(key)){ //注意是equals,而不是==
return entry.value;
}
entry = entry.next;
}
return null; // 沒找到的情況。 注:null 也可以作爲泛型的返回值
}
private class Entry<K,V>{
K key;
V value;
Entry<K,V> next;
int cap; //表示起hash衝突的個數
public Entry(){
}
public Entry(K key,V value,Entry<K,V> next){
this.key = key;
this.value = value;
this.next = next;
}
}
}
運行結果如下:
符合預期。
(四)HashMap和HashTable的區別
其實這個問題上一篇文章裏已經有提到,爲了內容的完整性,再次敘述一遍。
父類 | 線程安全性 | null值 | 遍歷方式 | 初始容量 | 計算Hash值的方式 | |
HashMap | AbstractMap類 | 不安全,需要自己增加同步處理 | 可以有null(key只能有一個null) | 根據不同結構採取不同遍歷方式 | 16(每次擴容爲2n) | 位運算(更快) |
HashTable | Dictionary(已被廢棄,詳情看源代碼) | 線程安全 | key、value均不能爲null | Iterator | 11(每次擴容2n+1) | 直接使用對象的hashCode |
注:HashMap中,null可以作爲鍵,這樣的鍵只有一個;可以有一個或多個鍵所對應的值爲null。當get()方法返回null值時,可能是 HashMap中沒有該鍵,也可能使該鍵所對應的值爲null。因此,在HashMap中不能由get()方法來判斷HashMap中是否存在某個鍵, 而應該用containsKey()方法來判斷。
2019-3-13 2:09