從Java代碼到Java堆理解和優化您的應用程序的內存使用
int
值置入一個Integer
對象的內存開銷、對象委託的成本和不同集合類型的內存效率。您將瞭解到如何確定應用程序中的哪些位置效率低下,以及如何選擇正確的集合來改進您的代碼。
優化應用程序代碼的內存使用並不是一個新主題,但是人們通常並沒有很好地理解這個主題。本文將簡要介紹 Java 進程的內存使用,隨後深入探討您編寫的 Java 代碼的內存使用。最後,本文將展示提高代碼內存效率的方法,特別強調了HashMap
和
ArrayList
等 Java 集合的使用。
架構提供的內存尋址能力依賴於處理器的位數,舉例來說,32 位或者 64 位,對於大型機來說,還有 31 位。進程能夠處理的位數決定了處理器能尋址的內存範圍:32 位提供了 2^32 的可尋址範圍,也就是 4,294,967,296 位,或者說 4GB。而 64 位處理器的可尋址範圍明顯增大:2^64,也就是 18,446,744,073,709,551,616,或者說
16 exabyte(百億億字節)。通過在命令行中執行 java
或者啓動某種基於 Java 的中間件來運行 Java 應用程序時,Java 運行時會創建一個操作系統進程,就像您運行基於 C 的程序時那樣。實際上,大多數 JVM 都是用 C 或者 C++ 語言編寫的。作爲操作系統進程,Java 運行時面臨着與其他進程完全相同的內存限制:架構提供的尋址能力以及操作系統提供的用戶空間。
處理器架構提供的部分可尋址範圍由 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 進程的內存佈局示例
在 圖 1 中,可尋址範圍總共有 4GB,OS 和 C 運行時大約佔用了其中的 1GB,Java 堆佔用了將近 2GB,本機堆佔用了其他部分。請注意,JVM 本身也要佔用內存,就像 OS 內核和 C 運行時一樣,而 JVB 佔用的內存是本機堆的子集。
在您的 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
對象的佈局示例
如 圖 2 所示,有 128 位的數據用於存儲int
值內的 32 位數據,而對象元數據佔用了其餘 128 位。
數組對象(例如一個 int
值數組)的形狀和結構與標準 Java 對象相似。主要差別在於數組對象包含說明數組大小的額外元數據。因此,數據對象的元數據包括:
-
類:一個指向類信息的指針,描述了對象類型。舉例來說,對於
int
字段數組,這是int[]
類的一個指針。 - 標記:一組標記,描述了對象的狀態,包括對象的散列碼(如果有),以及對象的形狀(也就是說,對象是否是數組)。
- 鎖:對象的同步信息,也就是說,對象目前是否正在同步。
- 大小:數組的大小。
圖 3 展示了一個 int
數組對象的佈局示例:
圖 3. 一個 32 位 Java 進程的
int
數組對象的佈局示例
如 圖 3 所示,有 160 位的數據用於存儲int
值內的 32 位數據,而數組元數據佔用了其餘 160 位。對於byte
、int
和long
等原語,從內存的方面考慮,單項數組比對應的針對單一字段的包裝器對象(Byte
、Integer
或Long
)的成本更高。
良好的面向對象設計與編程鼓勵使用封裝(提供接口類來控制數據訪問)和委託(使用 helper 對象來實施任務)。封裝和委託會使大多數數據結構的表示形式中包含多個對象。一個簡單的示例就是java.lang.String
對象。java.lang.String
對象中的數據是一個字符數組,由管理和控制對字符數組的訪問的java.lang.String
對象封裝。圖 4 展示了一個 32 位 Java 進程的java.lang.String
對象的佈局示例:
圖 4. 一個 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 位 Java 進程。在 背景信息:Java 進程的內存使用
一節中提到,64 位處理器的內存可尋址能力比 32 位處理器高得多。對於 64 位進程,Java 對象中的某些數據字段的大小(特別是對象元數據或者表示另一個對象的任何字段)也需要增加到 64 位。其他數據字段類型(例如int
、byte
和long
)的大小不會更改。圖 5 展示了一個 64 位 Integer
對象和一個
int
數組的佈局:
圖 5. 一個 64 位進程的
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 位。
IBM 和 Oracle JVM 分別通過壓縮引用 (-Xcompressedrefs
) 和壓縮 OOP (-XX:+UseCompressedOops
) 選項提供對象引用壓縮功能。利用這些選項,即可在 32 位(而非 64 位)中存儲對象字段和對象元數據值。在應用程序從
32 位 Java 運行時遷移到 64 位 Java 運行時的時候,這能消除 Java 堆內存使用量增加 70% 的負面影響。請注意,這些選項對於本機堆的內存使用無效,本機堆在 64 位 Java 運行時中的內存使用量仍然比 32 位 Java 運行時中的使用量高得多。
在大多數應用程序中,大量數據都是使用核心 Java API 提供的標準 Java Collections 類來存儲和管理的。如果內存佔用對於您的應用程序極爲重要,那麼就非常有必要了解各集合提供的功能以及相關的內存開銷。總體而言,集合功能的級別越高,內存開銷就越高,因此使用提供的功能多於您需要的功能的集合類型會帶來不必要的額外內存開銷。
其中部分最常用的集合如下:
除了 HashSet
之外,此列表是按功能和內存開銷進行降序排列的。(HashSet
是包圍一個HashMap
對象的包裝器,它提供的功能比HashMap
少,同時容量稍微小一些。)
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
的內存使用和佈局
圖 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):所用時間是一個常量時間,無論要素數量如何都是如此(假設無散列衝突) |
HashMap
是Map
接口的實現。Java Platform SE 6 API 文檔對於HashMap
的描述如下:
一個將鍵映射到值的對象。一個映射中不能包含重複的鍵;每個鍵僅可映射到至多一個值。
HashMap
提供了一種存儲鍵/值對的方法,使用散列函數將鍵轉換爲存儲鍵/值對的集合中的索引。這允許快速訪問數據位置。允許存在空條目和重複條目;因此,HashMap
是HashSet
的簡化版。
HashMap
將實現爲一個HashMap$Entry
對象數組。圖 7 展示了 32 位 Java 運行時中的一個HashMap
的內存使用和佈局:
圖 7. 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
來說,僅僅 HashMap
、HashMap$Entry
數組和HashMap$Entry
對象的開銷就在 360K 左右。這還沒有考慮所存儲的鍵和值的大小。
表 3 展示了 HashMap
的屬性:
表 3. 一個
HashMap
的屬性默認容量 | 16 個條目 |
---|---|
空時的大小 | 128 個字節 |
開銷 | 64 字節加上每個條目 36 字節 |
一個 10K 集合的開銷 | ~360K |
搜索/插入/刪除性能 | O(1):所用時間是一個常量時間,無論要素數量如何都是如此(假設無散列衝突) |
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
的內存使用和佈局
圖 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
無法接受空鍵和空值,而且是默認同步的,但這可能是不必要的,還有可能降低集合的性能。
LinkedList
是List
接口的鏈表實現。Java Platform SE 6 API 文檔對於LinkedList
的描述如下:
一種有序集合(也稱爲序列)。此接口的用戶可以精確控制將各元素插入列表時的位置。用戶可以按照整數索引(代表在列表中的位置)來訪問元素,也可以搜索列表中的元素。與其他集合 (set) 不同,該集合 (collection) 通常允許存在重複的元素。
實現是 LinkedList$Entry
對象鏈表。圖 9 展示了 32 位 Java 運行時中的LinkedList
的內存使用和佈局:
圖 9. 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):所用時間與元素數量線性相關。 |
ArrayList
是List
接口的可變長數組實現。Java Platform SE 6 API 文檔對於ArrayList
的描述如下:
一種有序集合(也稱爲序列)。此接口的用戶可以精確控制將各元素插入列表時的位置。用戶可以按照整數索引(代表在列表中的位置)來訪問元素,也可以搜索列表中的元素。與其他集合 (set) 不同,該集合 (collection) 通常允許存在重複的元素。
不同於 LinkedList
,ArrayList
是使用一個Object
數組實現的。圖 10 展示了一個 32 位 Java 運行時中的ArrayList
的內存使用和佈局:
圖 10. 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
的內存使用和佈局
圖 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
的內存使用如 圖 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
的內存使用現在,如 圖 13 所示,您得到了一個 32 個條目的字符數組,但僅僅使用了 17 個條目,填充率爲 0.53。填充率並未顯著下滑,但您現在需要爲多餘的容量付出 240 字節的開銷。
對於小字符串和集合,低填充率和多餘容量的開銷可能並不會被視爲嚴重問題,而在大小增加時,這樣的問題就會愈加明顯,代價也就愈加高昂。例如,如果您創建了一個StringBuffer
,其中僅包含 16MB 的數據,那麼(在默認情況下)它將使用大小設置爲可容納 32MB 數據的字符數組,這造成了以多餘容量形式存在的 16MB 的額外開銷。
表 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%)。這就意味着,如果您能更改集合類型,或者確保集合的大小更加準確,那麼就有可能實現可觀的內存節約。
IBM Java 監控和診斷工具(Memory Analyzer 工具是在 IBM Support Assistant 中提供的)可以分析 Java 集合的內存使用情況(請參閱參考資料 部分)。其功能包括分析集合的填充率和大小。您可以使用這樣的分析來識別需要優化的集合。
Memory Analyzer 中的集合分析位於 Open Query Browser -> Java Collections 菜單中,如圖 14 所示:
圖 14. 在 Memory Analyzer 中分析 Java 集合的填充率
在判斷當前大小超出需要的大小的集合時,圖 14 中選擇的 Collection Fill Ratio 查詢是最有用的。您可以爲該查詢指定多種選項,這些選項包括:
- 對象:您關注的對象類型(集合)
- 分段:用於分組對象的填充率範圍
將對象選項設置爲 "java.util.Hashtable"、將分段選項設置爲 "10",之後運行查詢將得到如圖 15 所示的輸出結果:
圖 15. 在 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
的傳入引用的分析
圖 16 表明,某些空
Hashtable
歸 javax.management.remote.rmi.NoCallStackClassLoader
代碼所有。
通過查看 Memory Analyzer 左側面板中的 Attributes 視圖,您就可以看到有關Hashtable
本身的具體細節,如圖 17 所示:
圖 17. 在 Memory Analyzer 中檢查空
Hashtable
圖 17 表明,Hashtable
的大小爲 11(默認大小),而且完全是空的。
對於 javax.management.remote.rmi.NoCallStackClassLoader
代碼,可以通過以下方法來優化集合使用:
-
延遲分配
Hashtable
:如果Hashtable
爲空是經常發生的普遍現象,那麼僅在存在需要存儲的數據時分配Hashtable
應該是一種合理的做法。 -
將
Hashtable
分配爲準確的大小:由於使用默認大小,因此完全可以使用更爲準確的初始大小。
這些優化是否適用取決於代碼的常用方式以及通常存儲的是哪些數據。
表 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
的內存佈局
圖 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 工具來分析現有應用程序中存在內存佔用優化潛力的部分。