目录
【JVM学习笔记】对象的创建过程、 对象的内存布局、 如何定位和使用对象
【JVM学习笔记】内存回收与内存回收算法 就哪些地方需要回收、什么时候回收、如何回收三个问题进行分析和说明
HotSpot VM垃圾收集器——Serial Parallel CMS G1垃圾收集器的JVM参数、使用说明、GC分析
【JVM基础内容速查表】JVM基础知识 默认参数 GC命令 工具使用 JVM参数设置、说明、使用方法、注意事项等
前言
JVM内存结构大致了解了,这一部分主要学习JVM将如何载入指定对象,对象又是以什么形式存在于JVM中,最后JVM是如何调用位于堆内存的对象的。
对象的创建过程
1. 确定对象的类型
解析class文件时,当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
符号引用扩展(可以跳过):
符号引用属于编译原理方面的概念,主要包括下面几类常量:
- 被模块导出或者开放的包(Package)
- 类和接口的全限定名(Fully Qualified Name)
- 字段的名称和描述符(Descriptor)
- 方法的名称和描述符
- 方法句柄和方法类型(Method Handle、Method Type、Invoke Dynamic)
- 动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)
关于符号引用,可以用一种直观的方式查阅。我们可以通过jdk自带命令查看class源文件:
javap -c ClientApplication
编译后的class源文件:
// ClientApplication.class
public class ClientApplication {
public ClientApplication() {
}
public static void main(String[] args) {
SpringApplication.run(ClientApplication.class, args);
}
}
查看类文件的字节码:
Compiled from "ClientApplication.java"
public class com.example.client.ClientApplication {
public com.example.client.ClientApplication();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: ldc #2 // class com/example/client/ClientApplication
2: aload_0
3: invokestatic #3 // Method org/springframework/boot/SpringApplication.run:(Ljava/lang/Class;[Ljava/lang/String;)Lorg/springframework/context/ConfigurableAppl
icationContext;
6: pop
7: return
}
上面“//”后的就是符号引用,他们可以在JVM中完整的唯一的代表某个类、方法等。
2. 确定对象内存大小
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定。
一个A.class,可以根据成员变量来确定类型占用的空间:每个基本类型有固定的大小,数组有长度限制,其它对象基本都是由基本类型和数组组成的。
比如String类,它有一个成员变量【char value[]】,在new String(“abc”)时,就会确定value数组的长度,每个char占用2字节,所以将会为value的元素分配n * 2个字节的内存,此外数组本身还会占用额外空间存储数组长度、hash、GC年龄等数据;这时候String的一部分内存就确定了,接着就会计算其它成员。到最后所有成员都确定后,对象本身还需要一些额外空间存储对象信息,最后将这些内存合起来就是创建改对象需要的内存大小了。
1byte = 8bit 1字节 = 8比特(位)
基本类型占用字节:
- boolean:1byte(由虚拟机决定)
- byte:1byte
- char:2byte
- short:2byte
- int:4byte
- long:8byte
- float:4byte
- double:8byte
3. 分配内存空间
确定对象的内存大小后,就会在堆中划分出一块同等大小的内存出来。
内存分配涉及如下问题:如何确定将哪一块内存分配给对象、内存分配时的并发冲突、内存分配后回收内存将会继续影响下一次的内存分配。
内存分配方式:
-
假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump ThePointer)。
-
如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。
内存分配基于垃圾收集器:
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。
-
当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效。
-
而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。
保证并发下内存分配的正确性:
对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
解决方案:
- 对分配内存空间的动作进行同步处理——虚拟机采用CAS配上失败重试的方式来保证更新操作的原子性
- 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local AllocationBuffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。
虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
4. 对象初始零值
内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。
不同类型对应的零值:
- boolean:false
- char:‘\u0000’
- byte:0
- short:0
- int:0
- long:0
- float:0.0
- double:0.0
- 引用类型:null
5. 对象头设置
Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
6. 对象初始化
对于JVM来说,对象已经被初始化完毕了,但是对于Java程序来说,对象的创建从构造函数才真正开始,当Class文件中的()方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。
一般来说(由字节码流中new指令后面是否跟随invokespecial指令所决定,Java编译器会在遇到new关键字的地方同时生成这两条字节码指令,但如果直接通过其他方式产生的则不一定如此),new指令之后会接着执行()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。
通过javap再次看看new一个对象时的字节码展示:
public static void main(String[] args) {
DataServiceImpl dataService = new DataServiceImpl();
String data = dataService.getData();
System.out.println(data);
}
如下字节码,在new后展示了要创建对象的符号引用,随其后的invokespecial指令调用了无参构造器(V代表void,无返回值)
字节码中符号引用的一些含义可以参考:JVM heap dump含义
public static void main(java.lang.String[]);
Code:
0: new #3 // class com/example/client/service/impl/DataServiceImpl
3: dup
4: invokespecial #4 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #5 // Method getData:()Ljava/lang/String;
12: astore_2
13: getstatic #6 // Field java/lang/System.out:Ljava/io/PrintStream;
16: aload_2
17: invokevirtual #7 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
20: return
对象的内存布局
对象结构:在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
1. 对象头
HotSpot虚拟机对象的对象头部分包括如下信息:
-
第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它为“Mark Word”。
-
另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。
-
如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。
Mark Word:
在32位的HotSpot虚拟机中,32位的Mark Word内容如下:
Mark Word 32位标记字段详情:
参考:深入理解Java的对象头mark word
|-------------------------------------------------------|--------------------|
| Mark Word (32 bits) | State |
|-------------------------------------------------------|--------------------|
| identity_hashcode:25 | age:4 | biased_lock:1 | lock:2 | Normal |
|-------------------------------------------------------|--------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1 | lock:2 | Biased |
|-------------------------------------------------------|--------------------|
| ptr_to_lock_record:30 | lock:2 | Lightweight Locked |
|-------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:30 | lock:2 | Heavyweight Locked |
|-------------------------------------------------------|--------------------|
| | lock:2 | Marked for GC |
|-------------------------------------------------------|--------------------|
-
lock:2位,锁状态标记位,分别表示无锁(01)、偏向锁(01)、轻量级锁(00)、重量级锁(10)、GC标记(11)。
-
biased_lock:1位,由于无锁和偏向锁的lock标记位一致,由改位用于区分对象是否启用偏向锁标记。无锁(0)、偏向锁(1)。
-
age:4位,Java对象年龄。在GC中,如果对象在Survivor区复制一次(serial new,parallel new等垃圾收集器),年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。
-
identity_hashcode:25位的对象标识Hash码,采用延迟加载技术。调用方法System.identityHashCode()计算,并会将结果写到该对象头中。当对象被锁定时,该值会移动到管程Monitor中。
-
thread:持有偏向锁的线程ID。
-
epoch:偏向线程时的偏向时间戳。
-
ptr_to_lock_record:指向栈中锁记录的指针。
-
ptr_to_heavyweight_monitor:指向管程Monitor的指针。
2. 实例数据
实例数据部分是对象真正存储的有效信息,我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。
这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。
HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。
如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。
3. 对齐填充
这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
对象的使用
创建对象自然是为了后续使用该对象,Java程序会通过栈上的reference数据来操作堆上的具体对象。
1. 对象的访问形式
由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种:
- 使用句柄访问:Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。
- 使用直接指针:Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。
这两种对象访问方式各有优势:
- 使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。
- 使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本。
就虚拟机HotSpot而言,它主要使用第二种方式进行对象访问。即在对象头中存储类型数据。
摘抄来源:深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)周志明