1. 程式人生 > >Java map簡介

Java map簡介

檢索 默認 內容 能夠 有時 有一個 map ldb true

了解最常用的集合類型之一 Map 的基礎知識以及如何針對您應用程序特有的數據優化 Map。

本文相關下載:
· Jack 的 HashMap 測試
· Oracle JDeveloper 10g

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

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

了解 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() 可能比預期的更有效。

查看 Map

叠代 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: 使用 JDeveloper 創建並運行 Map 測試類

技術分享
圖 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: 哈希工作原理

該圖介紹了哈希映射的基本原理,但我們還沒有對其進行詳細介紹。我們的哈希函數將任意對象映射到一個數組位置,但如果兩個不同的鍵映射到相同的位置,情況將會如何? 這是一種必然發生的情況。在哈希映射的術語中,這稱作沖突。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 並不合適。但這些情況很少見,並且應具體情況具體處理。這就是監測的用途。

結束語

通過 Oracle JDeveloper 可以非常輕松地創建一個用於比較各種 Map 性能的測試類。更重要的是,集成良好的監測器可以在開發過程中快速、輕松地識別性能瓶頸 - 集成到 IDE 中的監測器通常被較頻繁地使用,以便幫助構建一個成功的工程。現在,您已經擁有了一個監測器並了解了有關通用 Map 及其性能的基礎知識,可以開始運行您自己的測試,以查明您的應用程序是否因 Map 而存在瓶頸以及在何處需要更改所使用的 Map。

Java map簡介