内容简介:从JVM角度看对象创建的过程
本博客为《深入理解 java 虚拟机》的学习笔记,所以大部分内容来自此书。本博客主要讲述对象创建的详细过程,即当虚拟机遇到一条new指令时是如何处理的呢?
提前说明:如果想比较好的理解对象创建的全过程,需要提前对类的加载过程需要有一个清晰的认识,可以参考我之前的一篇博客《 类的加载过程 》,地址: https://yq.aliyun.com/articles/377198
接下来我们根据一个示例讲述对象的创建过程。示例代码中我们创建一个对象仅仅需要写一句简单的代码Test test = new Test(),但jvm做的事情可能远远超过我们的想想。
1 示例
1) 源码
public class Test { public static void main(String[] args) { Test test = new Test(); } }
2) 编译后
Constant pool: #1 = Methodref #4.#13 // java/lang/Object."<init>":()V #2 = Class #14 // com/wzf/greattruth/jvm/Test #3 = Methodref #2.#13 // com/wzf/greattruth/jvm/Test."<init>":()V #4 = Class #15 // java/lang/Object #5 = Utf8 <init> #6 = Utf8 ()V #7 = Utf8 Code #8 = Utf8 LineNumberTable #9 = Utf8 main #10 = Utf8 ([Ljava/lang/String;)V #11 = Utf8 SourceFile #12 = Utf8 Test.java #13 = NameAndType #5:#6 // "<init>":()V #14 = Utf8 com/wzf/greattruth/jvm/Test #15 = Utf8 java/lang/Object { public com.wzf.greattruth.jvm.Test(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 3: 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 // 创建一个对象,并将其引用值压入栈顶 3: dup // 复制栈顶值并将复制值压入栈顶 4: invokespecial #3 // 调用"<init>":()V方法,即调用实例构造器 7: astore_1 // 将站定引用类型的数值存入指定本地变量变第1个slot中 8: return // LineNumberTable: line 6: 0 line 7: 8 }
2 查找类的符号引用
虚拟机遇到一条new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用。
从上面示例代码中可以看到,通过以下过程定位类的符号引用。
0: new #2 // 创建一个对象,并将其引用值压入栈顶
通过new的参数,从常量池查找,最终将找到一个类的符号引用。如下下所示:
Constant pool:
#1 = Methodref #4.#13 // java/lang/Object."<init>":()V
#2 = Class #14 // com/wzf/greattruth/jvm/Test
#14 = Utf8 com/wzf/greattruth/jvm/Test
3 加载、准备、解析、初始化
找到类的符号引用以后,检查这个符号引用代表的类是否已经被加载、准备、解析、初始化。如果没有,则执行加载、准备、解析、初始化等操作。
1) 加载阶段
主要完成三件事情:
-
- 通过类的权限定名来获取此类的二进制字节流。
- 将这个类的二进制字节流转换成方法区的运行时数据结构。
- 在内存中生成代表这个类的java.lang.Class对象,作为方法区这个类的访问入口。
2) 准备阶段
为类变量分配内存并设置类变量初始值的阶段。类变量指的是使用static修饰的变量。
3) 解析阶段
是将常量池内的符号引用替换成直接引用的过程。
4) 其他说明
其实,类加载过程包括加载、验证、准备、解析、初始化等阶段,因为验证与这里讲述的内容关系不大,所以就没有提及,详情可参考博客《虚拟机类加载过程》, 类的生命周期如下图所示:
4 分配内存
接下来就是为对象分配内存。对象所需要的内存大小在类加载完成后便可以确定,为对象分配内存的任务等同于从堆中划分出一块确定大小的内存出来。接下来我们就从细节上讨论其是如何实现的。
1) 如何确定对象需要多大的内存?
对象的内存布局包括:对象头、实例数据、对其填充三部分。
a) 对象头
对象头以分为 Mark Word、类型 指针、数组长度(如果是数组的话)。
à  Mark Word
用来存储对象自身的运行时基本数据信息,如 hashCode、GC 分代年龄、锁状态标志 (轻量级锁、重量级锁) 、线程持有的锁 (轻量级锁、重量级锁) 等。 这部分数据的长度在32bit和64bit虚拟机上分别为32bit和64bit 。
à  类型 指针
指向类的元数据信息的引用 , JVM通过这个指针来确定这个对象是哪个类的实例。
à  数组长度
如果对象是一个Java数组,对象头中还要有一块记录数组长度的数据。
à  空间总计
在64位的服务器上,对象头占用16 byte的空间(8 byte的mark word + 8 byte的类型指针);如果是数组的话,对象头占用20 byte的空间(16 byte + 4 byte的数组长度,因为数组长度类型为int)。
b) 实例数据
存储的是真正有效数据,如各种字段内容,各字段的分配策略为longs/doubles、ints、shorts/chars、bytes/boolean、oops(ordinary object pointers) ,相同宽度的字段总是被分配到一起,便于之后取数据, 父类定义的变量会出现在子类前面。
各种类型数据的大小为:
类型 |
占用空间(byte) |
boolean |
1 |
byte |
1 |
short |
2 |
char |
2 |
int |
4 |
float |
4 |
long |
8 |
double |
8 |
reference |
32 位操作系统:4 64 位操作系统:8 |
c) 对齐填充
HotSpot的对齐方式为8字节对齐,所以要保证对象头、实例数据、对齐填充应该是8的倍数,即:
(对象头 + 实例数据 + padding)% 8 = 0
0 <= padding < 8
d) 计算示例
以下内存占用均在64位的服务器上计算,并且关闭UseCompressedOops 。这里讲述三个简单的示例,更多示例请参考博客内容。我写了一个 工具 类,可以方便的计算对象大小,如果有兴趣可以可以测试一下,git地址:git@gitee.com:wuzhengfei/sword-size.git,请按照README.md文件的说明操作。
à  示例一
public class A { // int占4 byte private int a; }
对象头占16 byte + a占4 byte = 20 byte;20不是8的倍数,需要加4 byte的对齐填充,总计占用内存24 byte
à  示例二
public class B { //int占4 byte private int a; //reference占8 byte private Long b; }
对象头占16 byte + a占4 byte + b占8 byte = 28 byte;28不是8的倍数,需要加4 byte的对齐填充,总计占用内存32 byte
à  示例三
new Integer[1]
对象头占20 byte + 8 byte的reference类型数据存储空间 = 28 byte,不是8的倍数,需要加4 byte的对其填充,总计占用内存32 byte。
new Integer[4]
对象头占20 byte + 4 * 8 byte的reference类型数据存储空间 = 52 byte,不是8的倍数,需要加4 byte的对其填充,总计占用内存56 byte。
2) 如何从堆中划分出一块确定大小的内存?
根据上面部分的内容我们可以计算出对象需要的内存大小,接下来就是从堆中划出一块内存区域给这个对象使用。如何划分,由java堆中的内存是否规整决定。
假设java堆中内存规整,所有用过的内存放在一块,没有使用过的内存放一块,中间放着一个指针作为分界点指示器,那么分配内存就是把指针向空闲空间那边挪动一段(大小等于对象内存),这种分配方式称为指针碰撞(Bump the pointer)。使用标记整理算法管理的内存块就属于此类。
假设java堆中内存不规整,已使用的内存和空闲内存相互交错,此时虚拟机就必须维护一个列表,记录上那些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表的记录(标识此快空间已被使用),这种分配方式称为空闲列表(Free List)。使用标记清除算法管理的内存块就属于此类。
为对象分配内存是非常频繁的动作,有并发开发经验的小伙伴可能会有疑问,并发情况下的线程安全如何保障?即假设正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存,此种情况下JVM如何保证不会出现这种问题的?解决此问题的方案有两个:
一是:对分配内存空间的动作进行同步处理,实际上虚拟机采用CAS加失败重试的方式保证更新操作的原子性。
二是:把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在java堆中预先分配一块内存,作为本地线程分配缓冲区(Thread Local Allocation Buffer, TLAB)。某一个线程需要分配内存时,在此线程的TLAB中分配即可,只有TLAB用完并分配新的TLAB时,才需要同步锁定。虚拟机通过-XX:+/-UseTLAB来决定是否使用TLAB。
需要注意的是:jvm对象的内存分配与回收是有一定策略的,分配是按照这些策略进行。策略如下:
- 对象优先分配到新生代的Eden区
- 大对象直接进入老年代。
- 长期存活的对象将进入老年代
- 动态对象年龄判断,超过阈值从新生代晋升到年老代。
- 空间分配担保。
因为讨论这些内容超出本文的范围,所以不过多讲述,如有兴趣可以参考垃圾回收与内存分配的博客。
5 将分配到的内存空间初始化为零值
内存分配完毕以后,虚拟机需要将分配的内存空间都初始化为零值(不包括对象头),如果使用TLAB,初始化为零值的过程提前至分配TLAB时进行。这一步操作保证对象的实例字段在java代码中可以不赋初始值就可以直接使用,此时实例字段根据类型分别取不同的值,具体如下:
数据类型 |
零值 |
int |
0 |
long |
0L |
short |
(short)0 |
char |
'\u0000' |
byte |
(byte)0 |
boolean |
false |
float |
0.0f |
double |
0.0d |
reference |
null |
6 对象头设置
接下来虚拟机需要设置对象的对象头。例如:对象的类型,如何找到累的元数据信息,对象的哈希码,gc年龄代信息,偏向锁、轻量级锁等。
7 执行构造函数
经过上面的几步,从虚拟机的角度来看,一个新的对象已经产生了,但从java程序的角度来看,对象的创建才刚刚开始,因为对象的构造函数还未执行,对象的所有实例字段都是零值。
接下来通过【invokespecial #3】指令将执行构造函数,按照我们的代码来执行初始化。此过程结束,对象方才算创建结束。字节码示例如下:
0: new #2 // 创建一个对象,并将其引用值压入栈顶
3: dup // 复制栈顶值并将复制值压入栈顶
4: invokespecial #3 // 调用"<init>":()V方法,即调用实例构造器
8 总结
让我们总结一下对象的创建过程:
通过new指令参数查找常量池中类的符号引用,检查此
符号引用代表的类是否已经被加载、准备、解析、初始化过,如果没有则先执行这些操作。
计算并为对象分配内存。
将分配的内存空间初始化为零值。
设置对象头
执行构造函数。
9 参考博客
类的加载过程
https://yq.aliyun.com/articles/377198
一个Java对象到底占用多大内存
http://blog.csdn.net/rainnnbow/article/details/48655671
JAVA Instrumentation
http://blog.csdn.net/productshop/article/details/50623626
以上所述就是小编给大家介绍的《从JVM角度看对象创建的过程》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。