概述

在现实的业务场景中,我们往往会把数据放在内存中进行缓存或其他处理,这就要求我们有必要知道这些数据占用的空间大小,进而去合理的规划机器配置、加载数据量的大小等。

通常,运行时数据区的内存布局不属于 JVM 规范的一部分,而是由实现者自行决定。因此,每个 JVM 实现在内存中布局对象和数组时可能会采用不同的策略。这反过来又会影响运行时的实例大小。

本次我们要分享的这个工具,在特定的JVM条件下:64bit HotSpot JVM(即:64位 HotSpot虚拟机)。

JVM中对象的内存布局

要想计算一个对象在JVM中的大小,我们首先得知道它在内存中的布局情况。

关于对象的布局,大体由以下三部分组成:

  • 对象头:Object Header,JVM记录特定信息,如:GC状态、同步状态、identity hash code、数组长度、以及class元信息的指针,组成为:8字节的MarkWord+4字节(开启指针压缩时为4字节,关闭时为8字节,jdk8及以后默认为开启状态)的klass pointer,如果是数组对象,则会仅跟着一个4字节的数组长度信息;
  • 实例数据:如字面意思,很好理解;
  • 内存填充/字节对齐:JVM为了效率,会采用固定长度(8的整数倍)统一存储,如果对象头+实例数据大小不足8的整数倍字节大小时,会进行补齐。

说明:在64位的HotSpot虚拟机中,类型指针、引用类型需要占用8个字节,这显然会增加对内存的消耗和使用,在jdk1.6开始,加入了UseCompressedOops参数,可对OOP(Ordinary Object Pointer,即:普通对象指针)进行压缩,从而使其仅占用4个字节。

关于指针压缩开启和关闭的相关参数如下:

-XX:+UseCompressedOops开启指针压缩;

-XX:-UseCompressedOops关闭指针压缩;

工具介绍与使用

对内存布局有个大概的了解之后,就可以进入主题了,也就是这次分享的工具:JOL。

什么是JOL?

JOL:Java Object Layout,由openJDK提供的一个工具,用于分析、了解一个java对象在内存中的具体分布情况。

官网:https://openjdk.org/projects/code-tools/jol/

 JOL Source Repository:GitHub - openjdk/jol: https://openjdk.org/projects/code-tools/jol

官网对它的介绍:

JOL (Java Object Layout) is the tiny toolbox to analyze object layout schemes in JVMs. 
These tools are using Unsafe, JVMTI, and Serviceability Agent (SA) heavily to decoder the actual object layout, footprint, and references. 
This makes JOL much more accurate than other tools relying on heap dumps, specification assumptions, etc.

如何使用JOL

JOL中常用的几个方法:

ClassLayout.parseInstance(obj).toPrintable():查看对象内部的内存布局;

ClassLayout.parseInstance(obj).instanceSize():计算对象的大小,单位为字节;

GraphLayout.parseInstance(obj).toPrintable():查看对象外部信息,包括引用的对象;

GraphLayout.parseInstance(obj).totalSize():查看对象占用空间总大小;

注:除了parseInstance(Object obj)外,还有parseClass(Class<?> class),用法基本相同。

实际使用示例:

万能第一步:引入maven依赖

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

注:较新的版本中简化了打印的信息,所以建议使用0.10,或0.9的版本。

为了更好地了解更复杂对象的大小,我们首先应该知道每种简单数据类型所占用的空间。为此,我们可以用 JOL 打印虚拟机信息:

System.out.println(VM.current().details());

上述代码将打印简单数据类型的大小如下:

java进程查看占用的内存 java 查看内存占用_java

以下是 JVM 中每种简单数据类型所需的空间:

  • 对象引用占用 4 个字节
  • 布尔值和字节值消耗 1 个字节
  • short 和 char 值占用 2 个字节
  • int 和 float 值占用 4 个字节
  • long 和 double 值消耗 8 个字节

值得一提的是,所有数据类型在作为数组组件类型使用时消耗的内存量相同。

假如我们关闭指针压缩,也就是-XX:-UseCompressedOops,再看上面代码的输出:

java进程查看占用的内存 java 查看内存占用_开发语言_02

现在,对象引用将占用 8 字节而不是 4 字节。其余数据类型的内存消耗量仍然相同。
此外,当堆大小超过 32 GB 时,HotSpot JVM 也无法使用压缩引用(除非我们更改对象对齐方式)。

好了,现在我们已经知道了基本数据类型的内存消耗量,那就来计算一下更复杂对象的内存消耗量吧。

查看一个不包含任何属性的类的内存布局:

import org.openjdk.jol.info.ClassLayout;

public class JOLTest {

    public static void main(String[] args) {
        Course course = new Course();
        //打印对象内存布局
        System.out.println(ClassLayout.parseInstance(course).toPrintable());
    }

    public static class Course {

    }

}

输出:

java进程查看占用的内存 java 查看内存占用_java_03

输出说明:(下同)

OFFSET:偏移地址,单位:字节;

SIZE:大小,单位:字节;

TYPE DESCRIPTION:类型描述

        object header:对象头,8字节MarkWord,4字节klass pointer;

        loss due to the next object alignment:由于下一个对象对齐而导致的丢失,内存补齐;

VALUE:对应内存中的值;

Instance size:实例字节数大小;(这里可以看出,不包含任何属性的实例大小为16bytes

再来看一个包含属性的例子:

public static void main(String[] args) {
        //打印对象内存布局
        System.out.println(ClassLayout.parseClass(Course.class).toPrintable());
    }

    @Data
    public static class Course {
        private String name;
    }

Course类,输出:

java进程查看占用的内存 java 查看内存占用_开发语言_04

12字节的object header, 4字节的name对象引用。

再看一个属性多一点的:

public static void main(String[] args) {
        //打印对象内存布局
        System.out.println(ClassLayout.parseClass(Professor.class).toPrintable());
    }

    @Data
    public static class Course {
        private String name;
    }
    @Data
    public static class Professor {
        private String name;
        private int level;
        private double score;
        private List<Course> courses = new ArrayList<>();
    }

Professor,输出:

java进程查看占用的内存 java 查看内存占用_java进程查看占用的内存_05

12字节object header,4字节int属性,8字节double属性,4字节name对象引用,4字节courses对象引用。

这里,代码中明明是name,level,score的顺序,为什么内存中确是level,score,name呢?

原因:对于对象的大小,JVM在分配内存的时候会有个【重排序】的处理,进而达到节省空间的目的。比如:如果先分配byte或boolean后分配int,那byte之后可能需要3bytes的补充进行padding,【重排序】就是通过减少这个补充的可能性而减少空间的使用。

一般情况下,遵循如下的优先级顺序:

double>long>int>float>chat>short>byte>boolean>Object Reference。

不过,如上面Professor的例子,12字节的object headere之后紧接着是int而不是double,是因为4字节的int在此处更有可能不需要padding,所以int优先了。

sizeOf:计算浅层大小的一个简单方法。(所谓浅层大小,就是指只计算引用大小,如:1个引用按4bytes算,而不计算引用对象的大小,如果同时计算了引用对象的大小,那就叫Deep Size)

public static void main(String[] args) {
        //打印对象内存布局
//        System.out.println(ClassLayout.parseClass(Professor.class).toPrintable());
        String ds = "Data Structures";
        Course course = new Course(ds);

        System.out.println("The shallow size is: " + VM.current().sizeOf(course));
    }

    @Data
    @AllArgsConstructor
    public static class Course {
        private String name;
    }

输出:

java进程查看占用的内存 java 查看内存占用_System_06

这里输出course的浅层大小为16bytes.

看过了浅层大小,我们再来看看Deep Size,Deep Size如上面的解释,除了引用大小,还计算引用对象的大小,对应到代码中就是String ds的大小。

System.out.println(ClassLayout.parseInstance(ds).toPrintable());

看看ds的输出:

java进程查看占用的内存 java 查看内存占用_java进程查看占用的内存_07

 可以看到该 String 实例的浅层大小为 24 字节,其中包括 4 字节的哈希码、4 字节的 char[] 引用和其他典型对象开销。

而,如果要看char[]数组的大小,可以通过如下方式:

System.out.println(ClassLayout.parseInstance(ds.toCharArray()).toPrintable());

输出:

java进程查看占用的内存 java 查看内存占用_java_08

这里,多了4字节的object header,表示数组的大小,这里值为15.

所以,Deep Size = 16bytes的Course实例+24bytes的String实例+48字节的char[]数组数据=88字节。

那么,用GraphLayout的totalSize API看下实际占用大小输出,与我们预期的是否一致:

public static void main(String[] args) {
        //打印对象内存布局
//        System.out.println(ClassLayout.parseClass(Professor.class).toPrintable());
        String ds = "Data Structures";
        Course course = new Course(ds);
        //打印对象内存占用
        System.out.println(GraphLayout.parseInstance(course).totalSize());
    }

输出:

java进程查看占用的内存 java 查看内存占用_java进程查看占用的内存_09

ok,完全符合预期!

最后,来一个完整版的示例输出展示:

public static void main(String[] args) {
        String ds = "Data Structures";
        Course course = new Course(ds);
        System.out.println("course内存布局:");
        System.out.println(ClassLayout.parseInstance(course).toPrintable());
        System.out.println("ds内存布局:");
        System.out.println(ClassLayout.parseInstance(ds).toPrintable());
        System.out.println("ds char[]内存布局:");
        System.out.println("ds char[]内存布局:" + ClassLayout.parseInstance(ds.toCharArray()).toPrintable());
        //打印对象内存占用
        System.out.println("course instance size:" + ClassLayout.parseInstance(course).instanceSize());
        System.out.println("ds instance size:" + ClassLayout.parseInstance(ds).instanceSize());
        System.out.println("ds char[] instance size:" + ClassLayout.parseInstance(ds.toCharArray()).instanceSize());
        System.out.println("course total size:" + GraphLayout.parseInstance(course).totalSize());
    }

输出:

java进程查看占用的内存 java 查看内存占用_内存布局_10

好了,到这一步,相信工具怎么使用应该就比较清楚了。