【JVM】面试题:全面剖析创建Java对象的过程

【JVM】面试题:全面剖析创建Java对象的过程


在Java中存在很多种创建对象的方式,最常见且最常用的则是 new关键字,但除开 new关键字之外,也存在其他几种创建对象的方式,如下:

  • ①通过调用Class类的newInstance方法完成对象创建。
  • ②通过反射机制调用Constructor类的newInstance方法完成创建。
  • ③类实现Cloneable接口,通过clone方法克隆对象完成创建。
  • ④从本地文件、网络中读取二进制流数据,通过反序列化完成创建。
  • ⑤使用第三方库Objenesis完成对象创建。

但无论通过哪种方式进行创建对象,虚拟机都会将创建的过程分为三步:类加载检测、内存分配以及对象头设置。

一:类加载检测

当虚拟机遇到一条创建指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,同时并检查这个符号引用代表的类是否被加载解析初始化过。如果没有,在双亲委派模式下,使用当前类加载器以当前创建对象的全限定名作为key值进行查找对应的.class文件,如果没有找到文件,则抛出ClassNotFoundException异常,找到了则先完成 【JVM】Java类加载机制详解 ,完成了类加载过程后,再开始为其对象分配内存。

二:内存分配

当一个对象的类已经被加载后,会依据第一阶段分析的方式去计算出该对象所需的内存空间大小,计算出大小后会开始对象分配过程,而内存分配就是指在内存中划出一块与对象大小相等的区域出来,然后将对象放进去的过程。但需要额外注意的是:Java的对象并不是直接一开始就尝试在堆上进行分配的,分配过程如下:
image-20230316220235390

1:栈上分配

栈上分配是属于C2编译器的激进优化,建立在逃逸分析的基础上,使用标量替换拆解聚合量,以基本量代替对象,然后最终做到将对象拆散分配在虚拟机栈的局部变量表中,从而减少对象实例的产生,减少堆内存的使用以及GC次数。

逃逸分析:逃逸分析是建立在方法为单位之上的,如果一个成员在方法体中产生,但是直至方法结束也没有走出方法体的作用域,那么该成员就可以被理解为未逃逸。反之,如果一个成员在方法最后被return出去了或在方法体的逻辑中被赋值给了外部成员,那么则代表着该成员逃逸了。
标量替换:建立在逃逸分析的基础上使用基本量标量代替对象这种聚合量,标量泛指不可再拆解的数据,八大基本数据类型就是典型的标量。

如果对象被分配在栈上,那么该对象就无需GC机制回收它,该对象会随着方法栈帧的销毁随之自动回收。但如果一个对象大小超过了栈可用空间(栈总大小-已使用空间),那么此时就不会尝试将对象进行栈上分配。

栈上分配因为是建立在逃逸分析之上的,所以能够被栈上分配的对象绝对是只在栈帧内有用的,也就代表栈上分配的对象不会有GC年龄,随着栈帧的入栈出栈动作而创建销毁。

2:TLAB分配

TLAB全称叫做Thread Local Allocation Buffer,是指JVM在Eden区为每条线程划分的一块私有缓冲内存。在上篇对于JVM内存区域分析的文章中曾分析到:大部分的Java对象是会被分配在堆上的,但也说到过堆是线程共享的,那么此时就会出现一个问题:当JVM运行时,如果出现两条线程选择了同一块内存区域分配对象时,不可避免的肯定会发生竞争,这样就导致了分配速度下降,举个例子理解一下:

背景:唐朝
故事:建房子
张三和李四两家的孩子都长大了(在古代男子成年后需要分家),张三和李四都有点小钱,所以都想着花钱去官府买块地,然后给各自的孩子建栋房子,后面张三和李四看上了同一块地皮,双方都不肯谦让。此时该怎么办?必然会出现冲突,谁赢了这块地归谁。而双方一发生冲突,从吵架、打架、报官、调解…,又会耽误一大段时间,最终导致建房子的事情一拖再拖…

从上述这个故事中可以看出,这种“多者看上同一块地皮”的事情是非常影响性能的,那此时如何解决这类问题呢?

对于官府而言,类似“张三李四”这样的事情如果是少量发生还好,但这种事情三天两头来一起,最终地方官府上报给朝廷,朝廷为了根治这类问题,直接推出了“土地私有化”制度,给每户人家分配几亩土地,如果要给自己的孩子建房子,那么不需要再在官府花钱买公用土地了,直接在自己分配的土地上建房子,此时这个问题就被根治了。

而在JVM中也存在类似的烦恼,在为对象分配内存时,往往会出现多条线程竞争同一块内存区域的“惨案”,虚拟机为了根治这个问题同样采取了类似于上述故事中“朝廷”的手段,为每条线程专门分配一块内存区域,这块区域就被称为TLAB区,当一条线程尝试为一个对象分配内存时,如果开启了TLAB分配的情况下,那么会先尝试在TLAB区域进行分配。(程序启动时可以通过参数-XX:UseTLAB设置是否开启TLAB分配)。

而值得一提的是:TLAB并不是独立在堆空间之外的区域,而是JVM直接在Eden区为每条线程划分出来的。默认情况下,TLAB区域的大小只占整个Eden区的1%,不过也可以通过参数:-XX:TLABWasteTargetPercent设置TLAB区所占用Eden区的空间占比。

一般情况下,JVM会将TLAB作为内存分配的首选项(C2激进优化下的栈上分配除外),只有当TLAB区分配失败时才会开始尝试在堆上分配。

TLAB分配过程

当创建一个对象时,开启了激进优化的情况时,首先会尝试栈上分配,如果栈上分配失败,会进行TLAB分配,首先会比较对象所需空间大小和TLAB剩余可用空间大小,如果TLAB可以放下去,那么就直接将对象分配在TLAB区。如果TLAB区的可用空间分配不下该对象,则会先判断剩余空间是否大于规定的最大空间浪费大小,如果大于则直接在堆上进行分配,如果不大于则先使用空对象填充内存间隙,然后将当前TLAB退回堆空间,重新根据期望值申请一个新的TLAB区,再次进行分配。如下:
image-20230316220356662

在上面的TLAB分配过程分析中,提到了几个名词:最大空间浪费大小、内存间隙以及期望值,释义如下:
最大空间浪费:其意如名,是指JVM允许一个TLAB区最多剩余多少内存不使用,一般来说这个值是动态的。
内存间隙:当前 TLAB不够分配时,如果剩余空间小于最大空间浪费限制,那么这个 TLAB区会被退回Eden区,然后重新申请一个新的TLAB,而这个TLAB被退回到Eden区之后,该TLAB的剩余空间就会成为孔隙。如果不管这些孔隙,由于TLAB仅线程内知道哪些被分配了,在GC扫描发生时,又需要做额外的检查,那么会影响GC扫描效率。所以TLAB回归Eden的时候,会将剩余可用的空间用一个dummy object(空对象) 填充满。如果填充已经确认会被回收的对象,也就是dummy object,GC会直接标记之后跳过这块内存,增加GC扫描效率。
期望值:期望值这个概念在JVM中是惯用的思想,无论是JIT还是GC等,都以期望值作为激进优化的基础,这个期望是根据JVM运行期间的“历史数据”计算得出的,也就是每次输入采样值,根据历史采样值得出最新的期望值。

TLAB中常用的期望值算法EMA - 指数移动平均数算法

EMA(Exponential Moving Average)算法的核心在于设置合适的最小权重,最小权重越大,变化得越快,受历史数据影响越小。根据应用设置合适的最小权重,可以让你的期望更加理想。具体可以参考:百度百科

注意:当TLAB退回给堆空间时,那原本里面存储的对象需要挪动到新的TLAB区域吗?

答案是不需要的,因为TLAB区本身使用的就是Eden区的内存划出来的,所以直接将间隙内存填充好空对象之后退回给堆空间即可,原本的对象不需要挪动到新分配的TLAB区中,照样是可以通过原本的引用指针访问之前位置中的对象的,唯一需要改变的就是将线程的TLAB区指向改成新申请的内存区域。

3:年老代分配

如果在TLAB区尝试分配失败后,对象会进行判定:是否满足年老代分配标准,如果满足了则直接在年老代空间中分配。可能有些小伙伴会疑惑:对象不是先尝试在新生代进行分配之后,再进入年老代分配吗?其实这是错误的概念,对象在初次分配时会先进行判定一次是否符合年老代分配标准,如果符合则直接进入年老代。

年老代分配条件

初次分配时,大对象直接进入年老代。
一般对象进入年老代的情况只有三种:大对象、长期存活对象以及动态年龄判断符合条件的对象,在JVM启动的时候你可以通过-XX:PretenureSizeThreshold参数指定大对象的阈值,如果对象在分配时超出这个大小,会直接进入年老代。

这样做的好处在于:可以避免一个大对象在两个survivor区域来回反复横跳。因为每次新生代GC时,都会将存活的对象从一个survivor区移动到另外一个survivor区,而一般来说,大对象绝对不属于朝生夕死的对象,所以就代表着:大对象被分配之后很大几率都会在两个survivor区来回移动,大对象的移动对于JVM来说是比较沉重的负担,内存分配、数据拷贝等都需要时间以及资源开销。同时因为大对象的迁移会存在耗时,所以也会导致GC时间变长。

所以对于大对象而言,直接进入年老代会比较合适,这也属于JVM的细节方面优化。

上述的这段是基于分代GC器而言的,实则不同的GC器对于大对象的判定标准也不一样,尤其是到了后面的不分代GC器,大对象则不会进入年老代,而是会有专门存储大对象的区域,如G1、ShenandoahGC中的Humongous区、ZGC中的Large区等。

4:新生代分配

如果栈上分配、TLAB分配、年老代分配都未成功,此时就会来到Eden区尝试新生代分配。而在新生代分配时,会存在两种分配方式:

  • ①指针碰撞:指针碰撞是Java在为对象分配堆内存时的一种内存分配方式,一般适用于

    Serial、ParNew
    

    等不会产生内存碎片、堆内存完整的的垃圾收集器。

    • 分配过程:堆中已用分配内存和为分配的空闲内存分别会处于不同的一侧,通过一个指针指向分界点区分,当JVM要为一个新的对象分配内存时,只需把指针往空闲的一端移动与对象大小相等的距离即可。
  • ②空闲列表:与指针碰撞一样,空闲列表同样是Java在为新对象分配堆内存时的一种内存分配方式,一般适用于CMS等一些会产生内存碎片、堆内存不完整的垃圾收集器。

    • 分配过程:堆中的已用内存和空闲内存相互交错,JVM通过维护一张内存列表记录可用的空闲内存块信息,当创建新对象需要分配内存时,从列表中找到一个足够大的内存块分配给对象实例,并同步更新列表上的记录,当GC收集器发生GC时,也会将已回收的内存更新到内存列表。

上述的两种内存分配方式,指针碰撞的方式更适用于内存整齐的堆空间,而空闲列表则更适合内存不完整的堆空间,一般来说,JVM会根据当前程序采用的GC器来决定究竟采用何种分配方式。

在Eden区分配内存时,因为是共享区域,必然会存在多条线程同时操作的可能,所以为了避免出现线程安全问题,在Eden区分配内存时需要进行同步处理,在HotSpot VM中采用的是线程CAS+失败换位重试的方式保证原子性。

5:内存分配小结

至此,关于Java对象的内存分配阶段已阐述完毕,简单来说,如果当前JVM处于热机状态,C2编译器已经介入的情况下,首先会尝试将对象在栈上分配,如果栈上分配失败则会尝试TLAB分配,TLAB分配失败则会判定对象是否满足年老代分配标准,如果满足则直接将对象分配在年老代,反之则尝试将对象在新生代Eden区进行分配。

JVM如果处于冷机状态,C2编译器还未工作的情况下,则TLAB分配作为对象分配的首选项。

三:初始化内存

经过内存分配的步骤之后,当前创建的Java对象会在内存中被分配到一块区域,接着则会初始化分配到的这块空间,JVM会将分配到的内存空间(不包括对象头)都初始化为零值,这样做的好处在于:可以保证对象的实例字段在Java代码中不赋初始值就直接使用,程序可以访问到字段对应数据类型所对应的零值,避免不赋值直接访问导致的空指针异常。

如果对象是被分配在栈上,那所有数据都会被分配在栈帧中的局部变量表中。
如果对象是TLAB分配,那么初始化内存这步操作会被提前到内存分配的阶段进行。

四:设置对象头

当初始化零值完成后,紧接着会对于对象的对象头进行设置。首先会将对象的原始哈希码、GC年龄、锁标志、锁信息组装成MrakWord放入对象头中,然后会将指向当前对象类元数据的类型指针KlassWord也加入对象头中,如果当前对象是数组对象,那么还会将编码时指定的数组长度ArrayLength放入对象中,最终当对象头中的所有数据全部组装完成后,会将该对象头放在对象分配的内存区域中存储。

五:执行<init>函数

当上述步骤全部完成后,最后会执行<init>函数,也就是构造函数,主要是对属性进行显式赋值。从Java层面来说,这也是真正的按照开发者的意愿对一个对象进行初始化赋值,经过这个步骤之后才能够在真正意义上构建出一个可用对象。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小颜-

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值