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
  • 对齐填充:4 bytes

总内存占用是 40 bytes(16 bytes + 24 bytes)。

4.2. 类属性2-null

还是上面的 MemoryObjMemory2Obj,我们修改一下 getObject() 方法:

    private static Object getObject() {
        MemoryObj obj = new MemoryObj(null,2);
        return obj;
    }

这里因为没有创建 Memory2Obj 对象,所以没有该对象的内存占用,只有 MemoryObj24 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
  • 对齐填充: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:这些类在范围 -128127 之间提供缓存机制,通过valueOf方法返回缓存中的对象。
  • Float和Double:没有提供类似的缓存机制,每次创建都会生成新的对象。
  • Character:在范围 0127 之间提供缓存机制。
示例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)


KerryWu
641 声望159 粉丝

保持饥饿


引用和评论

0 条评论