HashMap深度分析

這次主要是分析下HashMap的工作原理,為什么我會拿這個東西出來分析,原因很簡單,以前我面試的時候,偶爾問起HashMap,99%的程序員都知道HashMap,基本都會用Hashmap,這其中不僅僅包括剛畢業的大學生,也包括已經工作5年,甚至是10年的程序員。HashMap涉及的知識遠遠不止put和get那么簡單。本次的分析希望對于面試的人起碼對于面試官的問題有所應付

** 一、先來回憶下我的面試過程**

** 問:“你用過HashMap,你能跟我說說它嗎?”**

** 答:**“當然用過,HashMap是一種<key,value>的存儲結構,能夠快速將key的數據put方式存儲起來,然后很快的通過get取出來”,然后說“HashMap不是線程安全的,
HashTable是線程安全的,通過synchronized實現的。HashMap取值非常快”等等。這個時候說明他已經很熟練使用HashMap的工具了。

問:“你知道HashMap 在put和get的時候是怎么工作的嗎?”

答:“HashMap是通過key計算出Hash值,然后將這個Hash值映射到對象的引用上,get的時候先計算key的hash值,然后找到對象”。這個時候已經顯得不自信了。

問:“HashMap的key為什么一般用字符串比較多,能用其他對象,或者自定義的對象嗎?為什么?”

答:“這個沒研究過,一般習慣用String。”

問:“你剛才提到HashMap不是線程安全的,你怎么理解線程安全。原理是什么?幾種方式避免線程安全的問題。”

答:“線程安全就是多個線程去訪問的時候,會對對象造成不是預期的結果,一般要加鎖才能線程安全。”

其實,問了以上那些問題,我基本能判定這個程序員的基本功了,一般技術中等,接下來的問題沒必要問了。

從我的個人角度來看,HashMap的面試問題能夠考察面試者的線程問題、Java內存模型問題、線程可見與不可變問題、Hash計算問題、鏈表結構問題、二進制的&、|、<<、>>等問題。所以一個HashMap就能考驗一個人的技術功底了。

二、概念分析

1、HashMap的類圖結構

 此處的類圖是根據JDK1.6版本畫出來的。如下圖1:

202221148131465.png

  圖(一)

2、HashMap存儲結構

** **HashMap的使用那么簡單,那么問題來了,它是怎么存儲的,他的存儲結構是怎樣的,很多程序員都不知道,其實當你put和get的時候,稍稍往前一步,你看到就是它的真面目。其實簡單的說HashMap的存儲結構是由數組和鏈表共同完成的。如圖:

210003116887371.png

從上圖可以看出HashMap是Y軸方向是數組,X軸方向就是鏈表的存儲方式。大家都知道數組的存儲方式在內存的地址是連續的,大小固定,一旦分配不能被其他引用占用。它的特點是查詢快,時間復雜度是O(1),插入和刪除的操作比較慢,時間復雜度是O(n),鏈表的存儲方式是非連續的,大小不固定,特點與數組相反,插入和刪除快,查詢速度慢。HashMap可以說是一種折中的方案吧。

3、HashMap基本原理

1、首先判斷Key是否為Null,如果為null,直接查找Enrty[0],如果不是Null,先計算Key的HashCode,然后經過二次Hash。得到Hash值,這里的Hash特征值是一個int值。

2、根據Hash值,要找到對應的數組啊,所以對Entry[]的長度length求余,得到的就是Entry數組的index。

3、找到對應的數組,就是找到了所在的鏈表,然后按照鏈表的操作對Value進行插入、刪除和查詢操作。

4、HashMap概念介紹

變量 術語 說明
size 大小 HashMap的存儲大小
threshold 臨界值 HashMap大小達到臨界值,需要重新分配大小。
loadFactor 負載因子 HashMap大小負載因子,默認為75%。
modCount 統一修改 HashMap被修改或者刪除的次數總數。
Entry 實體 HashMap存儲對象的實際實體,由Key,value,hash,next組成。

5、HashMap初始化

默認情況下,大多數人都調用new HashMap()來初始化的,我在這里分析new HashMap(int initialCapacity, float loadFactor)的構造函數,代碼如下:

public HashMap(int initialCapacity, float loadFactor) {
     // initialCapacity代表初始化HashMap的容量,它的最大容量是MAXIMUM_CAPACITY = 1 << 30。
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;

     // loadFactor代表它的負載因子,默認是是DEFAULT_LOAD_FACTOR=0.75,用來計算threshold臨界值的。
        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;

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

由上面的代碼可以看出,初始化的時候需要知道初始化的容量大小,因為在后面要通過按位與的Hash算法計算Entry數組的索引,那么要求Entry的數組長度是2的N次方。

6、HashMap中的Hash計算和碰撞問題

HashMap的hash計算時先計算hashCode(),然后進行二次hash。代碼如下:

// 計算二次Hash    
int hash = hash(key.hashCode());

// 通過Hash找數組索引
int i = indexFor(hash, table.length);

先不忙著學習HashMap的Hash算法,先來看看JDK的String的Hash算法。代碼如下:

 /**
     * Returns a hash code for this string. The hash code for a
     * <code>String</code> object is computed as
     * <blockquote><pre>
     * s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
     * </pre></blockquote>
     * using <code>int</code> arithmetic, where <code>s[i]</code> is the
     * <i>i</i>th character of the string, <code>n</code> is the length of
     * the string, and <code>^</code> indicates exponentiation.
     * (The hash value of the empty string is zero.)
     *
     * @return  a hash code value for this object.
     */
    public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }

從JDK的API可以看出,它的算法等式就是s[0]31^(n-1) + s[1]31^(n-2) + ... + s[n-1],其中s[i]就是索引為i的字符,n為字符串的長度。這里為什么有一個固定常量31呢,關于這個31的討論很多,基本就是優化的數字,主要參考Joshua Bloch's Effective Java的引用如下:

The value 31 was chosen because it is an odd prime. If it were even and the multiplication overflowed, information would be lost, as multiplication by 2 is equivalent to shifting. The advantage of using a prime is less clear, but it is traditional. A nice property of 31 is that the multiplication can be replaced by a shift and a subtraction for better performance: 31 * i == (i << 5) - i. Modern VMs do this sort of optimization automatically.

大體意思是說選擇31是因為它是一個奇素數,如果它做乘法溢出的時候,信息會丟失,而且當和2做乘法的時候相當于移位,在使用它的時候優點還是不清楚,但是它已經成為了傳統的選擇,31的一個很好的特性就是做乘法的時候可以被移位和減法代替的時候有更好的性能體現。例如31i相當于是i左移5位減去i,即31i == (i<<5)-i。現代的虛擬內存系統都使用這種自動優化。

現在進入正題,HashMap為什么還要做二次hash呢? 代碼如下:

static int hash(int h) {
        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

回答這個問題之前,我們先來看看HashMap是怎么通過Hash查找數組的索引的。

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

其中h是hash值,length是數組的長度,這個按位與的算法其實就是h%length求余,一般什么情況下利用該算法,典型的分組。例如怎么將100個數分組16組中,就是這個意思。應用非常廣泛。

既然知道了分組的原理了,那我們看看幾個例子,代碼如下:

        int h=15,length=16;
        System.out.println(h & (length-1));
        h=15+16;
        System.out.println(h & (length-1));
        h=15+16+16;
        System.out.println(h & (length-1));
        h=15+16+16+16;
        System.out.println(h & (length-1));

運行結果都是15,為什么呢?我們換算成二進制來看看。

System.out.println(Integer.parseInt("0001111", 2) & Integer.parseInt("0001111", 2));

System.out.println(Integer.parseInt("0011111", 2) & Integer.parseInt("0001111", 2));

System.out.println(Integer.parseInt("0111111", 2) & Integer.parseInt("0001111", 2));

System.out.println(Integer.parseInt("1111111", 2) & Integer.parseInt("0001111", 2));

這里你就發現了,在做按位與操作的時候,后面的始終是低位在做計算,高位不參與計算,因為高位都是0。這樣導致的結果就是只要是低位是一樣的,高位無論是什么,最后結果是一樣的,如果這樣依賴,hash碰撞始終在一個數組上,導致這個數組開始的鏈表無限長,那么在查詢的時候就速度很慢,又怎么算得上高性能的啊。所以hashmap必須解決這樣的問題,盡量讓key盡可能均勻的分配到數組上去。避免造成Hash堆積。

回到正題,HashMap怎么處理這個問題,怎么做的二次Hash。

 static int hash(int h) {
        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

這里就是解決Hash的的沖突的函數,解決Hash的沖突有以下幾種方法:
1. 開放定址法
線性探測再散列,二次探測再散列,偽隨機探測再散列)
 2. 再哈希法
3. 鏈地址法
4. 建立一 公共溢出區

而HashMap采用的是鏈地址法,這幾種方法在以后的博客會有單獨介紹,這里就不做介紹了。

7、HashMap的put()解析

以上說了一些基本概念,下面該進入主題了,HashMap怎么存儲一個對象的,代碼如下:

 /**
     * Associates the specified value with the specified key in this map.
     * If the map previously contained a mapping for the key, the old
     * value is replaced.
     *
     * @param key key with which the specified value is to be associated
     * @param value value to be associated with the specified key
     * @return the previous value associated with <tt>key</tt>, or
     *         <tt>null</tt> if there was no mapping for <tt>key</tt>.
     *         (A <tt>null</tt> return can also indicate that the map
     *         previously associated <tt>null</tt> with <tt>key</tt>.)
     */
    public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value);
        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;
            }
        }

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

從代碼可以看出,步驟如下:

(1) 首先判斷key是否為null,如果是null,就單獨調用putForNullKey(value)處理。代碼如下:

 /**
     * Offloaded version of put for null keys
     */
    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;
    }

從代碼可以看出,如果key為null的值,默認就存儲到table[0]開頭的鏈表了。然后遍歷table[0]的鏈表的每個節點Entry,如果發現其中存在節點Entry的key為null,就替換新的value,然后返回舊的value,如果沒發現key等于null的節點Entry,就增加新的節點。

(2) 計算key的hashcode,再用計算的結果二次hash,通過indexFor(hash, table.length);找到Entry數組的索引i。

(3) 然后遍歷以table[i]為頭節點的鏈表,如果發現有節點的hash,key都相同的節點時,就替換為新的value,然后返回舊的value。

(4) modCount是干嘛的啊? 讓我來為你解答。眾所周知,HashMap不是線程安全的,但在某些容錯能力較好的應用中,如果你不想僅僅因為1%的可能性而去承受hashTable的同步開銷,HashMap使用了Fail-Fast機制來處理這個問題,你會發現modCount在源碼中是這樣聲明的。

volatile關鍵字聲明了modCount,代表了多線程環境下訪問modCount,根據JVM規范,只要modCount改變了,其他線程將讀到最新的值。其實在Hashmap中modCount只是在迭代的時候起到關鍵作用。

private abstract class HashIterator<E> implements Iterator<E> {
        Entry<K,V> next;    // next entry to return
        int expectedModCount;    // For fast-fail
        int index;        // current slot
        Entry<K,V> current;    // current entry

        HashIterator() {
            expectedModCount = modCount;
            if (size > 0) { // advance to first entry
                Entry[] t = table;
                while (index < t.length && (next = t[index++]) == null)
                    ;
            }
        }

        public final boolean hasNext() {
            return next != null;
        }

        final Entry<K,V> nextEntry() {
        // 這里就是關鍵
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            Entry<K,V> e = next;
            if (e == null)
                throw new NoSuchElementException();

            if ((next = e.next) == null) {
                Entry[] t = table;
                while (index < t.length && (next = t[index++]) == null)
                    ;
            }
        current = e;
            return e;
        }

        public void remove() {
            if (current == null)
                throw new IllegalStateException();
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            Object k = current.key;
            current = null;
            HashMap.this.removeEntryForKey(k);
            expectedModCount = modCount;
        }

    }

使用Iterator開始迭代時,會將modCount的賦值給expectedModCount,在迭代過程中,通過每次比較兩者是否相等來判斷HashMap是否在內部或被其它線程修改,如果modCount和expectedModCount值不一樣,證明有其他線程在修改HashMap的結構,會拋出異常。

所以HashMap的put、remove等操作都有modCount++的計算。

(5) 如果沒有找到key的hash相同的節點,就增加新的節點addEntry(),代碼如下:

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);
        if (size++ >= threshold)
            resize(2 * table.length);
    }

這里增加節點的時候取巧了,每個新添加的節點都增加到頭節點,然后新的頭節點的next指向舊的老節點。

(6) 如果HashMap大小超過臨界值,就要重新設置大小,擴容,見第9節內容。

8、HashMap的get()解析

理解上面的put,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;
    }

別看這段代碼,它帶來的問題是巨大的,千萬記住,HashMap是非線程安全的,所以這里的循環會導致死循環的。為什么呢?當你查找一個key的hash存在的時候,進入了循環,恰恰這個時候,另外一個線程將這個Entry刪除了,那么你就一直因為找不到Entry而出現死循環,最后導致的結果就是代碼效率很低,CPU特別高。一定記住。

9、HashMap的size()解析

HashMap的大小很簡單,不是實時計算的,而是每次新增加Entry的時候,size就遞增。刪除的時候就遞減。空間換時間的做法。因為它不是線程安全的。完全可以這么做。效力高。

9、HashMap的reSize()解析

當HashMap的大小超過臨界值的時候,就需要擴充HashMap的容量了。代碼如下:

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

從代碼可以看出,如果大小超過最大容量就返回。否則就new 一個新的Entry數組,長度為舊的Entry數組長度的兩倍。然后將舊的Entry[]復制到新的Entry[].代碼如下:

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

在復制的時候數組的索引int i = indexFor(e.hash, newCapacity);重新參與計算。

至此,HashMap還有一些迭代器的代碼,這里不一一做介紹了,在JDK1.7版本中HashMap也做了一些升級,具體有Hash因子的參與。

今天差不多完成了HashMap的源碼解析,下一步將會分析ConcurrencyHashMap的源碼。ConcurrencyHashMap彌補了HashMap線程不安全、HashTable性能低的缺失。是目前高性能的線程安全的HashMap類。

很晚了,希望對大家有所幫助,晚安。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,363評論 6 532
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,497評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,305評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,962評論 1 311
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,727評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,193評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,257評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,411評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,945評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,777評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,978評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,519評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,216評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,657評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,960評論 2 373

推薦閱讀更多精彩內容

  • HashMap 是 Java 面試必考的知識點,面試官從這個小知識點就可以了解我們對 Java 基礎的掌握程度。網...
    野狗子嗷嗷嗷閱讀 6,680評論 9 107
  • 一、基本數據類型 注釋 單行注釋:// 區域注釋:/* */ 文檔注釋:/** */ 數值 對于byte類型而言...
    龍貓小爺閱讀 4,282評論 0 16
  • 5.1、對于HashMap需要掌握以下幾點 Map的創建:HashMap() 往Map中添加鍵值對:即put(Ob...
    rochuan閱讀 691評論 0 0
  • 最近看到小型的插畫,也是記錄這個小女孩的日常生活吧,所以很喜歡這種簡略的帶一點點素描的畫。 現在可真熱啊!多想像這...
    一只好coffee閱讀 2,284評論 6 11
  • 勇氣對于一個人的成長非常重要,很多時候,我們的智力是夠用的,甚至是超前的,但就是缺乏與之對應的勇氣,缺乏那臨門一腳...
    Yao_3019閱讀 208評論 0 0