JVM中的对象探秘(一)- 对象的创建

  JAVA代码中我们最熟悉的关键词莫过于 new 了,使用new我们可以轻而易举的创建一个对象,可是在jvm中这个对象是如何被创建的呢?今天,我们一起来探秘一下。

  当JVM遇到一条new指令时,首先会去检查这个指令的参数能否在常量池中定义到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那么必须先进行相应的类加载过程(类加载过程我之后别的文章会详解一下,本期暂不讲解)。

  当类加载检查通过以后,JVM就可以为新生对象分配内存了。那么为新生对象分配内存这件事其实就是在JAVA堆内存中划分一块地址给它而已。分配方法主要有两种:指针碰撞法和空闲列表法。

  1. 指针碰撞

    我们试想一下,假设内存现在是规整的,已分配的在一边,未分配的在一边,两段链接的地方有个指针;那么分配内存这件事就简单多了,只要将这个指针向未分配内存那边挪动与对象大小相等的距离即可,这样的分配方式叫做指针碰撞。

    JVM中的对象探秘(一)- 对象的创建

  2. 空闲列表

    那么如果JAVA堆内存现在是不规整的呢,清楚垃圾回收算法的同学应该都知道,使用标记清除算法进行垃圾回收的话是会产生空间碎片的,这样的内存是不规整的,已分配内存和未分配内存穿插在一起;这样的话在JVM中必须维护一个列表,记录哪些内存是可用的、有多大。使用空闲列表分配内存的话则需要在这个列表上寻找一块足够大的内存空间划分给新生对象。

JVM中的对象探秘(一)- 对象的创建

  图片来自 一张图解释指针碰撞和空闲列表

  因此使用标记整理算法垃圾收集器的系统通常采用指针碰撞的方式来分配内存(Serial Old、Parallel Old),使用标记清除算法垃圾收集器的系统采用空闲列表方式来分配内存(CMS)。

  但是我们需要注意的是虽然为对象分配内存时仅仅就是在JAVA堆内存中划分一部分区域出来并将指针相应的移动,但是这个操作是非常频繁的。在并发情况下并不是安全的,很有可能这个线程为对象A分配了这段内存,指针并没来得及修改,另一个线程又为对象B分配了这同一段内存。所以JVM为了解决这一问题采用了CAS + 失败重试的方法来保证分配内存这一动作的原子性;

  此外JVM还为我们提供了另一种方案来解决并发安全问题。TLAB即(Thread Local Allocation Buffer)本地线程分配缓冲,JVM会为每一个线程在堆内预先分配一小块内存,当哪个线程需要为对象分配内存时,只需要在自己的那一小块分配即可。既然每个线程都只在自己的那一部分分配内存,这样自然不会产生并发安全问题。当然,这一小块内存也是有限的,当这一小块内存用完了的话,还是会采用CAS的方式在堆内进行内存分配或者再申请一块TLAB空间(根据所申请的大小和TLAB最大可允许浪费的空间来决定)。

  JVM源码分析-TLAB 对源码感兴趣的同学,可以跟着这个老哥的思路看一下源码
  HotSpot源码在线地址 我们也可以把源码下载下来,自己慢慢读;习惯了用IDEA的同学,可以下载一个CLION来看C源码。

  需要注意的是,虽然对象的内存是分配在TLAB中,但它本质还是在堆中的,所以其它线程也是可以访问的。TLAB仅仅是在分配内存这一操作上是被线程独占的。换而言之,别的线程也是可以读取这部分内存的,只是不能进行分配而已。

  虚拟机是否使用TLAB,我们可以通过-XX+/-UseTLAB 参数来设定,默认是开启的。

  内存分配完成之后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步保操作保证了对象的实例字段在JAVA代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

  接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

  在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java程序的视角来看,对象创建才刚刚开始,<init>方法还没有执行,所有的字段都还为零。执行new指令之后会接着执行<init>方法,把对象按照程序员的意愿进行初始化,即为属性赋值,执行构造方法等,这样一个真正可用的对象才算完全产生出来。