[轉] HashMap的存取之美

本文轉自 http://www.nowamagic.net/librarys/veda/detail/1202html

 

HashMap是一種十分經常使用的數據結構,做爲一個應用開發人員,對其原理、實現的加深理解有助於更高效地進行數據存取。本文所用的jdk版本爲1.5。前端

使用HashMap

《Effective JAVA》中認爲,99%的狀況下,當你覆蓋了equals方法後,請務必覆蓋hashCode方法。默認狀況下,這二者會採用Object的「原生」實現方式,即:java

1 protected native int hashCode(); 
2 public boolean equals(Object obj) { 
3   return (this == obj); 
4 }
 

hashCode方法的定義用到了native關鍵字,表示它是由C或C++採用較爲底層的方式來實現的,你能夠認爲它返回了該對象的內存地址;而缺省equals則認爲,只有當二者引用同一個對象時,才認爲它們是相等的。若是你只是覆蓋了equals()而沒有從新定義hashCode(),在讀取HashMap的時候,除非你使用一個與你保存時引用徹底相同的對象做爲key值,不然你將得不到該key所對應的值。算法

另外一方面,你應該儘可能避免使用「可變」的類做爲HashMap的鍵。若是你將一個對象做爲鍵值並保存在HashMap中,以後又改變了其狀態,那麼HashMap就會產生混亂,你所保存的值可能丟失(儘管遍歷集合可能能夠找到)。數據庫

HashMap存取機制

Hashmap其實是一個數組和鏈表的結合體,利用數組來模擬一個個桶(相似於Bucket Sort)以快速存取不一樣hashCode的key,對於相同hashCode的不一樣key,再調用其equals方法從List中提取出和key所相對應的value。編程

Java中hashMap的初始化主要是爲initialCapacity和loadFactor這兩個屬性賦值。前者表示hashMap中用來區分不一樣hash值的key空間長度,後者是指定了當hashMap中的元素超過多少的時候,開始自動擴容,。默認狀況下initialCapacity爲16,loadFactor爲0.75,它表示一開始hashMap能夠存放16個不一樣的hashCode,當填充到第12個的時候,hashMap會自動將其key空間的長度擴容到32,以此類推;這點能夠從源碼中看出來:數組

1 void addEntry(int hash, K key, V value, int bucketIndex) {  
2     Entry<K,V> e = table[bucketIndex];  
3         table[bucketIndex] = new Entry<K,V>(hash, key, value, e);  
4         if (size++ >= threshold)  
5             resize(2 * table.length);  
6 }  

而每當hashMap擴容後,內部的每一個元素存放的位置都會發生變化(由於元素的最終位置是其hashCode對key空間長度取模而得),所以resize方法中又會調用transfer函數,用來從新分配內部的元素;這個過程成爲rehash,是十分消耗性能的,所以在可預知元素的個數的狀況下,通常應該避免使用缺省的initialCapacity,而是經過構造函數爲其指定一個值。例如咱們可能會想要將數據庫查詢所得1000條記錄以某個特定字段(好比ID)爲key緩存在hashMap中,爲了提升效率、避免rehash,能夠直接指定initialCapacity爲2048。緩存

另外一個值得注意的地方是,hashMap其key空間的長度必定爲2的N次方,這一點能夠從一下源碼中看出來:安全

 1 int capacity = 1; 數據結構

 2 while (capacity < initialCapacity)

 3   capacity <<= 1;

即便咱們在構造函數中指定的initialCapacity不是2的平方數,capacity仍是會被賦值爲2的N次方。

爲何Sun Microsystem的工程師要將hashMap key空間的長度設爲2的N次方呢?這裏參考R.W.Floyed給出的衡量散列思想的三個標準: 一個好的hash算法的計算應該是很是快的, 一個好的hash算法應該是衝突極小化, 若是存在衝突,應該是衝突均勻化。

爲了將各元素的hashCode保存至長度爲Length的key數組中,通常採用取模的方式,即index = hashCode % Length。不可避免的,存在多個不一樣對象的hashCode被安排在同一位置,這就是咱們平時所謂的「衝突」。若是僅僅是考慮元素均勻化與衝突極小化,彷佛應該將Length取爲素數(儘管沒有明顯的理論來支持這一點,但數學家們經過大量的實踐得出結論,對素數取模的產生結果的無關性要大於其它數字)。爲此,Craig Larman and Rhett Guthrie《Java Performence》中對此也大加抨擊。爲了弄清楚這個問題,Bruce Eckel(Thinking in JAVA的做者)專程採訪了java.util.hashMap的做者Joshua Bloch,並將他採用這種設計的緣由放到了網上(http://www.roseindia.net/javatutorials/javahashmap.shtml) 。

上述設計的緣由在於,取模運算在包括Java在內的大多數語言中的效率都十分低下,而當除數爲2的N次方時,取模運算將退化爲最簡單的位運算,其效率明顯提高(按照Bruce Eckel給出的數據,大約能夠提高5~8倍) 。看看JDK中是如何實現的:

1 static int indexFor(int h, int length) {  
2     return h & (length-1);  
3 }    

當key空間長度爲2的N次方時,計算hashCode爲h的元素的索引能夠用簡單的與操做來代替笨拙的取模操做!假設某個對象的hashCode爲35(二進制爲100011),而hashMap採用默認的initialCapacity(16),那麼indexFor計算所得結果將會是100011 & 1111 = 11,即十進制的3,是否是剛好是35 Mod 16。

上面的方法有一個問題,就是它的計算結果僅有對象hashCode的低位決定,而高位被通通屏蔽了;以上面爲例,19(10011)、35(100011)、67(1000011)等就具備相同的結果。針對這個問題, Joshua Bloch採用了「防護性編程」的解決方法,在使用各對象的hashCode以前對其進行二次Hash,參看JDK中的源碼:

1 static int hash(Object x) {  
2         int h = x.hashCode();  
3         h += ~(h << 9);  
4         h ^=  (h >>> 14);  
5         h +=  (h << 4);  
6         h ^=  (h >>> 10);  
7         return h;  
8     }   

採用這種旋轉Hash函數的主要目的是讓原有hashCode的高位信息也能被充分利用,且兼顧計算效率以及數據統計的特性,其具體的原理已超出了本文的領域。

加快Hash效率的另外一個有效途徑是編寫良好的自定義對象的HashCode,String的實現採用了以下的計算方法:

 1 for (int i = 0; i < len; i++) { 2 h = 31*h + val[off++]; 3 } 4 hash = h;  

這種方法HashCode的計算方法可能最先出如今Brian W. Kernighan和Dennis M. Ritchie的《The C Programming Language》中,被認爲是性價比最高的算法(又被稱爲times33算法,由於C中乘數常量爲33,JAVA中改成31),實際上,包括List在內的大多數的對象都是用這種方法計算Hash值。

另外一種比較特殊的hash算法稱爲布隆過濾器,它以犧牲細微精度爲代價,換來存儲空間的大量節儉,經常使用於諸如判斷用戶名重複、是否在黑名單上等等。

Fail-Fast機制

衆所周知,HashMap不是線程安全的集合類。但在某些容錯能力較好的應用中,若是你不想僅僅由於1%的可能性而去承受hashTable的同步開銷,則能夠考慮利用一下HashMap的Fail-Fast機制,其具體實現以下:

Entry<K,V> nextEntry() {   
if (modCount != expectedModCount)  
    throw new ConcurrentModificationException();  
                     …… 
}

其中modCount爲HashMap的一個實例變量,而且被聲明爲volatile,表示任何線程均可以看到該變量被其它線程修改的結果(根據JVM內存模型的優化,每個線程都會存一份本身的工做內存,此工做內存的內容與本地內存並不是時時刻刻都同步,所以可能會出現線程間的修改不可見的問題) 。使用Iterator開始迭代時,會將modCount的賦值給expectedModCount,在迭代過程當中,經過每次比較二者是否相等來判斷HashMap是否在內部或被其它線程修改。HashMap的大多數修改方法都會改變ModCount,參考下面的源碼:

 1 public V put(K key, V value) {  
 2     K k = maskNull(key);  
 3         int hash = hash(k);  
 4         int i = indexFor(hash, table.length);  
 5         for (Entry<K,V> e = table[i]; e != null; e = e.next) {  
 6             if (e.hash == hash && eq(k, e.key)) {  
 7                 V oldValue = e.value;  
 8                 e.value = value;  
 9                 e.recordAccess(this);  
10                 return oldValue;  
11             }  
12         }  
13         modCount++;  
14         addEntry(hash, k, value, i);  
15         return null;  
16     }    

以put方法爲例,每次往HashMap中添加元素都會致使modCount自增。其它諸如remove、clear方法也都包含相似的操做。 從上面能夠看出,HashMap所採用的Fail-Fast機制本質上是一種樂觀鎖機制,經過檢查狀態——沒有問題則忽略——有問題則拋出異常的方式,來避免線程同步的開銷,下面給出一個在單線程環境下發生Fast-Fail的例子:

 1 class Test {    
 2     public static void main(String[] args) {               
 3         java.util.HashMap<Object,String> map=new java.util.HashMap<Object,String>();    
 4        map.put(new Object(), "a");    
 5        map.put(new Object(), "b");    
 6        java.util.Iterator<Object> it=map.keySet().iterator();    
 7        while(it.hasNext()){    
 8            it.next();    
 9            map.put("", "");         
10         System.out.println(map.size());    
11     }    
12 }  
 

運行上面的代碼會拋出java.util.ConcurrentModificationException,由於在迭代過程當中修改了HashMap內部的元素致使modCount自增。若將上面代碼中 map.put(new Object(), "b") 這句註釋掉,程序會順利經過,由於此時HashMap中只包含一個元素,通過一次迭代後已到了尾部,因此不會出現問題,也就沒有拋出異常的必要了。

在一般併發環境下,仍是建議採用同步機制。這通常經過對天然封裝該映射的對象進行同步操做來完成。若是不存在這樣的對象,則應該使用 Collections.synchronizedMap 方法來「包裝」該映射。最好在建立時完成這一操做,以防止意外的非同步訪問。

LinkedHashMap

遍歷HashMap所獲得的數據是雜亂無章的,這在某些狀況下客戶須要特定遍歷順序時是十分有用的。好比,這種數據結構很適合構建 LRU 緩存。調用 put 或 get 方法將會訪問相應的條目(假定調用完成後它還存在)。putAll 方法以指定映射的條目集合迭代器提供的鍵-值映射關係的順序,爲指定映射的每一個映射關係生成一個條目訪問。Sun提供的J2SE說明文檔特別規定任何其餘方法均不生成條目訪問,尤爲,collection 集合類的操做不會影響底層映射的迭代順序。

LinkedHashMap的實現與 HashMap 的不一樣之處在於,前者維護着一個運行於全部條目的雙重連接列表。此連接列表定義了迭代順序,該迭代順序一般就是集合中元素的插入順序。該類定義了header、before與after三個屬性來表示該集合類的頭與先後「指針」,其具體用法相似於數據結構中的雙鏈表,以刪除某個元素爲例:

1 private void remove() {  
2        before.after = after;  
3        after.before = before;  
4 }    

實際上就是改變先後指針所指向的元素。

顯然,因爲增長了維護連接列表的開支,其性能要比 HashMap 稍遜一籌,不過有一點例外:LinkedHashMap的迭代所需時間與其的所包含的元素成比例;而HashMap 迭代時間極可能開支較大,由於它所須要的時間與其容量(分配給Key空間的長度)成比例。一言以蔽之,隨機存取用HashMap,順序存取或是遍歷用LinkedHashMap。

LinkedHashMap還重寫了removeEldestEntry方法以實現自動清除過時數據的功能,這在HashMap中是沒法實現的,由於後者其內部的元素是無序的。默認狀況下,LinkedHashMap中的removeEldestEntry的做用被關閉,其具體實現以下:

1 protected boolean removeEldestEntry(Map.Entry<k,v> eldest) { 
2     return false; 
3 } 

可使用以下的代碼覆蓋removeEldestEntry:

1 private static final int MAX_ENTRIES = 100;  
2   
3 protected boolean removeEldestEntry(Map.Entry eldest) {  
4     return size() > MAX_ENTRIES;  
5 }    

它表示,剛開始,LinkedHashMap中的元素不斷增加;當它內部的元素超過MAX_ENTRIES(100)後,每當有新的元素被插入時,都會自動刪除雙鏈表中最前端(最舊)的元素,從而保持LinkedHashMap的長度穩定。

缺省狀況下,LinkedHashMap採起的更新策略是相似於隊列的FIFO,若是你想實現更復雜的更新邏輯好比LRU(最近最少使用) 等,能夠在構造函數中指定其accessOrder爲true,由於的訪問元素的方法(get)內部會調用一個「鉤子」,即recordAccess,其具體實現以下:

1 void recordAccess(HashMap<K,V> m) {  
2     LinkedHashMap<K,V> lm = (LinkedHashMap<K,V>)m;  
3     if (lm.accessOrder) {  
4         lm.modCount++;  
5         remove();  
6         addBefore(lm.header);  
7     }  
8 }   
 

上述代碼主要實現了這樣的功能:若是accessOrder被設置爲true,則每次訪問元素時,都將該元素移至headr的前面,即鏈表的尾部。將removeEldestEntry與accessOrder一塊兒使用,就能夠實現最基本的內存緩存,具體代碼可參考http://bluepopopo.javaeye.com/blog/180236。

WeakHashMap

99%的Java教材教導咱們不要去幹預JVM的垃圾回收機制,但JAVA中確實存在着與其密切相關的四種引用:強引用、軟引用、弱引用以及幻象引用。

Java中默認的HashMap採用的是採用相似於強引用的強鍵來管理的,這意味着即便做爲key的對象已經不存在了(指沒有任何一個引用指向它),也仍然會保留在HashMap中,在某些狀況下(例如內存緩存)中,這些過時的條目可能會形成內存泄漏等問題。

WeakHashMap採用的策略是,只要做爲key的對象已經不存在了(超出生命週期),就不會阻止垃圾收集器清空此條目,即便當前機器的內存並不緊張。不過,因爲GC是一個優先級很低的線程,所以不必定會很快發現那些只具備弱引用的對象,除非你顯示地調用它,能夠參考下面的例子:

 1 public static void main(String[] args) {  
 2     Map<String, String>map = new WeakHashMap<String, String>();  
 3     map.put(new String("Alibaba"), "alibaba");  
 4     while (map.containsKey("Alibaba")) {  
 5         try {  
 6             Thread.sleep(500);  
 7          } catch (InterruptedException ignored) {  
 8          }  
 9          System.out.println("Checking for empty");  
10          System.gc();  
11     }    
 

上述代碼輸出一次Checking for empty就退出了主線程,意味着GC在最近的一次垃圾回收週期中清除了new String(「Alibaba」),同時WeakHashMap也作出了及時的反應,將該鍵對應的條目刪除了。若是將map的類型改成HashMap的話,因爲其內部採用的是強引用機制,所以即便GC被顯示調用,map中的條目依然存在,程序會不斷地打出Checking for empty字樣。另外,在使用WeakHashMap的狀況下,如果將 map.put(new String("Alibaba"), "alibaba"); 改成 map.put("Alibaba", "alibaba"); 程序仍是會不斷輸出Checking for empty。這與前面咱們分析的WeakHashMap的弱引用機制並不矛盾,由於JVM爲了減少重複建立和維護多個相同String的開銷,其內部採用了蠅量模式(《Java與模式》),此時的「Alibaba」是存放在常量池而非堆中的,所以即便沒有對象指向「Alibaba」,它也不會被GC回收。弱引用特別適合如下對象:佔用大量內存,但經過垃圾回收功能回收之後很容易從新建立。

介於HashMap和WeakHashMap之中的是SoftHashMap,它所採用的軟引用的策略指的是,垃圾收集器並不像其收集弱可及的對象同樣儘可能地收集軟可及的對象,相反,它只在真正 「須要」 內存時才收集軟可及的對象。軟引用對於垃圾收集器來講是一種「睜一隻眼,閉一隻眼」方式,即 「只要內存不太緊張,我就會保留該對象。可是若是內存變得真正緊張了,我就會去收集並處理這個對象。」 就這一點看,它其實要比WeakHashMap更適合於實現緩存機制。遺憾的是,JAVA中並無實現相關的SoftHashMap類(Apache和Google提供了第三方的實現),但它倒是提供了兩個十分重要的類java.lang.ref.SoftReference以及ReferenceQueue,能夠在對象應用狀態發生改變是獲得通知,能夠參考com.alibaba.common.collection.SofthashMap中processQueue方法的實現:

 1 private ReferenceQueue queue = new ReferenceQueue(); 
 2 ValueCell vc; 
 3 Map hash = new HashMap(initialCapacity, loadFactor); 
 4 …… 
 5 while ((vc = (ValueCell) queue.poll()) != null) { 
 6 if (vc.isValid()) { 
 7           hash.remove(vc.key); 
 8            } else { 
 9              valueCell.dropped--; 
10            } 
11 } 
12 }   
 

processQueue方法會在幾乎全部SoftHashMap的方法中被調用到,JVM會經過ReferenceQueue的poll方法通知該對象已通過期而且當前的內存現狀須要將它釋放,此時咱們就能夠將其從hashMap中剔除。事實上,默認狀況下,Alibaba的MemoryCache所使用的就是SoftHashMap。

相關文章
相關標籤/搜索