回首Java——深入理解HashMap相關

讀這篇文章可以學到什麼?
  • 什麼是哈希(Hash)值?
  • 什麼是HashMap?什麼又是HashSet?
  • 它們的區別和聯繫是什麼?
  • HashMap的實現原理是什麼?

什麼是哈希(Hash)值,hashCode()又是什麼?

  • Hash,一般翻譯做“散列”,也有直接音譯爲”哈希”的。是哈希算法將任意長度的二進制值映射爲固定長度的較小二進制值,這個小的二進制值稱爲哈希值。哈希值是一段數據唯一且極其緊湊的數值表示形式。如果散列一段明文而且哪怕只更改該段落的一個字母,隨後的哈希都將產生不同的值。要找到散列爲同一個值的兩個不同的輸入,在計算上來說基本上是不可能的。**直觀解釋起來,就是對一串數據m進行雜糅,輸出另一段固定長度的數據h,作爲這段數據的特徵(指紋)。
    也就是說,無論數據塊m有多大,其輸出值h爲固定長度。**

  • 消息身份驗證代碼 (MAC) 哈希函數通常與數字簽名一起用於對數據進行簽名,而消息檢測代碼 (MDC) 哈希函數則用於數據完整性,常常用於數據驗證,比如用來檢查文件下載是否正確,下載完畢時,再HASH文件一遍,查看此值是否和地址給出的一樣來 檢查文件是否出錯或被更改過。

  • 哈希算法:是一種將任意內容的輸入轉換成相同長度輸出的加密方式,其輸出被稱爲哈希值。比如一些著名的hash算法,MD5SHA1Hash算法。

  • 而hashCode是jdk根據對象的地址或者字符串或者數字算出來的int類型的數值。
    如果根據 equals(Object) 方法,兩個對象是相等的,那麼對這兩個對象中的每個對象調用 hashCode 方法都必須生成相同的整數結果,才能說完全相等。

  • 在重寫父類的equals方法時,也重寫hashcode方法,使相等的兩個對象獲取的HashCode也相等,這樣當此對象做Map類中的Key時,兩個equals爲true的對象其獲取的value都是同一個,比較符合實際。

  • 哈希表:根據設定的哈希函數H(key)和處理衝突方法將一組關鍵字映象到一個有限的地址區間上,並以關鍵字在地址區間中的象作爲記錄在表中的存儲位置,這種表稱爲哈希表或散列,所得存儲位置稱爲哈希地址或散列地址。


HashMap和HashSet的區別

讓我們來看看什麼是HashMap和HashSet,然後再來比較它們之間的分別。


  • 什麼是HashSet?

HashSet實現了Set接口,它不允許集合中有重複的值,當我們提到HashSet時,第一件事情就是在將對象存儲在HashSet之前,要先確保對象重寫equals()和hashCode()方法,這樣才能比較對象的值是否相等,以確保set中沒有儲存相等的對象。如果我們沒有重寫這兩個方法,將會使用這個方法的默認實現。

public boolean add(Object o)方法用來在Set中添加元素,當元素值重複時則會立即返回false,如果成功添加的話會返回true。


  • 什麼是HashMap

HashMap實現了Map接口,Map接口對鍵值對進行映射。Map中不允許重複的鍵。Map接口有兩個基本的實現,HashMap和TreeMap。TreeMap保存了對象的排列次序,而HashMap則不能。HashMap允許鍵和值爲null。HashMap是非synchronized的,但collection框架提供方法能保證HashMap synchronized,這樣多個線程同時訪問HashMap時,能保證只有一個線程更改Map(下面會說到如何安全的使用HashMap)。

public Object put(Object Key,Object value)方法用來將元素添加到map中。


  • HashSet和HashMap的區別
HashMap HashSet
HashMap實現了Map接口 HashSet實現了Set接口
HashMap儲存鍵值對 hashSet僅僅存儲對象
使用put()方法將元素放入map中 使用add()方法將元素放入set中
HashMap中使用鍵對象來計算hashcode值 HashSet使用成員對象來計算hashcode值,對於兩個對
HashMap比較快,因爲是使用唯一的鍵來獲取對象 HashSet較HashMap來說比較慢
線程不安全 線程安全

  • HashMap和Hashtable的區別(Hashtable已經摒棄了,瞭解就可以)

Hashtable是個過時的集合類,存在於Java API中很久了。在Java 4中被重寫了,實現了Map接口,所以自此以後也成了Java集合框架中的一部分。

HashMap和Hashtable都實現了Map接口,但決定用哪一個之前先要弄清楚它們之間的分別。主要的區別有:線程安全性同步(synchronization),以及速度

HashMap幾乎可以等價於Hashtable,除了HashMap是非synchronized的,並可以接受null(HashMap可以接受爲null的鍵值(key)和值(value),而Hashtable則不行)。

HashMap是非synchronized,而Hashtable是synchronized,這意味着Hashtable是線程安全的,多個線程可以共享一個Hashtable;而如果沒有正確的同步的話,多個線程是不能共享HashMap的。Java 5提供了ConcurrentHashMap,它是HashTable的替代,比HashTable的擴展性更好。

另一個區別是HashMap的迭代器(Iterator)是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所以當有其它線程改變了HashMap的結構(增加或者移除元素),將會拋出ConcurrentModificationException,但迭代器本身的remove()方法移除元素則不會拋出ConcurrentModificationException異常。但這並不是一個一定發生的行爲,要看JVM。這條同樣也是Enumeration和Iterator的區別。

由於Hashtable是線程安全的也是synchronized,所以在單線程環境下它比HashMap要慢。如果你不需要同步,只需要單一線程,那麼使用HashMap性能要好過Hashtable。
HashMap不能保證隨着時間的推移Map中的元素次序是不變的。
要注意的一些重要術語:

  1. sychronized意味着在一次僅有一個線程能夠更改Hashtable。就是說任何線程要更新Hashtable時要首先獲得同步鎖,其它線程要等到同步鎖被釋放之後才能再次獲得同步鎖更新Hashtable。

  2. Fail-safe和iterator迭代器相關。如果某個集合對象創建了Iterator或者ListIterator,然後其它的線程試圖“結構上”更改集合對象,將會拋出ConcurrentModificationException異常。但其它線程可以通過set()方法更改集合對象是允許的,因爲這並沒有從“結構上”更改集合。但是假如已經從結構上進行了更改,再調用set()方法,將會拋出IllegalArgumentException異常。

  3. 結構上的更改指的是刪除或者插入一個元素,這樣會影響到map的結構。


  • 既然HashMap不是線程安全的,那麼如何線程安全的使用HashMap呢?

    1. 爲什麼HashMap是線程不安全的?

先來簡單瞭解一下HashMap源碼中的使用的存儲結構(這裏引用的是Java 8的源碼,與7是不一樣的)和它的擴容機制。

transient Node<K,V>[] table;

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
}

可以看到HashMap內部存儲使用了一個Node數組(默認大小是16),而Node類包含一個類型爲Node的next的變量,也就是相當於一個鏈表,所有hash值相同(即產生了衝突)的key會存儲到同一個鏈表裏,

  • HashMap的自動擴容機制

HashMap內部的Node數組默認的大小是16,假設有100萬個元素,那麼最好的情況下每個hash桶裏都有62500個元素,這時get(),put(),remove()等方法效率都會降低。爲了解決這個問題,HashMap提供了自動擴容機制,當元素個數達到數組大小loadFactor後會擴大數組的大小,在默認情況下,數組大小爲16,loadFactor爲0.75,也就是說當HashMap中的元素超過16\0.75=12時,會把數組大小擴展爲2*16=32,並且重新計算每個元素在新數組中的位置。

  • 爲什麼線程不安全

HashMap在併發時可能出現的問題主要是兩方面,首先如果多個線程同時使用put方法添加元素,而且假設正好存在兩個put的key發生了碰撞(hash值一樣),那麼根據HashMap的實現,這兩個key會添加到數組的同一個位置,這樣最終就會發生其中一個線程的put的數據被覆蓋。第二就是如果多個線程同時檢測到元素個數超過數組大小*loadFactor,這樣就會發生多個線程同時對Node數組進行擴容,都在重新計算元素位置以及複製數據,但是最終只有一個線程擴容後的數組會賦給table,也就是說其他線程的都會丟失,並且各自線程put的數據也丟失。

  • 如何線程安全的使用HashMap
  • Hashtable
  • ConcurrentHashMap
  • Synchronized

Hashtable

//Hashtable
Map<String, String> hashtable = new Hashtable<>();

//synchronizedMap
Map<String, String> synchronizedHashMap = Collections.synchronizedMap(new HashMap<String, String>());

//ConcurrentHashMap
Map<String, String> concurrentHashMap = new ConcurrentHashMap<>();

HashTable源碼中是使用synchronized來保證線程安全的,比如下面的get方法和put方法:

public synchronized V get(Object key) {
       // 省略實現
}
public synchronized V put(K key, V value) {
    // 省略實現
}

所以當一個線程訪問HashTable的同步方法時,其他線程如果也要訪問同步方法,會被阻塞住。舉個例子,當一個線程使用put方法時,另一個線程不但不可以使用put方法,連get方法都不能做,效率很低,現在基本不會選擇它了。

ConcurrentHashMap

ConcurrentHashMap(以下簡稱CHM)是JUC包中的一個類,在java7中用的是鎖分段技術,在java8中使用的是一種全新的CAS算法。

SynchronizedMap

// synchronizedMap方法
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
       return new SynchronizedMap<>(m);
   }
// SynchronizedMap類
private static class SynchronizedMap<K,V>
       implements Map<K,V>, Serializable {
       private static final long serialVersionUID = 1978198479659022715L;

       private final Map<K,V> m;     // Backing Map
       final Object      mutex;        // Object on which to synchronize

       SynchronizedMap(Map<K,V> m) {
           this.m = Objects.requireNonNull(m);
           mutex = this;
       }

       SynchronizedMap(Map<K,V> m, Object mutex) {
           this.m = m;
           this.mutex = mutex;
       }

       public int size() {
           synchronized (mutex) {return m.size();}
       }
       public boolean isEmpty() {
           synchronized (mutex) {return m.isEmpty();}
       }
       public boolean containsKey(Object key) {
           synchronized (mutex) {return m.containsKey(key);}
       }
       public boolean containsValue(Object value) {
           synchronized (mutex) {return m.containsValue(value);}
       }
       public V get(Object key) {
           synchronized (mutex) {return m.get(key);}
       }

       public V put(K key, V value) {
           synchronized (mutex) {return m.put(key, value);}
       }
       public V remove(Object key) {
           synchronized (mutex) {return m.remove(key);}
       }
       // 省略其他方法
   }

從源碼中可以看出調用synchronizedMap()方法後會返回一個SynchronizedMap類的對象,而在SynchronizedMap類中使用了synchronized同步關鍵字來保證對Map的操作是線程安全的。

  • 性能對比

寫個測試用例,實際的比較一下這三種方式的效率(源碼來源),下面的代碼分別通過三種方式創建Map對象,使用ExecutorService來併發運行5個線程,每個線程添加/獲取500K個元素。


import java.util.Collections;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class CrunchifyConcurrentHashMapVsSynchronizedMap {

    public final static int THREAD_POOL_SIZE = 5;

    public static Map<String, Integer> crunchifyHashTableObject = null;
    public static Map<String, Integer> crunchifySynchronizedMapObject = null;
    public static Map<String, Integer> crunchifyConcurrentHashMapObject = null;

    public static void main(String[] args) throws InterruptedException {

        // Test with Hashtable Object
        crunchifyHashTableObject = new Hashtable<>();
        crunchifyPerformTest(crunchifyHashTableObject);

        // Test with synchronizedMap Object
        crunchifySynchronizedMapObject = Collections.synchronizedMap(new HashMap<String, Integer>());
        crunchifyPerformTest(crunchifySynchronizedMapObject);

        // Test with ConcurrentHashMap Object
        crunchifyConcurrentHashMapObject = new ConcurrentHashMap<>();
        crunchifyPerformTest(crunchifyConcurrentHashMapObject);

    }

    public static void crunchifyPerformTest(final Map<String, Integer> crunchifyThreads) throws InterruptedException {

        System.out.println("Test started for: " + crunchifyThreads.getClass());
        long averageTime = 0;
        for (int i = 0; i < 5; i++) {

            long startTime = System.nanoTime();
            ExecutorService crunchifyExServer = Executors.newFixedThreadPool(THREAD_POOL_SIZE);

            for (int j = 0; j < THREAD_POOL_SIZE; j++) {
                crunchifyExServer.execute(new Runnable() {
                    @SuppressWarnings("unused")
                    @Override
                    public void run() {

                        for (int i = 0; i < 500000; i++) {
                            Integer crunchifyRandomNumber = (int) Math.ceil(Math.random() * 550000);

                            // Retrieve value. We are not using it anywhere
                            Integer crunchifyValue = crunchifyThreads.get(String.valueOf(crunchifyRandomNumber));

                            // Put value
                            crunchifyThreads.put(String.valueOf(crunchifyRandomNumber), crunchifyRandomNumber);
                        }
                    }
                });
            }

            // Make sure executor stops
            crunchifyExServer.shutdown();

            // Blocks until all tasks have completed execution after a shutdown request
            crunchifyExServer.awaitTermination(Long.MAX_VALUE, TimeUnit.DAYS);

            long entTime = System.nanoTime();
            long totalTime = (entTime - startTime) / 1000000L;
            averageTime += totalTime;
            System.out.println("2500K entried added/retrieved in " + totalTime + " ms");
        }
        System.out.println("For " + crunchifyThreads.getClass() + " the average time is " + averageTime / 5 + " ms\n");
    }

}

result:

Test started for: class java.util.Hashtable
2500K entried added/retrieved in 1802 ms
2500K entried added/retrieved in 1706 ms
2500K entried added/retrieved in 1674 ms
2500K entried added/retrieved in 1602 ms
2500K entried added/retrieved in 1569 ms
For class java.util.Hashtable the average time is 1670 ms

Test started for: class java.util.Collections$SynchronizedMap
2500K entried added/retrieved in 1656 ms
2500K entried added/retrieved in 1534 ms
2500K entried added/retrieved in 1602 ms
2500K entried added/retrieved in 1638 ms
2500K entried added/retrieved in 1511 ms
For class java.util.Collections$SynchronizedMap the average time is 1588 ms

Test started for: class java.util.concurrent.ConcurrentHashMap
2500K entried added/retrieved in 2312 ms
2500K entried added/retrieved in 586 ms
2500K entried added/retrieved in 557 ms
2500K entried added/retrieved in 759 ms
2500K entried added/retrieved in 526 ms
For class java.util.concurrent.ConcurrentHashMap the average time is 948 ms

CHM性能是明顯優於Hashtable和SynchronizedMap的,有一篇如何在java中使用ConcurrentHashMap


Java HashMap工作原理及實現

先來些簡單的問題
  • “你用過HashMap嗎?” “什麼是HashMap?你爲什麼用到它?”

幾乎每個人都會回答“是的”,然後回答HashMap的一些特性,譬如HashMap可以接受null鍵值和值,而Hashtable則不能;HashMap是非synchronized;HashMap很快;以及HashMap儲存的是鍵值對等等。這顯示出你已經用過HashMap,而且對它相當的熟悉。但是面試官來個急轉直下,從此刻開始問出一些刁鑽的問題,關於HashMap的更多基礎的細節。面試官可能會問出下面的問題:

  • “你知道HashMap的工作原理嗎?” “你知道HashMap的get()方法的工作原理嗎?”

    你也許會回答“我沒有詳查標準的Java API,你可以看看Java源代碼或者Open JDK。”“我可以用Google找到答案。”


但一些面試者可能可以給出答案,“HashMap是基於hashing的原理,我們使用put(key, value)存儲對象到HashMap中,使用get(key)從HashMap中獲取對象。當我們給put()方法傳遞鍵和值時,我們先對鍵調用hashCode()方法,返回的hashCode用於找到bucket位置來儲存Entry對象。”這裏關鍵點在於指出,HashMap是在bucket中儲存鍵對象和值對象,作爲Map.Entry。這一點有助於理解獲取對象的邏輯。如果你沒有意識到這一點,或者錯誤的認爲僅僅只在bucket中存儲值的話,你將不會回答如何從HashMap中獲取對象的邏輯。這個答案相當的正確,也顯示出面試者確實知道hashing以及HashMap的工作原理。但是這僅僅是故事的開始,當面試官加入一些Java程序員每天要碰到的實際場景的時候,錯誤的答案頻現。下個問題可能是關於HashMap中的碰撞探測(collision detection)以及碰撞的解決方法:
  • “當兩個對象的hashcode相同會發生什麼?”

從這裏開始,真正的困惑開始了,一些面試者會回答因爲hashcode相同,所以兩個對象是相等的,HashMap將會拋出異常,或者不會存儲它們。然後面試官可能會提醒他們有equals()和hashCode()兩個方法,並告訴他們兩個對象就算hashcode相同,但是它們可能並不相等。一些面試者可能就此放棄,而另外一些還能繼續挺進,他們回答“因爲hashcode相同,所以它們的bucket位置相同,‘碰撞’會發生。因爲HashMap使用鏈表存儲對象,這個Entry(包含有鍵值對的Map.Entry對象)會存儲在鏈表中。”這個答案非常的合理,雖然有很多種處理碰撞的方法,這種方法是最簡單的,也正是HashMap的處理方法。但故事還沒有完結,面試官會繼續問:

  • “如果兩個鍵的hashcode相同,你如何獲取值對象?”

試者會回答:當我們調用get()方法,HashMap會使用鍵對象的hashcode找到bucket位置,然後獲取值對象。面試官提醒他如果有兩個值對象儲存在同一個bucket,他給出答案:將會遍歷鏈表直到找到值對象。面試官會問因爲你並沒有值對象去比較,你是如何確定確定找到值對象的?除非面試者知道HashMap在鏈表中存儲的是鍵值對,否則他們不可能回答出這一題。

其中一些記得這個重要知識點的面試者會說,找到bucket位置之後,會調用keys.equals()方法去找到鏈表中正確的節點,最終找到要找的值對象。完美的答案!

許多情況下,面試者會在這個環節中出錯,因爲他們混淆了hashCode()和equals()方法。因爲在此之前hashCode()屢屢出現,而equals()方法僅僅在獲取值對象的時候纔出現。一些優秀的開發者會指出使用不可變的、聲明作final的對象,並且採用合適的equals()和hashCode()方法的話,將會減少碰撞的發生,提高效率。不可變性使得能夠緩存不同鍵的hashcode,這將提高整個獲取對象的速度,使用String,Interger這樣的wrapper類作爲鍵是非常好的選擇。

如果你認爲到這裏已經完結了,那麼聽到下面這個問題的時候,你會大喫一驚。

  • 如果HashMap的大小超過了負載因子(load factor)定義的容量,怎麼辦?”

”除非你真正知道HashMap的工作原理,否則你將回答不出這道題。默認的負載因子大小爲0.75,也就是說,當一個map填滿了75%的bucket時候,和其它集合類(如ArrayList等)一樣,將會創建原來HashMap大小的兩倍的bucket數組,來重新調整map的大小,並將原來的對象放入新的bucket數組中。這個過程叫作rehashing,因爲它調用hash方法找到新的bucket位置。
如果你能夠回答這道問題,下面的問題來了:

  • “你瞭解重新調整HashMap大小存在什麼問題嗎?”

你可能回答不上來,這時面試官會提醒你當多線程的情況下,可能產生條件競爭(race condition)。

當重新調整HashMap大小的時候,確實存在條件競爭,因爲如果兩個線程都發現HashMap需要重新調整大小了,它們會同時試着調整大小。在調整大小的過程中,存儲在鏈表中的元素的次序會反過來,因爲移動到新的bucket位置的時候,HashMap並不會將元素放在鏈表的尾部,而是放在頭部,這是爲了避免尾部遍歷(tail traversing)。如果條件競爭發生了,那麼就死循環了。這個時候,你可以質問面試官,爲什麼這麼奇怪,要在多線程的環境下使用HashMap呢?:)

  • 熱心的讀者貢獻了更多的關於HashMap的問題:

    1. 爲什麼String, Interger這樣的wrapper類適合作爲鍵? String, Interger這樣的wrapper類作爲HashMap的鍵是再適合不過了,而且String最爲常用。因爲String是不可變的,也是final的,而且已經重寫了equals()和hashCode()方法了。其他的wrapper類也有這個特點。不可變性是必要的,因爲爲了要計算hashCode(),就要防止鍵值改變,如果鍵值在放入時和獲取時返回不同的hashcode的話,那麼就不能從HashMap中找到你想要的對象。不可變性還有其他的優點如線程安全。如果你可以僅僅通過將某個field聲明成final就能保證hashCode是不變的,那麼請這麼做吧。因爲獲取對象的時候要用到equals()和hashCode()方法,那麼鍵對象正確的重寫這兩個方法是非常重要的。如果兩個不相等的對象返回不同的hashcode的話,那麼碰撞的機率就會小些,這樣就能提高HashMap的性能。

    2. 我們可以使用自定義的對象作爲鍵嗎? 這是前一個問題的延伸。當然你可能使用任何對象作爲鍵,只要它遵守了equals()和hashCode()方法的定義規則,並且當對象插入到Map中之後將不會再改變了。如果這個自定義對象時不可變的,那麼它已經滿足了作爲鍵的條件,因爲當它創建之後就已經不能改變了。

    3. 我們可以使用CocurrentHashMap來代替Hashtable嗎?這是另外一個很熱門的面試題,因爲ConcurrentHashMap越來越多人用了。我們知道Hashtable是synchronized的,但是ConcurrentHashMap同步性能更好,因爲它僅僅根據同步級別對map的一部分進行上鎖。


總結

HashMap的工作原理
HashMap基於hashing原理,我們通過put()和get()方法儲存和獲取對象。當我們將鍵值對傳遞給put()方法時,它調用鍵對象的hashCode()方法來計算hashcode,讓後找到bucket位置來儲存值對象。當獲取對象時,通過鍵對象的equals()方法找到正確的鍵值對,然後返回值對象。HashMap使用鏈表來解決碰撞問題,當發生碰撞了,對象將會儲存在鏈表的下一個節點中。 HashMap在每個鏈表節點中儲存鍵值對對象。

當兩個不同的鍵對象的hashcode相同時會發生什麼? 它們會儲存在同一個bucket位置的鏈表中。鍵對象的equals()方法用來找到鍵值對。

在HashMap中有兩個很重要的參數,容量(Capacity)和負載因子(Load factor,默認爲0.75 簡單的說,Capacity就是buckets的數目,Load factor就是buckets填滿程度的最大比例。如果對迭代性能要求很高的話不要把capacity設置過大,也不要把load factor設置過小。當bucket填充的數目(即hashmap中元素的個數)大於capacity*load factor時就需要調整buckets的數目爲當前的2倍。


參考:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章