在开始MySQL的学习之前,还想写一篇文章把前面学习的知识点回顾一下,就有了今天的这篇文章。
示例
有类School,这个类中有3个成员变量:引用类型String类型的schoolName,通过显式代码块初始化;基本数据类型int型studentsNum,显式初始化;引用类型Class类型student,通过School的构造函数初始化。
我们使用main函数创建School的一个对象,那么这个过程发生了哪些事情?在JVM内存中多了什么呢?让我们一起看下吧!
- public class School {
- private String schoolName;
- private int studentsNum = 10000;
- private Student student;
- {
- schoolName = "清华大学";
- }
- public School(){
- student = new Student();
- }
- }
- class Student{
- }
- class Test{
- public static void main(String[] args) {
- School school = new School();
- }
- }
当我们执行new School()时,进行了对象的创建,大致可以分为以下5步:
在详细了解这5个步骤之前我们再详细聊一下对象头,在synchronized锁升级过程分析的时候我们已经初步接触过它。
对象的内存布局
对象在堆空间的内存布局包含了3个部分:对象头(Header)、实例数据(Intance Data)、对齐填充(Padding)。
对象头
对象头包含了两部分:运行时元数据、指向类元数据的指针kclass,确认这个对象所属的类型。
运行时元数据(Mark Word)包含:哈希值、GC分代年龄、锁状态标志位、偏向线程ID。运行时元数据的信息是变化的,在synchronized锁的升级过程中,Mark Word在不同的锁状态下是不一样的。
下图展示展示了无锁状态、偏向锁、轻量级锁、重量锁以及对象被GC标记的对象头中的运行时数据信息:
实例数据
实例数据是对象真正存储的有效信息,它包含了对象中定义的各种类型的字段。这些字段有对象本身定义的,也有从所有父对象继承的字段。
父类的构造方法先于子类执行,所以父类变量的定义都在子类前面。
对齐填充
对齐填充不是必须的,也没有实在的意义,它仅仅是个占位符的作用。HotSpot虚拟机要求对象的起始地址必须是8字节的整数倍,因此当对象没有满足的时候,就需要对齐填充来补全。
现在我们已经了解了对象在堆内存的布局,在之前的JVM文章中也学习了虚拟机栈结构和方法区(JDK1.8之后称为元空间,勾勾之前习惯称为方法区,但是怕大家混淆后续我们都用元空间表示),那么接下来我们详细分析school对象创建的整个过程。
对象创建的步骤
对象的创建是在主线程的main()方法中,所以在主线程的虚拟机栈中就会创建main()的栈帧,main()就是当前方法。
我们回顾下栈和栈帧。
JVM内存区域划分为5个模块:堆、元空间、虚拟机栈、本地方法栈和程序计数器(也成为pc寄存器)。
虚拟机栈和本地方法栈都属于栈,本地方法栈中只存放native方法的栈信息。
虚拟机栈的生命周期和线程的生命周期一致,它随着线程的创建而创建,随着线程的销毁而销毁,所以它是线程私有的内存区域。
虚拟机栈是由栈帧组成的,栈帧中包含了局部变量表、操作数栈、动态链接、方法返回地址、附加信息。栈帧是随着方法的调用而创建的。所以当主线程调用main()方法时,此时在主线程的虚拟机栈中就创建了main()栈帧。
main()栈帧中的局部变量表包含两个变量:args和school。
主线程的虚拟机栈的栈帧结构如下图:
main()方法想要将school这个局部变量实例化,就需要执行School这个类的实例化。
那么new School()发生了什么呢?我们接下来详细分析之前的5个步骤。
判断对象的类是否已经加载
当虚拟机遇到new这个指令时,会首先检查这个指令的参数能否在元空间的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载,即判断元空间中是否包含这个类的类元信息。
我们通过javap -v -p Test.clas查看Test类的字节码信息:
- Classfile /E:/study/javacodegirl/src/main/java/com/study/test/code/girl/base/jvm/Test.class
- Last modified 2021-2-21; size 352 bytes
- MD5 checksum 2df3d394ac88d2aa4da9d27f848067c5
- Compiled from "School.java"
- class com.study.test.code.girl.base.jvm.Test
- minor version: 0
- major version: 52
- flags: ACC_SUPER
- Constant pool:
- #1 = Methodref #5.#14 // java/lang/Object."<init>":()V
- #2 = Class #15 // com/study/test/code/girl/base/jvm/School
- #3 = Methodref #2.#14 // com/study/test/code/girl/base/jvm/School."<init>":()V
- #4 = Class #16 // com/study/test/code/girl/base/jvm/Test
- #5 = Class #17 // java/lang/Object
- #6 = Utf8 <init>
- #7 = Utf8 ()V
- #8 = Utf8 Code
- #9 = Utf8 LineNumberTable
- #10 = Utf8 main
- #11 = Utf8 ([Ljava/lang/String;)V
- #12 = Utf8 SourceFile
- #13 = Utf8 School.java
- #14 = NameAndType #6:#7 // "<init>":()V
- #15 = Utf8 com/study/test/code/girl/base/jvm/School
- #16 = Utf8 com/study/test/code/girl/base/jvm/Test
- #17 = Utf8 java/lang/Object
- {
- com.study.test.code.girl.base.jvm.Test();
- descriptor: ()V
- flags:
- Code:
- stack=1, locals=1, args_size=1
- 0: aload_0
- 1: invokespecial #1 // Method java/lang/Object."<init>":()V
- 4: return
- LineNumberTable:
- line 28: 0
- public static void main(java.lang.String[]);
- descriptor: ([Ljava/lang/String;)V
- flags: ACC_PUBLIC, ACC_STATIC
- Code:
- stack=2, locals=2, args_size=1
- 0: new #2 // class com/study/test/code/girl/base/jvm/School
- 3: dup
- 4: invokespecial #3 // Method com/study/test/code/girl/base/jvm/School."<init>":()V
- 7: astore_1
- 8: return
- LineNumberTable:
- line 30: 0
- line 31: 8
- }
- SourceFile: "School.java"
在main()中new指令的参数是#2,我们可以在Constant pool中找到#2对应的类信息。
如果没有这个类的信息,那么就会按照双亲委派模型加载School类。
类的加载过程:加载、连接、初始化,其中连接包括:验证、准备、解析。
执行类的加载的是类加载器,它分为:启动类加载器、扩展类加载器、应用类加载器和自定义加载器。
School类是ClassPath下的文件,它的类加载是应用类加载器,当应用类加载器按照ClassLoader+包名+类名查找对应的.class文件时,如果找不到这个文件就会抛出ClassNotFoundException异常,如果找到了则进行类的加载,并生成对应的Class类对象。这个时候在元空间中就有了School的类元数据了。
为对象分配内存空间
接下来就需要计算对象占用的空间大小,基本类型除了long和double是8个字节,byte和boolean是1个字节,char和short是2个字节,其他基本类型都是4个字节,引用类型也是4个字节。
内存大小计算好之后在堆中划分一块内存空间给新对象。大部分情况下,对象是在新生代的Eden区中分配,如果此时Eden区没有足够的内存空间进行分配,虚拟机将发起一次Minor GC。但是当我们为一个很长的字符串或者数组分配内存时,这种类型的大对象需要连续的内存空间,可以直接在老年代进行分配,这样做可以避免Eden和两个S区发生大量的内存复制。但是大对象可能会导致连续空间不足而提前触发GC,我们开发中也应该尽量避免大对象。
内存分配有两种方式:指针碰撞和空闲列表分配。
- 指针碰撞:当内存使用的GC算法是标记整理或者复制算法时,内存是规整的,此时我们为对象分配内存只需要移动指针位置就可以。Serial和ParNew使用的GC回收算法是标记复制算法,内存的分配就是指针碰撞的方式。
- 空闲列表分配:当内存使用的GC算法是标记清除算法时,内存是规整的,这个时候维护了内存空闲的列表,在为新对象分配内存时从空闲列表中找到内存就可以。CMS使用的GC回收算法是标记清除算法,内存的分配方式就是空闲列表分配。
看完内存的分配你有没有疑问?堆内存是所有线程共享的,如果两个线程同时都想占用这一块内存空间怎么办呢?这就涉及到了分配内存空间时的并发安全问题。
JVM提供了两种处理并发安全的方式:一种是我们常用的CAS失败重试+区域锁来保证内存分配的原子性,另外一种是通过开启-XX:+UseTLAB参数为每个线程预分配一块TLAB,在JDK1.8中这个参数是默认开启的。
经过了这一步之后,堆内存中就有了School实例的一块内存区域了:
初始化分配到的内存空间
属性的赋值操作分为3个类型,我们在示例中都有举例:
- 默认值初始化
- 显式初始化和代码块初始化
- 构造方法初始化
初始化分配到的内存空间是默认值初始化,它为类的成员变量设置默认值,保证对象实例字段在不赋值时可以直接使用。基本数据类型的默认值为0,布尔类型的默认值为false,引用类型的默认值为null。
不要把这一步的初始化和类加载过程中的初始化混淆了!
类加载过程中的初始化是对类的静态变量初始化,不包含类的实例变量。
执行了这一步之后,内存中的情况如下图:
设置对象的对象头
将对象的所属的类、对象的HashCode值、对象的GC信息、锁信息等数据存放在对象头中。它取决于JVM实现。对象头的信息我们前面已经讲过,这里不再赘述。
执行了这一步之后内存中的数据变化:
执行init进行初始化
这个时候初始化过程才真正开始。这个过程是对应字节码invokespecial,执行init方法。
它会执行实例化代码块、调用类的构造方法、将堆内对象的首地址赋值给引用变量。这一步之后真正可用的对象才算创建完成。
执行了这一步之后内存中的变化如下图:
总结
对象的创建过程:类元数据加载->分配内存空间并解决并发问题->初始化分配的内存空间->设置对象头信息->执行init方法进行初始化。
对象的整个创建过程大家要对JVM的内存区域比较了解,熟悉每个区域存放的数据,并知道在哪个过程存的数据。
类元数据的加载是元空间的数据来源,我们还可以回顾下类加载机制、双亲委派模型、哪些场景下需要打破双亲委派,之前勾勾分析了JDBC的SPI机制,利用线程上下文类加载器打破双亲委派。
对象的创建都是基于堆空间的,我们可以回顾下堆空间的内存分配、GC回收算法和GC回收器。
设置对象头信息我们需要了解对象头,还可以按照对象头的数据变化回顾synchronized锁的升级过程。
对象创建之后内存的数据变化如下图: