1. 前言
平时在写代码的时候,我们很多人基本都不太关注应用中占用的内存,因为通常业务场景中,内存占用量也就2、3G,不会很大。
如果并发量很高,临时对象创建的很多,总体的内存占用量瞬间就上去了。虽然每次请求完成后对象的引用关系解除了,对象内存会在Jvm的下一次GC中被释放掉。但如果一直并发度高,整体来看内存占用量不会因为GC而减少。
另外有些业务中会基于内存做缓存(如:Map、Caffeine等等),因为在查询性能上比RocksDB、Redis更高。但无论是占用堆内内存,还是堆外内存,它们不会像前面的临时对象一样被Jvm释放,除非在业务中主动删除。这类的内存占用几乎是永驻的,更需要我们精简内存结构。
一定还会有一些其他的场景,这里就不一一列举了。当你的系统有内存瓶颈时,在写代码时就需要好好思考一下内存结构。
2. 对象内存-基本
2.1. 内存结构
一个 Java 对象的内存结构一般包括以下几个部分:
对象头 (Object Header):
- Mark Word:通常是 8 字节,用于存储对象的哈希码、GC 状态、锁状态等信息。
- Class Pointer:通常是 4 字节或 8 字节(取决于 JVM 是否开启压缩指针),指向对象的类元数据。
- 实例数据 (Instance Data):存储对象的字段(包括从父类继承的字段)。
- 对齐填充 (Padding):JVM 要求对象大小是 8 字节的倍数,因此可能会有一些填充字节。
2.2. 测试工具
使用JOL计算对象大小,依赖:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.16</version>
</dependency>
如下,在 getObject()
方法中返回对象,main
方法中打印该对象占用内存。
public static void main(String[] args) {
// 使用 JOL 计算对象大小
long size = GraphLayout.parseInstance((getObject())).totalSize();
System.out.println("Object size: " + size + " bytes");
}
private static Object getObject() {
MemoryObj obj = new MemoryObj();
return obj;
}
2.3. 空对象内存1
已经知道对象的内存组成了,如果定义一个空类:
@Data
public class MemoryObj {
}
执行上述方法,结果为 16 bytes
。
解释
- 对象头:12 bytes
- 实例数据:0
- 对齐填充:4 bytes
因为类没有任何属性,即没有 实例数据 的内存,所有内存为 对象头,共占用内存12 bytes
(已开启压缩指针),经过 对齐填充 后为 16 bytes
。
2.4. 空对象内存2
现在我们要给类加些属性了,先加1个 int
属性,先告诉你一个 int
占用内存 4 bytes
。
@Data
public class MemoryObj {
private int attr1;
}
执行上述方法,结果为 16 bytes
。
- 对象头:12 bytes
- 实例数据:4 bytes
- 对齐填充:0 bytes
如上 对象头 和 实例数据 的内存加起来已经有 16 bytes
(8 bytes 的倍数),因此不需要 对齐填充。
2.5. 空对象内存3
@Data
public class MemoryObj {
private int attr1;
private int attr2;
}
执行上述方法,结果为 24 bytes
。
- 对象头:12 bytes
- 实例数据:8 bytes
- 对齐填充:4 bytes
此时2个 int
属性, 对象头 和 实例数据 的内存加起来有 20 bytes
,需要 对齐填充。
3. 基本数据类型及包装类内存
3.1. 基本数据类型
- byte:1 字节
- boolean:1 字节
- char:2 字节
- short:2 字节
- int:4 字节
- float:4 字节
- long:8 字节
- double:8 字节
3.2. 包装类
首先,包装类是类,所以计算它的内存占用,就用对象内存占用来计算。
另外,每种基本数据类型对应的包装类,都只包含一个属性,就是对应的基本数据类型。
因此各基本数据类型的包装类对象内存占用分别如下。
1、Byte
类方法:
public final class Byte {
private final byte value;
...
}
对象总内存 16 bytes
:
- 对象头:12 bytes
- 实例数据:1 bytes
- 对齐填充:3 bytes
2、Boolean
类方法:
public final class Boolean {
private final boolean value;
...
}
对象总内存 16 bytes
:
- 对象头:12 bytes
- 实例数据:1 bytes
- 对齐填充:3 bytes
3、Character
类方法:
public final class Character {
private final char value;
...
}
对象总内存 16 bytes
:
- 对象头:12 bytes
- 实例数据:2 bytes
- 对齐填充:2 bytes
4、Short
类方法:
public final class Short {
private final short value;
...
}
对象总内存 16 bytes
:
- 对象头:12 bytes
- 实例数据:2 bytes
- 对齐填充:2 bytes
5、Integer
类方法:
public final class Integer {
private final int value;
...
}
对象总内存 16 bytes
:
- 对象头:12 bytes
- 实例数据:4 bytes
- 对齐填充:0 bytes
6、Float
类方法:
public final class Float {
private final float value;
...
}
对象总内存 16 bytes
:
- 对象头:12 bytes
- 实例数据:4 bytes
- 对齐填充:0 bytes
7、Long
类方法:
public final class Long {
private final long value;
...
}
对象总内存 24 bytes
:
- 对象头:12 bytes
- 实例数据:8 bytes
- 对齐填充:4 bytes
8、Double
类方法:
public final class Double {
private final double value;
...
}
对象总内存 24 bytes
:
- 对象头:12 bytes
- 实例数据:8 bytes
- 对齐填充:4 bytes
4. 对象内存-类属性
我们再重新回到对象的内存计算,前面对象的属性是基本数据类型,但很多时候属性同样也为类对象,还包含包装类、集合类。
4.1. 类属性1-引用
@Data
public class Memory2Obj {
private int attr1;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class MemoryObj {
private Memory2Obj attr1;
private int attr2;
}
private static Object getObject() {
Memory2Obj attr1 = new Memory2Obj(1);
MemoryObj obj = new MemoryObj(attr1,2);
return obj;
}
引用类型的内存占用
在Java中,当一个对象的属性是引用类型时,该属性在内存中的占用实际上是一个指针(也称为引用)。这个引用指向堆内存中的实际对象,而不是直接存储对象的数据。引用类型可以包括类实例、接口类型、数组等。
因此在计算包含引用类型的内存占用时,要分开算各自对象的内存占用。
首先看Memory2Obj
对象的内存结果为 16 bytes
,这个前面已经算过了。
- 对象头:12 bytes
- 实例数据:4 bytes
- 对齐填充:0 bytes
而 MemoryObj
对象的内存结果为 24 bytes
:
- 对象头:12 bytes
实例数据:
- 引用类型(attr1):4 bytes,指向
Memory2Obj
对象的指针 - 基本数据类型(attr2):4 bytes
- 引用类型(attr1):4 bytes,指向
- 对齐填充:4 bytes
总内存占用是 40 bytes
(16 bytes + 24 bytes)。
4.2. 类属性2-null
还是上面的 MemoryObj
、Memory2Obj
,我们修改一下 getObject()
方法:
private static Object getObject() {
MemoryObj obj = new MemoryObj(null,2);
return obj;
}
这里因为没有创建 Memory2Obj
对象,所以没有该对象的内存占用,只有 MemoryObj
的 24 bytes
:
- 对象头:12 bytes
实例数据:
- 引用类型(attr1):4 bytes
- 基本数据类型(attr2):4 bytes
- 对齐填充:4 bytes
这里 attr1
的值为 null
,但它作为一个实例字段,无论它是否指向一个有效的对象,它都需要在内存中占有一个空间来存储这个引用。
因此总内存占用是 24 bytes
。
4.3. 类属性3-多引用
我们修改一下,MemoryObj
类中有两个 Memory2Obj
类型属性:
@AllArgsConstructor
@NoArgsConstructor
@Data
public class Memory2Obj {
private int attr1;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class MemoryObj {
private Memory2Obj attr1;
private Memory2Obj attr2;
private int attr3;
}
private static Object getObject() {
Memory2Obj attr = new Memory2Obj(1);
MemoryObj obj = new MemoryObj(attr, attr, 2);
return obj;
}
首先看Memory2Obj
对象的内存结果为 16 bytes
。
- 对象头:12 bytes
- 实例数据:4 bytes
- 对齐填充:0 bytes
而 MemoryObj
对象的内存结果为 24 bytes
:
- 对象头:12 bytes
实例数据:
- 引用类型(attr1):4 bytes,指向
Memory2Obj
对象的指针 - 引用类型(attr2):4 bytes,指向
Memory2Obj
对象的指针 - 基本数据类型(attr3):4 bytes
- 引用类型(attr1):4 bytes,指向
- 对齐填充:0 bytes
总内存占用是 40 bytes
(16 bytes + 24 bytes)。
虽然 MemoryObj
对象有2个 Memory2Obj
对象属性,但两个属性指针指向的对象地址是同一个,所以只是多了一个指针的内存(4 bytes)。
5. 特殊对象
5.1. 数组
5.1.1. 数组也是类
数组在Java中是一个特殊的对象类型,具有一些与类类似的特征,但也有其独特的属性。
1、对象性质:数组在Java中是对象。你可以使用instanceof操作符检查一个变量是否是数组类型。例如:
int[] array = new int[10];
System.out.println(array instanceof Object); // 输出 true
2、类加载:数组类型是由JVM在运行时动态生成的,每种数组类型都有一个与之对应的类。你可以通过调用getClass()方法来获取数组的类信息:
int[] array = new int[10];
System.out.println(array.getClass().getName()); // 输出 [I
其中,[I
表示一个整数(int)数组。在Java中,类名的表示方式是以方括号开头的。
3、类层次结构:所有的数组类型都是Object类的子类,并且实现了Serializable和Cloneable接口:
int[] array = new int[10];
System.out.println(array instanceof Object); // true
System.out.println(array instanceof Serializable); // true
System.out.println(array instanceof Cloneable); // true
5.1.2. 内存结构
对象头 (Object Header):
- Mark Word:通常是 8 字节,用于存储对象的哈希码、GC 状态、锁状态等信息。
- Class Pointer:通常是 4 字节或 8 字节(取决于 JVM 是否开启压缩指针),指向对象的类元数据。
- 数组长度字段:数组对象包含一个4字节的
int
类型字段,用于存储数组的长度。 数组元素:
基本类型数组:每个元素的内存占用等于基本类型的大小。
- boolean[]:每个元素1字节。
- byte[]:每个元素1字节。
- char[]:每个元素2字节。
- short[]:每个元素2字节。
- int[]:每个元素4字节。
- float[]:每个元素4字节。
- long[]:每个元素8字节。
- double[]:每个元素8字节。
- 引用类型数组:每个元素的内存占用为引用的大小。启用了指针压缩的64位JVM中,每个引用占用4字节。
- 对齐填充 (Padding):JVM 要求对象大小是 8 字节的倍数,因此可能会有一些填充字节。
数组长度有上限
在Java中,数组的长度是一个非负的int值。这意味着数组的最大长度是Integer.MAX_VALUE,即 2147483647
。这是因为int类型的数据范围是从 -2^31 到 2^31 - 1,但长度不能为负数,所以数组的最大长度为 2^31 - 1
,在大多数场景已经够用了。
5.1.3. 示例
1、long[5]
假设我们有一个长度为5的long数组:
long[] longArray = new long[5];
内存结构如下:
| 对象头 (12字节) | 数组长度 (4字节) | 元素1 (8字节) | 元素2 (8字节) | 元素3 (8字节) | 元素4 (8字节) | 元素5 (8字节) |
具体内存占用:
- 对象头:12 bytes
- 数组长度字段:4 bytes
- 数组元素:8 bytes * 5 = 40 bytes
- 对齐填充:0 bytes
总内存占用:12 + 4 + 40 = 56 bytes
2、Long[5]
假设我们有一个长度为5的Long数组:
Long[] longArray = new Long[5];
内存结构如下:
| 对象头 (12字节) | 数组长度 (4字节) | 元素1 (4字节) | 元素2 (4字节) | 元素3 (4字节) | 元素4 (4字节) | 元素5 (4字节) |
具体内存占用:
- 对象头:12 bytes
- 数组长度字段:4 bytes
- 数组元素:4 bytes * 5 = 20 bytes
- 对齐填充:4 bytes
总内存占用:12 + 4 + 20 = 40 bytes
5.2. String
以下是String类在JDK 8中的简化定义:
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
private final char[] value; // 实际存储字符串的字符数组
private int hash; // 缓存的哈希码
// 构造器和其他方法省略
}
5.2.1. 内存结构
对象头 (Object Header):
- Mark Word:通常是 8 字节,用于存储对象的哈希码、GC 状态、锁状态等信息。
- Class Pointer:通常是 4 字节或 8 字节(取决于 JVM 是否开启压缩指针),指向对象的类元数据。
- char[] value:用于存储字符串的字符数据。不过数组是对象,这里存储的是引用类型的指针,所以固定为 4 字节。
- int hash:缓存字符串的哈希码,用于提高哈希操作的效率。int类型 4 字节。
- 对齐填充 (Padding):JVM 要求对象大小是 8 字节的倍数,因此可能会有一些填充字节。
String对象内存固定
由上可知,无论 String 存储的值是什么,String对象
的内存固定为 24 bytes
。 真正可变的,是 char[]
数组占用的内存大小。
另外前面说过数组最大长度是 2^31 - 1
,这决定了 String 能存储的最大字符串长度也是有限制的,其实也够了。由于每个char占用2字节,因此存储接近最大长度的字符串需要接近4GB的内存,仅用于存储字符数组。
5.2.2. 示例
1、Hello World
我们看看 "Hello World" 这个字符串占用内存数。
private static Object getObject() {
return "Hello World";
}
首先算 String 对象
的内存:
- 对象头:12 bytes
- char[] value:数组的引用指针,4 bytes。
- int hash:int类型 4 bytes。
- 对齐填充:4 bytes
String 对象
的内存为 24 bytes
。
再计算 char[] 数组对象
的内存:
- 对象头:12 bytes
- 数组长度字段:4 bytes
- 数组元素:
char
类型每个元素2 bytes
,2 bytes * 11 = 22 bytes - 对齐填充:2 bytes
char[] 数组对象
的内存 为 40 bytes
因此总内存占用为 64 bytes
(24 bytes + 40 bytes)
6. 常量池
6.1. 整数常量池
先看总结:
- Integer、Long、Byte、Short:这些类在范围
-128
到127
之间提供缓存机制,通过valueOf方法返回缓存中的对象。 - Float和Double:没有提供类似的缓存机制,每次创建都会生成新的对象。
- Character:在范围
0
到127
之间提供缓存机制。
示例1
Java提供了对于某些范围内的整数进行缓存的机制,这个范围通常是-128到127。这个机制是在JVM启动时通过IntegerCache类实现的。
如果值在-128到127之间,会返回缓存中的对象,而不是创建一个新的对象。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class MemoryObj {
private Integer attr1;
private Integer attr2;
}
private static Object getObject() {
return new MemoryObj(127,127);
}
MemoryObj
对象内存为24 bytes
:
- 对象头:12 bytes
- 实例数据:2个指向 Integer 对象的指针,4 bytes * 2 = 8 bytes
- 对齐填充:4 bytes
Integer
对象内存为16 bytes
:
- 对象头:12 bytes
- 实例数据:int 值 4 bytes
- 对齐填充:0 bytes
因为 Integer 的值没有超过 127,所以两个属性的指针都指向常量池内同一个对象,所以总内存为:40 bytes
(24 bytes + 16 bytes)
示例2
private static Object getObject() {
return new MemoryObj(128,128);
}
因为 Integer 的值超过了 127,所以每次都需要创建一个新的对象,所以总内存为:56 bytes
(24 bytes + 16 bytes * 2)
6.2. 字符串常量池
字符串常量池是Java中专门用于存储字符串字面量的区域,在元空间(JDK 1.7及之后)中。
有2种方式可以使用字符串常量池:
- 字符串字面量:在编译时确定的字符串,例如
"hello"
。 - Interned字符串:通过调用
String.intern()
方法显式加入池中的字符串。
特点
- 字符串常量池中的字符串是不可变的。
- 如果一个字符串已经存在于常量池中,创建相同字符串时不会再分配新的内存。
public class StringPoolExample {
public static void main(String[] args) {
String str1 = "hello";
String str2 = "hello";
System.out.println(str1 == str2); // 输出 true
String str3 = new String("hello");
String str4 = str3.intern();
System.out.println(str1 == str4); // 输出 true
}
}
示例1
@Data
@NoArgsConstructor
@AllArgsConstructor
public class MemoryObj {
private String attr1;
private String attr2;
}
private static Object getObject() {
return new MemoryObj("hello","hello");
}
MemoryObj
对象内存为24 bytes
:
- 对象头:12 bytes
- 实例数据:2个指向 String 对象的指针,4 bytes * 2 = 8 bytes
- 对齐填充:4 bytes
String 对象
的内存之前已经说过了,固定为 24 bytes
。
最后计算 char[] 数组对象
的内存:
- 对象头:12 bytes
- 数组长度字段:4 bytes
- 数组元素:
char
类型每个元素2 bytes
,2 bytes * 5 = 10 bytes - 对齐填充:6 bytes
char[] 数组对象
的内存 为 32 bytes
由于2个字符串都是在编译时确定的,而且相同,所以两个属性的指针指向字符串常量池同一个对象,总内存为:80 bytes
(24 bytes + 24 bytes + 32 bytes)
示例2
private static Object getObject() {
for (int i = 0; i < 10; i++) {
String attr1 = new String("hell" + i);
String attr2 = "hell0";
return new MemoryObj(attr1, attr2);
}
return null;
}
虽然我们知道输出的结果一定是两个 hell0
字符串属性,但因为在编译时确定不了(引入了变量i
),所以 attr1
的值不会进入常量池,从而 attr2
需要重新场景。
这里我们手动创建的2个String对象,所以总内存为:136 bytes
(24 bytes + 24 bytes 2 + 32 bytes 2)
示例3
private static Object getObject() {
for (int i = 0; i < 10; i++) {
String attr1 = new String("hell" + i);
attr1.intern();
String attr2 = "hell0";
return new MemoryObj(attr1, attr2);
}
return null;
}
这里我们手动将 attr1
加入常量池,这样 attr2
就直接从常量池中读数据了。
这里我们手动创建的2个String对象,所以总内存为:80 bytes
(24 bytes + 24 bytes + 32 bytes)
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。