從 Java 程式碼到 Java 堆

青色刀客發表於2013-07-25

優化應用程式程式碼的記憶體使用並不是一個新主題,但是人們通常並沒有很好地理解這個主題。本文將簡要介紹 Java 程式的記憶體使用,隨後深入探討您編寫的 Java 程式碼的記憶體使用。最後,本文將展示提高程式碼記憶體效率的方法,特別強調了 HashMap 和 ArrayList 等 Java 集合的使用。

背景資訊:Java 程式的記憶體使用

參考知識

如需進一步瞭解 Java 應用程式的程式記憶體使用,請參閱 Andrew Hall 撰寫的 developerWorks 文章 “記憶體詳解”。這篇文章介紹了 記憶體詳解 以及 AIX® 提供的佈局和使用者空間,以及 Java 堆和本機堆之間的互動。

通過在命令列中執行 java 或者啟動某種基於 Java 的中介軟體來執行 Java 應用程式時,Java 執行時會建立一個作業系統程式,就像您執行基於 C 的程式時那樣。實際上,大多數 JVM 都是用 C 或者 C++ 語言編寫的。作為作業系統程式,Java 執行時面臨著與其他程式完全相同的記憶體限制:架構提供的定址能力以及作業系統提供的使用者空間。

架構提供的記憶體定址能力依賴於處理器的位數,舉例來說,32 位或者 64 位,對於大型機來說,還有 31 位。程式能夠處理的位數決定了處理器能定址的記憶體範圍:32 位提供了 2^32 的可定址範圍,也就是 4,294,967,296 位,或者說 4GB。而 64 位處理器的可定址範圍明顯增大:2^64,也就是 18,446,744,073,709,551,616,或者說 16 exabyte(百億億位元組)。

處理器架構提供的部分可定址範圍由 OS 本身佔用,提供給作業系統核心以及 C 執行時(對於使用 C 或者 C++ 編寫的 JVM 而言)。OS 和 C 執行時佔用的記憶體數量取決於所用的 OS,但通常數量較大:Windows 預設佔用的記憶體是 2GB。剩餘的可定址空間(用術語來表示就是使用者空間)就是可供執行的實際程式使用的記憶體。

對於 Java 應用程式,使用者空間是 Java 程式佔用的記憶體,實際上包含兩個池:Java 堆和本機(非 Java)堆。Java 堆的大小由 JVM 的 Java 堆設定控制:-Xms 和 -Xmx 分別設定最小和最大 Java 堆。在按照最大的大小設定分配了 Java 堆之後,剩下的使用者空間就是本機堆。圖 1 展示了一個 32 位 Java 程式的記憶體佈局:


圖 1. 一個 32 位 Java 程式的記憶體佈局示例
一個 32 位 Java 程式的記憶體佈局示例檢視 

在 圖 1 中,可定址範圍總共有 4GB,OS 和 C 執行時大約佔用了其中的 1GB,Java 堆佔用了將近 2GB,本機堆佔用了其他部分。請注意,JVM 本身也要佔用記憶體,就像 OS 核心和 C 執行時一樣,而 JVM 佔用的記憶體是本機堆的子集。

Java 物件詳解

在您的 Java 程式碼使用 new 操作符建立一個 Java 物件的例項時,實際上分配的資料要比您想的多得多。例如,一個 int 值與一個 Integer 物件(能包含 int 值的最小物件)的大小比率是 1:4,這個比率可能會讓您感到吃驚。額外的開銷源於 JVM 用於描述 Java 物件的後設資料,在本例中也就是Integer

根據 JVM 的版本和供應的不同,物件後設資料的數量也各有不同,但其中通常包括:

  • :一個指向類資訊的指標,描述了物件型別。舉例來說,對於 java.lang.Integer 物件,這是java.lang.Integer 類的一個指標。
  • 標記:一組標記,描述了物件的狀態,包括物件的雜湊碼(如果有),以及物件的形狀(也就是說,物件是否是陣列)。
  • :物件的同步資訊,也就是說,物件目前是否正在同步。

物件後設資料後緊跟著物件資料本身,包括物件例項中儲存的欄位。對於 java.lang.Integer 物件,這就是一個 int

如果您正在執行一個 32 位 JVM,那麼在建立 java.lang.Integer 物件例項時,物件的佈局可能如圖 2 所示:


圖 2. 一個 32 位 Java 程式的 java.lang.Integer 物件的佈局示例
一個 32 位 Java 程式的 java.lang.Integer 物件的佈局示例 

如 圖 2 所示,有 128 位的資料被佔用,其中用於儲存 int 值的為 32 位,而物件後設資料佔用了其餘的 96 位。

Java 陣列物件詳解

陣列物件(例如一個 int 值陣列)的形狀和結構與標準 Java 物件相似。主要差別在於陣列物件包含說明陣列大小的額外後設資料。因此,資料物件的後設資料包括:

  • :一個指向類資訊的指標,描述了物件型別。舉例來說,對於 int 欄位陣列,這是 int[] 類的一個指標。
  • 標記:一組標記,描述了物件的狀態,包括物件的雜湊碼(如果有),以及物件的形狀(也就是說,物件是否是陣列)。
  • :物件的同步資訊,也就是說,物件目前是否正在同步。
  • 大小:陣列的大小。

圖 3 展示了一個 int 陣列物件的佈局示例:


圖 3. 一個 32 位 Java 程式的 int 陣列物件的佈局示例
一個 32 位程式的 int 陣列物件的佈局示例 

如 圖 3 所示,有 160 位的資料用於儲存 int 值內的 32 位資料,而陣列後設資料佔用了其餘 160 位。對於 byteint 和 long 等原語,從記憶體的方面考慮,單項陣列比對應的針對單一欄位的包裝器物件(ByteInteger 或 Long)的成本更高。

更為複雜資料結構詳解

良好的物件導向設計與程式設計鼓勵使用封裝(提供介面類來控制資料訪問)和委託(使用 helper 物件來實施任務)。封裝和委託會使大多數資料結構的表示形式中包含多個物件。一個簡單的示例就是java.lang.String 物件。java.lang.String 物件中的資料是一個字元陣列,由管理和控制對字元陣列的訪問的 java.lang.String 物件封裝。圖 4 展示了一個 32 位 Java 程式的java.lang.String 物件的佈局示例:


圖 4. 一個 32 位 Java 程式的 java.lang.String 物件的佈局示例
一個 32 位 Java 程式的 java.lang.String 物件的佈局示例 

如 圖 4 所示,除了標準物件後設資料之外,java.lang.String 物件還包含一些用於管理字串資料的欄位。通常情況下,這些欄位是雜湊值、字串大小計數、字串資料偏移量和對於字元陣列本身的物件引用。

這也就意味著,對於一個 8 個字元的字串(128 位的 char 資料),需要有 256 位的資料用於字元陣列,224 位的資料用於管理該陣列的 java.lang.String 物件,因此為了表示 128 位(16 個位元組)的資料,總共需要佔用 480 位(60 位元組)。開銷比例為 3.75:1。

總體而言,資料結構越是複雜,開銷就越高。下一節將具體討論相關內容。

32 位和 64 位 Java 物件

之前的示例中的物件大小和開銷適用於 32 位 Java 程式。在 背景資訊:Java 程式的記憶體使用 一節中提到,64 位處理器的記憶體可定址能力比 32 位處理器高得多。對於 64 位程式,Java 物件中的某些資料欄位的大小(特別是物件後設資料或者表示另一個物件的任何欄位)也需要增加到 64 位。其他資料欄位型別(例如 intbyte 和 long )的大小不會更改。圖 5 展示了一個 64 位 Integer 物件和一個int 陣列的佈局:


圖 5. 一個 64 位程式的 java.lang.Integer 物件和 int 陣列的佈局示例
一個 64 位 Java 程式的 java.lang.Integer 物件和 int 陣列的佈局示例 

圖 5 表明,對於一個 64 位 Integer 物件,現在有 224 位的資料用於儲存 int 欄位所用的 32 位,開銷比例是 7:1。對於一個 64 位單元素 int 陣列,有 288 位的資料用於儲存 32 位 int 條目,開銷比例是 9:1。這在實際應用程式中產生的影響在於,之前在 32 位 Java 執行時中執行的應用程式若遷移到 64 位 Java 執行時,其 Java 堆記憶體使用量會顯著增加。通常情況下,增加的數量是原始堆大小的 70% 左右。舉例來說,一個在 32 位 Java 執行時中使用 1GB Java 堆的 Java 應用程式在遷移到 64 位 Java 執行時之後,通常需要使用 1.7GB 的 Java 堆。

請注意,這種記憶體增加並非僅限於 Java 堆。本機堆記憶體區使用量也會增加,有時甚至要增加 90% 之多。

表 1 展示了一個應用程式在 32 位和 64 位模式下執行時的物件和陣列欄位大小:


表 1. 32 位和 64 位 Java 執行時的物件中的欄位大小
欄位型別 欄位大小(位)
物件 陣列
32 位 64 位 32 位 64 位
boolean 32 32 8 8
byte 32 32 8 8
char 32 32 16 16
short 32 32 16 16
int 32 32 32 32
float 32 32 32 32
long 32 32 64 64
double 32 32 64 64
物件欄位 32 64 (32*) 32 64 (32*)
物件後設資料 32 64 (32*) 32 64 (32*)

* 物件欄位的大小以及用於各物件後設資料條目的資料的大小可通過 壓縮引用或壓縮 OOP 技術減小到 32 位。

壓縮引用和壓縮普通物件指標 (OOP)

IBM 和 Oracle JVM 分別通過壓縮引用 (-Xcompressedrefs) 和壓縮 OOP (-XX:+UseCompressedOops) 選項提供物件引用壓縮功能。利用這些選項,即可在 32 位(而非 64 位)中儲存物件欄位和物件後設資料值。在應用程式從 32 位 Java 執行時遷移到 64 位 Java 執行時的時候,這能消除 Java 堆記憶體使用量增加 70% 的負面影響。請注意,這些選項對於本機堆的記憶體使用無效,本機堆在 64 位 Java 執行時中的記憶體使用量仍然比 32 位 Java 執行時中的使用量高得多。

Java 集合的記憶體使用

在大多數應用程式中,大量資料都是使用核心 Java API 提供的標準 Java Collections 類來儲存和管理的。如果記憶體佔用對於您的應用程式極為重要,那麼就非常有必要了解各集合提供的功能以及相關的記憶體開銷。總體而言,集合功能的級別越高,記憶體開銷就越高,因此使用提供的功能多於您需要的功能的集合型別會帶來不必要的額外記憶體開銷。

其中部分最常用的集合如下:

除了 HashSet 之外,此列表是按功能和記憶體開銷進行降序排列的。(HashSet 是包圍一個 HashMap 物件的包裝器,它提供的功能比 HashMap 少,同時容量稍微小一些。)

Java 集合:HashSet

HashSet 是 Set 介面的實現。Java Platform SE 6 API 文件對於 HashSet 的描述如下:

一個不包含重複元素的集合。更正式地來說,set(集)不包含元素 e1 和 e2 的配對 e1.equals(e2),而且至多包含一個空元素。正如其名稱所表示的那樣,這個介面將建模數學集抽象。

HashSet 包含的功能比 HashMap 要少,只能包含一個空條目,而且無法包含重複條目。該實現是包圍HashMap 的一個包裝器,以及管理可在 HashMap 物件中存放哪些內容的 HashSet 物件。限制 HashMap 功能的附加功能表示 HashSet 的記憶體開銷略高。

圖 6 展示了 32 位 Java 執行時中的一個 HashSet 的佈局和記憶體使用:


圖 6. 32 位 Java 執行時中的一個 HashSet 的記憶體使用和佈局
32 位 Java 執行時中的一個 HashSet 的記憶體使用和佈局 

圖 6 展示了一個 java.util.HashSet 物件的 shallow 堆(獨立物件的記憶體使用)以及保留堆(獨立物件及其子物件的記憶體使用),以位元組為單位。shallow 堆的大小是 16 位元組,保留堆的大小是 144 位元組。建立一個 HashSet 時,其預設容量(也就是該集中可以容納的條目數量)將設定為 16 個條目。按照預設容量建立 HashSet,而且未在該集中輸入任何條目時,它將佔用 144 個位元組。與 HashMap 的記憶體使用相比,超出了 16 個位元組。表 2 顯示了 HashSet 的屬性:


表 2. 一個 HashSet 的屬性
預設容量 16 個條目
空時的大小 144 個位元組
開銷 16 位元組加 HashMap 開銷
一個 10K 集合的開銷 16 位元組加 HashMap 開銷
搜尋/插入/刪除效能 O(1):所用時間是一個常量時間,無論要素數量如何都是如此(假設無雜湊衝突)

Java 集合:HashMap

HashMap 是 Map 介面的實現。Java Platform SE 6 API 文件對於 HashMap 的描述如下:

一個將鍵對映到值的物件。一個對映中不能包含重複的鍵;每個鍵僅可對映到至多一個值。

HashMap 提供了一種儲存鍵/值對的方法,使用雜湊函式將鍵轉換為儲存鍵/值對的集合中的索引。這允許快速訪問資料位置。允許存在空條目和重複條目;因此,HashMap 是 HashSet 的簡化版。

HashMap 將實現為一個 HashMap$Entry 物件陣列。圖 7 展示了 32 位 Java 執行時中的一個 HashMap 的記憶體使用和佈局:


圖 7. 32 位 Java 執行時中的一個 HashMap 的記憶體使用和佈局
32 位 Java 執行時中的一個 HashMap 的記憶體使用和佈局 

如 圖 7 所示,建立一個 HashMap 時,結果是一個 HashMap 物件以及一個採用 16 個條目的預設容量的HashMap$Entry 物件陣列。這提供了一個 HashMap,在完全為空時,其大小是 128 位元組。插入 HashMap 的任何鍵/值對都將包含於一個 HashMap$Entry 物件之中,該物件本身也有一定的開銷。

大多數 HashMap$Entry 物件實現都包含以下欄位:

  • int KeyHash
  • Object next
  • Object key
  • Object value

一個 32 位元組的 HashMap$Entry 物件用於管理插入集合的資料鍵/值對。這就意味著,一個 HashMap 的總開銷包含 HashMap 物件、一個 HashMap$Entry 陣列條目和與各條目對應的 HashMap$Entry 物件的開銷。可通過以下公式表示:

HashMap 物件 + 陣列物件開銷 + (條目數量 * (HashMap$Entry 陣列條目 + HashMap$Entry 物件))

對於一個包含 10,000 個條目的 HashMap 來說,僅僅 HashMapHashMap$Entry 陣列和 HashMap$Entry 物件的開銷就在 360K 左右。這還沒有考慮所儲存的鍵和值的大小。

表 3 展示了 HashMap 的屬性:


表 3. 一個 HashMap 的屬性
預設容量 16 個條目
空時的大小 128 個位元組
開銷 64 位元組加上每個條目 36 位元組
一個 10K 集合的開銷 ~360K
搜尋/插入/刪除效能 O(1):所用時間是一個常量時間,無論要素數量如何都是如此(假設無雜湊衝突)

Java 集合:Hashtable

Hashtable 與 HashMap 相似,也是 Map 介面的實現。Java Platform SE 6 API 文件對於 Hashtable 的描述如下:

這個類實現了一個雜湊表,用於將鍵對映到值。對於非空物件,可以將它用作鍵,也可以將它用作值。

Hashtable 與 HashMap 極其相似,但有兩項限制。無論是鍵還是值條目,它均不接受空值,而且它是一個同步集合。相比之下,HashMap 可以接受空值,且不是同步的,但可以利用 Collections.synchronizedMap()方法來實現同步。

Hashtable 的實現同樣類似於 HashMap,也是條目物件的陣列,在本例中即 Hashtable$Entry 物件。圖 8 展示了 32 位 Java 執行時中的一個 Hashtable 的記憶體使用和佈局:


圖 8. 32 位 Java 執行時中的一個 Hashtable 的記憶體使用和佈局
32 位 Java 執行時中的一個 Hashtable 的記憶體使用和佈局 

圖 8 顯示,建立一個 Hashtable 時,結果會是一個佔用了 40 位元組的記憶體的 Hashtable 物件,另有一個預設容量為 11 個條目的 Hashtable$entry 陣列,在 Hashtable 為空時,總大小為 104 位元組。

Hashtable$Entry 儲存的資料實際上與 HashMap 相同:

  • int KeyHash
  • Object next
  • Object key
  • Object value

這意味著,對於 Hashtable 中的鍵/值條目,Hashtable$Entry 物件也是 32 位元組,而 Hashtable 開銷的計算和 10K 個條目的集合的大小(約為 360K)與 HashMap 類似。

表 4 顯示了 Hashtable 的屬性:


表 4. 一個 Hashtable 的屬性
預設容量 11 個條目
空時的大小 104 個位元組
開銷 56 位元組加上每個條目 36 位元組
一個 10K 集合的開銷 ~360K
搜尋/插入/刪除效能 O(1):所用時間是一個常量時間,無論要素數量如何都是如此(假設無雜湊衝突)

如您所見,Hashtable 的預設容量比 HashMap 要稍微小一些(分別是 11 與 16)。除此之外,兩者之間的主要差別在於 Hashtable 無法接受空鍵和空值,而且是預設同步的,但這可能是不必要的,還有可能降低集合的效能。

Java 集合:LinkedList

LinkedList 是 List 介面的連結串列實現。Java Platform SE 6 API 文件對於 LinkedList 的描述如下:

一種有序集合(也稱為序列)。此介面的使用者可以精確控制將各元素插入列表時的位置。使用者可以按照整數索引(代表在列表中的位置)來訪問元素,也可以搜尋列表中的元素。與其他集合 (set) 不同,該集合 (collection) 通常允許存在重複的元素。

實現是 LinkedList$Entry 物件連結串列。圖 9 展示了 32 位 Java 執行時中的 LinkedList 的記憶體使用和佈局:


圖 9. 32 位 Java 執行時中的一個 LinkedList 的記憶體使用和佈局
32 位 Java 執行時中的一個 LinkedList 的記憶體使用和佈局 

圖 9 表明,建立一個 LinkedList 時,結果將得到一個佔用 24 位元組記憶體的 LinkedList 物件以及一個LinkedList$Entry 物件,在 LinkedList 為空時,總共佔用的記憶體是 48 個位元組。

連結串列的優勢之一就是能夠準確調整其大小,且無需重新調整。預設容量實際上就是一個條目,能夠在新增或刪除條目時動態擴大或縮小。每個 LinkedList$Entry 物件仍然有自己的開銷,其資料欄位如下:

  • Object previous
  • Object next
  • Object value

但這比 HashMap 和 Hashtable 的開銷低,因為連結串列僅儲存單獨一個條目,而非鍵/值對,由於不會使用基於陣列的查詢,因此不需要儲存雜湊值。從負面角度來看,在連結串列中查詢的速度要慢得多,因為連結串列必須依次遍歷才能找到需要查詢的正確條目。對於較大的連結串列,結果可能導致漫長的查詢時間。

表 5 顯示了 LinkedList 的屬性:


表 5. 一個 LinkedList 的屬性
預設容量 1 個條目
空時的大小 48 個位元組
開銷 24 位元組加上每個條目 24 位元組
一個 10K 集合的開銷 ~240K
搜尋/插入/刪除效能 O(n):所用時間與元素數量線性相關。

Java 集合:ArrayList

ArrayList 是 List 介面的可變長陣列實現。Java Platform SE 6 API 文件對於 ArrayList 的描述如下:

一種有序集合(也稱為序列)。此介面的使用者可以精確控制將各元素插入列表時的位置。使用者可以按照整數索引(代表在列表中的位置)來訪問元素,也可以搜尋列表中的元素。與其他集合 (set) 不同,該集合 (collection) 通常允許存在重複的元素。

不同於 LinkedListArrayList 是使用一個 Object 陣列實現的。圖 10 展示了一個 32 位 Java 執行時中的ArrayList 的記憶體使用和佈局:


圖 10. 32 位 Java 執行時中的一個 ArrayList 的記憶體使用和佈局
32 位 Java 執行時中的一個 ArrayList 的記憶體使用和佈局 

圖 10 表明,在建立 ArrayList 時,結果將得到一個佔用 32 位元組記憶體的 ArrayList 物件,以及一個預設大小為 10 的 Object 陣列,在 ArrayList 為空時,總計佔用的記憶體是 88 位元組。這意味著 ArrayList 無法準確調整大小,因此擁有一個預設容量,恰好是 10 個條目。

表 6 展示了一個 ArrayList 的屬性:


表 6. 一個 ArrayList 的屬性
預設容量 10
空時的大小 88 個位元組
開銷 48 位元組加上每個條目 4 位元組
一個 10K 集合的開銷 ~40K
搜尋/插入/刪除效能 O(n):所用時間與元素數量線性相關

其他型別的 “集合”

除了標準集合之外,StringBuffer 也可以視為集合,因為它管理字元資料,而且在結構和功能上與其他集合相似。Java Platform SE 6 API 文件對於 StringBuffer 的描述如下:

執行緒安全、可變的字元序列……每個字串緩衝區都有相應的容量。只要字串緩衝區內包含的字元序列的長度不超過容量,就不必分配新的內部緩衝區陣列。如果內部緩衝區溢位,則會自動為其擴大容量。

StringBuffer 是作為一個 char 陣列來實現的。圖 11 展示了一個 32 位 Java 執行時中的 StringBuffer 的記憶體使用和佈局:


圖 11. 32 位 Java 執行時中的一個 StringBuffer 的記憶體使用和佈局
32 位 Java 執行時中的一個 StringBuffer 的記憶體使用和佈局 

圖 11 展示,建立一個 StringBuffer 時,結果將得到一個佔用 24 位元組記憶體的 StringBuffer 物件,以及一個預設大小為 16 的字元陣列,在 StringBuffer 為空時,資料總大小為 72 位元組。

與集合相似,StringBuffer 擁有預設容量和重新調整大小的機制。表 7 顯示了 StringBuffer 的屬性:


表 7. 一個 StringBuffer 的屬性
預設容量 16
空時的大小 72 個位元組
開銷 24 個位元組
一個 10K 集合的開銷 24 個位元組
搜尋/插入/刪除效能 不適用

集合中的空白空間

擁有給定數量物件的各種集合的開銷並不是記憶體開銷的全部。前文的示例中的度量假設集合已經得到了準確的大小調整。然而,對於大多數集合來說,這種假設都是不成立的。大多數集合在建立時都指定給定的初始容量,資料將置入集合之中。這也就是說,集合擁有的容量往往大於集合中儲存的資料容量,這造成了額外的開銷。

考慮一個 StringBuffer 的示例。其預設容量是 16 個字元條目,大小為 72 位元組。初始情況下,72 個位元組中未儲存任何資料。如果您在字元陣列中儲存了一些字元,例如 "MY STRING" ,那麼也就是在 16 個字元的陣列中儲存了 9 個字元。圖 12 展示了 32 位 Java 執行時中的一個包含 "MY STRING" 的StringBuffer 的記憶體使用和佈局:


圖 12. 32 位 Java 執行時中的一個包含 "MY STRING" 的 StringBuffer 的記憶體使用
32 位執行時中的一個包含 'MY STRING' 的 StringBuffer 的記憶體使用 

如 圖 12 所示,陣列中有 7 個可用的字元條目未被使用,但佔用了記憶體,在本例中,這造成了 112 位元組的額外開銷。對於這個集合,您在 16 的容量中儲存了 9 個條目,因而填充率 為 0.56。集合的填充率越低,因多餘容量而造成的開銷就越高。

集合的擴充套件和重新調整

在集合達到容量限制時,如果出現了在集合中儲存額外條目的請求,那麼會重新調整集合,並擴充套件它以容納新條目。這將增加容量,但往往會降低填充比,造成更高的記憶體開銷。

各集合所用的擴充套件演算法各有不同,但一種通用的做法就是將集合的容量加倍。這也是 StringBuffer 採用的方法。對於前文示例中的 StringBuffer,如果您希望將 " OF TEXT" 新增到緩衝區中,生成 "MY STRING OF TEXT",則需要擴充套件集合,因為新的字符集合擁有 17 個條目,當前容量 16 無法滿足其要求。圖 13 展示了所得到的記憶體使用:


圖 13. 32 位 Java 執行時中的一個包含 "MY STRING OF TEXT" 的 StringBuffer 的記憶體使用
32 位執行時中的一個包含 'MY STRING OF TEXT' 的 StringBuffer 的記憶體使用 

現在,如 圖 13 所示,您得到了一個 32 個條目的字元陣列,但僅僅使用了 17 個條目,填充率為 0.53。填充率並未顯著下滑,但您現在需要為多餘的容量付出 240 位元組的開銷。

對於小字串和集合,低填充率和多餘容量的開銷可能並不會被視為嚴重問題,而在大小增加時,這樣的問題就會愈加明顯,代價也就愈加高昂。例如,如果您建立了一個 StringBuffer,其中僅包含 16MB 的資料,那麼(在預設情況下)它將使用大小設定為可容納 32MB 資料的字元陣列,這造成了以多餘容量形式存在的 16MB 的額外開銷。

Java 集合:彙總

表 8 彙總了集合的屬性:


表 8. 集合屬性彙總
集合 效能 預設容量 空時的大小 10K 條目的開銷 準確設定大小? 擴充套件演算法
HashSet O(1) 16 144 360K x2
HashMap O(1) 16 128 360K x2
Hashtable O(1) 11 104 360K x2+1
LinkedList O(n) 1 48 240K +1
ArrayList O(n) 10 88 40K x1.5
StringBuffer O(1) 16 72 24 x2

Hash 集合的效能比任何 List 的效能都要高,但每條目的成本也要更高。由於訪問效能方面的原因,如果您正在建立大集合(例如,用於實現快取),那麼最好使用基於 Hash 的集合,而不必考慮額外的開銷。

對於並不那麼注重訪問效能的較小集合而言,List 則是合理的選擇。ArrayList 和 LinkedList 集合的效能大體相同,但其記憶體佔用完全不同:ArrayList 的每條目大小要比 LinkedList 小得多,但它不是準確設定大小的。List 要使用的正確實現是 ArrayList 還是 LinkedList 取決於 List 長度的可預測性。如果長度未知,那麼正確的選擇可能是 LinkedList,因為集合包含的空白空間更少。如果大小已知,那麼ArrayList 的記憶體開銷會更低一些。

選擇正確的集合型別使您能夠在集合效能與記憶體佔用之間達到合理的平衡。除此之外,您可以通過正確調整集合大小來最大化填充率、最小化未得到利用的空間,從而最大限度地減少記憶體佔用。

集合的實際應用:PlantsByWebSphere 和 WebSphere Application Server Version 7

在 表 8 中,建立一個包含 10,000 個條目、基於 Hash 的集合的開銷是 360K。考慮到,複雜的 Java 應用程式常常使用大小為數 GB 的 Java 堆執行,因此這樣的開銷看起來並不是非常高,當然,除非使用了大量集合。

表 9 展示了在包含五個使用者的負載測試中執行 WebSphere® Application Server Version 7 提供的 PlantsByWebSphere 樣例應用程式時,Java 堆使用的 206MB 中的集合物件使用量:


表 9. WebSphere Application Server v7 中的 PlantsByWebSphere 的集合使用量
集合型別 例項數量 集合總開銷 (MB)
Hashtable 262,234 26.5
WeakHashMap 19,562 12.6
HashMap 10,600 2.3
ArrayList 9,530 0.3
HashSet 1,551 1.0
Vector 1,271 0.04
LinkedList 1,148 0.1
TreeMap 299 0.03
總計 306,195 42.9

通過 表 9 可以看到,這裡使用了超過 30 萬個不同的集合,而且僅集合本身(不考慮其中包含的資料)就佔用了 206MB 的 Java 堆用量中的 42.9MB(21%)。這就意味著,如果您能更改集合型別,或者確保集合的大小更加準確,那麼就有可能實現可觀的記憶體節約。

通過 Memory Analyzer 查詢低填充率

IBM Java 監控和診斷工具(Memory Analyzer 工具是在 IBM Support Assistant 中提供的)可以分析 Java 集合的記憶體使用情況(請參閱 參考資料 部分)。其功能包括分析集合的填充率和大小。您可以使用這樣的分析來識別需要優化的集合。

Memory Analyzer 中的集合分析位於 Open Query Browser -> Java Collections 選單中,如圖 14 所示:


圖 14. 在 Memory Analyzer 中分析 Java 集合的填充率
在 Memory Analyzer 中分析 Java 集合的填充率 

在判斷當前大小超出需要的大小的集合時,圖 14 中選擇的 Collection Fill Ratio 查詢是最有用的。您可以為該查詢指定多種選項,這些選項包括:

  • 物件:您關注的物件型別(集合)
  • 分段:用於分組物件的填充率範圍

將物件選項設定為 "java.util.Hashtable"、將分段選項設定為 "10",之後執行查詢將得到如圖 15 所示的輸出結果:


圖 15. 在 Memory Analyzer 中對 Hashtable 的填充率分析
在 Memory Analyzer 中對 Hashtable 的分析 

圖 15 表明,在 java.util.Hashtable 的 262,234 個例項中,有 127,016 (48.4%) 的例項完全未空,幾乎所有例項都僅包含少量條目。

隨後便可識別這些集合,方法是選擇結果表中的一行,右鍵單擊並選擇 list objects -> with incoming references,檢視哪些物件擁有這些集合,或者選擇 list objects -> with outgoing references,檢視這些集合中包含哪些條目。圖 16 展示了檢視對於空 Hashtable 的傳入引用的結果,圖中展開了一些條目:


圖 16. 在 Memory Analyzer 中對於空 Hashtable 的傳入引用的分析 
在 Memory Analyzer 中對於空 Hashtable 的傳入引用的分析 

圖 16 表明,某些空 Hashtable 歸 javax.management.remote.rmi.NoCallStackClassLoader 程式碼所有。

通過檢視 Memory Analyzer 左側皮膚中的 Attributes 檢視,您就可以看到有關 Hashtable 本身的具體細節,如圖 17 所示:


圖 17. 在 Memory Analyzer 中檢查空 Hashtable 
在 Memory Analyzer 中檢查孔 Hashtable 

圖 17 表明,Hashtable 的大小為 11(預設大小),而且完全是空的。

對於 javax.management.remote.rmi.NoCallStackClassLoader 程式碼,可以通過以下方法來優化集合使用:

  • 延遲分配 Hashtable:如果 Hashtable 為空是經常發生的普遍現象,那麼僅在存在需要儲存的資料時分配 Hashtable 應該是一種合理的做法。
  • 將 Hashtable 分配為準確的大小:由於使用預設大小,因此完全可以使用更為準確的初始大小。

這些優化是否適用取決於程式碼的常用方式以及通常儲存的是哪些資料。

PlantsByWebSphere 示例中的空集合

表 10 展示了分析 PlantsByWebSphere 示例中的集合來確定哪些集合為空時的分析結果:


表 10. WebSphere Application Server v7 中 PlantsByWebSphere 的空集合使用量
集合型別 例項數量 空例項 空例項百分比
Hashtable 262,234 127,016 48.4
WeakHashMap 19,562 19,465 99.5
HashMap 10,600 7,599 71.7
ArrayList 9,530 4,588 48.1
HashSet 1,551 866 55.8
Vector 1,271 622 48.9
總計 304,748 160,156 52.6

表 10 表明,平均而言,超過 50% 的集合為空,也就是說通過優化集合使用能夠實現可觀的記憶體佔用節約。這種優化可以應用於應用程式的各個級別:應用於 PlantsByWebSphere 示例程式碼中、應用於 WebSphere Application Server 中,以及應用於 Java 集合類本身。

在 WebSphere Application Server 版本 7 與版本 8 之間,我們做出了一些努力來改進 Java 集合和中介軟體層的記憶體效率。舉例來說,java.util.WeahHashMap 例項的開銷中,有很大一部分比例源於其中包含用來處理弱引用的 java.lang.ref.ReferenceQueue 例項。圖 18 展示了 32 位 Java 執行時中的一個WeakHashMap 的記憶體佈局:


圖 18. 32 位 Java 執行時中的一個 WeakHashMap 的記憶體佈局
32 位 Java 執行時中的一個 WeakHashMap 的記憶體佈局 

圖 18 表明,ReferenceQueue 物件負責保留佔用 560 位元組的資料,即便在 WeakHashMap 為空、不需要ReferenceQueue 的情況下也是如此。對於 PlantsByWebSphere 示例來說,在空 WeakHashMap 的數量為 19,465 的情況下,ReferenceQueue 物件將額外增加 10.9MB 的非必要資料。在 WebSphere Application Server 版本 8 和 IBM Java 執行時的 Java 7 釋出版中,WeakHashMap 得到了一定的優化:它包含一個ReferenceQueue,這又包含一個 Reference 物件陣列。該陣列已經更改為延遲分配,也就是說,僅在向ReferenceQueue 新增了物件的情況下執行分配。

結束語

在任何給定應用程式中,都存在著數量龐大(或許達到驚人的程度)的集合,複雜應用程式中的集合數量可能會更多。使用大量集合往往能夠提供通過選擇正確的集合、正確地調整其大小(或許還能通過延遲分配集合)來實現有時極其可觀的記憶體佔用節約的範圍。這些決策最好在設計和開發的過程中制定,但您也可以利用 Memory Analyzer 工具來分析現有應用程式中存在記憶體佔用優化潛力的部分。


http://www.ibm.com/developerworks/cn/java/j-codetoheap/

相關文章