在平時開發中,HashMap,HashTable,HashSet 都是經常用到的鍵值映射數據結構,在這裏我主要寫一些平時我們使用這些數據結構中容易忽視的問題。
HashMap
HashMap的結構
HashMap 底層是一個Entry數組來支撐的,我覺得叫Entry鏈表數組支撐更爲合適。
結構圖:
每個entry數組裏面的元素要麼爲null要麼就是一個entry鏈表;而每個entry對象就是一個entry鏈表的節點也是一個鍵值對的抽象表示;
HashMap的性能因素
HashMap主要影響其性能的有兩個因素,一個是初始容量,一個是載入因子;HashMap(int initialCapacity初始容量, float loadFactor載入因子),我們在遍歷HashMap的時候,會對整個數組都進行遍歷,也就是說性能跟entry數組的長度有關(容量)。如果將初始容量設置的過大,實際上我們沒裝幾個東西在裏面,那麼遍歷的時候,會遍歷所有數組組元素。這裏已經指出了,我們不希望容量設置的過大,那麼當put數據的時候檢測到容量超過我們的閥值threshold,就會重新構造一個兩倍的數組出來,從而達到擴容的母的。 if (size++ >= threshold) resize(2 * table.length); threshold = 當前容量*loadFactor載入因子。我們始終要抓住一點,HashMap要經常遍歷,我們應該讓他在合適的時間選擇擴容,避免過早的遍歷更大的容量數組。所以我們應當儘量避免將loadFactor設置的過小。
哈希衝突
當我們put兩個元素的時候,如果他們的哈希值都一樣,或者說哈希值不一樣,但是數組下標一樣的時候,那麼到底誰該放在同個槽裏呢?這就是通俗的哈希衝突。爲了解決哈希衝突,jdk採用鏈表的方式來解決哈希值的衝突。下面我們看看源碼來分析。
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
int hash = hash(key.hashCode());//計算鍵的哈希值
int i = indexFor(hash, table.length);//找到該哈希值對應的entry數組下標
for (Entry<K,V> e = table[i]; e != null; e = e.next) {//如果entry數組下標對應的entry鏈表裏面,put之前就存在與這個指定的key關聯的entry對象,那麼直接替換舊的value,並返回這個舊的value給調用者。
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {//當哈希算法計算出來的哈希值相同,並且(key是同一個對象||兩個key equals判斷相同)即表示存在舊的key關聯的entry
V oldValue = e.value;
e.value = value;
e.recordAccess(this);//這個 hashMap 無需關心。
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);//當entry數組下標對應的entry鏈表沒有與指定的key關聯的entry對象時,增加一個新的entry對象,哈希衝突也是在這個函數裏解決的。
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);//採用頭插法,直接插到鏈表的頭部
if (size++ >= threshold)
resize(2 * table.length);
}
假設我們要put兩組數據,分別是put(0,0),put(10,10) ,假設計算哈希值的算法 int hash = key % 10; 那麼0 和 10 的哈希值都爲0,然後int i = indexFor(hash, table.length);
兩組數據key對應的數組下標都是0;
那麼是怎麼插入的呢?先put(0,0)
在put(10,10) 哈希衝突,在鏈表頭部插入解決。
併發情況下HashMap的死循環問題
其實HashMap本不該在併發環境下使用,應該考慮選擇HashTable,ConcurrentHashMap。我們就來分析下HashMap的死循環問題。
當多個線程同時put數據的時候就有可能出現死循環的問題。
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);//多線程下,多個線程可能會同時執行這個函數
}
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);//當2個線程同時執行這個轉移數據到新的數組時就有可能出現問題。
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 {//這個do while 循環要做的操作就是翻轉舊的鏈表插入到新數組裏面。
Entry<K,V> next = e.next;//標記1,假設線程1執行完這步
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
我們來個正常版的單線程環境下的resize操作,看圖:
trasnfer(newTable)之前
trasnfer(newTable)完成後
我們可以看出來,實際就是翻轉鏈表插入到新容量的entry數組裏面。
再來看看死循環版本,有兩個線程put數據都進行transfer(newTable) 操作,那麼就會可能出現死循環。
當線程1 傳輸數據時,執行完了標記1的時候切換到了線程2,線程2執行了一次完整的翻轉鏈表到新的entry數組時,線程1繼續跑就會出現。
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 {//這個do while 循環要做的操作就是翻轉舊的鏈表插入到數組裏面。
Entry<K,V> next = e.next;//標記1,假設線程1執行完這步
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
用圖分析吧:
原數組:
此時線程1執行到了標記1,然後切換到了線程2執行一個鏈表的翻轉插入。
然後此時鏈表情況是這樣的。
線程1在do while 裏面就不斷的循環 do (.....)while (e.next != null),這樣線程1就根本停不下來了。
HashSet
HashSet底層的存儲結構是一個HashMap,HashSet 添加的每一個值實際上就是在底層的HashMap裏添加一個實體Entry<Key,Object> e,而這裏的Object 其實就是一個擺設。HashSet利用了HashMap的Key的唯一性,確保了在該數據結構中不會添加重複的值(保證了值的唯一性)。另外HashSet是允許插入null值的。根據HashSet的值的唯一性和快速添加的特性,我們可以想到,如果我們要快速添加大量的不能重複的元素到一個數據結構中,那麼HashSet 是一個非常好的選擇。
HashTable
HashTable 跟HashMap 一樣是一個存儲鍵值對映射的數據結構,跟HashMap 不一樣的是,HashTable 是線程安全的,HashTable 要插入的key 和 value 都不能爲null。爲啥不能爲null呢?jdk文檔裏面說了,HashTable 是繼承了字典的一種數據結構,我們可以在這種字典裏提高一個鍵值對以供查找,但是key 或者 value 任何一個都不能爲null。
我是怎麼認爲的呢?就像我們查字典一樣,你總不能造個沒有含義,沒有表現的文字在字典裏面吧,如果有我查到了,這是個null,沒有含義,這完全違背了我們想通過查字典獲取真相的初衷啊。
HashTable 的線程安全型是靠對每個操作加鎖的方式完成的。也就是鎖住當前的HashTable實例對象。如果在併發大量的情況下,那麼鎖競爭會很嚴重。我以爲如果在併發情況不大的情況下當我們又想保證數據的併發安全性,我覺得HashTable也是一種非常好的選擇。當然在併發量大的情況下,就優先選擇ConcurrentHashMap 。
我自己寫了個測試程序,在計數爲2億次的併發put測試中,不同線程數量,對HashTable 和 ConcurrentHashMap 的表現分析。
代碼:
package hash_set_map_table;
import java.util.Hashtable;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public class HashThreadTask implements Runnable {
static int TIME = 200000000;
//2個線程,併發put 200000000次,ConcurrentHashMap 31718 ms,Hashtable 35535 ms;
//20個線程,併發put 200000000次,ConcurrentHashMap 38732 ms,Hashtable 48357 ms;
//100個線程 併發put 200000000次,ConcurrentHashMap 36380 ms,Hashtable 46299 ms;
//200個線程 併發put 200000000次,ConcurrentHashMap 35801 ms, Hashtable 50579 ms;
private int threadId;
public HashThreadTask(int threadId) {
this.threadId = threadId;
}
public int getThreadId() {
return threadId;
}
public void setThreadId(int threadId) {
this.threadId = threadId;
}
static AtomicInteger count = new AtomicInteger();
public static Hashtable<Integer, Integer> getHashTableInstance() {
return TableHolder.table;
}
public static ConcurrentHashMap<Integer, Integer> getConcurrentHashMap() {
return ConcurrentHashMapHolder.map;
}
public static class TableHolder {
public static Hashtable<Integer, Integer> table = new Hashtable<Integer, Integer>();
}
public static class ConcurrentHashMapHolder {
public static ConcurrentHashMap<Integer, Integer> map = new ConcurrentHashMap<Integer, Integer>();
}
public static void main(String[] args) {
for (int i = 0; i < 200; i++) {//這裏控制線程數量 測試數據依次爲 2個線程,20個線程,100個線程,200個線程
HashThreadTask task = new HashThreadTask(i);
Thread thread = new Thread(task);
thread.start();
}
long s = System.currentTimeMillis();
while (count.get() != TIME) {
}
System.out.println("cost time : "+ (System.currentTimeMillis() - s) + " count:" + count.get());
}
public void run() {
// ConcurrentHashMap<Integer, Integer> container = getConcurrentHashMap();
Random random = new Random(System.currentTimeMillis());
Hashtable<Integer, Integer> container = getHashTableInstance();
do {
int old = count.get();
if (old < TIME) {
int i = random.nextInt(10000);
container.put(i, i);
count.compareAndSet(old, old+1);
}
}
while (count.get() < TIME);
}
}
結果:
//2個線程,併發put 200000000次,ConcurrentHashMap 31718 ms,Hashtable 35535 ms;
//20個線程,併發put 200000000次,ConcurrentHashMap 38732 ms,Hashtable 48357 ms;
//100個線程 併發put 200000000次,ConcurrentHashMap 36380 ms,Hashtable 46299 ms;
//200個線程 併發put 200000000次,ConcurrentHashMap 35801 ms, Hashtable 50579 ms;
在併發量不大的時候,當我們又想保證數據的併發安全性的話,我覺得HashTable 優於 ConcurrentHashMap,因爲Hashtable 沒那麼吃內存。
當在併發量大的時候,Hashtable 就輸的一塌糊塗了,所以在這種大併發環境下,我們應當毫不猶豫的選擇ConcurrentHashMap。