讀這篇文章可以學到什麼?
- 什麼是哈希(Hash)值?
- 什麼是HashMap?什麼又是HashSet?
- 它們的區別和聯繫是什麼?
- HashMap的實現原理是什麼?
什麼是哈希(Hash)值,hashCode()
又是什麼?
Hash,一般翻譯做“散列”,也有直接音譯爲”哈希”的。是哈希算法將任意長度的二進制值映射爲固定長度的較小二進制值,這個小的二進制值稱爲哈希值。哈希值是一段數據唯一且極其緊湊的數值表示形式。如果散列一段明文而且哪怕只更改該段落的一個字母,隨後的哈希都將產生不同的值。要找到散列爲同一個值的兩個不同的輸入,在計算上來說基本上是不可能的。**直觀解釋起來,就是對一串數據m進行雜糅,輸出另一段固定長度的數據h,作爲這段數據的特徵(指紋)。
也就是說,無論數據塊m有多大,其輸出值h爲固定長度。**消息身份驗證代碼 (MAC) 哈希函數通常與數字簽名一起用於對數據進行簽名,而消息檢測代碼 (MDC) 哈希函數則用於
數據完整性
,常常用於數據驗證,比如用來檢查文件下載是否正確,下載完畢時,再HASH文件一遍,查看此值是否和地址給出的一樣來 檢查文件是否出錯或被更改過。哈希算法:是一種將任意內容的輸入轉換成相同長度輸出的加密方式,其輸出被稱爲哈希值。比如一些著名的hash算法,
MD5
和SHA1
Hash算法。而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中的元素次序是不變的。
要注意的一些重要術語:
sychronized意味着在一次僅有一個線程能夠更改Hashtable。就是說任何線程要更新Hashtable時要首先獲得同步鎖,其它線程要等到同步鎖被釋放之後才能再次獲得同步鎖更新Hashtable。
Fail-safe和iterator迭代器相關。如果某個集合對象創建了Iterator或者ListIterator,然後其它的線程試圖“結構上”更改集合對象,將會拋出ConcurrentModificationException異常。但其它線程可以通過set()方法更改集合對象是允許的,因爲這並沒有從“結構上”更改集合。但是假如已經從結構上進行了更改,再調用set()方法,將會拋出IllegalArgumentException異常。
結構上的更改指的是刪除或者插入一個元素,這樣會影響到map的結構。
既然HashMap不是線程安全的,那麼如何線程安全的使用HashMap呢?
- 爲什麼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的問題:
爲什麼String, Interger這樣的wrapper類適合作爲鍵? String, Interger這樣的wrapper類作爲HashMap的鍵是再適合不過了,而且String最爲常用。因爲String是不可變的,也是final的,而且已經重寫了equals()和hashCode()方法了。其他的wrapper類也有這個特點。不可變性是必要的,因爲爲了要計算hashCode(),就要防止鍵值改變,如果鍵值在放入時和獲取時返回不同的hashcode的話,那麼就不能從HashMap中找到你想要的對象。不可變性還有其他的優點如線程安全。如果你可以僅僅通過將某個field聲明成final就能保證hashCode是不變的,那麼請這麼做吧。因爲獲取對象的時候要用到equals()和hashCode()方法,那麼鍵對象正確的重寫這兩個方法是非常重要的。如果兩個不相等的對象返回不同的hashcode的話,那麼碰撞的機率就會小些,這樣就能提高HashMap的性能。
我們可以使用自定義的對象作爲鍵嗎? 這是前一個問題的延伸。當然你可能使用任何對象作爲鍵,只要它遵守了equals()和hashCode()方法的定義規則,並且當對象插入到Map中之後將不會再改變了。如果這個自定義對象時不可變的,那麼它已經滿足了作爲鍵的條件,因爲當它創建之後就已經不能改變了。
我們可以使用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倍。
參考: