作者:米哈伊爾·沃龍佐夫
為什麼要減少內存佔用
本文將為您提供有關 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,boolean | 1個字節 |
|---|---|
| short,char | 2個字節 |
| integer,float | 4個字節 |
| long,double | 8個字節 |
本質上,這些信息對於 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, Boolean | 16 bytes |
|---|---|
| Short, Character | 16 bytes |
| Integer, Float | 16 bytes |
| Long, Double | 24 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 面試的推薦準備書之一。