5
头图

作者:米哈伊尔·沃龙佐夫

为什么要减少内存占用

本文将为您提供有关 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 面试的推荐准备书之一。


Yujiaao
12.7k 声望4.7k 粉丝

[链接]