注: 今天看到的一篇講hashMap,hashTable,concurrentHashMap很透徹的一篇文章, 感謝原作者的分享.
原文地址: http://blog.csdn.net/zhangerqing/article/details/8193118
Java集合類是個非常重要的知識點,HashMap、HashTable、ConcurrentHashMap等算是集合類中的重點,可謂“重中之重”,首先來看個問題,如面試官問你:HashMap和HashTable有什么區別,一個比較簡單的回答是:
1、HashMap是非線程安全的,HashTable是線程安全的。
2、HashMap的鍵和值都允許有null值存在,而HashTable則不行。
3、因為線程安全的問題,HashMap效率比HashTable的要高。
能答出上面的三點,簡單的面試,算是過了,但是如果再問:Java中的另一個線程安全的與HashMap極其類似的類是什么?同樣是線程安全,它與HashTable在線程同步上有什么不同?能把第二個問題完整的答出來,說明你的基礎算是不錯的了。帶着這個問題,本章開始系Java之美[從菜鳥到高手演變]系列之深入解析HashMap和HashTable類應用而生!總想在文章的開頭說點兒什么,但又無從說起。從最近的一些面試說起吧,感受就是:知識是永無止境的,永遠不要覺得自己已經掌握了某些東西。如果對哪一塊知識感興趣,那么,請多多的花時間,哪怕最基礎的東西也要理解它的原理,盡量往深了研究,在學習的同時,記得多與大家交流溝通,因為也許某些東西,從你自己的角度,是很難發現的,因為你並沒有那么多的實驗環境去發現他們。只有交流的多了,才能及時找出自己的不足,才能認識到:“哦,原來我還有這么多不知道的東西!”。
一、HashMap的內部存儲結構
Java中數據存儲方式最底層的兩種結構,一種是數組,另一種就是鏈表,數組的特點:連續空間,尋址迅速,但是在刪除或者添加元素的時候需要有較大幅度的移動,所以查詢速度快,增刪較慢。而鏈表正好相反,由於空間不連續,尋址困難,增刪元素只需修改指針,所以查詢慢、增刪快。有沒有一種數據結構來綜合一下數組和鏈表,以便發揮他們各自的優勢?答案是肯定的!就是:哈希表。哈希表具有較快(常量級)的查詢速度,及相對較快的增刪速度,所以很適合在海量數據的環境中使用。一般實現哈希表的方法采用“拉鏈法”,我們可以理解為“鏈表的數組”,如下圖:
從上圖中,我們可以發現哈希表是由數組+鏈表組成的,一個長度為16的數組中,每個元素存儲的是一個鏈表的頭結點。那么這些元素是按照什么樣的規則存儲到數組中呢。一般情況是通過hash(key)%len獲得,也就是元素的key的哈希值對數組長度取模得到。比如上述哈希表中,12%16=12,28%16=12,108%16=12,140%16=12。所以12、28、108以及140都存儲在數組下標為12的位置。它的內部其實是用一個Entity數組來實現的,屬性有key、value、next。接下來我會從初始化階段詳細的講解HashMap的內部結構。
1、初始化
首先來看三個常量:
static final int DEFAULT_INITIAL_CAPACITY = 16; 初始容量:16
static final int MAXIMUM_CAPACITY = 1
<< 30; 最大容量:2的30次方:1073741824
static final float DEFAULT_LOAD_FACTOR = 0.75f;
裝載因子,后面再說它的作用
來看個無參構造方法,也是我們最常用的:
loadFactor、threshold的值在此處沒有起到作用,不過他們在后面的擴容方面會用到,此處只需理解table=new Entry[DEFAULT_INITIAL_CAPACITY].說明,默認就是開辟16個大小的空間。另外一個重要的構造方法:
就是說傳入參數的構造方法,我們把重點放在:
上面,該代碼的意思是,實際的開辟的空間要大於傳入的第一個參數的值。舉個例子:
new HashMap(7,0.8),loadFactor為0.8,capacity為7,通過上述代碼后,capacity的值為:8.(1 << 2的結果是4,2 << 2的結果為8<此處感謝網友wego1234的指正>)。所以,最終capacity的值為8,最后通過new Entry[capacity]來創建大小為capacity的數組,所以,這種方法最紅取決於capacity的大小。
2、put(Object key,Object value)操作
當調用put操作時,首先判斷key是否為null,如下代碼1處:
如果key是null,則調用如下代碼:
就是說,獲取Entry的第一個元素table[0],並基於第一個元素的next屬性開始遍歷,直到找到key為null的Entry,將其value設置為新的value值。
如果沒有找到key為null的元素,則調用如上述代碼的addEntry(0, null, value, 0);增加一個新的entry,代碼如下:
先獲取第一個元素table[bucketIndex],傳給e對象,新建一個entry,key為null,value為傳入的value值,next為獲取的e對象。如果容量大於threshold,容量擴大2倍。
如果key不為null,這也是大多數的情況,重新看一下源碼:
看源碼中2處,首先會進行key.hashCode()操作,獲取key的哈希值,hashCode()是Object類的一個方法,為本地方法,內部實現比較復雜,我們
會在后面作單獨的關於Java中Native方法的分析中介紹。hash()的源碼如下:
int i = indexFor(hash, table.length);的意思,相當於int i = hash % Entry[].length;得到i后,就是在Entry數組中的位置,(上述代碼5和6處是如果Entry數組中不存在新要增加的元素,則執行5,6處的代碼,如果存在,即Hash沖突,則執行 3-4處的代碼,此處HashMap中采用鏈地址法解決Hash沖突。此處經網友bbycszh指正,發現上述陳述有些問題)。重新解釋:其實不管Entry數組中i位置有無元素,都會去執行5-6處的代碼,如果沒有,則直接新增,如果有,則將新元素設置為Entry[0],其next指針指向原有對象,即原有對象為Entry[1]。具體方法可以解釋為下面的這段文字:(3-4處的代碼只是檢查在索引為i的這條鏈上有沒有key重復的,有則替換且返回原值,程序不再去執行5-6處的代碼,無則無處理)
上面我們提到過Entry類里面有一個next屬性,作用是指向下一個Entry。如, 第一個鍵值對A進來,通過計算其key的hash得到的i=0,記做:Entry[0] = A。一會后又進來一個鍵值對B,通過計算其i也等於0,現在怎么辦?HashMap會這樣做:B.next = A,Entry[0] = B,如果又進來C,i也等於0,那么C.next = B,Entry[0] = C;這樣我們發現i=0的地方其實存取了A,B,C三個鍵值對,他們通過next這個屬性鏈接在一起,也就是說數組中存儲的是最后插入的元素。
到這里為止,HashMap的大致實現,我們應該已經清楚了。當然HashMap里面也包含一些優化方面的實現,這里也說一下。比如:Entry[]的長度一定后,隨着map里面數據的越來越長,這樣同一個i的鏈就會很長,會不會影響性能?HashMap里面設置一個因素(也稱為因子),隨着map的size越來越大,Entry[]會以一定的規則加長長度。
2、get(Object key)操作
get(Object key)操作時根據鍵來獲取值,如果了解了put操作,get操作容易理解,先來看看源碼的實現:
意思就是:1、當key為null時,調用getForNullKey(),源碼如下:
2、當key不為null時,先根據hash函數得到hash值,在更具indexFor()得到i的值,循環遍歷鏈表,如果有:key值等於已存在的key值,則返回其value。如上述get()代碼1處判斷。
總結下HashMap新增put和獲取get操作:
理解了就比較簡單。
此處附一個簡單的HashMap小算法應用:
此處注意兩個地方,map.containsKey(),還有就是上述1-2處的代碼。
理解了HashMap的上面的操作,其它的大多數方法都很容易理解了。搞清楚它的內部存儲機制,一切OK!
二、HashTable的內部存儲結構
HashTable和HashMap采用相同的存儲機制,二者的實現基本一致,不同的是:
1、HashMap是非線程安全的,HashTable是線程安全的,內部的方法基本都是synchronized。
2、HashTable不允許有null值的存在。
在HashTable中調用put方法時,如果key為null,直接拋出NullPointerException。其它細微的差別還有,比如初始化Entry數組的大小等等,但基本思想和HashMap一樣。
三、HashTable和ConcurrentHashMap的比較
如我開篇所說一樣,ConcurrentHashMap是線程安全的HashMap的實現。同樣是線程安全的類,它與HashTable在同步方面有什么不同呢?
之前我們說,synchronized關鍵字加鎖的原理,其實是對對象加鎖,不論你是在方法前加synchronized還是語句塊前加,鎖住的都是對象整體,但是ConcurrentHashMap的同步機制和這個不同,它不是加synchronized關鍵字,而是基於lock操作的,這樣的目的是保證同步的時候,鎖住的不是整個對象。事實上,ConcurrentHashMap可以滿足concurrentLevel個線程並發無阻塞的操作集合對象。關於concurrentLevel稍后介紹。
1、構造方法
為了容易理解,我們先從構造函數說起。ConcurrentHashMap是基於一個叫Segment數組的,其實和Entry類似,如下:
默認傳入值16,調用下面的方法:
你會發現比HashMap的構造函數多一個參數,paramInt1就是我們之前談過的initialCapacity,就是數組的初始化大小,paramfloat為loadFactor(裝載因子),而paramInt2則是我們所要說的concurrentLevel,這三個值分別被初始化為16,0.75,16,經過:
后,j就是我們最終要開辟的數組的size值,當paramInt1為16時,計算出來的size值就是16.通過:
this.segments = Segment.newArray(j)后,我們看出了,最終稿創建的Segment數組的大小為16.最終創建Segment對象時:
需要cap值,而cap值來源於:
組后創建大小為cap的數組。最后根據數組的大小及paramFloat的值算出了threshold的值:
this.threshold = (int)(paramArrayOfHashEntry.length * this.loadFactor)。
2、put操作
與HashMap不同的是,如果key為null,直接拋出NullPointer異常,之后,同樣先計算hashCode的值,再計算hash值,不過此處hash函數和HashMap中的不一樣:
根據上述代碼找到Segment對象后,調用put來操作:
先調用lock(),lock是ReentrantLock類的一個方法,用當前存儲的個數+1來和threshold比較,如果大於threshold,則進行rehash,將當前的容量擴大2倍,重新進行hash。之后對hash的值和數組大小-1進行按位於操作后,得到當前的key需要放入的位置,從這兒開始,和HashMap一樣。
從上述的分析看出,ConcurrentHashMap基於concurrentLevel划分出了多個Segment來對key-value進行存儲,從而避免每次鎖定整個數組,在默認的情況下,允許16個線程並發無阻塞的操作集合對象,盡可能地減少並發時的阻塞現象。
在多線程的環境中,相對於HashTable,ConcurrentHashMap會帶來很大的性能提升!
歡迎讀者批評指正,有任何建議請聯系:
EGG:xtfggef@gmail.com http://weibo.com/xtfggef
四、HashMap常見問題分析
1、此處我覺得網友huxb23@126的一篇文章說的很好,分析多線程並發寫HashMap線程被hang住的原因 ,因為是優秀的資源,此處我整理下搬到這兒。
以下內容轉自博文:http://blog.163.com/huxb23@126/blog/static/625898182011211318854/
先看原問題代碼:
就是啟了兩個線程,不斷的往一個非線程安全的HashMap中put內容,put的內容很簡單,key和value都是從0自增的整數(這個put的內容做的並不好,以致於后來干擾了我分析問題的思路)。對HashMap做並發寫操作,我原以為只不過會產生臟數據的情況,但反復運行這個程序,會出現線程t1、t2被hang住的情況,多數情況下是一個線程被hang住另一個成功結束,偶爾會兩個線程都被hang住。說到這里,你如果覺得不好好學習ConcurrentHashMap而在這瞎折騰就手下留情跳過吧。
好吧,分析下HashMap的put函數源碼看看問題出在哪,這里就羅列出相關代碼(jdk1.6):
通過jconsole(或者thread dump),可以看到線程停在了transfer方法的while循環處。這個transfer方法的作用是,當Map中元素數超過閾值需要resize時,它負責把原Map中的元素映射到新Map中。我修改了HashMap,加上了@標記2和@標記3的代碼片斷,以打印出死循環時的狀態,結果死循環線程總是出現類似這樣的輸出:“Thread-1,e==next:false,e==next.next:true,e:108928=108928,next:108928=108928,eq:true”。
這個輸出表明:
1)這個Entry鏈中的兩個Entry之間的關系是:e=e.next.next,造成死循環。
2)e.equals(e.next),但e!=e.next。因為測試例子中兩個線程put的內容一樣,並發時可能同一個key被保存了多個value,這種錯誤是在addEntry函數產生的,但這和線程死循環沒有關系。
接下來就分析transfer中那個while循環了。先所說這個循環正常的功能:src[j]保存的是映射成同一個hash值的多個Entry的鏈表,這個src[j]可能為null,可能只有一個Entry,也可能由多個Entry鏈接起來。假設是多個Entry,原來的鏈是(src[j]=a)->b(也就是src[j]=a,a.next=b,b.next=null),經過while處理后得到了(newTable[i]=b)->a。也就是說,把鏈表的next關系反向了。
再看看這個while中可能在多線程情況下引起問題的語句。針對兩個線程t1和t2,這里它們可能的產生問題的執行序列做些個人分析:
1)假設同一個Entry列表[e->f->...],t1先到,t2后到並都走到while中。t1執行“e.next = newTable[i];newTable[i] = e;”這使得e.next=null(初始的newTable[i]為null),newTable[i]指向了e。這時t2執行了“e.next = newTable[i];newTable[i] = e;”,這使得e.next=e,e死循環了。因為循環開始處的“final Entry next = e.next;”,盡管e自己死循環了,在最后的“e = next;”后,兩個線程都會跳過e繼續執行下去。
2)在while中逐個遍歷Entry鏈表中的Entry而把next關系反向時,newTable[i]成為了被交換的引用,可疑的語句在於“e.next = newTable[i];”。假設鏈表e->f->g被t1處理成e<-f<-g,newTable[i]指向了g,這時t2進來了,它一執行“e.next = newTable[i];”就使得e->g,造成了死循環。所以,理論上來說,死循環的Entry個數可能很多。盡管產生了死循環,但是t1執行到了死循環的右邊,所以是會繼續執行下去的,而t2如果執行“final Entry next = e.next;”的next為null,則也會繼續執行下去,否則就進入了死循環。
3)似乎情況會更復雜,因為即便線程跳出了死循環,它下一次做resize進入transfer時,有可能因為之前的死循環Entry鏈表而被hang住(似乎是一定會被hang住)。也有可能,在put檢查Entry鏈表時(@標記1),因為Entry鏈表的死循環而被hang住。也似乎有可能,活着的線程和死循環的線程同時執行在while里后,兩個線程都能活着出去。所以,可能兩個線程平安退出,可能一個線程hang在transfer中,可能兩個線程都被hang住而又不一定在一個地方。
4)我反復的測試,出現一個線程被hang住的情況最多,都是e=e.next.next造成的,這主要就是例子put兩份增量數據造成的。我如果去掉@標記3的輸出,有時也能復現兩個線程都被hang住的情況,但加上后就很難復現出來。我又把put的數據改了下,比如讓兩個線程put范圍不同的數據,就能復現出e=e.next,兩個線程都被hang住的情況。
上面羅哩羅嗦了很多,一開始我簡單的分析后覺得似乎明白了怎么回事,可現在仔細琢磨后似乎又不明白了許多。有一個細節是,每次死循環的key的大小也是有據可循的,我就不打哈了。感覺,如果樣本多些,可能出現問題的原因點會很多,也會更復雜,我姑且不再蛋疼下去。至於有人提到ConcurrentHashMap也有這個問題,我覺得不大可能,因為它的put操作是加鎖的,如果有這個問題就不叫線程安全的Map了。
2、HashMap中Value可以相同,但是鍵不可以相同
當插入HashMap的key相同時,會覆蓋原有的Value,且返回原Value值,看下面的程序:
相同的鍵會被覆蓋,且返回原值。
3、HashMap按值排序
給定一個數組,求出每個數據出現的次數並按照次數的由大到小排列出來。我們選用HashMap來做,key存儲數組元素,值存儲出現的次數,最后用Collections的sort方法對HashMap的值進行排序。代碼如下:
輸出:
2-6
5-5
3-4
8-3
7-3
9-1
0-1