GeekIBLi

JVM-对象创建

2021-07-30

1.对象创建方式

Java中有一下几种方式创建对象:

方式 实质
使用new关键 调用无参或有参构造器函数创建
使用Class的newInstance方法 调用无参或有参构造器函数创建,且需要是publi的构造函数
使用Constructor类的newInstance方法 调用有参和私有private构造器函数创建,实用性更广
使用Clone方法 不调用任何参构造器函数,且对象需要实现Cloneable接口并实现其定义的clone方法,且默认为浅复制
第三方库Objenesis 利用了asm字节码技术,动态生成Constructor对象

2、对象创建过程

image-20210730153219366

2.1 类的加载

虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

2.2 分配内存

类的加载检查通过后,接下来是为新生对象分配内存。但类加载完成后所需的内存大小就已经完全确定,为对象分配空间的任务等同于把一块确定大小的内存从java堆中划分出来。分配内存有两种方式:

  • 指针碰撞(Bump the Pointer):假设java堆中内存是绝对规整的,所有用过得内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪到一段与对象大小相等的距离
  • 空闲列表(Free List):如果java堆中的内存并不是完整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。

选择哪种分配方式由java堆是否完整决定,而java堆是否规整又由所采用的垃圾收集器是否带有压缩整理(标记-整理)功能决定。因此,在使用Serial、ParNew等带有Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。

还有一个问题需要考虑,在虚拟机中对象频繁的创建(即使是修改一个指针所指的位置),在并发情况下会带来线程安全的问题。作为虚拟机来说,必须保证线程安全,所有虚拟机采用两种方式保证线程安全:

  • CAS+失败重试:CAS是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用CAS配上失败重试的方式保证更新操作的原子性
  • 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB):为每一个线程预先在Eden区分配一块内存,JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中剩余内存或TLAB的内存已用尽时,在采用上述的CAS进行内存分配

2.3 初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这⼀步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使⽤,程序能访问到这些字段的数据类型所对应的零值

2.4 设置对象头

初始化零值完成之后,虚拟机要对对象进⾏必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运⾏状态的不同,如是否启⽤偏向锁等,对象头会有不同的设置⽅式。

2.5 执行Init方法

在上⾯⼯作都完成之后,从虚拟机的视⻆来看,⼀个新的对象已经产⽣了,但从 Java 程序的视⻆来看,对象创建才刚开始, <init> ⽅法还没有执⾏,所有的字段都还为零。所以⼀般来说,执⾏ new 指令之后会接着执⾏ <init>⽅法,把对象按照程序员的意愿进⾏初始化,这样⼀个真正可⽤的对象才算完全产⽣出来。

3.对象在内存布局

image-20210730160335842

对象头(Header):包含两部分,第一部分用于存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,32 位虚拟机占 32 bit,64 位虚拟机占 64 bit。官方称为 ‘Mark Word’。第二部分是类型指针,即对象指向它的类的元数据指针,虚拟机通过这个指针确定这个对象是哪个类的实例。另外,如果是 Java 数组,对象头中还必须有一块用于记录数组长度的数据,因为普通对象可以通过 Java 对象元数据确定大小,而数组对象不可以。

实例数据(Instance Data):程序代码中所定义的各种类型的字段内容(包含父类继承下来的和子类中定义的)。

对齐填充(Padding):不是必然需要,主要是占位,保证对象大小是某个字节的整数倍。

4. 对象访问

建⽴对象就是为了使⽤对象,我们的Java程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问⽅式有虚拟机实现⽽定,⽬前主流的访问⽅式有使⽤句柄和直接指针两种:

4.1 句柄访问

如果使⽤句柄的话,那么Java堆中将会划分出⼀块内存来作为句柄池,reference 中存储的就是对象的句柄地址,⽽句柄中包含了对象实例数据与类型数据各⾃的具体地址信息;

image-20210730160814175

4 .2 直接指针

如果使⽤直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,⽽reference 中存储的直接就是对象的地址。

image-20210730161006154

这两种对象访问⽅式各有优势。使⽤句柄来访问的最⼤好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,⽽ reference 本身不需要修改。使⽤直接指针访问⽅式最⼤的好处就是速度快,它节省了⼀次指针定位的时间开销。

5、对象内存分配

image-20210730164126810

Java内存体系中所提倡的自动内存管理最终可以归结为自动化地解决两个问题:给对象分配内存回收分配给对象的内存

对象的内存分配,往大方向讲,就是在堆上分配,对象主要分配在新生代的Eden区域,如果启动了本地线程分配缓冲,将按线程优先分配在TLAB上。少数情况下也可能直接分配在老年代中。具体的分配规则取决于垃圾收集器的类型以及虚拟机中参数的配置。但是有几条最普遍的内存分配规则如下:

5.1 对象优先在Eden分配

大多数情况下,对象在新生代Eden区进行分配。当Eden区没有足够内存进行分配时,虚拟机将会发起一次Minor GC。

5.2 大对象直接进入老年代

所谓大对象,是指需要大量连续存储空间的Java对象,最典型的大对象就是那种很长的字符串或者数组。大对象对虚拟机分配来说是一个坏消息,经常出现大对象会导致虚拟机需要经常调用GC来为这些大对象整理出足够的连续空间。

5.3长期存活的对象将进入老年代

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应该放在新生代,哪些对象应该放在老年代。为了做到这一点,虚拟机给每一个对象定义了一个对象年龄计数器。如果对象在Eden出生并且经过了第一次Minor GC后仍然存活,并且能够被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设置为1。对象在Survivor区域中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁)对象将会被晋身到老年代中。

5.4 动态对象年龄判定

为了能够更好的适应不同程序的内存状况,虚拟机并不是每次都要等到对象的年龄到达阈值才将对象移动到老年代。如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等待年龄增长。

5.6 空间分配担保

在发生Minor GC之前,虚拟机会先检查老年代中最大可用的连续空间是否大于新生代所有对象空间综合,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次GC是有风险的;如果小于,或者设置不允许,那这时将改为进行一次Full GC。

Tags: JVM