1. 程式人生 > >淺析HashMap的實現和效能分析

淺析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需要存放的元素數量可以預估的情況下,預先設定一個初始容量,來避免自動擴容的操作來提高效能。