介绍完Java虚拟机的运行时数据区后,我们大致了解了虚拟机内存的情况。现在我们来了解HotSpot虚拟机在Java堆中对象分配,布局和访问的全过程。对象的创建分为在虚拟机中对象创建过程和Java程序上的对象创建两部分。
在虚拟机中对象创建过程
①通过java.exe运行class文件,随后类被加载到JVM中,方法区的元空间存储这个class文件类的信息,包含类的类信息,常量,静态变量,即时编译器编译后的代码等。然后进入主函数入口,为主函数创建栈帧,开始执行main函数。
②虚拟机遇到一条new指令时,首先会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已经被加载,解析和初始化过。如果没有,那么必须执行执行相应的类加载过程,将这个类的信息保存到方法区的元空间中。
③在类加载检查通过后,虚拟机会给新生对象分配内存,对象所需要的内存大小在类加载完成后便可以确定下来。为对象分配空间相当于在Java堆中划分出一块与对象所需内存大小相同的内存空间。至于如何划分具体需要看Java堆内存是否规整且是否采用带有压缩整理功能的垃圾收集器。
如果Java堆中内存是绝对规整的,所有用过的内存放到一边,空闲的内存放到一边,中间放一个指针作为分界点的指示器,分配内存的动作就是将指针移向空闲空间那边,从而划分出一段与所需内存空间大小相同的内存。这种分配所需内存空间的方式叫做“指针碰撞(Bump the Pointer)”。
如果Java堆中内存不是规整的,所有用过的内存与空闲的内存错乱放置,那么就无法进行指针碰撞,虚拟机就会维护一个列表,表中记录了哪些内存块是可用的,在分配所需内存时,就会在表中查找合适的内存空间并划分给对象实例,并更新列表上的记录。这种分配所需内存空间的方式叫做“空闲列表(Free List)”。
使用Serial,ParNew等带有Compact过程的收集器时,系统采用分配算法是指针碰撞。
使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。
在虚拟机中创建对象是十分频繁的事情,那么势必要考虑到在并发情况下出现的线程安全问题,比如A对象正在分配内存,还未修改指针时,另一个B对象就使用这个指针进行分配内存。
解决方案有两种:
第一种:对分配内存空间的动作进行同步处理 --- 虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。
第二种:把内存分配的动作按照线程划分到不同的空间执行 --- 即每个线程在Java堆中预先分配一块小内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。哪个线程需要分配内存,就到自己的TLAB中分配。TLAB不足时就会分配新的TLAB,这时就需要同步锁定。虚拟机是否采用TLAB,可以通过-XX:+/-UseTLAB参数设定。
④内存分配完毕后,虚拟机就会对分配到的内存空间都初始化为零值(不包括对象头(Object Header))。如果使用TLAB,那么这一工作过程也可以提前至TLAB分配时进行。这一步的操作目的保证了对象的实例字段在Java代码中可以不赋初始值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值。
⑤接着虚拟机就会对对象进行必要的设置,例如这个对象是哪个类的实例,如何找到类的元数据信息,对象的哈希码,对象的GC分代年龄等信息。这些信息都保存在对象的对象头(Object Header)之中。
上面都完成后,在虚拟机中对象创建过程就完成了,从虚拟机的角度来看,一个新的对象就以产生。但是从Java角度来看,对象创建才刚刚开始。
在Java程序中对象创建过程
虚拟机执行完new指令后,就会执行<init>方法,对象按照程序员的意愿进行初始化,这样一个新对象才算完全创建出来。
对象的内存布局
在创建完新对象后,我们了解一下在HotSpot虚拟机中,对象在内存中存储的布局。
对象在内存中分配的内存大致可以分为三大部分:对象头(Header),实例数据(Instance Data)和 对齐填充(Padding)。
①对象头(Header):在HotSpot虚拟机中的对象头可以分为两部分:Mark Word 与 类型指针。
第一部分用于存储对象自身的运行时数据,比如:哈希码(HashCode),GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等,占用内存与虚拟机位长一致。官方城这些数据为“Mark Word”。由于对象需要存储的运行时数据有很多,而这些数据大部分都超过了32位,64位Bitmap结构所能记录的限度,考虑虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的信息。
比如在32位HotSpot中通过markOop类型实现的Mark Word,查看 markOop.hpp文件

以下是图中的各个字段意思
hash: 保存对象的哈希码
age: 保存对象的分代年龄
biased_lock: 偏向锁标识位
lock: 锁状态标识位
JavaThread:* 保存持有偏向锁的线程ID
epoch: 保存偏向时间戳
在不同的锁状态下,存储的运行时数据均有不同:

了解此图中的含义,有助于了解synchronized锁的实现
推荐阅读
https://www.jianshu.com/p/9c19eb0ea4d8
第二部分是类型指针,即对象指向它的类元数据的指针。作用是虚拟机通过这个可以判定该对象是哪个类的实例,当然反过来讲,要判断该对象是哪一个类的实例并非一定要通过类型指针。如果对象是一个Java数组,那么在对象头中还必须要有一块用于记录数组长度的数据,因为虚拟机无法通过数组的类元数据判断该数组的大小。
②实例数据(Instance Data):存储对象真正的有效信息,包括:程序代码中定义的各种类型的字段内容(父类与子类均包括)。实例数据的存储会受到虚拟机分配策略参数(FieldsAllocationStyle)和Java源码中的定义顺序影响。而虚拟机分配策略参数(FieldsAllocationStyle)是将相同宽度的字段分配在一起(并非绝对),然后父类字段通常会出现在子类之前(并非绝对,若子类字段较窄,也有可能插到父类变量的空隙中)。
③对齐填充(Padding):这个部分是不强制要求存在的部分,起着占位符的作用。在虚拟机中要求对象的起始地址必须是8字节的整数倍,而对象头这部分刚好也是8字节的整数倍,因此当实例数据部分没有凑齐8字节的整数倍时,对齐填充来补全空缺。
对象的访问定位
栈保存了基本型与对象引用,我们在访问对象时,就需要通过栈上的reference数据来定位访问堆中的具体对象。
目前主流的通过栈上的对象引用访问堆中的具体对象的方式由两种:句柄和直接指针。
①使用句柄访问堆上对象。
在Java堆中将会划分出一块内存来作为句柄池。在栈中的reference存储的就是对象的句柄地址,而句柄中又保存了实例数据与类型数据各自的具体的地址信息。

②使用直接指针访问堆上对象。
reference变量中直接存储的就是对象的地址,而java堆对象一部分存储了对象实例数据,另外一部分存储了对象类型数据。

使用句柄访问方式最大好处就是reference中存储的是稳定的句柄地址,在对象移动时只需要改变句柄中的实例数据指针,而reference不需要改变。使用指针访问方式最大好处就是速度快,它节省了一次指针定位的时间开销,就HotSpot而言,它使用的是第二种方式(直接指针访问)。
推荐阅读 https://blog.csdn.net/java2000_wl/article/details/8015105
网友评论