乞丐是如何節約Java記憶體的

Yujiaao發表於2022-04-30

作者:米哈伊爾·沃龍佐夫

為什麼要減少記憶體佔用

本文將為您提供有關 Java 記憶體消耗優化的一般建議。

記憶體使用優化在 Java 中很重要。系統效能主要限於記憶體訪問效能而非 CPU 主頻,否則,為什麼 CPU 生產商要實現所有這些 L1、L2 和 L3 快取?這意味著通過減少應用程式記憶體佔用,您很可能會通過讓 CPU 等待更少量的資料來提高程式資料處理速度。即:節省記憶體會提高效能!

Java 記憶體佈局方式

我們先複習一下小學二年級學的 Java 物件的記憶體佈局:任何 Java Object佔用至少 16 個位元組,其中 12 個位元組被 Java 物件頭佔用。除此之外,所有 Java 物件都按 8 位元組邊界對齊。這意味著,一個包含 2 個欄位:int 和 byte的物件:將佔用 17 個位元組(12 + 4 + 1),而不是 24 個位元組(17 個由 8 個位元組對齊)。

如果 Java 堆在 32G 以下且開啟選項 XX:+UseCompressedOops(從JDK6_u23開始UseCompressedOops 被預設開啟了),則每個Object引用佔用 4 個位元組。否則,Object引用佔用 8 個位元組。

所有原始資料型別都佔用其確切的位元組大小:

byte,boolean1個位元組
short,char2個位元組
integer,float4個位元組
long,double8個位元組

本質上,這些資訊對於 Java 記憶體優化來說已經足夠了。但是如果您知道 Array/String 數字包裝器的記憶體消耗 ,那將會更方便。

最常見的 Java 型別記憶體消耗

陣列消耗 12 個位元組加上它們的長度乘以它們的元素大小(當然,還有 8 個位元組對齊的額外佔用)。

從 Java 7 build 06 開始, String,包含 3 個欄位 - 一個char[]帶有字串資料的int欄位加上 2 個帶有 2 個由不同演算法計算的雜湊碼的欄位。這意味著 String 本身需要 12 (header) + 4 ( char[]reference) + 4 2 (int) = 24 位元組(如您所見,它完全適合 8 位元組對齊)。除此之外,char[]帶有String資料佔用 12 + 長度 2 個位元組(加上對齊)。這意味著 String 佔用 36 + length*2 位元組對齊 8 個位元組(順便說一下,這比Java 7 build 06 String之前的記憶體消耗少 8 個位元組)。

數字包裝佔用 12 個位元組加上基礎型別的大小。Byte/Short/Character/Integer/Long 由 JDK 快取,因此對於 -128~127 範圍內的值,實際記憶體消耗可能會更小。無論如何,這些型別可能是基於集合的應用程式中嚴重記憶體開銷的來源:

Byte, Boolean16 bytes
Short, Character16 bytes
Integer, Float16 bytes
Long, Double24 bytes

一般 Java 記憶體優化技巧

掌握了所有這些知識,不難給出一般的 Java 記憶體優化技巧:

  • 優選原始型別而不是它們的 Object 包裝器。使用包裝器型別的主要原因是 JDK Collections,因此請考慮使用像 Trove 這樣的原始型別集合框架之一。
  • 控制您擁有的 Object 數量。例如,優先考慮基於陣列的結構,而不是基於指標的結構,如: ArrayList/ArrayDeque/LinkedList

Java記憶體優化示例

這是一個例子。假設您必須建立一個從 int 到 20 個字元長的字串的對映。此對映的大小等於一百萬,並且所有對映都是靜態的和預定義的(例如,儲存在某些字典中)。

第一種方法是使用 Map<Integer, String> 標準 JDK 中的一個。我們粗略估計一下這個結構的記憶體消耗。每個Integer佔用 16 個位元組加上 4 個位元組用於Integer對映的引用。每 20 個字元長String佔用 36 + 20*2 = 76 個位元組(見上文String描述),對齊到 80 個位元組。加上 4 個位元組作為參考。總記憶體消耗大約為(16 + 4 + 80 + 4) * 1M = 104M

更好的方法是用 String 字串包裝第 1 部分UTF-8 編碼用 byte[]替換(參見將字元轉換為位元組文章)。我們的 Map 將是Map<Integer, byte[]>. 假設所有字串字元都屬於 ASCII 集 (0-127),這在大多數英語國家都是如此。byte[20]佔用 12 (header) + 20*1 = 32 位元組,方便地適合 8 位元組對齊。整個 Map 現在將佔據(16 + 4 + 32 + 4) * 1M = 56M,比上一個示例少 1 半。

現在讓我們使用Trove TIntObjectMap<byte[]>。int[] 與 JDK 集合中的包裝器型別相比,它正常儲存鍵值。現在每個鍵將佔用 4 個位元組。總記憶體消耗將下降到(4 + 32 + 4) * 1M = 40M

最終的結構會更復雜。所有String值將byte[]一個接一個地儲存(我們仍然假設我們有一個基於文字的 ASCII 字串),中間用一個位元組0作分隔符。整體byte[]將佔據 (20 + 1) * 1M = 21M。我們的 Map 將儲存字串的偏移量,byte[]而不是字串本身。為此目的我們將使用 Trove 的 TIntIntMap。它將消耗 (4 + 4) * 1M = 8M。此示例中的總記憶體消耗將為8M + 21M = 29M。順便說一句,這是第一個依賴該資料集不變性的示例。

我們能取得更好的結果嗎?是的,我們可以,但代價是 CPU 消耗。顯而易見的“優化”是在將值儲存到一個大的byte[]. 現在我們可以將鍵值儲存在中int[]並使用二分搜尋來查詢鍵值。如果找到一個鍵,它的索引乘以 21(請記住,所有字串都具有相同的長度)將為我們提供一個值在byte[]. 與雜湊對映情況下的查詢相比,此結構“僅”佔用21M + 4M(對於int[])= 25M,其代價是查詢複雜度從O(1) 變成 O(log N)。

這是我們能做的最好的嗎?不!我們忘記了所有值都是 20 個字元長,所以我們實際上不需要byte[]之間的分隔符. 這意味著如果我們同意以O( log N )進行查詢,我們可以使用24M 記憶體來儲存我們的“Map”。與理論資料大小相比,完全沒有開銷,並且比原始解決方案( Map<Integer, String> )所需的量少了近 4.5 倍!誰告訴你 Java 程式很耗記憶體?

總結

優先考慮原始型別而不是它們的 Object 包裝器。使用包裝器型別的主要原因是 JDK 集合,因此請考慮使用像 Trove 這樣的原始型別集合框架之一。

儘量減少您擁有的 Object 數量。例如,偏向基於陣列的結構,而不是基於指標的結構,如. ArrayList/ArrayDeque/LinkedList

推薦閱讀

如果您想了解更多關於聰明的資料壓縮演算法的資訊,值得閱讀 Jon Bentley 的“Programming Pearls”(第二版)。這是一個非常出人意料的演算法的精彩集合。例如,在第 13.8 節中,作者描述了 Doug McIlroy 如何設法在 64 KB 的 RAM 中安裝一個 75,000 字的拼寫檢查器。那個拼寫檢查器把所有需要的資訊都儲存在這麼小的記憶體中,而且不使用磁碟!可能還需要注意的是,《Programming Pearls》是 Google SRE 面試的推薦準備書之一。

相關文章