引言
在Java语言中,我们创建对象通常使用new关键字,一个对象就创建好了。但是在虚拟机中,这中间有经历了什么过程呢?
这篇文章就下面几个问题展开
- 对象是如何创建的呢?(仅限于普通Java对象,不包括数组和Class对象)
- 对象又是如何存储的呢?
- 我怎么知道对象存在新生代还是老年代?
- 怎么去访问存储的对象呢?
下面基于HotSpot介绍对象的创建过程,然后介绍对象的内存布局,最后介绍对象的访问。
对象的创建过程
以HotSpot为例,创建过程如图:
对象的创建过程如下:
JVM遇到new指令时,首先去检查这个指令的参数(也就是类名)是否能在常量池中定位到类的符号引用(@1.检查常量池中有无这个类的符号应用)
回顾一下,方法区的运行时常量池中存储的是编译器生成的各种字变量和符号引用
如果在常量池中找到了类的符号引用,接下来会 @2检查这个符号应用代表的类是否被加载、解析、初始化过,如果没有,必须先执行相应的类加载过程 。类的加载过程在后面的文章中会专门提到
@3类加载检查通过后,JVM会为新生对象分配内存。所谓分配内存,就是要划分一块区域存放新生对象,那么需要解决两个问题:给多大内存?以及在Java堆的哪里划分内存?
第一个问题:在类加载完成后便可以完全确定对象所需的内存大小;
第二个问题,堆的哪一部分给它划分内存呢?假设堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配的内存就仅仅是把那个指针向空闲空间那边移动一段与对象所需内存大小相同的距离,这种方式成为指针碰撞。
还有一种情况,Java堆中使用过的内存和空闲内存相互交错,就没有办法简单地进行指针碰撞了,JVM就必须拿个小本本记下来哪块内存是没有用过的,在分配内存的时候,从小本本找一块够新对象用的内存区域分配给它,然后更新一下小本本的记录,这种方式称为空闲列表
至于选择哪种方式分配内存由Java堆是否规整决定,Java堆是否规整又与JVM采用哪种垃圾收集器确定,比如Serial,ParNew等采用指针碰撞,CMS,G1采用空闲列表
内存分配完成后,虚拟机会 @4.将分配到的内存空间都初始化为零值(不包括对象头),保证对象的示例字段在Java代码中不用赋初始值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值。
接下来 @5.设置对象头信息,例如这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希吗,对象的GC分代年龄等,这些信息存放在对象的对象头中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头有不同的设置方式,关于对象头,稍后做详细介绍。
上面的工作完成后,从虚拟机的角度看,一个新对象已经产生,但从Java程序的角度看,度一想创建才刚刚开始–方法还没执行,所有的字段都还为零值,执行new指令后会接着 @6.执行< init >方法,按程序猿的意愿初始化对象,一个真正可用的对象才算完全产生。
对象的内存布局
在HotSpot虚拟机中,对象在内存中存储的布局可分为:对象头,示例数据,对齐填充 3块区域,如图:
对象头 包括两部分信息
1.存储对象自身的运行时数据,如 哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁,偏向线程ID、偏向时间戳等,官方称他为“Mark Word”。
2.对象头除了“Mark Word”,第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果对象是Java数组,则对象头中多一个用于记录数组长度的数据。
实例数据
这里对象存储的真正有效的信息,也就是代码中定义的在各种类型的字段内容,包括从父类继承的和自己定义的。这里的存储顺序会收到分配策略参数和Java源码中字段定义的顺序影响,总结一下就是 相同宽度的字段总是被分配到一起。
对齐填充
仅仅起占位符的作用,保证对象大小是8字节的整数倍
内存分配策略
我们都知道对象存在Java堆中,那到底存在堆的哪里呢?
对象的内存分配,往大方向上讲,就是在堆上分配(暂不讨论栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程f分配缓冲,将按线程优先在TLAB上分配。少数情况也可能直接分配在老年代中,分配的规则并不是100%固定的,其细节取决于当前使用的事哪一种垃圾收集器,以及虚拟机中内存相关的参数设置。接下来讲解几条最普遍的内存分配规则
1.对象优先在Eden分配
大多数情况下,对象在新生代的Eden区上分配。当Eden区中没有足够的空间进行分配时,虚拟机将会发起一次Minor GC(使用复制算法,针对新生代的GC)
2.大对象直接进入老年代
所谓大对象,是指需要大量连续内存空间的Java对象,比如很长的字符串以及数组。大对象对虚拟机的内存分配来说就是一个坏消息,更坏的消息就遇到一群“朝生夕死”的“大对象”。。。。(程序中应尽量避免),经常出现大对象容易导致还有不少空间时就不得不提前触发垃圾收集以获取足够的空间“安置”它们。虚拟机提供了一个 -XX:PretenureSizeThreshold参数,对象大小超过这个值的对象直接存放在老年代
3.长期存活的对象将进入老年代
新生代中对象,从分配开始,每“熬过”一次Minor GC还不死,并且能被Survivor容纳的话,对象年龄+1,当它的年龄增加到一定程度(默认15岁),就将会被晋升到老年代。当然,这个默认年龄可以通过设置参数
-XX:MaxTenuringThreshold指定
4.动态对象年龄判定
还有一种情况,不必非得要求对象年龄大于MaxTenuringThreshold才能晋升老年代:如果在Survivor空间中有这么一群对象,它们年龄相同,并且这群对象的大小总和超过整个Survivor空间的一半,那么这群对象还有年龄比它们大的对象,直接晋升到老年代。
5.空间分配担保
在发生Minor GC之间,虚拟机先会检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立,那么Minor GC可以确保是安全的,如果不成立,则虚拟机会查看HandlePromoptionmFailure设置值是否允许担保失败,如果允许,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时候也要改为进行一次Full GC(复制算法会把新生代所有存活对象复制到另一个Survivor区中,如果Survivor空间不够,则会放到老年代)。文字描述又掉绕,给个图:
对象的访问定位
我们都知道,对象是存储在堆中的,栈中存在一个(变量)引用,指向堆中某个具体对象,基于上面说到的Java对象的内存布局,可以通过对象头中的类型指针,指向方法区中的对象数据类型(已被虚拟机加载的类信息)。这种方式叫直接指针访问,HotSpot使用的就是这种方式。
另外一种主流的访问方式是使用句柄,差别在于栈中的引用指向对象的句柄地址(直接引用指向的是对象地址),而句柄中又包含了对象实例数据与类型数据各自的具体地址信息。
对比一下:采用句柄的好处就是如果对象化被移动(垃圾收集时很普遍),只用改变句柄中实例数据的指针就好了,引用本身不用修改;直接指针访问方式的好处是更快,因为少了一次指针定位的时间开销(引用直接定位到对象了)。、
本篇到这里就结束了,总结一下,描述了对象的创建流程,对象的内存布局与访问定位,下一篇开始 垃圾收集器与内存分配策略。