Hash算法
Hash,一般翻譯做“散列”,也直接音譯為“哈希”。就是把任意長度的輸入通過散列算法,變換成固定長度的輸出,該輸出就是散列值(Hash值)。
這種轉換是一種壓縮映射,也就是,散列值的空間通常遠小于輸入的空間,不同的輸入可能會散列成相同的輸出,而不可能從散列值來唯一的確定輸入值。簡單的說就是一種將任意長度的消息壓縮到某一固定長度的消息摘要的函數。
Hash表
數組的特點是:尋址容易,插入和刪除困難;
鏈表的特點是:尋址困難,插入和刪除容易。
那么綜合兩者的優勢,得到一種尋址容易,插入刪除也容易的數據結構,這就是哈希表。哈希表有多種不同的實現方法,這里說的是最常用的一種方法:拉鏈法,我們可以理解為“鏈表的數組”,如圖(來自于網絡):
圖中的Hash算法即是:index = hash % 16;
。說明:本圖的結構與HashMap十分相似,HashMap中存儲的是鍵值對,而本圖的數值相當于HashMap的鍵。
前方涉及很多源碼,注意保護眼睛!
HashMap結構
HashMap的存儲容器就是一個線性數組。這可能讓我們很不解,一個線性的數組怎么實現按鍵值對來存取數據呢?這里HashMap做了一些處理。
首先,HashMap里面實現一個靜態內部類Entry:
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
... ...
}
重要的屬性有key,value,next,從屬性key,value我們就能很明顯的看出來Entry就是HashMap鍵值對實現的一個基礎,而next則是用于鏈表鏈接的。我們說HashMap就是由一個線性數組實現,這個數組就是Entry[],Map里面的內容都保存在Entry[]里面。由于每一個Entry內部都有指向下一個Entry的引用(next),所以這個數組中的每個元素,實際上是一個鏈表的頭部。
/**
* An empty table instance to share when the table is not inflated.
*/
static final Entry<?,?>[] EMPTY_TABLE = {};
/**
* The table, resized as necessary. Length MUST Always be a power of two.
*/
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
HashMap構造
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
public HashMap(int initialCapacity, float loadFactor) {
... ...
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
public HashMap(Map<? extends K, ? extends V> m) {
... ...
}
通過源碼的注釋可以看出:
- HashMap():構建一個初始容量為 16,負載因子為 0.75 的 HashMap。
- HashMap(int initialCapacity):構建一個初始容量為 initialCapacity,負載因子為 0.75 的 HashMap。
- HashMap(int initialCapacity, float loadFactor):以指定初始容量、指定的負載因子創建一個 HashMap。
- HashMap的基礎構造器HashMap(int initialCapacity, float loadFactor)帶有兩個參數,它們是初始容量initialCapacity和負載因子loadFactor。
- initialCapacity:HashMap的最大容量,即為底層數組的長度。
- loadFactor:負載因子loadFactor定義為:散列表的實際元素數目(n)/ 散列表的容量(m)。
HashMap存儲數據的過程
大概的過程是這樣的:
計算hash值
final int hash(Object k) {
... ...
}
將hash值轉換為數組索引
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
}
對數組進行存儲
存儲時若該位置有值,則判斷是否equals:是,則替換;否,則將其插入鏈表表頭
看一下源碼:
public V put(K key, V value) {
... ...(這里忽略了對null鍵的處理)
int hash = hash(key);
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;
}
通過源碼可以看出,我們調用put后:
- 先處理null鍵的情況(閱讀源碼的處理方式為:存在替換,不存在插入table[0])
- 計算hash值
- 通過hash值計算數組中索引位置
- 遍歷該位置的鏈表
若存在該值(equals返回true),則替換并返回舊值
若不存在則調用addEntry
方法,我們看一下這個方法:
void addEntry(int hash, K key, V value, int bucketIndex) {
... ...(省略處理resize)
createEntry(hash, key, value, bucketIndex);
}
該方法調用了createEntry
,再來看一下:
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
在本方法中,將原來的值e變為了新值的next(將新值插入了鏈表頭部)
可以看一下Entry的構造方法:
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
HashMap讀取數據過程
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
有了上面的基礎,這段代碼很容易理解。
HashMap的resize過程
當HashMap中的元素越來越多的時候,hash沖突的幾率也就越來越高,因為數組的長度是固定的。所以為了提高查詢的效率,就要對HashMap的數組進行擴容,數組擴容這個操作也會出現在ArrayList中,這是一個常用的操作,而在HashMap數組擴容之后,最消耗性能的點就出現了:原數組中的數據必須重新計算其在新數組中的位置,并放進去,這就是resize。
在數據存儲過程中,調用addEntry時,需要先處理resize(調整大小)的過程:
if ((size >= threshold) && (null != table[bucketIndex])) {
// threshold = (int)(capacity * loadFactor);
resize(2 * table.length);
... ...
}
在這里需要指出:
負載因子衡量的是一個散列表的空間的使用程度,負載因子越大表示散列表的裝填程度越高,反之愈小。如果負載因子越大,對空間的利用越充分,然而后果是查找效率的降低;如果負載因子太小,那么散列表的數據將過于稀疏,對空間造成嚴重浪費。
這里是resize的過程,就不贅述了:
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, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
transfer函數中進行了對hash值的重新計算。
在addEntry函數中可以看出,resize時是
*2
的(大小變為原來的2倍)。那么,我們會有個疑問:為什么是擴大為原來的2倍呢?
看一看上面定義Entry[] table
時,有這樣一個注釋:
The table, resized as necessary. Length MUST Always be a power of two.
長度必須為2的倍數。那么我們又會有疑問,為什么長度一定要是2的冪呢?這就涉及到HashMap的映射算法了。
HashMap的Hash值映射
在使用HashMap時,我們希望這個HashMap里面的元素位置盡量的分布均勻些,最好使得每個位置上的元素數量只有一個,那么當我們用hash算法求得這個位置的時候,馬上就可以知道對應位置的元素就是我們要的,而不用再去遍歷鏈表,這樣就大大優化了查詢的效率。
最普遍的想法是把hash值對數組長度進行取模運算,這樣一來,元素的分布相對來說是比較均勻的。但是,“模”運算的消耗還是比較大的,在HashMap中是這樣做的:調用
indexFor(int h, int length)
方法來計算該對象應該保存在table數組的哪個索引處。
方法的代碼如下:
static int indexFor(int h, int length) {
return h & (length-1);
}
這個方法很巧妙,它通過h & (table.length -1)
來得到該對象的保存位置,而HashMap底層數組的length總是 2 的n次方(length-1
為2^n-1,全一),這是HashMap在速度上的優化。
而這個又會帶了一個問題就是hash值往往很長(很可能比length長得多),這樣會導致即使hash值不同,但hash值的低位相同,與length-1進行&操作后的值仍然相同,雖然不影響使用,但會降低效率。
這里HashMap使用了一種技巧來計算hash值:
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
這里使用了hash算法重新計算了hash值,而不是直接使用的hashCode方法。
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
此算法加入了高位計算,防止低位不變,高位變化時,造成的hash沖突。
參考
http://www.cnblogs.com/xwdreamer/archive/2012/06/03/2532832.html
JDK API:HashMap