1. 程式人生 > >java開發常被問到的面試題-HashMap的底層原理

java開發常被問到的面試題-HashMap的底層原理

java開發人員面試的時候會經常被問到HashMap的底層是怎麼實現的,以下做簡要分析:
HashMap是基於雜湊表的Map介面的非同步實現,
HashMap實際上是一個“連結串列雜湊”的資料結構,即陣列和連結串列的結合體。
首先來了解一下資料結構中陣列和連結串列來實現對資料的儲存,但這兩者基本上是兩個極端。

陣列

陣列儲存區間是連續的,佔用記憶體嚴重,故空間複雜的很大。但陣列的二分查詢時間複雜度小,為O(1);陣列的特點是:定址容易,插入和刪除困難。

連結串列

連結串列儲存區間離散,佔用記憶體比較寬鬆,故空間複雜度很小,但時間複雜度很大,達O(N)。連結串列的特點是:定址困難,插入和刪除容易。

雜湊表

那麼我們能不能綜合兩者的特性,做出一種定址容易,插入刪除也容易的資料結構?答案是肯定的,這就是我們要提起的雜湊表。雜湊表((Hash table)既滿足了資料的查詢方便,同時不佔用太多的內容空間,使用也十分方便。

  雜湊表有多種不同的實現方法,我接下來解釋的是最常用的一種方法—— 拉鍊法,我們可以理解為“連結串列的陣列” ,如圖:

這裡寫圖片描述
這裡寫圖片描述

  從上圖我們可以發現雜湊表是由陣列+連結串列組成的,一個長度為16的陣列中,每個元素儲存的是一個連結串列的頭結點。那麼這些元素是按照什麼樣的規則儲存到陣列中呢。一般情況是通過hash(key)%len獲得,也就是元素的key的雜湊值對陣列長度取模得到。比如上述雜湊表中,12%16=12,28%16=12,108%16=12,140%16=12。所以12、28、108以及140都儲存在陣列下標為12的位置。

 首先HashMap裡面實現一個靜態內部類Entry,其重要的屬性有 key , value, next,從屬性key,value我們就能很明顯的看出來Entry就是HashMap鍵值對實現的一個基礎bean,我們上面說到HashMap的基礎就是一個線性陣列,這個陣列就是Entry[],Map裡面的內容都儲存在Entry[]裡面。

    /**
     * The table, resized as necessary. Length MUST Always be a power of two.
     */
    transient Entry[] table;

1. HashMap的存取實現


既然是線性陣列,為什麼能隨機存取?這裡HashMap用了一個小演算法,大致是這樣實現:

// 儲存時:
int hash = key.hashCode(); // 這個hashCode方法這裡不詳述,只要理解每個key的hash是一個固定的int值
int index = hash % Entry[].length;
Entry[index] = value;

// 取值時:
int hash = key.hashCode();
int index = hash % Entry[].length;
return Entry[index];

put()
疑問:如果兩個key通過hash%Entry[].length得到的index相同,會不會有覆蓋的危險?
  這裡HashMap裡面用到鏈式資料結構的一個概念。上面我們提到過Entry類裡面有一個next屬性,作用是指向下一個Entry。打個比方, 第一個鍵值對A進來,通過計算其key的hash得到的index=0,記做:Entry[0] = A。一會後又進來一個鍵值對B,通過計算其index也等於0,現在怎麼辦?HashMap會這樣做:B.next = A,Entry[0] = B,如果又進來C,index也等於0,那麼C.next = B,Entry[0] = C;這樣我們發現index=0的地方其實存取了A,B,C三個鍵值對,他們通過next這個屬性連結在一起。所以疑問不用擔心。也就是說陣列中儲存的是最後插入的元素。到這裡為止,HashMap的大致實現,我們應該已經清楚了。

 public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value); //null總是放在陣列的第一個連結串列中
        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;
            //如果key在連結串列中已存在,則替換為新value
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }

        modCount++;
        addEntry(hash, key, value, i);
        return null;
    }



void addEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e); //引數e, 是Entry.next
    //如果size超過threshold,則擴充table大小。再雜湊
    if (size++ >= threshold)
            resize(2 * table.length);
}

  當然HashMap裡面也包含一些優化方面的實現,這裡也說一下。比如:Entry[]的長度一定後,隨著map裡面資料的越來越長,這樣同一個index的鏈就會很長,會不會影響效能?HashMap裡面設定一個因子,隨著map的size越來越大,Entry[]會以一定的規則加長長度。

get()

 public V get(Object key) {
        if (key == null)
            return getForNullKey();
        int hash = hash(key.hashCode());
        //先定位到陣列元素,再遍歷該元素處的連結串列
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
                return e.value;
        }
        return null;
}

null key的存取

null key總是存放在Entry[]陣列的第一個元素。

   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;
    }

    private V getForNullKey() {
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null)
                return e.value;
        }
        return null;
    }

確定陣列index:hashcode % table.length取模
HashMap存取時,都需要計算當前key應該對應Entry[]陣列哪個元素,即計算陣列下標;演算法如下:

   /**
     * Returns index for hash code h.
     */
    static int indexFor(int h, int length) {
        return h & (length-1);
    }

按位取並,作用上相當於取模mod或者取餘%。
這意味著陣列下標相同,並不表示hashCode相同。

table初始大小

  public HashMap(int initialCapacity, float loadFactor) {
        .....

        // Find a power of 2 >= initialCapacity
        int capacity = 1;
        while (capacity < initialCapacity)
            capacity <<= 1;

        this.loadFactor = loadFactor;
        threshold = (int)(capacity * loadFactor);
        table = new Entry[capacity];
        init();
    }

注意table初始大小並不是建構函式中的initialCapacity!!

而是 >= initialCapacity的2的n次冪!!!!

再雜湊rehash過程

當雜湊表的容量超過預設容量時,必須調整table的大小。當容量已經達到最大可能值時,那麼該方法就將容量調整到Integer.MAX_VALUE返回,這時,需要建立一張新表,將原表的對映到新表中。

   /**
     * Rehashes the contents of this map into a new array with a
     * larger capacity.  This method is called automatically when the
     * number of keys in this map reaches its threshold.
     *
     * If current capacity is MAXIMUM_CAPACITY, this method does not
     * resize the map, but sets threshold to Integer.MAX_VALUE.
     * This has the effect of preventing future calls.
     *
     * @param newCapacity the new capacity, MUST be a power of two;
     *        must be greater than current capacity unless current
     *        capacity is MAXIMUM_CAPACITY (in which case value
     *        is irrelevant).
     */
    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);
    }



    /**
     * Transfers all entries from current table to newTable.
     */
    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;
                    //重新計算index
                    int i = indexFor(e.hash, newCapacity);
                    e.next = newTable[i];
                    newTable[i] = e;
                    e = next;
                } while (e != null);
            }
        }
    }

hashmap的擴容機制

1、當我們往hashmap中put元素的時候,先根據key的hash值得到這個元素在 陣列中的位置(即下標),然後就可以把這個元素放到對應的位置中了。如果這個元素所在的位子上已經存放有其他元素了,那麼在同一個位子上的元素將以連結串列的 形式存放,新加入的放在鏈頭,比如a->b->c,新加入的d放到a的位置前面,最先加入的放在鏈尾,也就是c。最後變成d->a->b->c,從hashmap中get元素時,首先計算key的hashcode,找到陣列中對應位置的某一元素, 然後通過key的equals方法在對應位置的連結串列中找到需要的元素。

2、

在hashmap中要找到某個元素,需要根據key的hash值來求得對應陣列中的位置。如何計算這個位置就是hash演算法。前面說過hashmap的資料結構是陣列和連結串列的結合,所以我們當然希望這個hashmap裡面的元素位置儘量的分佈均勻些,儘量使得每個位置上的元素數量只有一個,那麼當我們用hash演算法求得這個位置 的時候,馬上就可以知道對應位置的元素就是我們要的,而不用再去遍歷連結串列。所以我們首先想到的就是把hashcode對陣列長度取模運算,這樣一來,元素的分佈相對來說是比較均勻的。但是,“模”運算的消耗還是比較大的,能不能找一種更快速,消耗更小的方式那?java中時這樣做的,

Java程式碼

staticintindexFor(inth,intlength){ 
returnh&(length-1); 
}

首 先算得key得hashcode值,然後跟陣列的長度-1做一次“與”運算(&)。看上去很簡單,其實比較有玄機。比如陣列的長度是24次方, 那麼hashcode就會和24次方-1做“與”運算。很多人都有這個疑問,為什麼hashmap的陣列初始化大小都是2的次方大小時,hashmap 的效率最高,我以24次方舉例,來解釋一下為什麼陣列大小為2的冪時hashmap訪問的效能最高。 看下圖,左邊兩組是陣列長度為1624次方),右邊兩組是陣列長度為15。兩組的hashcode均為89,但是很明顯,當它們和1110“與”的 時候,產生了相同的結果,也就是說它們會定位到陣列中的同一個位置上去,這就產生了碰撞,89會被放到同一個連結串列上,那麼查詢的時候就需要遍歷這個鏈 表,得到8或者9,這樣就降低了查詢的效率。同時,我們也可以發現,當陣列長度為15的時候,hashcode的值會與141110)進行“與”,那麼 最後一位永遠是0,而0001001101011001101101111101這幾個位置永遠都不能存放元素了,空間浪費相當大,更糟的是 這種情況中,陣列可以使用的位置比陣列長度小了很多,這意味著進一步增加了碰撞的機率,減慢了查詢的效率!所以說,當陣列長度為2的n次冪的時候,不同的key算得得index相同的機率較小,那麼資料在陣列上分佈就比較均勻,也就是說碰撞的機率小,相對的,查詢的時候就不用遍歷某個位置上的連結串列,這樣查詢效率也就較高了。說到這裡,我們再回頭看一下hashmap中預設的陣列大小是多少,檢視原始碼可以得知是16,為什麼是16,而不是15,也不是20呢,看到上面 annegu的解釋之後我們就清楚了吧,顯然是因為162的整數次冪的原因,在小資料量的情況下161520更能減少key之間的碰撞,而加快查詢 的效率。

3、

當hashmap中的元素越來越多的時候,碰撞的機率也就越來越高(因為陣列的長度是固定的),所以為了提高查詢的效率,就要對hashmap的陣列進行 擴容,陣列擴容這個操作也會出現在ArrayList中,所以這是一個通用的操作,很多人對它的效能表示過懷疑,不過想想我們的“均攤”原理,就釋然了, 而在hashmap陣列擴容之後,最消耗效能的點就出現了:原陣列中的資料必須重新計算其在新陣列中的位置,並放進去,這就是resize。

那麼hashmap什麼時候進行擴容呢?當hashmap中的元素個數超過陣列大小*loadFactor時,就會進行陣列擴容,loadFactor的 預設值為0.75,也就是說,預設情況下,陣列大小為16,那麼當hashmap中元素個數超過16*0.75=12的時候,就把陣列的大小擴充套件為 2*16=32,即擴大一倍,然後重新計算每個元素在陣列中的位置,而這是一個非常消耗效能的操作,所以如果我們已經預知hashmap中元素的個數,那 麼預設元素的個數能夠有效的提高hashmap的效能。

比如說,我們有1000個元素new HashMap(1000), 但是理論上來講new HashMap(1024)更合適,不過上面annegu已經說過,即使是1000,hashmap也自動會將其設定為1024。 但是new HashMap(1024)還不是更合適的,因為0.75*1000 < 1000, 也就是說為了讓0.75 * size > 1000, 我們必須這樣new HashMap(2048)才最合適,既考慮了&的問題,也避免了resize的問題。

雜湊表及處理hash衝突的方法

看了ConcurrentHashMap的實現, 使用的是拉鍊法.

雖然我們不希望發生衝突,但實際上發生衝突的可能性仍是存在的。當關鍵字值域遠大於雜湊表的長度,而且事先並不知道關鍵字的具體取值時。衝突就難免會發生。

另外,當關鍵字的實際取值大於雜湊表的長度時,而且表中已裝滿了記錄,如果插入一個新記錄,不僅發生衝突,而且還會發生溢位。

因此,處理衝突和溢位是雜湊技術中的兩個重要問題。





雜湊法又稱雜湊法、雜湊法以及關鍵字地址計演算法等,相應的表稱為雜湊表。這種方法的基本思想是:首先在元素的關鍵字k和元素的儲存位置p之間建立一個對應關係f,使得p=f(k),f稱為雜湊函式。建立雜湊表時,把關鍵字為k的元素直接存入地址為f(k)的單元;以後當查詢關鍵字為k的元素時,再利用雜湊函式計算出該元素的儲存位置p=f(k),從而達到按關鍵字直接存取元素的目的。
   當關鍵字集合很大時,關鍵字值不同的元素可能會映象到雜湊表的同一地址上,即 k1≠k2 ,但 H(k1)=H(k2),這種現象稱為衝突,此時稱k1和k2為同義詞。實際中,衝突是不可避免的,只能通過改進雜湊函式的效能來減少衝突。
綜上所述,雜湊法主要包括以下兩方面的內容:
 1)如何構造雜湊函式
 2)如何處理衝突。