Java 容器源碼分析之 Map

ava.util 中的集合類包含 Java 中某些最經常使用的類。最經常使用的集合類是 List 和 Map。List 的具體實現包括 ArrayList 和 Vector,它們是可變大小的列表,比較適合構建、存儲和操做任何類型對象元素列表。List 適用於按數值索引訪問元素的情形。html

Map 提供了一個更通用的元素存儲方法。Map 集合類用於存儲元素對(稱做「鍵」和「值」),其中每一個鍵映射到一個值。從概念上而言,您能夠將 List 看做是具備數值鍵的 Map。而實際上,除了 List 和 Map 都在定義 java.util 中外,二者並無直接的聯繫。本文將着重介紹核心 Java 發行套件中附帶的 Map,同時還將介紹如何採用或實現更適用於您應用程序特定數據的專用 Map。java

瞭解 Map 接口和方法算法

Java 核心類中有不少預約義的 Map 類。在介紹具體實現以前,咱們先介紹一下 Map 接口自己,以便了解全部實現的共同點。Map 接口定義了四種類型的方法,每一個 Map 都包含這些方法。下面,咱們從兩個普通的方法(表 1 )開始對這些方法加以介紹。編程

表 1:覆蓋的方法。咱們將這 Object 的這兩個方法覆蓋,以正確比較 Map 對象的等價性。數組

equals(Object o) 比較指定對象與此 Map 的等價性
hashCode() 返回此 Map 的哈希碼

 

Map 構建安全

Map 定義了幾個用於插入和刪除元素的變換方法(表 2 )。服務器

表 2:Map 更新方法: 能夠更改 Map 內容。多線程

clear() 從 Map 中刪除全部映射
remove(Object key) 從 Map 中刪除鍵和關聯的值
put(Object key, Object value) 將指定值與指定鍵相關聯
clear() 從 Map 中刪除全部映射
putAll(Map t) 將指定 Map 中的全部映射覆制到此 map

 

儘管您可能注意到,縱然假設忽略構建一個須要傳遞給 putAll() 的 Map 的開銷,使用 putAll() 一般也並不比使用大量的 put() 調用更有效率,但 putAll() 的存在一點也不稀奇。這是由於,putAll() 除了迭代 put() 所執行的將每一個鍵值對添加到 Map 的算法之外,還須要迭代所傳遞的 Map 的元素。但應注意,putAll() 在添加全部元素以前能夠正確調整 Map 的大小,所以若是您未親自調整 Map 的大小(咱們將對此進行簡單介紹),則 putAll() 可能比預期的更有效。併發

查看 Maporacle

迭代 Map 中的元素不存在直接了當的方法。若是要查詢某個 Map 以瞭解其哪些元素知足特定查詢,或若是要迭代其全部元素(不管緣由如何),則您首先須要獲取該 Map 的「視圖」。有三種可能的視圖(參見表 3 

  • 全部鍵值對 — 參見 entrySet()
  • 全部鍵 — 參見 keySet()
  • 有值 — 參見 values()

 

前兩個視圖均返回 Set 對象,第三個視圖返回 Collection 對象。就這兩種狀況而言,問題到這裏並無結束,這是由於您沒法直接迭代 Collection 對象或 Set 對象。要進行迭代,您必須得到一個 Iterator 對象。所以,要迭代 Map 的元素,必須進行比較煩瑣的編碼

 

Iterator keyValuePairs = aMap.entrySet().iterator();
Iterator keys = aMap.keySet().iterator();
Iterator values = aMap.values().iterator();

 

值得注意的是,這些對象(Set、Collection 和 Iterator)其實是基礎 Map 的視圖,而不是包含全部元素的副本。這使它們的使用效率很高。另外一方面,Collection 或 Set 對象的 toArray() 方法卻建立包含 Map 全部元素的數組對象,所以除了確實須要使用數組中元素的情形外,其效率並不高。

我運行了一個小測試(隨附文件中的),該測試使用了 HashMap,並使用如下兩種方法對迭代 Map 元素的開銷進行了比較:

 

int mapsize = aMap.size();

                                     
Iterator keyValuePairs1 = aMap.entrySet().iterator();
for (int i = 0; i < mapsize; i++) { Map.Entry entry = (Map.Entry) keyValuePairs1.next(); Object key = entry.getKey(); Object value = entry.getValue(); ... }
Object[] keyValuePairs2 = aMap.entrySet().toArray();
for (int i = 0; i < rem; i++) { { Map.Entry entry = (Map.Entry) keyValuePairs2[i]; Object key = entry.getKey();
Profilers in Oracle JDeveloper

 

Oracle JDeveloper 包含一嵌入的監測器,它測量內存和執行時間,使您可以快速識別代碼中的瓶頸。 我曾使用 Jdeveloper 的執行監測器監測 HashMap 的 containsKey() 和 containsValue() 方法,並很快發現 containsKey() 方法的速度比 containsValue() 方法慢不少(實際上要慢幾個數量級!)。 (參見圖 1圖 2,以及隨附文件中的 類)。

  Object value = entry.getValue();
  ...
}

                                  

此測試使用了兩種測量方法: 一種是測量迭代元素的時間,另外一種測量使用 toArray 調用建立數組的其餘開銷。第一種方法(忽略建立數組所需的時間)代表,使用已從 toArray 調用中建立的數組迭代元素的速度要比使用 Iterator 的速度大約快 30%-60%。但若是將使用 toArray 方法建立數組的開銷包含在內,則使用 Iterator 實際上要快 10%-20%。所以,若是因爲某種緣由要建立一個集合元素的數組而非迭代這些元素,則應使用該數組迭代元素。但若是您不須要此中間數組,則不要建立它,而是使用 Iterator 迭代元素。

 

表 3:返回視圖的 Map 方法: 使用這些方法返回的對象,您能夠遍歷 Map 的元素,還能夠刪除 Map 中的元素。

entrySet() 返回 Map 中所包含映射的 Set 視圖。Set 中的每一個元素都是一個 Map.Entry 對象,可使用 getKey() 和 getValue() 方法(還有一個 setValue() 方法)訪問後者的鍵元素和值元素
keySet() 返回 Map 中所包含鍵的 Set 視圖。刪除 Set 中的元素還將刪除 Map 中相應的映射(鍵和值)
values() 返回 map 中所包含值的 Collection 視圖。刪除 Collection 中的元素還將刪除 Map 中相應的映射(鍵和值)

 

訪問元素

表 4 中列出了 Map 訪問方法。Map 一般適合按鍵(而非按值)進行訪問。Map 定義中沒有規定這確定是真的,但一般您能夠指望這是真的。例如,您能夠指望 containsKey() 方法與 get() 方法同樣快。另外一方面,containsValue() 方法極可能須要掃描 Map 中的值,所以它的速度可能比較慢。

表 4:Map 訪問和測試方法: 這些方法檢索有關 Map 內容的信息但不更改 Map 內容。

get(Object key) 返回與指定鍵關聯的值
containsKey(Object key) 若是 Map 包含指定鍵的映射,則返回 true
containsValue(Object value) 若是此 Map 將一個或多個鍵映射到指定值,則返回 true
isEmpty() 若是 Map 不包含鍵-值映射,則返回 true
size() 返回 Map 中的鍵-值映射的數目

 

對使用 containsKey() 和 containsValue() 遍歷 HashMap 中全部元素所需時間的測試代表,containsValue() 所需的時間要長不少。實際上要長几個數量級!(參見圖 1 圖 2 ,以及隨附文件中的 。所以,若是 containsValue() 是應用程序中的性能問題,它將很快顯現出來,並能夠經過監測您的應用程序輕鬆地將其識別。這種狀況下,我相信您可以想出一個有效的替換方法來實現 containsValue() 提供的等效功能。但若是想不出辦法,則一個可行的解決方案是再建立一個 Map,並將第一個 Map 的全部值做爲鍵。這樣,第一個 Map 上的 containsValue() 將成爲第二個 Map 上更有效的 containsKey()。

圖 1
圖 1: 使用 JDeveloper 建立並運行 Map 測試類

 

圖 2
圖 2: 在 JDeveloper 中使用執行監測器進行的性能監測查出應用程序中的瓶頸

 

核心 Map

Java 自帶了各類 Map 類。這些 Map 類可歸爲三種類型:

 

  1. 通用 Map,用於在應用程序中管理映射,一般在 java.util 程序包中實現
    • HashMap
    • Hashtable
    • Properties
    • LinkedHashMap
    • IdentityHashMap
    • TreeMap
    • WeakHashMap
    • ConcurrentHashMap
  2. 專用 Map,您一般沒必要親自建立此類 Map,而是經過某些其餘類對其進行訪問
    • java.util.jar.Attributes
    • javax.print.attribute.standard.PrinterStateReasons
    • java.security.Provider
    • java.awt.RenderingHints
    • javax.swing.UIDefaults
  3. 一個用於幫助實現您本身的 Map 類的抽象類
    • AbstractMap

 

內部哈希: 哈希映射技術

幾乎全部通用 Map 都使用哈希映射。這是一種將元素映射到數組的很是簡單的機制,您應瞭解哈希映射的工做原理,以便充分利用 Map。

哈希映射結構由一個存儲元素的內部數組組成。因爲內部採用數組存儲,所以必然存在一個用於肯定任意鍵訪問數組的索引機制。實際上,該機制須要提供一個小於數組大小的整數索引值。該機制稱做哈希函數。在 Java 基於哈希的 Map 中,哈希函數將對象轉換爲一個適合內部數組的整數。您沒必要爲尋找一個易於使用的哈希函數而大傷腦筋: 每一個對象都包含一個返回整數值的 hashCode() 方法。要將該值映射到數組,只需將其轉換爲一個正值,而後在將該值除以數組大小後取餘數便可。如下是一個簡單的、適用於任何對象的 Java 哈希函數

 

int hashvalue = Maths.abs(key.hashCode()) % table.length;

 

(% 二進制運算符(稱做模)將左側的值除以右側的值,而後返回整數形式的餘數。)

實際上,在 1.4 版發佈以前,這就是各類基於哈希的 Map 類所使用的哈希函數。但若是您查看一下代碼,您將看到

 

int hashvalue = (key.hashCode() & 0x7FFFFFFF) % table.length;

 

它其實是使用更快機制獲取正值的同一函數。在 1.4 版中,HashMap 類實現使用一個不一樣且更復雜的哈希函數,該函數基於 Doug Lea 的 util.concurrent 程序包(稍後我將更詳細地再次介紹 Doug Lea 的類)。

圖 3
圖 3: 哈希工做原理

 

該圖介紹了哈希映射的基本原理,但咱們尚未對其進行詳細介紹。咱們的哈希函數將任意對象映射到一個數組位置,但若是兩個不一樣的鍵映射到相同的位置,狀況將會如何? 這是一種必然發生的狀況。在哈希映射的術語中,這稱做衝突。Map 處理這些衝突的方法是在索引位置處插入一個連接列表,並簡單地將元素添加到此連接列表。所以,一個基於哈希的 Map 的基本 put() 方法可能以下所示

 

public Object put(Object key, Object value) {
  //咱們的內部數組是一個 Entry 對象數組
  //Entry[] table;

  //獲取哈希碼,並映射到一個索引
  int hash = key.hashCode();
  int index = (hash & 0x7FFFFFFF) % table.length;

  //循環遍歷位於 table[index] 處的連接列表,以查明
  //咱們是否擁有此鍵項 — 若是擁有,則覆蓋它
  for (Entry e = table[index] ; e != null ; e = e.next) {
    //必須檢查鍵是否相等,緣由是不一樣的鍵對象
    //可能擁有相同的哈希
    if ((e.hash == hash) && e.key.equals(key)) {
      //這是相同鍵,覆蓋該值
      //並從該方法返回 old 值
      Object old = e.value;
      e.value = value;
      return old;
    }
  }

  //仍然在此處,所以它是一個新鍵,只需添加一個新 Entry
  //Entry 對象包含 key 對象、 value 對象、一個整型的 hash、
  //和一個指向列表中的下一個 Entry 的 next Entry

  //建立一個指向上一個列表開頭的新 Entry,
  //並將此新 Entry 插入表中
  Entry e = new Entry(hash, key, value, table[index]);
  table[index] = e;

  return null;
}

 

若是看一下各類基於哈希的 Map 的源代碼,您將發現這基本上就是它們的工做原理。此外,還有一些須要進一步考慮的事項,如處理空鍵和值以及調整內部數組。此處定義的 put() 方法還包含相應 get() 的算法,這是由於插入包括搜索映射索引處的項以查明該鍵是否已經存在。(即 get() 方法與 put() 方法具備相同的算法,但 get() 不包含插入和覆蓋代碼。) 使用連接列表並非解決衝突的惟一方法,某些哈希映射使用另外一種「開放式尋址」方案,本文對其不予介紹。

優化 Hasmap

若是哈希映射的內部數組只包含一個元素,則全部項將映射到此數組位置,從而構成一個較長的連接列表。因爲咱們的更新和訪問使用了對連接列表的線性搜索,而這要比 Map 中的每一個數組索引只包含一個對象的情形要慢得多,所以這樣作的效率很低。訪問或更新連接列表的時間與列表的大小線性相關,而使用哈希函數問或更新數組中的單個元素則與數組大小無關 — 就漸進性質(Big-O 表示法)而言,前者爲 O(n),然後者爲 O(1)。所以,使用一個較大的數組而不是讓太多的項彙集在太少的數組位置中是有意義的。

調整 Map 實現的大小

在哈希術語中,內部數組中的每一個位置稱做「存儲桶」(bucket),而可用的存儲桶數(即內部數組的大小)稱做容量 (capacity)。爲使 Map 對象有效地處理任意數目的項,Map 實現能夠調整自身的大小。但調整大小的開銷很大。調整大小須要將全部元素從新插入到新數組中,這是由於不一樣的數組大小意味着對象如今映射到不一樣的索引值。先前衝突的鍵可能再也不衝突,而先前不衝突的其餘鍵如今可能衝突。這顯然代表,若是將 Map 調整得足夠大,則能夠減小甚至再也不須要從新調整大小,這頗有可能顯著提升速度。

使用 1.4.2 JVM 運行一個簡單的測試,即用大量的項(數目超過一百萬)填充 HashMap。表 5 顯示告終果,並將全部時間標準化爲已預先設置大小的服務器模式(關聯文件中的 。對於已預先設置大小的 JVM,客戶端和服務器模式 JVM 運行時間幾乎相同(在放棄 JIT 編譯階段後)。但使用 Map 的默認大小將引起屢次調整大小操做,開銷很大,在服務器模式下要多用 50% 的時間,而在客戶端模式下幾乎要多用兩倍的時間!

表 5:填充已預先設置大小的 HashMap 與填充默認大小的 HashMap 所需時間的比較

  客戶端模式 服務器模式
預先設置的大小 100% 100%
默認大小 294% 157%

 

使用負載因子

爲肯定什麼時候調整大小,而不是對每一個存儲桶中的連接列表的深度進行記數,基於哈希的 Map 使用一個額外參數並粗略計算存儲桶的密度。Map 在調整大小以前,使用名爲「負載因子」的參數指示 Map 將承擔的「負載」量,即它的負載程度。負載因子、項數(Map 大小)與容量之間的關係簡單明瞭:

 

  • 若是(負載因子)x(容量)>(Map 大小),則調整 Map 大小

 

例如,若是默認負載因子爲 0.75,默認容量爲 11,則 11 x 0.75 = 8.25,該值向下取整爲 8 個元素。所以,若是將第 8 個項添加到此 Map,則該 Map 將自身的大小調整爲一個更大的值。相反,要計算避免調整大小所需的初始容量,用將要添加的項數除以負載因子,並向上取整,例如,

 

  • 對於負載因子爲 0.75 的 100 個項,應將容量設置爲 100/0.75 = 133.33,並將結果向上取整爲 134(或取整爲 135 以使用奇數)

 

奇數個存儲桶使 map 可以經過減小衝突數來提升執行效率。雖然我所作的測試(關聯文件中的 並未代表質數能夠始終得到更好的效率,但理想情形是容量取質數。1.4 版後的某些 Map(如 HashMap 和 LinkedHashMap,而非 Hashtable 或 IdentityHashMap)使用須要 2 的冪容量的哈希函數,但下一個最高 2 的冪容量由這些 Map 計算,所以您沒必要親自計算。

負載因子自己是空間和時間之間的調整折衷。較小的負載因子將佔用更多的空間,但將下降衝突的可能性,從而將加快訪問和更新的速度。使用大於 0.75 的負載因子多是不明智的,而使用大於 1.0 的負載因子確定是不明知的,這是由於這一定會引起一次衝突。使用小於 0.50 的負載因子好處並不大,但只要您有效地調整 Map 的大小,應不會對小負載因子形成性能開銷,而只會形成內存開銷。但較小的負載因子將意味着若是您未預先調整 Map 的大小,則致使更頻繁的調整大小,從而下降性能,所以在調整負載因子時必定要注意這個問題。

選擇適當的 Map

應使用哪一種 Map? 它是否須要同步? 要得到應用程序的最佳性能,這多是所面臨的兩個最重要的問題。當使用通用 Map 時,調整 Map 大小和選擇負載因子涵蓋了 Map 調整選項。

如下是一個用於得到最佳 Map 性能的簡單方法

  1. 將您的全部 Map 變量聲明爲 Map,而不是任何具體實現,即不要聲明爲 HashMap 或 Hashtable,或任何其餘 Map 類實現。 
    Map criticalMap = new HashMap(); //好
    
    HashMap criticalMap = new HashMap(); //差

    這使您可以只更改一行代碼便可很是輕鬆地替換任何特定的 Map 實例。

  2. 下載 Doug Lea 的 util.concurrent 程序包 (http://gee.cs.oswego.edu/dl/classes/EDU/oswego/cs/dl/util/concurrent/intro.html)。將 ConcurrentHashMap 用做默認 Map。當移植到 1.5 版時,將 java.util.concurrent.ConcurrentHashMap 用做您的默認 Map。不要將 ConcurrentHashMap 包裝在同步的包裝器中,即便它將用於多個線程。使用默認大小和負載因子。
  3. 監測您的應用程序。若是發現某個 Map 形成瓶頸,則分析形成瓶頸的緣由,並部分或所有更改該 Map 的如下內容:Map 類;Map 大小;負載因子;關鍵對象 equals() 方法實現。專用的 Map 的基本上都須要特殊用途的定製 Map 實現,不然通用 Map 將實現您所需的性能目標。

 

Map 選擇

也許您曾指望更復雜的考量,而這其實是否顯得太容易? 好的,讓咱們慢慢來。首先,您應使用哪一種 Map?答案很簡單: 不要爲您的設計選擇任何特定的 Map,除非實際的設計須要指定一個特殊類型的 Map。設計時一般不須要選擇具體的 Map 實現。您可能知道本身須要一個 Map,但不知道使用哪一種。而這偏偏就是使用 Map 接口的意義所在。直到須要時再選擇 Map 實現 — 若是隨處使用「Map」聲明的變量,則更改應用程序中任何特殊 Map 的 Map 實現只須要更改一行,這是一種開銷不多的調整選擇。是否要使用默認的 Map 實現? 我很快將談到這個問題。

同步 Map

同步與否有何差異? (對於同步,您既可使用同步的 Map,也可使用 Collections.synchronizedMap() 將未同步的 Map 轉換爲同步的 Map。後者使用「同步的包裝器」)這是一個異常複雜的選擇,徹底取決於您如何根據多線程併發訪問和更新使用 Map,同時還須要進行維護方面的考慮。例如,若是您開始時未併發更新特定 Map,但它後來更改成併發更新,狀況將如何? 在這種狀況下,很容易在開始時使用一個未同步的 Map,並在後來嚮應用程序中添加併發更新線程時忘記將此未同步的 Map 更改成同步的 Map。這將使您的應用程序容易崩潰(一種要肯定和跟蹤的最糟糕的錯誤)。但若是默認爲同步,則將因隨之而來的可怕性能而序列化執行多線程應用程序。看起來,咱們須要某種決策樹來幫助咱們正確選擇。

Doug Lea 是紐約州立大學奧斯威戈分校計算機科學系的教授。他建立了一組公共領域的程序包(統稱 util.concurrent),該程序包包含許多能夠簡化高性能並行編程的實用程序類。這些類中包含兩個 Map,即 ConcurrentReaderHashMap 和 ConcurrentHashMap。這些 Map 實現是線程安全的,而且不須要對併發訪問或更新進行同步,同時還適用於大多數須要 Map 的狀況。它們還遠比同步的 Map(如 Hashtable)或使用同步的包裝器更具伸縮性,而且與 HashMap 相比,它們對性能的破壞很小。util.concurrent 程序包構成了 JSR166 的基礎;JSR166 已經開發了一個包含在 Java 1.5 版中的併發實用程序,而 Java 1.5 版將把這些 Map 包含在一個新的 java.util.concurrent 程序包中。

 

 

全部這一切意味着您不須要一個決策樹來決定是使用同步的 Map 仍是使用非同步的 Map, 而只需使用 ConcurrentHashMap。固然,在某些狀況下,使用 ConcurrentHashMap 並不合適。但這些狀況不多見,而且應具體狀況具體處理。這就是監測的用途。 

結束語

    能夠很是輕鬆地建立一個用於比較各類 Map 性能的測試類。更重要的是,集成良好的監測器能夠在開發過程當中快速、輕鬆地識別性能瓶頸 - 集成到 IDE 中的監測器一般被較頻繁地使用,以便幫助構建一個成功的工程。如今,您已經擁有了一個監測器並瞭解了有關通用 Map 及其性能的基礎知識,能夠開始運行您本身的測試,以查明您的應用程序是否因 Map 而存在瓶頸以及在何處須要更改所使用的 Map。

相關文章
相關標籤/搜索