java 内存篇:内存对象有多胖?
引子
相比于初级开发人员,高级开发以及架构师往往会额外考虑更多的问题,其中最常见的两个点是:
网络是不可靠的
计算机的资源是有限的
如果要讲清楚以上问题,可能要写书才行- -,所以这里我只从一个门槛最低的资源问题着手——我们写的代码,在运行时占据了多少内存?
从宏观角度来说,我们可以通过压测大约得知——多少 QPS|TPS,可以打爆计算器的 CPU 和内存(这里不论代码的优劣),然后根据业务目标、业务预测等运营指标,计算出需要怎样规格的服务器等等。
然而,从微观角度,作为一个开发人员,我们首先要知道,我们写的一段代码,会占据多少内存。有了这些认知,至少不会写出特别糟糕的代码,导致内存的紧张。
诚然,你的业务并发也许不高,你的服务器足够强劲,然而墨菲定律告诉我们——实际情况往往是怕什么来什么——当因为 OOM 和频繁 GC 而导致系统不可用的时候,我们是否有信心地说【我们的代码的内存成本已经足够小了】?
当然,内存资源不足还有其他原因导致——没有做好限流、GC 的选型和配置不是最优等等。但是了解对象大小的确是一个开发人员需要掌握的知识,在软件设计阶段或者开发阶段,我们就可以大致预测系统的流量边界在什么地方。
java 基础数据类型
java 有 8 种基础数据类型,Java 虚拟机规范(JVM Specification)规定了基本数据类型的最小存储需求:
byte: 1 字节
short: 2 字节
int: 4 字节
long: 8 字节
float: 4 字节
double: 8 字节
char: 2 字节
boolean:1 字节
那么基础类型使用在什么地方呢?主要是以下两个场景:
一段代码的局部变量
java 对象中的字段
java 对象
然而,大部分时候,我们创建都是 java 对象,它们才是消耗内存的大头,java 对象分配在堆内存。那么 java 对象占据多大的内存呢?从概念上来说,分为以下几个部分:
对象头:存储的是对象头信息,用于存储关于对象的状态和标记信息,包括对象的锁状态、垃圾收集相关的信息等。了解即可,本文不做展开;
class 指针:存放一段指针,引用对象所属类的 Class 实例;
数组长度:对于数组类型,存放数组的长度;
对齐开销:由于内存申请存在最小单位,可能存在一些【无法使用】|【没有使用】的空余空间;
基础类型字段:上述 8 中基础类型的字段对应的值;
引用类型字段:存放一段指针,引用其他对象;
这里我们使用一个开源的代码库来帮助我们计算 java 对象的内存大小,它就是 jol(这里先说明一点,jol 在统计对象大小时,并不会统计其引用的对象,只包含引用对象的地址指针)。
maven:
gradle:
可访问https://mvnrepository.com/artifact/org.openjdk.jol/jol-core获取最新版本。
Integer 对象的大小
以下示例代码用获取一个 Integer 实例的大小
其中[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 的大小是 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 字节)
多字段布局
对于一个有多个字段的类型,java 是否会智能调整内存布局以避免 gap 的出现?我们可以尝试一下:
先定义一个简单的 Class,拥有一个 long 和 int 字段。
那么其对象的内存布局是根据 Class 中字段的声明顺序(这样就会存在 gap)?还是说会通过算法分析后,给出一个更合理的顺序?我们来看代码:
答案显然是后者。
引用类型的字段
我们已经知道了基础类型的大小,如果引用类型的字段有多大呢?
首先我们要明确一个概念,假设 IntegerWrapper 中有一个字段 Integer intObj,那么 jol 在统计 IntegerWrapper 实例时(下文称 wrapper),是不会统计 intObj 的大小的,对于 wrapper.intObj 这个字段来说,它只是存储了 intObj 的内存地址,真正的 intObj 的内存空间是另外一块地盘,与 wrapper 的地盘没有关系。
我们看到 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]的例子:
总结
基础类型
对象
数组
评论