淺析HashMap的實現和效能分析
前段時間面試,被問及hashmap的實現,瞬間蒙了,最後被虐成了狗。痛定思過,發現自己最近一年以來走入了一些歧途,有些本末倒置。故從基礎開始,從跌倒的地方開始。
Java集合框架強大、簡單、易用。尤其在設計業務邏輯的程式設計中,集合框架可以說是使用最多的類。Hashmap作為其中一員,是一種把鍵(key)和值(value)的結構,在實際引用中及其廣泛。本篇簡單分析java中hashmap的實現,並簡單分析它的一些效能,使用過程中的需要注意的地方。
建構函式
Java中hashmap的實現,最基本的原理是連結串列陣列。如下圖,即把鍵的hash值對陣列長度取餘作為index,然後存到對應陣列的連結串列中。
以上原理看起來很簡單,實際實現中還有一些細節需要考慮,讓我們來看看它的建構函式,預設構造時值為 16和0.75
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); // Find a power of 2 >= initialCapacity int capacity = 1; while (capacity < initialCapacity) capacity <<= 1; //負載因子,預設構造時為0.75 this.loadFactor = loadFactor; //容量和負載因子的乘積 threshold = (int)(capacity * loadFactor); //連結串列陣列,真正放鍵值對的地方 table = new Entry[capacity]; //回撥。子類方可覆蓋,預設實現為空 init(); }
注意程式碼中註釋。無論呼叫哪個建構函式,最後執行的都是上面的這個,這個建構函式接受兩個引數:初始容量和負載因子。它們是hashmap最重要的指標。
初識容量從程式碼中,可看出指的是連結串列陣列的長度,負載因子是hashmap中當前元素數量/初始容量的一個上限(此上限程式碼中用threshold(容量*負載因子)來衡量)。當超過整個限度時,會把連結串列陣列的長度增加,重新計算各個元素的位置(最耗效能)。
我們接下來看下連結串列陣列中Entry的結構,只列出了欄位和關鍵方法。可以看出其是一個連結串列節點,每個節點包含鍵值對、hash值和下個節點的引用。其equal()和hashcode()方法同時兼顧了鍵值。這點在判斷是否相等時很有必要。
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
final int hash;
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;
}
public final int hashCode() {
return (key==null ? 0 : key.hashCode()) ^
(value==null ? 0 : value.hashCode());
}
}
Put
瞭解以上基本結構,就可以看put操作了。以下的程式碼摘自jdk,註明了詳細的註釋:
/**
* put操作的基本邏輯為,如果當前鍵已經在hashmap中存在,那麼覆蓋之,並返回原來的值,否則返回null
*/
public V put(K key, V value) {
if (key == null)//鍵值可以為null,且專門存放在table[0]這個連結串列中,見putForNullKey() 這點和hashtable不同,
return putForNullKey(value);
//計算hash值,
int hash = hash(key.hashCode());
//求在陣列連結串列中的下標,這裡用了非常巧妙的方法
int i = indexFor(hash, table.length);
//在對應中的連結串列中找師傅已經存在,存在的話,覆蓋之,返回原來的值
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))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
// 原來不存在的話,則插入到hashmap中,並返回null.
modCount++;//這裡是改變次數,在返回迭代器的時候,用來判斷迭代器的失效,有興趣自行研究
//真正新增
addEntry(hash, key, value, i);
return null;
}
//專門放null鍵,直接取下標0,其他和put操作完全一樣
private V putForNullKey(V value) {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
//put 操作時,原hashmap中不存在鍵key ,則新建一個 Entry,放到對應陣列下標的連結串列中
void addEntry(int hash, K key, V value, int bucketIndex) {
//取連結串列頭結點,當此連結串列沒元素時為null,初識就是這樣
Entry<K,V> e = table[bucketIndex];
//構造新節點,並作為頭指標存在陣列中
table[bucketIndex] = new Entry<>(hash, key, value, e);
//注意這裡最耗效能,重新hash,這也是我們如果可以需要避免的地方
if (size++ >= threshold)
resize(2 * table.length);
}
//hashmap擴容,增加連結串列陣列的長度,所有的元素重新計算hash位置。最耗時的操作
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;
threshold = (int)(newCapacity * loadFactor);
}
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
//雙層迴圈,每個元素都重新計算位置
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
最後看看神祕的indexFor
//可以這麼理解 length肯定是 2的冪,如 16 轉換 2禁制是 10000 ,減一為01111 ,進行&運算就可以得到h對應的低位,剛好是相當於
//h%length
static int indexFor(int h, int length) {
return h & (length-1);
}
Get
理解了put,get就是小菜:
public V get(Object key) {
//null鍵專門取,即從table[0]取
if (key == null)
return getForNullKey();
//求hash,hash函式這裡不研究
int hash = hash(key.hashCode());
//從對應的陣列連結串列中查詢資料
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.equals(k)))
return e.value;
}
returnnull;
}
分析
理解了hashmap的實現,聰明的人肯定已經知道怎麼更加高效能的使用hashmap。不過在此之前還是先說明下初始容量和負載因子的含義。
Hashmap的設想是在O(1)的時間複雜度存取資料,根據我們的分析,在最壞情況下,時間複雜度很可能是o(n),但這肯定極少出現。但是某個連結串列中存在多個元素還是有相當大的可能的。當hashmap中的元素數量越接近陣列長度,這個機率就越大。為了保證hashmap的效能,我們對元素數量/陣列長度的值做了上限,此值就是負載因子。當比值大於負載因子時,就需要對內建陣列進行擴容,從而提高讀寫效能。但這也正是問題的所在,對陣列擴容,代價較大,時間複雜度時O(n)。
故我們在hashmap需要存放的元素數量可以預估的情況下,預先設定一個初始容量,來避免自動擴容的操作來提高效能。