当前位置: 首页 > 科技观察

new堆中一个对象的历史

时间:2023-03-11 20:21:33 科技观察

大家好,我是小牛~我写文章的过程一般是边看书边看博客边做笔记,过段时间再发这些笔记总结成文章和输出,这样才能加深影响力,文章的质量也不会太低。这篇文章的草稿笔记决定开始写,其实已经一个月了。想着趁着寒假,甩掉恶心的深度学习,把JVM知识点全扫一遍,顺理成章。我只是储存了几篇文章。谁知回家后无心读书,只能每天刷几下LeetCode来弥补第二天积累的焦虑和愧疚。停止,废话结束。今天介绍两个JVM中高频的基础问题:对象的创建过程(一个新对象在堆中的历史)和堆上对象分配的两种方法。对象创建过程分为五步,如下图:感觉JVM如果不看GC收集器(滑稽),好像老规矩不多,背诵版在文章的结尾。点击阅读原文直达我收集整理的各大厂面试真题。对象创建过程的第一步是类加载检查。所谓类加载检查,就是检测我们要new出的对象的类是否已经被JVM成功加载,解析并初始化(具体的类加载过程会在后续的文章中详细讲解~)具体来说,当Java虚拟机遇到字节码new指令时:1)首先查看常量池表(ConstantPoolTable)能否找到该类对应的符号引用这里可以回顾一下常量池表(ConstantPoolTable)的概念:用于存放编译过程中产生的各种字面量(字面量在Java语言层面相当于常量字面串、常量值等概念declaredfinal等)和符号引用。有些文章会把类常量池表称为静态常量池。它们都是常量池。常量池表和方法区的运行时常量池有关系吗?运行时常量池有什么用?运行时常量池可以在运行时解析类常量池表中的符号引用,供直接引用。简单的说,类常量池表相当于一堆索引。运行时常量池使用这些索引来查找相应方法或字段的类型信息、名称和描述符信息。2)然后到方法区的运行时常量池中查找符号引用指向的类是否已经被JVM加载、解析和初始化。如果没有,则先执行相应的类加载过程。如果是,则转到下一步并为新对象分配内存。最后,如果以后创建了这个对象,那肯定是有地方放的吧?那么JVM就会为新对象分配内存空间。至于JVM怎么知道要分配多少空间呢?实际上,对象需要的内存大小在类加载完成后就可以完全确定了。在Hotspot虚拟机中,对象在内存中的布局可以分为三个区域:对象头、实例数据和对齐填充。1)Hotspot虚拟机的对象头包括两部分信息:第一部分用于存放对象本身的运行时数据(如哈希码(HashCode)、GC分代年龄、锁状态标志、持有的锁thread,biasedthreadID,biastimestamp等,这部分数据的长度在32位和64位虚拟机中分别是32位和64位(没有开启压缩指针),官方称之为《标记词》。学过synchronized的小伙伴们一定很熟悉吧~)另一部分是类型指针,即对象指向其类型元数据的指针。虚拟机使用这个指针来确定对象是哪个类的实例。2)实例数据部分存储了这个对象真正有效的信息,即程序代码中定义的各类字段的内容,无论是继承自父类还是定义在子类中,都必须记录。3)alignmentpadding部分不是必须的,没有特殊意义,只是占位。因为Hotspot虚拟机的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,换句话说,对象的大小必须是8字节的整数倍。对象头部分恰好是8字节的倍数(1倍或2倍),因此,当对象实例数据部分不对齐时,需要通过对齐填充来完成。堆上对象的两种分配方式。为一个对象分配内存空间的任务一般来说就是从Java堆中为这个对象划分出一定大小的内存块。根据堆中的内存是否规律,有两种划分方式,或者说在堆上分配对象有两种方式:1)假设Java堆中的内存是绝对规律的,所有使用过的内存都放在aside,空闲内存放在另一边,中间放一个指针作为分界点的指示。分配的内存只是将指针移动到空闲空间的距离等于对象的大小。这种分配方式叫做Forpointercollision(BumpThePointer)2)如果Java堆中的内存不规则,已用内存和空闲内存相互交错,那么就没有办法简单的bumpthepointer,并且虚拟机必须维护一个列表来记录哪些内存块是可用的。分配时,从链表中找到足够大的连续空间分配给这个对象,并更新链表上的记录。这种分配方式称为空闲列表(FreeList)。选择哪种分配方式取决于Java堆是否规则。那么同学们会问,谁来决定堆是否规则呢?Java堆是否规则取决于所使用的垃圾收集器是否具有空间压缩(Compact)由垃圾收集器的能力决定(或者由垃圾收集器采用的垃圾收集算法,见后续文章对于具体的垃圾收集算法):因此,在使用Serial、ParNew等带有compaction过程的收集器时,系统使用的分配算法是指针碰撞,简单高效。当使用基于Sweep算法的收集器时,比如CMS,理论上只能使用更复杂的freelist来分配内存。创建对象时的并发安全问题。另外,在Whencreatingmemoryforobjects中,还有一个问题需要考虑:并发安全问题。对象创建是虚拟机中非常频繁的行为。以上面介绍的指针碰撞方式为例,即使只修改了一个指针,在并发情况下也不是线程安全的。对象A分配内存,指针还没来得及修改,另一个线程创建对象B,同时使用原来的指针分配内存。解决这个问题有两种方案:方案一:CAS+失败重试:CAS大家都应该很熟悉,比较交换,乐观锁方案,如果失败就重试,直到成功。方案二:本地线程分配缓冲区(ThreadLocalAllocationBuffer,TLAB):每个线程在堆中预先分配一小块内存,每个线程拥有的这一小块内存称为TLAB。哪个线程要分配内存,就在那个线程的TLAB中分配,这样各个线程互不干扰。如果一个线程的TLAB用完了,那么虚拟机就需要为它分配一个新的TLAB,然后需要同步锁。是否使用TLAB可以通过-XX:+/-UseTLAB参数设置。零值初始化内存分配完成后,JVM会将分配的内存空间(当然不包括对象头)初始化为零值,比如将boolean字段初始化为false,int字段初始化为0等。-by-step操作确保对象的实例字段可以在Java代码中直接使用而无需赋初值,从而程序可以访问这些字段的数据类型对应的零值。如果使用TLAB,初始化零值的工作可以在分配TLAB时提前完成,顺便设置对象头。上面我们说过,对象在内存中的布局可以分为三个区域:对象头(ObjectHeader)、Instancedata和alignmentpaddingAlignmentpadding是没有意义的数据。在上一步中,我们将实例数据初始化为零,那么对象头中的其余信息就不用多说了。一些赋值操作:比如对象是哪个类的实例,如何找到类的元数据信息,对象的哈希码,对象的GC分代年龄等信息。根据虚拟机当前的运行状态,比如是否开启偏向锁等,对象头会有不同的设置方式。执行完init方法的以上四步之后,从JVM的角度来看,一个新的对象其实已经成功诞生了。但是从我们程序员的角度来看,这个对象确实已经创建了,只是没有按照我们定义的构造函数进行赋值,所有的字段还是默认的零值。构造器就是Class文件中的()方法。一般来说,()方法会在new指令之后执行,对象会按照构造函数的意图进行初始化,这样才会完整的构造出一个真正可用的对象,皆大欢喜。最后放上本题的背诵版: