Java 对象大小:通过分析估计、测量和验证

2021-09-02 10:25:28 浏览数 (3130)

本篇文章,我们将学习如何估计所有可能的 Java 对象或原始数据类型(​Primitive​)。这些知识非常重要,尤其是 对于生产应用程序。你可能认为现在大多数服务器都有足够的内存来满足所有可能的应用程序需求。在某种程度而言你是对的——硬件,它相对于一个开发人员的薪水算是比较便宜。但是,仍然很容易满足非常消耗性的情况,例如:

  • 缓存特别是长字符串。
  • 结构有一个大数目的记录(例如,具有节点树从构建庞大的XML文件)。
  • 从数据库复制数据的任何结构。

在下一步中,我们开始估计从原始结构到更复杂结构的 Java 对象。

Java 原始数据类型

Java ​Primitives ​的大小是众所周知的,并且从包装盒中提供:

Java 原始大小

32 位和 64 位系统的最小内存字

32 位和 64 位内存字的最小大小分别为 8 和 16 字节。任何较小的长度都按 8 舍入。在计算过程中,我们将考虑这两种情况。

内存大小差异由于内存(字节大小)结构的性质,任何内存都是 8 的倍数,如果不是系统会自动添加额外的字节(但 32/64 系统的最小大小仍然是 8 和 16 字节)

内存示例

Java对象

Java 对象内部没有字段,根据规范,它只有称为header 的元数据。Header 包含两部分:标记词(​Mark Word​) 和 类指针(​class pointer​)。 


功能用途大小 32 位操作系统大小 64 位
标记词锁(同步)、垃圾收集器信息、哈希码(来自本机调用)4字节8 字节
类指针块指针,数组长度(如果对象是数组)4字节4字节
全部的
8 字节(0 字节偏移)16 字节(4 字节偏移)

以及它在 Java Memory 中的样子: Java 内存中的 Java 对象

Java 原始包装器

在 Java 中,除了基元和引用(最后一个是隐藏的)之外,一切都是对象。所以所有的包装类只是包装相应的原始类型。所以包装器大小一般=对象头对象+内部原始字段大小+内存间隙。所有原始包装器的大小如下表所示:

类型内部原始尺寸标题 32 位标题 64 位总大小 32 位总大小 64 位总大小 32 位,带间隙总大小 64 位,带间隙
Byte18129131616
Boolean18129131616
Int481212161616
Float481212161616
Short281210141616
Char281210141616
Long881216201624
Double881216201624

Java数组

Java数组是非常相似的对象-他们也有不同的原始和对象值。该数组包含headers、数组长度及其单元格(到基元)或对其单元格的引用(对于对象)。为了方便大家清楚明白,我们绘制一个原始整数和大整数(包装器)的数组。

基元数组(在我们的例子中是整数)

原始数组(整数)

对象数组(在我们的例子中是位整数)

对象数组(位整数)

因此,你可以看到原始数组和对象数组之间的主要区别——带有引用的附加层。在这个例子中,大多数内存丢失的原因是使用一个整数包装器,它增加了 12 个额外的字节(比原始数据多 3 倍!)。

Java类

现在我们知道如何计算 Java Object、Java Primitive 和 Java Primitive Wrapper 和 Arrays。Java 中的任何类都不过是一个混合了所有提到的组件的对象:

  • 标头(32/64 位操作系统的 8 或 12 字节)。
  • 原始(类型字节取决于原始类型)。
  • 对象/类/数组(4 字节参考大小)。

Java字符串

Java string 它是类的一个很好的例子,所以除了 header 和 hash 之外,它还封装了 char 数组,所以对于长度为 500 的长字符串,我们有:

String
封装字符数组
header
8-12 字节(32/64 位操作系统)header8-12 字节(32/64 位操作系统)
hash4字节数组长度4字节
char[](参考)4字节500 个字符500 * 2 字节 = 1000 字节
字符串大小16 或 24 字节 总阵列大小16(考虑间隙)+ 1000 字节 = 1016 字节
总尺寸(16 or 24) + 1016 = 1032 or 1040 bytes (for 32 and 64 bit os)

但是我们要考虑到Java String 类有不同的实现,但一般来说,主要大小由char 数组保存。

如何以编程方式计算

使用运行时检查大小 freeMemory

最简单但不可靠的方法是比较内存初始化前后总内存和空闲内存的差异:

long beforeUsedMem=Runtime.getRuntime().totalMemory()-Runtime.getRuntime().freeMemory();
Object[] myObjArray = new Object[100_000];
long afterUsedMem=Runtime.getRuntime().totalMemory()-Runtime.getRuntime().freeMemory();

使用 Jol 库

最好的方法是使用Aleksey Shipilev 编写的Jol 库。这个解决方案会让您惊喜地发现我们可以轻松地调查任何对象/原语/数组。为此,您需要添加下一个 Maven 依赖项:

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.16</version>
</dependency>

并提供给 ClassLayout.parseInstance 你想要估计的任何内容:

int primitive = 3; // put here any class/object/primitive/array etc
System.out.println(VM.current().details());
System.out.println(ClassLayout.parseInstance(primitive).toPrintable());

作为输出,你将看到:

纯文本1

# Running 64-bit HotSpot VM.
# Using compressed oop with 0-bit shift.
# Using compressed klass with 3-bit shift.
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]

java.lang.Integer object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   4        (object header: class)    0x200021de
 12   4    int Integer.value             3
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

使用探查器

作为一种选择,怒可以使用分析器(​JProfiler​、​VM Visualizer​、​JConsole​ 等)来观察此结构或其他结构消耗了多少内存。但是这个解决方案是关于分析内存而不是对象结构。在下一段中,我们将使用 JProfiler 来确认我们的计算是正确的。

制作数据库缓存类并计算其大小

作为一个现实的例子,我们创建类来表示某个数据库表中的数据,其中包含 5 列和 1.000.000 条记录。 

public class UserCache{
    public static void main(String[] args){
        User [] cachedUsers = new User[1_000_000];
        while(true){}
    }
    private static class User{
        Long id;
        String name; //assume 36 characters long
        Integer salary;
        Double account;
        Boolean isActive;
    }
}

所以现在我们创建了 100 万用户,对吗?好吧,它在 User 类中的内容并不重要——我们刚刚创建了 1M 个引用。内存使用:1M * 4 字节 = 4000 KB 或 4MB。甚至没有开始,但支付了 4MB。

为 64 位系统分析 Java 内存

为了确认我们的计算,我们执行我们的代码并将​JProfile​附加到它。作为替代方案,你可以使用任何其他分析器,例如​VisualVM​(它是免费的)。。

为 64 位系统分析 Java 内存

提示:当你分析应用程序时,你可以不时运行 GC 以清理未使用的对象。所以分析的结果:我们有User[]4M 记录的参考点,大小为 4000KB。当我们分析:

用户[]参考

作为下一步,我们初始化对象并将它们添加到我们的数组中(名称是唯一的 UUID 36 长度大小):

for(int i = 0;i<1_000_000;i++){
    User tempUser = new User();
    tempUser.id = (long)i;
    tempUser.name = UUID.randomUUID().toString();
    tempUser.salary = (int)i;
    tempUser.account = (double) i;
    tempUser.isActive = Boolean.FALSE;
    cachedUsers[i] = tempUser;
}

现在让我们分析这个应用程序并确认我们的期望。你可能会提到某些值不精确,例如,字符串的大小为 24.224 而不是 24.000,但我们计算了所有字符串,包括内部 JVM 字符串以及与Boolean.FALSE对象相关的相同内容(估计为 16 字节,但在配置文件中,它显然Boolean.TRUE是32,因为也是JVM 内部使用)。

应用程序分析

对于 1M 记录,我们花费了 212MB,它只有 5 个字段,并且所有字符串长度都受 36 个字符的限制。正如你所看到的,对象非常贪婪。让我们改进 User 对象并用原语替换所有对象(字符串除外)。

用户[]对象分析

仅仅通过将字段更改为基元,我们就节省了 56MB(大约 25% 的已用内存)。但我们还通过删除用户和原语之间的额外引用来提高性能。 

如何减少内存消耗

让我们列出一些简单的方法来节省内存消耗:

压缩的 OOP

对于 64 位系统,你可以使用压缩的 oop 参数执行 JVM。这是一个相当大的主题,

将数据从子对象提取到父对象

如果设计允许将字段从子类移动到父类,则可能会节省一些内存:

将数据从子对象提取到父对象

带有原语的集合

从前面的例子中,我们看到了原语包装器是如何浪费大量内存的。原始数组不像 ​Java Collection​ 接口那样用户友好。但是还有一个替代方案:​Trove​、​FastUtils​、​Eclipse Collection​ 等。让我们比较 ​来自​Trove 库​的​simpleArrayList<Double>​和​TDoubleArrayListd内存使用情况。

TDoubleArrayList arrayList = new TDoubleArrayList(1_000_000);
List<Double> doubles = new ArrayList<>();
for (int i = 0; i < 1_000_000; i++) {
  arrayList.add(i);
  doubles.add((double) i);
}

通常,关键区别隐藏在 ​Double Primitive Wrapper​ 对象中,而不是 ​ArrayList ​或 ​TDoubleArrayList ​结构中。因此简化 1M 记录的差异:

ArrayList 与 TDoubleArrayList

JProfiler ​证实了这一点:

因此,只需更改集合,我们就可以轻松地将消耗减少 3 倍。


本文有关 Java 内存结构的基本内容就介绍到此结束了,感谢各位的阅读。