深入JVM(一)类加载过程和内存分配

深入JVM(一)类加载过程和内存分配

本篇需要完成的内容是完成对对象从加载到创建过程的分析,与过程中的内存分配的方式等进行总结

内存区域

在分析对象创建过程前,需要先了解Java虚拟机中对内存区域的划分与管理。

Untitled

红色部分的方法区在1.8之后已经被移除,变为元数据区,红色内的运行时常量池已经成为堆中的一部分使用!!

上图中展示了Java虚拟机在运行时对管理的内存区域的划分。其中,分为线程私有的程序计数器、本地方法栈、虚拟机栈和线程共享的堆与方法区和直接内存。

1. 程序计数器

程序计数器是线程私有的、当前线程所执行的字节码的行号指示器,就像名字一样,它是可以看做计数的,每一条指令执行完后,会挪动程序计数器的指针,并指向下一条指令。(就像在平时debug一样)。

  • 占用大小:程序计数器占用非常小的部分
  • 生命周期:跟随线程的创建而创建,销毁而销毁
  • 作用:简单来说可以执行该线程执行字节码的位置。在多线程切换的情况下也能够知道当前线程执行的位置。

2. Java虚拟机栈

虚拟机栈有一个一个的栈针组成,而每一个栈针内由局部变量表、操作数栈、动态链接、方法出口信息组成。线程的方法调用过程(方法调用链)会将其中的每一个方法依次压入栈中,最后在调用过后,依次出栈,也就是说,方法执行的过程也就是栈针压栈到弹出的过程。

一个线程中方法的调用链可能会很长,很多方法都同时处于执行状态。对于JVM说,在活动线程中,只有位于JVM虚拟机栈栈顶的元素才是有效的,即称为当前栈帧,也就是当前执行的方法。

局部变量表

局部变量表是我们在使用和了解Java虚拟机栈的主要部分,是变量值的存储空间,主要存放了编译时可知的基本数据类型(boolean、byte、char、short、int、float、long、double)和方法引用(reference)。在编译时,局部变量表的空间就确定下来。

操作数栈

操作数栈在方法执行的过程中,起到了对常量或者变量暂存和计算的作用,当方法开始时,操作数栈时空的,随着方法的进行,会将局部变量表中或者实例对象中,复制常量变量到操作数栈,再随着方法的执行,进行数据的运算。最后出栈返回给方法调用者。

和局部变量表相同的是,操作数栈的栈的深度在编译时就已经确认下来。

动态链接

一个方法调用另一个方法时,会通过寻找符号引用的方式查找到直接内存地址。而方法的符号引用存在于运行时常量池中。

在每一个栈针中,会通过栈针中的动态链接,来持有方法的符号引用。能够动态的寻找到方法对应的内存地址完成调用。

方法出口信息

方法返回时可能需要在栈帧中保存一些信息,用来于恢复调用者(调用当前方法的方法)的执行状态。

如果正常返回,会在调用者的栈针中保存调用方法的返回值,而被调用者的栈针可能会保存调用者当前程序计数器的值。

如果异常退出,也就是在调用过程中发现异常,调用者的返回值将由异常处理器返回,而被调用者中就不可能存在调用者的计数器值。

实例

通过下面的代码,来看虚拟机栈的调用返回过程和操作数栈的入栈出栈过程。

源程序:

1
2
3
4
5
6
7
8
9
10
11
12
public class NewObject {

public static void main(String[] args) {
NewObject newObject = new NewObject();
newObject.add();
}
public int add(){
int a = 5;
int b = 6;
return a+b;
}
}

反编译后的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void main(java.lang.String[]);
Code:
0: new #2 // class com/java8/Jvm/NewObject 半初始化申请内存地址
3: dup // 复制栈顶数值并将复制值压入栈顶
4: invokespecial #3 // Method "<init>":()V 构造初始化
7: astore_1 // 建立关联,将初始化后的存到变量newObject上
8: aload_1 // 从本地变量上加载newObject
9: invokevirtual #4 // Method add:()I 执行add()方法
12: pop // 栈顶数值弹出
13: return
public int add();
Code:
0: iconst_5 // 将5推至操作数栈顶
1: istore_1 // 将栈顶int型数值存入第1个本地变量 也就是a,保存到局部变量表中
2: bipush 6 //将单字节的常量值6推送至操作数栈顶
4: istore_2 // 将栈顶int型数值存入第2个本地变量 也就是b,保存到局部变量表中
5: iload_1 // 将第1个int型本地变量推送至操作数栈顶
6: iload_2 // 将第2个int型本地变量推送至操作数栈顶
7: iadd // 将栈顶两int型数值相加并将结果压入操作数栈顶
8: ireturn //返回

调用过程是:先通过在main方法里面创建一个newObject对象,再调用其中的add方法。其中newObject的过程先略过,后面讲,先看add方法执行的过程。

在add方法内,每次会将int类型的值先推到栈顶,然后执行赋值操作,将赋值的变量保存到局部变量表中,最后计算后将结果return。

3. 本地方法栈

虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

和虚拟机栈相同,每次方法执行时,都会创建一个栈针,存放本地方法的局部变量表、操作数栈、动态链接、方法出口信息。

4. 堆

堆是Java虚拟机管理内存中的最大的部分,也是进行GC主要部分。几乎所有的实例变量在堆中分配内存,少数小的对象可能在栈上分配。

在栈上分配的好处,首先快,其次不需要额外的垃圾回收的介入即可完成内存的释放。

在1.7之后,字符串常量池从方法区中挪到了堆中

5. 方法区

方法区和堆一样是线程共享的内容,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

方法区是Java虚拟机定义的规范,在1.7之前,Hotspot虚拟机使用永久代实现方法区。在1.7后,使用了元空间区实现方法区。两者的差异在于元空间使用的是直接内存,整个永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。

运行时常量池

有三个比较容易混淆的概念,什么是类常量池,什么是运行时常量池,什么是字符串常量池,三者的区别是什么。

  • 类常量池存在于字节码文件中,在字节码文件中存在类的常量池,其中存放两个类常量:字面量和符号引用,其中字面量的含义就是文本字符串和final修饰的常量值等。符号引用包含import的包、类和接口的全限定名、字段名称和描述符、方法类型和引用等等。
  • 运行时常量池存在于内存中,在类加载的过程时,会将存在于字节码中的类常量池中的内容加载到运行时常量池中。相较于类常量池,运行时常量池具有动态性,不一定是在编译时期就确定的放入的。
  • 字符串常量池,也叫做StringTable,在java7之后被放到了堆内,存放了字符串常量和字符串对象的引用。在class中的内容加载到运行时常量池后,在解析阶段,会把符号引用替换为直接引用,解析的过程会去查询字符串常量池,以保证运行时常量池所引用的字符串与字符串常量池中是一致的。

下图表示运行时常量池中的内容:

http://my-blog-to-use.oss-cn-beijing.aliyuncs.com/18-9-14/26038433.jpg

方法区和常量池_Java_屌丝程序员的奋斗之路-CSDN博客

6. 直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。

对象创建过程和对象布局

对象的创建过程

使用new关键字创建对象的过程分为5步,类加载检查、内存分配、初始化零值、设置对象头、执行init方法。参考下图:

Untitled 1

1. 类加载检查

虚拟机遇到new指令时,会先进行类加载检查,如果再运行时常量池中未发现类的符号引用,则先去加载、解析、初始化类这一些列类加载到内存中的过程。

2. 分配内存

在类加载到内存后,对象在堆中分配的大小已经确定,利用指针碰撞或者空闲列表的方式将堆中指定大小的空间分配给新创建的类对象。

3. 初始化零值

在为对象分配完内存后,首先需要对对象的初始化零值,来保证对象不手动赋值也能够正常被访问。此时的对象处于半初始化状态。

4. 设置对象头

在初始化零值后,还需要为对象设置头部信息,包括Mark Word、Kclass Word、数组长度(如果是数组的话),具体会在后面提到。

5. 执行init方法

通过init方法,为对象真正的赋值,当执行init方法后,一个需要的对象才产生出来。

下面通过反编译的代码来看创建一个Object对象的过程:

1
2
3
4
5
6
7
public static void main(java.lang.String[]);
Code:
0: new #2 // class java/lang/Object 申请内存空间
3: dup
4: invokespecial #1 // Method java/lang/Object."<init>":()V 构造初始化
7: astore_1 // 建立关联,将初始化后的存到变量Object上
8: return

通过javap反编译可以看出一个对象,通过使用new命令后在堆中申请一块内存空间后,将o推到栈顶,使用invokespecial命令进行初始化,最后使用astore命令将对象o和创建的对象关联到一起。

对象布局

一个对象包含三个部分,分别为对象头、实例数据(对象体)、Padding对其字节。

对象头(Header)

在对象头中包含了三个部分:Mark Word、Class Pointer、数组长度(如果是数组)

  • Mark Word:MarkWord中包含了自身的hashcode,gc的分代信息和锁标志位等,其中当上锁、上不同的锁的时候,头部信息都会出现不同的变化,具体占用长度和信息可以参考下图:

Untitled 2

  • Class Pointer: 在Class Pointer中包含了对方法区中的类元数据的引用,在64位系统下,如果不开启指针压缩时,会比32位多占二分之一的空间,所以在1.6之后,通过使用JVM的参数-XX:+UseCompressedOops可以开启指针压缩将原来的8字节压缩为现在的4字节。
  • 数组长度:如果当前对象时数组时,会额外产生一部分空间存储数组的长度

实例数据(Instance Data)

对象中主要的内容,存放实例数据,包括了对象的所有成员变量,其大小由各个成员变量的大小决定。

对其字节(Padding)

Java对象占用空间是8字节对齐的,即所有Java对象占用bytes数必须是8的倍数。

观察对象空间实例

1
2
3
4
5
6
public static void main(String[] args) {
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
int[] arr = new int[10];
System.out.println(ClassLayout.parseInstance(arr).toPrintable());
}

当使用上面的方式创建一个对象o和一个数组对象arr时,利用openjdk的jol(Java Object Layout)工具进行内存的分析,产生如下的分析结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

[I object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 6d 01 00 f8 (01101101 00000001 00000000 11111000) (-134217363)
12 4 (object header) 0a 00 00 00 (00001010 00000000 00000000 00000000) (10)
16 40 int [I.<elements> N/A
Instance size: 56 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

因为当前环境为64位,所以生成对象的大小应为8的倍数。可以看到对象o的layout中,前8个字节表示64位的MarkWord,之后4个字节表示KlassWord,最后四个字节并没有内容,是loss掉的,用于补齐字节用的,保证能够被8整除。

而在下面的数组对象arr中,可以看到最后的四个字节并不是对其,而是存储量数组的长度,而最后的字节表示了对象体,也就是初始化的数组内容。

在使用 -XX:-UseCompressedOops 关闭指针压缩时,观察对象o所占大小,发现大小未发生变化,然而原来的padding字节不见了,class pointer 变为了8个字节,一共16个字节,所以不需要padding来对其空间。

1
2
3
4
5
6
7
8
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 00 1c b6 0d (00000000 00011100 10110110 00001101) (230038528)
12 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

补充:javap和jol工具

  1. 使用JDK中自带的javap对class文件进行反编译,能够获得反编译到的执行过程。
  2. 使用IDEA中的插件:Jclasslib可以更具体的看反编译的代码和常量池中的内容
  3. 使用jol可以查看对象布局,使用方法,将OpenJDK中的jol的jar包加入到项目中,在main方法中使用ClassLayout静态方法解析对象后输出:
1
2
3
4
5
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
-------------本文结束感谢您的阅读-------------

本文标题:深入JVM(一)类加载过程和内存分配

文章作者:NanYin

发布时间:2020年05月10日 - 15:05

最后更新:2020年07月02日 - 09:07

原始链接:https://nanyiniu.github.io/2020/05/10/%E6%B7%B1%E5%85%A5JVM%EF%BC%88%E4%B8%80%EF%BC%89%E7%B1%BB%E5%8A%A0%E8%BD%BD%E8%BF%87%E7%A8%8B%E5%92%8C%E5%86%85%E5%AD%98%E5%88%86%E9%85%8D/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。