本文主要簡要分析了Java中和Redis中HashMap的實現,並且對比了兩者的異同
1.Java的實現
下圖表示了Java中一個HashMap的主要實現方式
因為大家對於Java中HashMap的實現方式,已經比較熟悉了,所以咱們只是簡單的說一下.
基本結構
table是一個Entry[]數組類型,而Entry實際上就是一個單向鏈表。哈希表的"key-value鍵值對"都是存儲在Entry數組中的。 size是HashMap的大小,它是HashMap保存的鍵值對的數量。 threshold是HashMap的閾值,用於判斷是否需要調整HashMap的容量。threshold的值="容量*加載因子",當HashMap中存儲數據的數量達到threshold時,就需要將HashMap的容量加倍。loadFactor就是加載因子。 modCount是用來實現fail-fast機制的。
計算Hash值和在數組中的位置
//length為Entry數組長度
static int indexFor(int h, int length) {
return h & (length - 1);
}
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
添加鍵值對時的操作(put)
// 將“key-value”添加到HashMap中
public V put(K key, V value) {
// 若“key為null”,則將該鍵值對添加到table[0]中。
if (key == null)
return putForNullKey(value);
// 若“key不為null”,則計算該key的哈希值,然后將其添加到該哈希值對應的鏈表中。
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取代舊的value。然后退出!
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value; e.value = value;
e.recordAccess(this); return oldValue;
}
}
// 若“該key”對應的鍵值對不存在,則將“key-value”添加到table中
modCount++;
addEntry(hash, key, value, i); return null;}
解決Hash沖突的方式,擴容時機
// 新增Entry。將“key-value”插入指定位置,bucketIndex是位置索引。
void addEntry(int hash, K key, V value, int bucketIndex) {
// 保存“bucketIndex”位置的值到“e”中
Entry<K,V> e = table[bucketIndex];
// 設置“bucketIndex”位置的元素為“新Entry”,
// 設置“e”為“新Entry的下一個節點”
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
// 若HashMap的實際大小 不小於 “閾值”,則調整HashMap的大小
if (size++ >= threshold)resize(2 * table.length);
}
擴容過程
// 重新調整HashMap的大小,newCapacity是調整后的單位
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
// 新建一個HashMap,將“舊HashMap”的全部元素添加到“新HashMap”中,
// 然后,將“新HashMap”賦值給“舊HashMap”。
Entry[] newTable = new Entry[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
// 將HashMap中的全部元素都添加到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;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
2.Redis的實現
整個基本結構
哈希表
typedef struct dictht {
// 哈希表數組
dictEntry **table;
// 哈希表大小(相當於Java中的capacity)
unsigned long size;
// 哈希表大小掩碼,用於計算索引值
// 總是等於 size - 1
unsigned long sizemask;
// 該哈希表已有節點的數量(相當於Java中的size)
unsigned long used;
} dictht;
鍵值對
typedef struct dictEntry {
// 鍵
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下個哈希表節點,形成鏈表
struct dictEntry *next;
} dictEntry;
哈希結構
typedef struct dict {
// 類型特定函數
dictType *type;
// 私有數據
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 當 rehash 不在進行時,值為 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
簡單例子:
添加鍵值對時的操作(dictAdd)
計算 hash 和 數組位置
# 使用字典設置的哈希函數,計算鍵 key 的哈希值(相當於hash())
hash = dict->type->hashFunction(key);
# 使用哈希表的 sizemask 屬性和哈希值,計算出索引值(相當於indexFor())
index = hash & dict->ht[x].sizemask;
先計算 key的哈希值 在將 該哈希值&(數組長度-1)確定下標(與Java極為相似)
注:Redis 使用 MurmurHash2 算法來計算鍵的哈希值;這種算法的優點在於, 即使輸入的鍵是有規律的, 算法仍能給出一個很好的隨機分布性, 並且算法的計算速度也非常快。關於 MurmurHash 算法的更多信息可以參考該算法的主頁: http://code.google.com/p/smhasher/ 。
解決hash沖突
用拉鏈法解決hash沖突,將舊entry鏈表插進新entry尾部(與Java極為相似)
擴容時機
當以下條件中的任意一個被滿足時, 程序會自動開始對哈希表執行擴展操作:
- 服務器目前沒有在執行 BGSAVE 命令或者 BGREWRITEAOF 命令, 並且哈希表的負載因子大於等於 1 ;
- 服務器目前正在執行 BGSAVE 命令或者 BGREWRITEAOF 命令, 並且哈希表的負載因子大於等於 5;
注:該值不可通過配置來修改,要變必須改源碼。
其中哈希表的負載因子可以通過公式:
# 負載因子 = 哈希表已保存節點數量 / 哈希表大小
load_factor = ht[0].used / ht[0].size
該值和Java不同,Java默認值為0.75,相比之下Java擴容更加積極。
根據 BGSAVE 命令或 BGREWRITEAOF 命令是否正在執行, 服務器執行擴展操作所需的負載因子並不相同, 這是因為在執行 BGSAVE 命令或 BGREWRITEAOF 命令的過程中, Redis 需要創建當前服務器進程的子進程, 而大多數操作系統都采用寫時復制(copy-on-write)技術來優化子進程的使用效率, 所以在子進程存在期間, 服務器會提高執行擴展操作所需的負載因子, 從而盡可能地避免在子進程存在期間進行哈希表擴展操作, 這可以避免不必要的內存寫入操作, 最大限度地節約內存。
縮容時機(Java中不會自動縮容)
當哈希表的負載因子小於 0.1
時, 程序自動開始對哈希表執行收縮操作
因為Java中HashMap不會自動縮容,所以在在大量put后,再大量remove,並且還持有該引用的話,會浪費很多內存
變容過程
漸進式轉移
擴展或收縮哈希表需要將 ht[0]里面的所有鍵值對 rehash 到 ht[1]里面, 但是, 這個 rehash 動作並不是一次性、集中式地完成的, 而是分多次、漸進式地完成的。
這樣做的原因在於, 如果 ht[0]里只保存着四個鍵值對, 那么服務器可以在瞬間就將這些鍵值對全部 rehash 到 ht[1]; 但是, 如果哈希表里保存的鍵值對數量不是四個, 而是四百萬、四千萬甚至四億個鍵值對, 那么要一次性將這些鍵值對全部 rehash 到 ht[1]的話, 龐大的計算量可能會導致服務器在一段時間內停止服務。
因此, 為了避免 rehash 對服務器性能造成影響, 服務器不是一次性將 ht[0]里面的所有鍵值對全部 rehash 到 ht[1], 而是分多次、漸進式地將 ht[0]里面的鍵值對慢慢地 rehash 到 ht[1]。
因為在進行漸進式 rehash 的過程中, 字典會同時使用 ht[0]和 ht[1]兩個哈希表, 所以在漸進式 rehash 進行期間, 字典的刪除(delete)、查(find)、更新(update)等操作會在兩個哈希表上進行: 比如說, 要在字典里面查找一個鍵的話, 程序會先在ht[0]里面進行查找, 如果沒找到的話, 就會繼續到 ht[1]里面進行查找, 諸如此類。
另外, 在漸進式 rehash 執行期間, 新添加到字典的鍵值對一律會被保存到 ht[1]里面, 而 ht[0]則不再進行任何添加操作: 這一措施保證了 ht[0]包含的鍵值對數量會只減不增, 並隨着 rehash 操作的執行而最終變成空表。
3.對比兩者的異同
Java Redis
基本結構
兩者都是鍵值對數組,鍵值對是鏈表
計算哈希值和數組位置
通過自身hash函數,計算hash值,與數組長度&,確定數組下標
解決Hash沖突的方式
拉鏈法
容量變化時機
默認值為0.75,更加消極,有縮容 默認值為1,更加積極,只可變大,不可變小
容量變化過程
一起完成 分次完成(漸進式)