写点什么

java 内存篇:内存对象有多胖?

作者:curtis
  • 2024-04-05
    上海
  • 本文字数:3956 字

    阅读完需:约 13 分钟

引子


相比于初级开发人员,高级开发以及架构师往往会额外考虑更多的问题,其中最常见的两个点是:

  • 网络是不可靠的

  • 计算机的资源是有限的


如果要讲清楚以上问题,可能要写书才行- -,所以这里我只从一个门槛最低的资源问题着手——我们写的代码,在运行时占据了多少内存?


从宏观角度来说,我们可以通过压测大约得知——多少 QPS|TPS,可以打爆计算器的 CPU 和内存(这里不论代码的优劣),然后根据业务目标、业务预测等运营指标,计算出需要怎样规格的服务器等等。


然而,从微观角度,作为一个开发人员,我们首先要知道,我们写的一段代码,会占据多少内存。有了这些认知,至少不会写出特别糟糕的代码,导致内存的紧张。


诚然,你的业务并发也许不高,你的服务器足够强劲,然而墨菲定律告诉我们——实际情况往往是怕什么来什么——当因为 OOM 和频繁 GC 而导致系统不可用的时候,我们是否有信心地说【我们的代码的内存成本已经足够小了】?


当然,内存资源不足还有其他原因导致——没有做好限流、GC 的选型和配置不是最优等等。但是了解对象大小的确是一个开发人员需要掌握的知识,在软件设计阶段或者开发阶段,我们就可以大致预测系统的流量边界在什么地方。


java 基础数据类型


java 有 8 种基础数据类型,Java 虚拟机规范(JVM Specification)规定了基本数据类型的最小存储需求:

  1. byte: 1 字节

  2. short: 2 字节

  3. int: 4 字节

  4. long: 8 字节

  5. float: 4 字节

  6. double: 8 字节

  7. char: 2 字节

  8. boolean:1 字节


那么基础类型使用在什么地方呢?主要是以下两个场景:

  • 一段代码的局部变量

{    //这里的i被分配在栈内存,占据4个字节    int i = 1;    //其他代码......    //作用域结束,i被立刻释放}
复制代码
  • java 对象中的字段

class Integer{    //i随实例创建而分配到堆内存,占据4个字节    private int i;}
{ //obj分配在堆内存,obj不止4个字节,因为java对象本身还会占据一定的内存 Integer obj = new Integer(); //其他代码...... //作用域结束,obj被标记为垃圾,后续会被GC线程异步释放}
复制代码

java 对象


然而,大部分时候,我们创建都是 java 对象,它们才是消耗内存的大头,java 对象分配在堆内存。那么 java 对象占据多大的内存呢?从概念上来说,分为以下几个部分:

  • 对象头:存储的是对象头信息,用于存储关于对象的状态和标记信息,包括对象的锁状态、垃圾收集相关的信息等。了解即可,本文不做展开;

  • class 指针:存放一段指针,引用对象所属类的 Class 实例;

  • 数组长度:对于数组类型,存放数组的长度;

  • 对齐开销:由于内存申请存在最小单位,可能存在一些【无法使用】|【没有使用】的空余空间;

  • 基础类型字段:上述 8 中基础类型的字段对应的值;

  • 引用类型字段:存放一段指针,引用其他对象;


这里我们使用一个开源的代码库来帮助我们计算 java 对象的内存大小,它就是 jol(这里先说明一点,jol 在统计对象大小时,并不会统计其引用的对象,只包含引用对象的地址指针)。

maven:

<dependency>    <groupId>org.openjdk.jol</groupId>    <artifactId>jol-core</artifactId>    <version>0.17</version></dependency>
复制代码

gradle:

dependencies {    dependency "org.openjdk.jol:jol-core:0.17"}
复制代码

可访问https://mvnrepository.com/artifact/org.openjdk.jol/jol-core获取最新版本。

Integer 对象的大小


以下示例代码用获取一个 Integer 实例的大小

{        Integer intObj = 0;
ClassLayout intLayout = ClassLayout.parseInstance(intObj); System.out.println(intLayout.toPrintable());}
//输出OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000002a2cdbd301 (hash: 0x2a2cdbd3; age: 0) 8 4 (object header: class) 0x000492a0 12 4 int Integer.value 0Instance size: 16 bytesSpace losses: 0 bytes internal + 0 bytes external = 0 bytes total
复制代码
  • 其中[0,7]字节(OFF 表示起点,SZ 表示长度,所以范围是[0,7],下同),也就是前 8 个字节;

  • [8,11],这 4 个字节是 Class 指针,这里它指向 java.lang.Integer 这个 Class 实例;

  • [12,15],这 4 个字节就是 int 类型的 value 字段所占的大小了;

因此 Integer 对象占据了 16 个字节。

Long 对象的大小


同样的方式,我们看看 Long 类型的大小:

{        Long longObj = 0L;
ClassLayout longLayout = ClassLayout.parseInstance(longObj); System.out.println(longLayout.toPrintable());}
//输出OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x000000075493c001 (hash: 0x075493c0; age: 0) 8 4 (object header: class) 0x0004b800 12 4 (alignment/padding gap) 16 8 long Long.value 0Instance size: 24 bytesSpace losses: 4 bytes internal + 0 bytes external = 4 bytes total
复制代码

以上,我们看到一点区别,最终显示 Long 的大小是 24 字节,而不是 8(对象头)+4(Class 指针)+8(long 的大小)=20 字节。其中差异就在第三行 12-15 这个区间:alignment/padding gap。


之所以会出现这段空间占用,是由于【对齐】。我们打个比方,如果一个 Java 对象所占的空间为一座联排的房子,那么其中的每个房子的大小为 8 字节(对于 32 位系统则是 4 字节),这样设计的目的是,提升 CPU 访问数据的速度。


以 Integer 为例,对象头(8 字节)、Class 指针(4 字节)和 int value(4 字节)刚好住满 2 个房子;

而对 Long 来说,对象头(8 字节)、Class 指针(4 字节)占据了 1 个半房子后(12 字节),long value(8 字节)不足以住下那半个房子,所以只能另开一个新房子(8 字节);


请注意,每次新申请空间,最小单位就是一个房子(8 字节)

Integer: |------对象头------|-class指针-|---int---|                8字节           4字节      4字节                      总计:16字节
Long: |------对象头------|-class指针-|---gap---|------long------| 8字节 4字节 4字节 8字节 总计:24字节
复制代码

多字段布局


对于一个有多个字段的类型,java 是否会智能调整内存布局以避免 gap 的出现?我们可以尝试一下:

先定义一个简单的 Class,拥有一个 long 和 int 字段。

    public class MyObject{        private long longVal;        private int intVal;    }
复制代码

那么其对象的内存布局是根据 Class 中字段的声明顺序(这样就会存在 gap)?还是说会通过算法分析后,给出一个更合理的顺序?我们来看代码:

{        final MyObject myObject = new MyObject();
ClassLayout myObjectLayout = ClassLayout.parseInstance(myObject); System.out.println(myObjectLayout.toPrintable());}
//输出OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0) 8 4 (object header: class) 0x00c01208 12 4 int MyObject.intVal 0 16 8 long MyObject.longVal 0Instance size: 24 bytesSpace losses: 0 bytes internal + 0 bytes external = 0 bytes total
复制代码

答案显然是后者。


引用类型的字段

我们已经知道了基础类型的大小,如果引用类型的字段有多大呢?

class IntegerWrapper{    private Integer intObj;}

复制代码

首先我们要明确一个概念,假设 IntegerWrapper 中有一个字段 Integer intObj,那么 jol 在统计 IntegerWrapper 实例时(下文称 wrapper),是不会统计 intObj 的大小的,对于 wrapper.intObj 这个字段来说,它只是存储了 intObj 的内存地址,真正的 intObj 的内存空间是另外一块地盘,与 wrapper 的地盘没有关系。


{        final IntegerWrapper wrapper = new IntegerWrapper();
ClassLayout wrapperLayout = ClassLayout.parseInstance(wrapper); System.out.println(wrapperLayout.toPrintable());}
//输出OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0) 8 4 (object header: class) 0x00c01208 12 4 java.lang.Integer IntegerWrapper.intObj nullInstance size: 16 bytesSpace losses: 0 bytes internal + 0 bytes external = 0 bytes total
复制代码

我们看到 intObj 字段的大小为 4 字节,可能有读者会问:"64 位 OS 的内存地址不应该是 8 个字节吗?为什么这里的地址值只用到了 4 个字节?"这是一个好问题,这里先说结论——Java 运用了一个叫做【指针压缩】的技术,可以使用一个 4 字节的值表示一个 8 字节的内存地址。


数组


最后说一下数组,数组是一个特殊的对象,除了上述的对象头、Class 指针之外,多出一个 int 类型字段表示数组的长度,另外就是数组实际占用的空间了。

数组类型不同,所占的空间也不同,本质上还是两类:基础类型和引用类型。

以一个长度为 10 的数组为例,int[10]占 40 字节,long[10]占 80 个字节;

如果是引用类型,无论 Integer[10]、Long[10]还是 Object[10],都是 40 个字节,因为数组里每个值只是存储了一个 4 字节的指针。

以下是 Object[10]的例子:

OFF  SZ               TYPE DESCRIPTION               VALUE  0   8                    (object header: mark)     0x0000000000000001 (non-biasable; age: 0)  8   4                    (object header: class)    0x00001550 12   4                    (array length)            10 16  40   java.lang.Object Object;.<elements>        N/AInstance size: 56 bytesSpace losses: 0 bytes internal + 0 bytes external = 0 bytes total
复制代码

总结

  • 基础类型


  • 对象



  • 数组



用户头像

curtis

关注

还未添加个人签名 2018-03-28 加入

还未添加个人简介

评论

发布
暂无评论
java内存篇:内存对象有多胖?_curtis_InfoQ写作社区