從 Java 程式碼到 Java 堆
優化應用程式程式碼的記憶體使用並不是一個新主題,但是人們通常並沒有很好地理解這個主題。本文將簡要介紹 Java 程式的記憶體使用,隨後深入探討您編寫的 Java 程式碼的記憶體使用。最後,本文將展示提高程式碼記憶體效率的方法,特別強調了 HashMap
和 ArrayList
等 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 程式的記憶體佈局示例
在 圖 1 中,可定址範圍總共有 4GB,OS 和 C 執行時大約佔用了其中的 1GB,Java 堆佔用了將近 2GB,本機堆佔用了其他部分。請注意,JVM 本身也要佔用記憶體,就像 OS 核心和 C 執行時一樣,而 JVM 佔用的記憶體是本機堆的子集。
在您的 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 位,而物件後設資料佔用了其餘的 96 位。
陣列物件(例如一個 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 工具來分析現有應用程式中存在記憶體佔用優化潛力的部分。
相關文章
- 從 Java 位元組碼到 ASM 實踐JavaASM
- 從Java到Kotlin(八)JavaKotlin
- 從Java到Kotlin(七)JavaKotlin
- java從菜鳥到碼神之路——運算子Java
- 從Java到Kotlin,然後又回到Java!JavaKotlin
- 效能分析之CPU分析-從CPU呼叫高到具體程式碼行(JAVA)Java
- 從 Java 到 Scala(四):TraitsJavaAI
- 從 Java 到 Scala(二):objectJavaObject
- 從 java 8到 java 11變化一覽Java
- java安全編碼指南之:堆汙染Heap pollutionJava
- Java程式從開發到執行經歷過程Java
- Java JVM——8.堆JavaJVM
- 從 Java 到 Kotlin - 介紹 KotlinJavaKotlin
- Java多執行緒-程式執行堆疊分析Java執行緒
- java 程式碼塊Java
- 從Java到JVM到OS執行緒睡眠JavaJVM執行緒
- Java ThreadLocal (Java程式碼實戰-006)Javathread
- Java ConcurrentHashMap (Java程式碼實戰-005)JavaHashMap
- Java ReEntrantLock (Java程式碼實戰-001)JavaReentrantLock
- Java AtomicBoolean (Java程式碼實戰-008)JavaBoolean
- 【資料結構】堆疊(順序棧、鏈棧)的JAVA程式碼實現資料結構Java
- [Inside HotSpot] Java分代堆IDEHotSpotJava
- java 從EXCEL匯入到系統JavaExcel
- JNI:Java程式碼呼叫原生程式碼Java
- Java程式碼建議Java
- Java NIO 程式碼示例Java
- java SPI 程式碼示例Java
- Java - 26 程式碼塊Java
- freemarker 生成 Java 程式碼Java
- 從 Java 到 Scala (三): object 的應用JavaObject
- Java集合從菜鳥到大神演變Java
- Java 從入門到精通-反射機制Java反射
- 漫畫 JAVA…… 從入門到入墳……Java
- java 從EXCEL匯入到資料庫JavaExcel資料庫
- 從JDK原始碼理解java引用JDK原始碼Java
- Java中棧和堆講解Java
- JAVA堆區棧區方法區Java
- java 堆外記憶體排查Java記憶體
- java高階用法之:在JNA中將本地方法對映到JAVA程式碼中Java