一:hashMap的原理
HashMap的主幹是一個Entry數組。Entry是HashMap的基本組成單元,每一個Entry包含一個key-value鍵值對。
//HashMap的主幹數組,可以看到就是一個Entry數組,初始值爲空數組{},主幹數組的長度一定是2的次冪,至於爲什麼這麼做,後面會有詳細分析。 transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
Entry是HashMap中的一個靜態內部類。代碼如下:
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;//存儲指向下一個Entry的引用,單鏈表結構
int hash;//對key的hashcode值進行hash運算後得到的值,存儲在Entry,避免重複計算
/**
* Creates new entry.
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
所以,HashMap的整體結構如下
簡單來說,HashMap由數組+鏈表組成的,數組是HashMap的主體,鏈表則是主要爲了解決哈希衝突而存在的,如果定位到的數組位置不含鏈表(當前entry的next指向null),那麼對於查找,添加等操作很快,僅需一次尋址即可;如果定位到的數組包含鏈表,對於添加操作,其時間複雜度爲O(n),首先遍歷鏈表,存在即覆蓋,否則新增;對於查找操作來講,仍需遍歷鏈表,然後通過key對象的equals方法逐一比對查找。所以,性能考慮,HashMap中的鏈表出現越少,性能纔會越好。
其他幾個重要字段
//實際存儲的key-value鍵值對的個數
transient int size;
//閾值,當table == {}時,該值爲初始容量(初始容量默認爲16);當table被填充了,也就是爲table分配內存空間後,threshold一般爲 capacity*loadFactory。HashMap在進行擴容時需要參考threshold,後面會詳細談到
int threshold;
//負載因子,代表了table的填充度有多少,默認是0.75
final float loadFactor;
//用於快速失敗,由於HashMap非線程安全,在對HashMap進行迭代時,如果期間其他線程的參與導致HashMap的結構發生變化了(比如put,remove等操作),需要拋出異常ConcurrentModificationException
transient int modCount;
HashMap有4個構造器,其他構造器如果用戶沒有傳入initialCapacity 和loadFactor這兩個參數,會使用默認值
initialCapacity默認爲16,loadFactory默認爲0.75
我們看下其中一個
public HashMap(int initialCapacity, float loadFactor) {
//此處對傳入的初始容量進行校驗,最大不能超過MAXIMUM_CAPACITY = 1<<30(230)
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
threshold = initialCapacity;
init();//init方法在HashMap中沒有實際實現,不過在其子類如 linkedHashMap中就會有對應實現
}
從上面這段代碼我們可以看出,在常規構造器中,沒有爲數組table分配內存空間(有一個入參爲指定Map的構造器例外),而是在執行put操作的時候才真正構建table數組
OK,接下來我們來看看put操作的實現吧
public V put(K key, V value) {
//如果table數組爲空數組{},進行數組填充(爲table分配實際內存空間),入參爲threshold,此時threshold爲initialCapacity 默認是1<<4(24=16)
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//如果key爲null,存儲位置爲table[0]或table[0]的衝突鏈上
if (key == null)
return putForNullKey(value);
int hash = hash(key);//對key的hashcode進一步計算,確保散列均勻
int i = indexFor(hash, table.length);//獲取在table中的實際位置
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
//如果該對應數據已存在,執行覆蓋操作。用新value替換舊value,並返回舊value
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++;//保證併發訪問時,若HashMap內部結構發生變化,快速響應失敗
addEntry(hash, key, value, i);//新增一個entry
return null;
}
先來看看inflateTable這個方法
private void inflateTable(int toSize) {
int capacity = roundUpToPowerOf2(toSize);//capacity一定是2的次冪
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//此處爲threshold賦值,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值,capaticy一定不會超過MAXIMUM_CAPACITY,除非loadFactor大於1
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
inflateTable這個方法用於爲主幹數組table在內存中分配存儲空間,通過roundUpToPowerOf2(toSize)可以確保capacity爲大於或等於toSize的最接近toSize的二次冪,比如toSize=13,則capacity=16;to_size=16,capacity=16;to_size=17,capacity=32.
private static int roundUpToPowerOf2(int number) {
// assert number >= 0 : "number must be non-negative";
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}
roundUpToPowerOf2中的這段處理使得數組長度一定爲2的次冪,Integer.highestOneBit是用來獲取最左邊的bit(其他bit位爲0)所代表的數值.
hash函數
//這是一個神奇的函數,用了很多的異或,移位等運算,對key的hashcode進一步進行計算以及二進制位的調整等來保證最終獲取的存儲位置儘量分佈均勻
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函數計算出的值,通過indexFor進一步處理來獲取實際的存儲位置
/**
* 返回數組下標
*/
static int indexFor(int h, int length) {
return h & (length-1);
}
h&(length-1)保證獲取的index一定在數組範圍內,舉個例子,默認容量16,length-1=15,h=18,轉換成二進制計算爲
1 0 0 1 0 & 0 1 1 1 1 __________________ 0 0 0 1 0 = 2
最終計算出的index=2。有些版本的對於此處的計算會使用 取模運算,也能保證index一定在數組範圍內,不過位運算對計算機來說,性能更高一些(HashMap中有大量位運算)
所以最終存儲位置的確定流程是這樣的:
再來看看addEntry的實現:
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);//當size超過臨界閾值threshold,並且即將發生哈希衝突時進行擴容
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
通過以上代碼能夠得知,當發生哈希衝突並且size大於閾值的時候,需要進行數組擴容,擴容時,需要新建一個長度爲之前數組2倍的新的數組,然後將當前的Entry數組中的元素全部傳輸過去,擴容後的新數組長度爲之前的2倍,所以擴容相對來說是個耗資源的操作。
二:hashMap中用到的方法(HashMap實現原理)
(1)get方法
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
1)先從數組下標,找到對應的Node
2)如果Node裏的第一個節點命中,直接返回
3)如果有衝突,則通過key.equals(k)去查找對應的entry
若爲樹,則在樹中通過key.equals(k)查找,O(logn);
若爲鏈表,則在鏈表中通過key.equals(k)查找,O(n)。
(2)put方法
這個中間涉及的邏輯多一些,方法需要分不同的步驟看。
思路:
- 對key的hashCode()做hash,然後再計算index;
- 如果沒碰撞直接放到bucket裏;
- 如果碰撞了,以鏈表的形式存在buckets後;
- 如果節點已經存在就替換old value(保證key的唯一性)
- 如果碰撞導致鏈表過長(大於等於TREEIFY_THRESHOLD),就把鏈表轉換成紅黑樹;
- 如果Node的容量滿了(超過load factor*current capacity),就要resize。
(3)resize方法
這個涉及的內容,有不少線需要捋一捋。首先看申明時候會resize()。它們都在調用put的時候執行的
三:hash衝突如何解決
(1)Hash函數
非哈希表的特點:關鍵字在表中的位置和它之間不存在一個確定的關係,查找的過程爲給定值一次和各個關鍵字進行比較,查找的效率取決於和給定值進行比較的次數。
哈希表的特點:關鍵字在表中位置和它之間存在一種確定的關係。
哈希函數:一般情況下,需要在關鍵字與它在表中的存儲位置之間建立一個函數關係,以f(key)作爲關鍵字爲key的記錄在表中的位置,通常稱這個函數f(key)爲哈希函數。
hash : 翻譯爲“散列”,就是把任意長度的輸入,通過散列算法,變成固定長度的輸出,該輸出就是散列值。
這種轉換是一種壓縮映射,散列值的空間通常遠小於輸入的空間,不同的輸入可能會散列成相同的輸出,所以不可能從散列值來唯一的確定輸入值。
簡單的說就是一種將任意長度的消息壓縮到莫伊固定長度的消息摘要的函數。
hash衝突:就是根據key即經過一個函數f(key)得到的結果的作爲地址去存放當前的key value鍵值對(這個是hashmap的存值方式),但是卻發現算出來的地址上已經有人先來了。就是說這個地方被搶了啦。這就是所謂的hash衝突啦。
(2)哈希函數處理衝突的方法
1).開放定址法:
其中 m 爲表的長度
對增量di有三種取法:
線性探測再散列 di = 1 , 2 , 3 , ... , m-1
平方探測再散列 di = 1 2 , -2 , 4 , -4 , 8 , -8 , ... , k的平方 , -k平方
隨機探測再散列 di 是一組僞隨機數列
2).鏈地址法
這種方法的基本思想是將所有哈希地址爲i的元素構成一個稱爲同義詞鏈的單鏈表,並將單鏈表的頭指針存在哈希表的第i個單元中,因而查找、插入和刪除主要在同義詞鏈中進行。鏈地址法適用於經常進行插入和刪除的情況。
3).再哈希
這種方法是同時構造多個不同的哈希函數:
Hi=RH1(key) i=1,2,…,k
當哈希地址Hi=RH1(key)發生衝突時,再計算Hi=RH2(key)……,直到衝突不再產生。這種方法不易產生聚集,但增加了計算時間。
4).建立公共溢出區
這種方法的基本思想是:將哈希表分爲基本表和溢出表兩部分,凡是和基本表發生衝突的元素,一律填入溢出表
HashMap的Hash衝突處理辦法
hashmap出現了Hash衝突的時候採用第二種辦法:鏈地址法。
代碼示例:
有一個”國家”(Country)類,我們將要用Country對象作爲key,它的首都的名字(String類型)作爲value。下面的例子有助於我們理解key-value對在HashMap中是如何存儲的。
public class Country {
String name;
long population;
public Country(String name, long population) {
super();
this.name = name;
this.population = population;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public long getPopulation() {
return population;
}
public void setPopulation(long population) {
this.population = population;
}
// If length of name in country object is even then return 31(any random
// number) and if odd then return 95(any random number).
// This is not a good practice to generate hashcode as below method but I am
// doing so to give better and easy understanding of hashmap.
@Override
public int hashCode() {
if (this.name.length() % 2 == 0)
return 31;
else
return 95;
}
@Override
public boolean equals(Object obj) {
Country other = (Country) obj;
if (name.equalsIgnoreCase((other.name)))
return true;
return false;
}
}
public class HashMapStructure {
public static void main(String[] args) {
Country india = new Country("India", 1000);
Country japan = new Country("Japan", 10000);
Country france = new Country("France", 2000);
Country russia = new Country("Russia", 20000);
HashMap<Country, String> countryCapitalMap = new HashMap<Country, String>();
countryCapitalMap.put(india, "Delhi");
countryCapitalMap.put(japan, "Tokyo");
countryCapitalMap.put(france, "Paris");
countryCapitalMap.put(russia, "Moscow");
Iterator<Country> countryCapitalIter = countryCapitalMap.keySet().iterator();// put debug point at this line
while (countryCapitalIter.hasNext()) {
Country countryObj = countryCapitalIter.next();
String capital = countryCapitalMap.get(countryObj);
System.out.println(countryObj.getName() + "----" + capital);
}
}
}
在註釋處加入debug,可以通過watch查看countryCapitalMap的結構:
從上圖可以觀察到以下幾點:
1)有一個叫做table大小是16的Entry數組。
2)這個table數組存儲了Entry類的對象。HashMap類有一個叫做Entry的內部類。這個Entry類包含了key-value作爲實例變量。我們來看下Entry類的結構。Entry類的結構:
static class Entry implements Map.Entry{
final K key;
V value;
Entry next;
final int hash;
...//More code goes here
}
1).每當往hashmap裏面存放key-value對的時候,都會爲它們實例化一個Entry對象,這個Entry對象就會存儲在前面提到的Entry數 組table中。現在你一定很想知道,上面創建的Entry對象將會存放在具體哪個位置(在table中的精確位置)。答案就是,根據key的 hashcode()方法計算出來的hash值(來決定)。hash值用來計算key在Entry數組的索引。
2).現在,如果你看下上圖中數組的索引15,它有一個叫做HashMap$Entry的Entry對象。
3).我們往hashmap放了4個key-value對,但是看上去好像只有1個元素!!!這是因爲,如果兩個元素有相同的hashcode,它們會 被放在同一個索引上。問題出現了,該怎麼放呢?原來它是以鏈表(LinkedList)的形式來存儲的(邏輯上)。因此他們都在hash值爲15的位置 上存着了,然後把多個Entry,用next進行鏈接。