1、HashMap的數據結構
數據結構中有數組和鏈表來實現對數據的存儲,但這兩者基本上是兩個極端。
數組
數組存儲區間是連續的,佔用內存嚴重,故空間複雜的很大。但數組的二分查找時間複雜度小,爲O(1);數組的特點是:尋址容易,插入和刪除困難;
鏈表
鏈表存儲區間離散,佔用內存比較寬鬆,故空間複雜度很小,但時間複雜度很大,達O(N)。鏈表的特點是:尋址困難,插入和刪除容易。
哈希表
那麼我們能不能綜合兩者的特性,做出一種尋址容易,插入刪除也容易的數據結構?答案是肯定的,這就是我們要提起的哈希表。哈希表((Hash
table)既滿足了數據的查找方便,同時不佔用太多的內容空間,使用也十分方便。
哈希表有多種不同的實現方法,我接下來解釋的是最常用的一種方法—— 拉鍊法,我們可以理解爲“鏈表的數組” ,如圖:
從上圖我們可以發現哈希表是由數組+鏈表組成的,一個長度爲16的數組中,每個元素存儲的是一個鏈表的頭結點。那麼這些元素是按照什麼樣的規則存儲到數組中呢。一般情況是通過hash(key)%len獲得,也就是元素的key的哈希值對數組長度取模得到。比如上述哈希表中,12%16=12,28%16=12,108%16=12,140%16=12。所以12、28、108以及140都存儲在數組下標爲12的位置。
HashMap其實也是一個線性的數組實現的,所以可以理解爲其存儲數據的容器就是一個線性數組。這可能讓我們很不解,一個線性的數組怎麼實現按鍵值對來存取數據呢?這裏HashMap有做一些處理。
首先HashMap裏面實現一個靜態內部類Entry,其重要的屬性有 key , value, next,從屬性key,value我們就能很明顯的看出來Entry就是HashMap鍵值對實現的一個基礎bean,我們上面說到HashMap的基礎就是一個線性數組,這個數組就是Entry[],Map裏面的內容都保存在Entry[]裏面。
/**
* The table, resized as necessary. Length MUST Always be a power of two.
*/
transient Entry[] table;
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
final int hash;
Entry<K,V> next;
..........
}
上面的Entry就是數組中的元素,它持有一個指向下一個元素的引用,這就構成了鏈表。
Entry是HashMap的內部類 包含四個值(next,key,value,hash),其中next是一個指向
Entry的指針,key相當於上面節點的值
value對應要保存的值,hash值由key產生,hashmap中要找到某個元素,需要根據hash值來求得對應數組中的位置,然後在由key來在鏈表中找Entry的位置。HashMap中的一切操作都是以Entry爲基礎進行的。HashMap的重點在於如何處理Entry。因此HashMap中的操作大部分都是調用Entry中的方法。可以說HashMap類本身只是提供了一個數組,和對Entry類中方法的一些封裝。
HashMap的原理圖是:
Hashmap實際上是一個數組和鏈表的結合體:
當我們往hashmap中put元素的時候,先根據key的hash值得到這個元素在數組中的位置(即下標),然後就可以把這個元素放到對應的位置中了。如果這個元素所在的位子上已經存放有其他元素了,那麼在同一個位子上的元素將以鏈表的形式存放,新加入的放在鏈頭,最先加入的放在鏈尾。從hashmap中get元素時,首先計算key的hashcode,找到數組中對應位置的某一元素,然後通過key的equals方法在對應位置的鏈表中找到需要的元素。從這裏我們可以想象得到,如果每個位置上的鏈表只有一個元素,那麼hashmap的get效率將是最高的,但是理想總是美好的,現實總是有困難需要我們去克服,哈哈~
2、hash算法
Hash,一般翻譯做“散列”,也有直接音譯爲”哈希”的,就是把任意長度的輸入(又叫做預映射,pre-image),通過散列算法,變換成固定長度的輸出,該輸出就是散列值。這種轉換是一種壓縮映射,也就是,散列值的空間通常遠小於輸入的空間,不同的輸入可能會散列成相同的輸出,而不可能從散列值來唯一的確定輸入值。
說的通俗一點,Hash 算法的意義在於提供了一種快速存取數據的方 法,它用一種算法建立鍵值與真實值之間的對應關係,(每一個真實值只 能有一個鍵值,但是一個鍵值可以對應多個真實值),這樣可以快速在數組等裏面存取數據。
我們可以看到在hashmap中要找到某個元素,需要根據key的hash值來求得對應數組中的位置。如何計算這個位置就是hash算法。前面說過hashmap的數據結構是數組和鏈表的結合,所以我們當然希望這個hashmap裏面的元素位置儘量的分佈均勻些,儘量使得每個位置上的元素數量只有一個,那麼當我們用hash算法求得這個位置的時候,馬上就可以知道對應位置的元素就是我們要的,而不用再去遍歷鏈表。
所以我們首先想到的就是把hashcode對數組長度取模運算,這樣一來,元素的分佈相對來說是比較均勻的。但是,“模”運算的消耗還是比較大的,能不能找一種更快速,消耗更小的方式那?java中時這樣做的,
static int indexFor(int h, int length) {
return h & (length-1);
}
首先算得key得hashcode值,然後跟數組的長度-1做一次“與”運算(&)。看上去很簡單,其實比較有玄機。比如數組的長度是2的4次方,那麼hashcode就會和2的4次方-1做“與”運算。很多人都有這個疑問,爲什麼hashmap的數組初始化大小都是2的次方大小時,hashmap的效率最高,我以2的4次方舉例,來解釋一下爲什麼數組大小爲2的冪時hashmap訪問的性能最高。
看下圖,左邊兩組是數組長度爲16(2的4次方),右邊兩組是數組長度爲15。兩組的hashcode均爲8和9,但是很明顯,當它們和1110“與”的時候,產生了相同的結果,也就是說它們會定位到數組中的同一個位置上去,這就產生了碰撞,8和9會被放到同一個鏈表上,那麼查詢的時候就需要遍歷這個鏈表,得到8或者9,這樣就降低了查詢的效率。同時,我們也可以發現,當數組長度爲15的時候,hashcode的值會與14(1110)進行“與”,那麼最後一位永遠是0,而0001,0011,0101,1001,1011,0111,1101這幾個位置永遠都不能存放元素了,空間浪費相當大,更糟的是這種情況中,數組可以使用的位置比數組長度小了很多,這意味着進一步增加了碰撞的機率,減慢了查詢的效率!
所以說,當數組長度爲2的n次冪的時候,不同的key算得得index相同的機率較小,那麼數據在數組上分佈就比較均勻,也就是說碰撞的機率小,相對的,查詢的時候就不用遍歷某個位置上的鏈表,這樣查詢效率也就較高了。
說到這裏,我們再回頭看一下hashmap中默認的數組大小是多少,查看源代碼可以得知是16,爲什麼是16,而不是15,也不是20呢,看到上面annegu的解釋之後我們就清楚了吧,顯然是因爲16是2的整數次冪的原因,在小數據量的情況下16比15和20更能減少key之間的碰撞,而加快查詢的效率。
所以,在存儲大容量數據的時候,最好預先指定hashmap的size爲2的整數次冪次方。就算不指定的話,也會以大於且最接近指定值大小的2次冪來初始化的,代碼如下(HashMap的構造方法中):
且最接近指定值大小的2次冪來初始化的,代碼如下(HashMap的構造方法中):
// Find a power of 2 >= initialCapacity
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
3、HashMap的重構resize
當hashmap中的元素越來越多的時候,碰撞的機率也就越來越高(因爲數組的長度是固定的),所以爲了提高查詢的效率,就要對hashmap的數組進行擴容,數組擴容這個操作也會出現在ArrayList中,所以這是一個通用的操作,很多人對它的性能表示過懷疑,不過想想我們的“均攤”原理,就釋然了,而在hashmap數組擴容之後,最消耗性能的點就出現了:原數組中的數據必須重新計算其在新數組中的位置,並放進去,這就是resize。
那麼hashmap什麼時候進行擴容呢?
當hashmap中的元素個數超過數組大小乘以loadFactor(負載因子)時,就會進行數組擴容,loadFactor的默認值爲0.75,也就是說,默認情況下,數組大小爲16,那麼當hashmap中元素個數超過16*0.75=12的時候,就把數組的大小擴展爲2*16=32,即擴大一倍,然後重新計算每個元素在數組中的位置,而這是一個非常消耗性能的操作,所以如果我們已經預知hashmap中元素的個數,那麼預設元素的個數能夠有效的提高hashmap的性能。比如說,我們有1000個元素new HashMap(1000), 但是理論上來講new HashMap(1024)更合適,不過上面annegu已經說過,即使是1000,hashmap也自動會將其設置爲1024。 但是new HashMap(1024)還不是更合適的,因爲0.75*1000 < 1000, 也就是說爲了讓0.75 * size >1000, 我們必須這樣new HashMap(2048)才最合適,既考慮了&的問題,也避免了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); table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
4、key的hashcode()與equals()方法改寫
在第一部分hashmap的數據結構中,annegu就寫了get方法的過程:首先計算key的hashcode,找到數組中對應位置的某一元素,然後通過key的equals方法在對應位置的鏈表中找到需要的元素。所以,hashcode與equals方法對於找到對應元素是兩個關鍵方法。
HashCode的計算方法是調用的各個對象自己的實現的 hashCode()方法。而這個方法是在Object對象中定義的,所以我們自己定義的類如果要在集合中使用的話,就需要正確的覆寫 hashCode() 方法。
Hashmap的key可以是任何類型的對象,例如User這種對象,爲了保證兩個具有相同屬性的user的hashcode相同,我們就需要改寫hashcode方法,比方把hashcode值的計算與User對象的id關聯起來,那麼只要user對象擁有相同id,那麼他們的hashcode也能保持一致了,這樣就可以找到在hashmap數組中的位置了。如果這個位置上有多個元素,還需要用key的equals方法在對應位置的鏈表中找到需要的元素,所以只改寫了hashcode方法是不夠的,equals方法也是需要改寫滴~當然啦,按正常思維邏輯,equals方法一般都會根據實際的業務內容來定義,例如根據user對象的id來判斷兩個user是否相等。
在改寫equals方法的時候,需要滿足以下三點:
(1) 自反性:就是說a.equals(a)必須爲true。
(2) 對稱性:就是說a.equals(b)=true的話,b.equals(a)也必須爲true。
(3) 傳遞性:就是說a.equals(b)=true,並且b.equals(c)=true的話,a.equals(c)也必須爲true。
通過改寫key對象的equals()和hashcode()方法,我們可以將任意的業務對象作爲map的key(前提是你確實有這樣的需要)。
假定我們寫了一個類:Person(人),我們判斷一個對象“人”是否指向同一個人,只要知道這個人的身份證號一直就可以了。
先看我們沒有實現 hashCode的情況:
//身份證類
class Code{
public final int id;//身份證號碼已經確認,不能改變
public Code(int i){
this.id=i;
}
//身份號號碼相同,則身份證相同
public boolean equals(Object anObject) {
if (anObject instanceof Code){
Code other=(Code) anObject;
return this.id==other.id;
}
return false;
}
public String toString() {
return "身份證:"+id;
}
}
//人員信息類
class Person {
public Code id;// 身份證
public String name;// 姓名
public Person(String name, Code id) {
this.id=id;
this.name=name;
}
//如果身份證號相同,就表示兩個人是同一個人
public boolean equals(Object anObject) {
if (anObject instanceof Person){
Person other=(Person) anObject;
return this.id.equals(other.id);
}
return false;
}
public String toString() {
return "姓名:"+name+" 身份證:"+id.id+"\n";
}
}
package com.test;
import java.util.HashMap;
public class HashCodeDemo {
public static void main(String[] args) {
HashMap map=new HashMap();
Person p1=new Person("張三",new Code(123));
Person p2=new Person("李四",new Code(456));
//我們根據身份證來作爲key值存放到Map中
map.put(p1.id,p1);
map.put(p2.id,p2);
System.out.println("HashMap 中存放的人員信息:\n"+map);
// 張三,改名爲:張山,但是還是同一個人(身份證id依然爲123)
Person p3=new Person("張山",new Code(123));
map.put(p3.id,p3);
System.out.println("張三改名後 HashMap 中存放的人員信 息:\n"+map);
//查找身份證爲:123 的人員信息
System.out.println("查找身份證爲:123 的人員信息:"+map.get(new Code(123)));
}
}
運行結果爲:
HashMap 中存放的人員信息:
HashMap 中存放的人員信息:
{ 身份證:456=姓名:李四 身份證:456,
身份證:123=姓名:張三 身份證:123
}
張三改名後 HashMap 中存放的人員信息:
{ 身份證:123=姓名:張山 身份證:123,
身份證:456=姓名:李四 身份證:456,
身份證:123=姓名:張三 身份證:123
}
查找身份證爲:123 的人員信息:null
上面的例子的演示的是,我們在一個HashMap中存放了一些人員的信息。並以這些人員的身份證最爲人員的“鍵”。
注意:此處之所以能夠成功put兩個Key(id)同爲“123”的人(實際上HashMap只能存在唯一的Key,Key重複的話,Value將被新值覆蓋),是因爲Code類還沒有重寫Hashcode()函數!!!
而例子的輸出結果表示,我們所做的更新和查找操作都失敗了。
失敗的原因就是我們的身份證類Code沒有覆寫hashCode()方法。
這個時候,當查找一樣的身份證號碼的鍵值對的時候,使用的是默認的對象的內存地址來進行定位(Object類定義的hashcode()方法默認返回的是對象的內存地址)。
這樣,後面的所有的身份證號對象new Code(123) 產生的hashCode()值都是不一樣的。所以導致操作失敗。
下面,我們給 Code類加上 hashCode()方法,然後再運行一下程序看看:
//身份證類
class Code{
public final int id;//身份證號碼已經確認,不能改變
public Code(int i){
this.id=i;
}
//身份號號碼相同,則身份證相同
public boolean equals(Object anObject) {
if (anObject instanceof Code){
Code other=(Code) anObject;
return this.id==other.id;
}
return false;
}
public String toString() {
return "身份證:"+id;
}
//覆寫hashCode方法,並使用身份證號作爲hash值
public int hashCode(){
return id;
}
}
//人員信息類
class Person {
public Code id;// 身份證
public String name;// 姓名
public Person(String name, Code id) {
this.id=id;
this.name=name;
}
//如果身份證號相同,就表示兩個人是同一個人
public boolean equals(Object anObject) {
if (anObject instanceof Person){
Person other=(Person) anObject;
return this.id.equals(other.id);
}
return false;
}
public String toString() {
return "姓名:"+name+" 身份證:"+id.id+"\n";
}
}
package com.test;
import java.util.HashMap;
public class HashCodeDemo {
public static void main(String[] args) {
HashMap map=new HashMap();
Person p1=new Person("張三",new Code(123));
Person p2=new Person("李四",new Code(456));
//我們根據身份證來作爲key值存放到Map中
map.put(p1.id,p1);
map.put(p2.id,p2);
System.out.println("HashMap 中存放的人員信息:\n"+map);
// 張三,改名爲:張山,但是還是同一個人(身份證id依然爲123)
Person p3=new Person("張山",new Code(123));
map.put(p3.id,p3);
System.out.println("張三改名後 HashMap 中存放的人員信 息:\n"+map);
//查找身份證爲:123 的人員信息
System.out.println("查找身份證爲:123 的人員信息:"+map.get(new Code(123)));
}
}
運行結果爲:
HashMap 中存放的人員信息:
HashMap 中存放的人員信息:
{ 身份證:456=姓名:李四 身份證:456,
身份證:123=姓名:張三 身份證:123
}
張三改名後 HashMap 中存放的人員信息:
{ 身份證:456=姓名:李四 身份證:456,
身份證:123=姓名:張三 身份證:123
}
查找身份證爲:123 的人員信息:姓名:張山 身份證:123
這個時候,我們發現。我們想要做的更新和查找操作都成功了。
(1)對於 Map部分的使用和實現,主要就是需要注意存放“鍵值對”中的對象的 equals()方法和 hashCode()方法的覆寫。
(2)如果需要使用到排序的話,那麼還需要實現Comparable接口中的compareTo()方法。
(3)我們需要注意 Map 中的“鍵”是不能重複的,而是否重複的判斷,是通過調用“鍵”對象的 equals()方法來決定的。
(4)而在HashMap中查找和存取“鍵值對”是同時使用 hashCode()方法和 equals()方法來決定的。
5、HashMap的存取實現
既然是線性數組,爲什麼能隨機存取?這裏HashMap用了一個小算法,大致是這樣實現:
// 存儲時:
int hash = key.hashCode(); // 這個hashCode方法這裏不詳述,只要理解每個key的hash是一個固定的int值
int index = hash % Entry[].length;
Entry[index] = value;
// 取值時:
int hash = key.hashCode();
int index = hash % Entry[].length;
return Entry[index];
1) put
疑問:如果兩個key通過hash%Entry[].length得到的index相同,會不會有覆蓋的危險?
這裏HashMap裏面用到鏈式數據結構的一個概念。上面我們提到過Entry類裏面有一個next屬性,作用是指向下一個Entry。打個比方, 第一個鍵值對A進來,通過計算其key的hash得到的index=0,記做:Entry[0] = A。一會後又進來一個鍵值對B,通過計算其index也等於0,現在怎麼辦?HashMap會這樣做:B.next = A,Entry[0] = B,如果又進來C,index也等於0,那麼C.next = B,Entry[0] = C;這樣我們發現index=0的地方其實存取了A,B,C三個鍵值對,他們通過next這個屬性鏈接在一起。所以疑問不用擔心。也就是說數組中存儲的是最後插入的元素。到這裏爲止,HashMap的大致實現,我們應該已經清楚了。
public V put(K key, V value) {
if (key == null)
return putForNullKey(value); //null總是放在數組的第一個鏈表中
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
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;
}
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); //參數e, 是Entry.next
//如果size超過threshold,則擴充table大小。再散列
if (size++ >= threshold)
resize(2 * table.length);
}
當然HashMap裏面也包含一些優化方面的實現,這裏也說一下。比如:Entry[]的長度一定後,隨着map裏面數據的越來越長,這樣同一個index的鏈就會很長,會不會影響性能?HashMap裏面設置一個因子,隨着map的size越來越大,Entry[]會以一定的規則加長長度。
2) get
public V get(Object key) {
if (key == null)
return getForNullKey();
int hash = hash(key.hashCode());
//先定位到數組元素,再遍歷該元素處的鏈表
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
return e.value;
}
return null;
}
6、解決hash衝突的辦法
開放定址法(線性探測再散列,二次探測再散列,僞隨機探測再散列)
再哈希法
鏈地址法
建立一個公共溢出區
Java中hashmap的解決辦法就是採用的鏈地址法。
7、ConcurrentHashMap
ConcurrentHashMap是Java5中新增加的一個線程安全的Map集合,可以用來替代HashTable。ConcurrentHashMap相比於HashTable,使用了多個鎖代替HashTable中的單個鎖,也就是鎖分離技術(Lock Stripping),使得其是線程安全並且高效的HashMap,在併發編程中經常可見它的使用。
在開始分析它的高併發實現機制前,先講講廢話,看看它是如何被引入jdk的。
(1) 線程不安全的HashMap
HashMap線程不安全,它的線程不安全主要發生在put等對HashEntry有直接寫操作的地方。
在多線程環境下,使用Hashmap進行put操作會引起死循環,導致CPU利用率接近100%,所以在併發情況下不能使用HashMap。
(2) 效率低下的HashTable
Hashtable線程安全,但是效率低下。
從Hashtable示例的源碼可以看出,Hashtable是用synchronized關鍵字來保證線程安全的,由於synchronized的機制是在同一時刻只能有一個線程操作,其他的線程阻塞或者輪詢等待,在線程競爭激烈的情況下,這種方式的效率會非常的低下。如線程1使用put進行添加元素,線程2不但不能使用put方法添加元素,並且也不能使用get方法來獲取元素,所以競爭越激烈效率越低。
(3) 高效且安全的ConcurrentHashMap
Hashtable低效主要是因爲所有訪問Hashtable的線程都爭奪一把鎖。如果容器有很多把鎖,每一把鎖控制容器中的一部分數據,那麼當多個線程訪問容器裏的不同部分的數據時,線程之前就不會存在鎖的競爭,這樣就可以有效的提高併發的訪問效率。這也正是ConcurrentHashMap使用的分段鎖技術。將ConcurrentHashMap容器的數據分段存儲,每一段數據分配一個Segment(鎖),當線程佔用其中一個Segment時,其他線程可正常訪問其他段數據。
(4) ConcurrentHashMap結構分析
從類圖可以看出:ConcurrentHashMap由Segment和HashEntry組成。
(1) Segment是可重入鎖,它在ConcurrentHashMap中扮演分離鎖的角色;
(2) HashEntry主要存儲鍵值對;
CurrentHashMap包含一個Segment數組,每個Segment包含一個HashEntry數組並且守護它,當修改HashEntry數組數據時,需要先獲取它對應的Segment鎖;而HashEntry數組採用開鏈法處理衝突,所以它的每個HashEntry元素又是鏈表結構的元素。
由此可以得出ConcurrentHashMap的結構圖如下: