对象创建过程


一、对象创建过程,也就是我们常说的  new一个对象大体上需要经历什么?

①  类加载检查 

     虚拟机在执行一条new指令时,首先会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程(类加载过程见博主的另一篇文章,关于这部分的详解)

分配内存

    在第一步类加载检查通过后,虚拟机将会为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务,其实就是把一块确定大小的内存  从java 堆中划分出来。

    划分内存的方法:

    1》 指针碰撞(Bump the Pointer):默认使用该方法

     如果java堆中内存是绝对规整的,已使用内存都放在一边和空闲内存放在另一边,中间用一个指针作为临界点分割。那么分配内存就是:  把指针向空闲那边挪动一段与对象大小相等的距离。

     理解下图:

     

  2》 空闲列表(Free List):

          如果java堆中内存并不是规整的,已使用内存和空闲的内存相互交错,那么,虚拟机需要维护一个列表,记录那些内存是可用的,在分配的时候会冲列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。

在这个创建过程中,聪明的你会发现在默认指针碰撞方式中其实存在安全问题:

在并发情况下,可能出现正在给对象A分配内存,指针还没有来得及修改,对象B又同时使用了原来的指针来分配内存的情况,

那么针对这种可能出现的情况,虚拟机是怎么解决的呢?

其实很简单:

1. CAS(compare and swap)(后续会专门写一篇文章介绍CAS的原理和实现)

虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配空间的动作进行同步处理

2. 本地线程分配缓存(Thread Local Allocation Buffer,TLAB)(后续会专门写一篇文章介绍CAS的原理和实现)

给每个线程在java堆中预分配一小块内存。实现线程中相互隔离。可通过参数:-XX:+/-UseTLAB 来设定虚拟机是否使用TLAB(jdk8 默认开启) -XX:TLABSize 指定TLAB大小。

③ 初始化

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段

在java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

④ 设置对象头

初始化后,虚拟机要对 对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。

这些信息存放在对象的对象头Object Header中。

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域: 对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)

Hotspot虚拟机的对象头包括两部分信息,

第一部分:用于存储对象自身的运行时数据(哈希码、GC分代年龄(在垃圾回收篇章详细探讨)、锁状态标志(在后续synchronized篇章详细探讨)、线程持有的锁、偏向线程ID、偏向时间戳等)

另一部分:类型指针,也就是指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

⑤ 执行init方法

 执行init方法,此阶段才是真正的給字段属性  赋值。

二、重点理解 对象内存分配 过程(JDK8)

  我们常常下意识,吐口而出 new出的 对象是放在堆里面的,这种描述其实是不准确的,不严谨的。

  正确的理解流程为:(先判断是否栈上分配,,最后才到堆分配)

  ① 栈上分配 (依赖于逃逸分析和标量替换

   1.我们知道,当对象没有被引用的时候,依靠GC进行回收内存,那么如果对象数量较多的时候,势必会给GC带来较大的压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,

JVM通过逃逸分析确定该对象是否被外部访问。如果不会逃逸,那么可以将对象在栈上分配内存,这样对象所占用的内存空间就可以随栈帧而销毁,就减轻了垃圾回收的压力了。

   2.通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。开启标量替换参数(-XX:EliminateAllocations,jdk7以后默认开启)

  关于逃逸分析简单描述:就是一个方法是否有返回值(返回值是对象),当有返回时,那么就存在被外部引用的可能,根据逃逸分析,这种就不能定义为无效对象,相反,没有返回值时,那么

就说明该对象在方法结束后,就是无效对象了。(参数:-XX:DoEscapeAnalysis jDK7后默认开启)

  ② 堆分配

   1. 首先在Eden区分配 (Eden与Survivor 默认8:1:1,该部分在后续垃圾回收篇里在详细讲解)

    当Eden区没有足够空间进行分配时,虚拟机会发起一次Minor GC 。

  给allocation2分配内存的时候eden区内存几乎已经被分配完了,我们刚刚讲了当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC,GC期间虚拟机又发现allocation1无法存入Survior空间,所以只好把新生代的对象提前转移到老年代中去,老年代上的    空间足够存放allocation1,所以不会出现Full GC。执行Minor GC后,后面分配的对象如果能够存在eden区的话,还是会在eden区分配内存。

   2.大对象直接进入老年代(-XX:PretenureSizeThreshold)    

    大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数 -XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下有效。

    比如设置JVM参数:-XX:PretenureSizeThreshold=1000000 (单位是字节)  -XX:+UseSerialGC  ,再执行下上面的第一个程序会发现大对象直接进了老年代。

   3.长期存活对象进入老年代  

  既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。

  如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor空间中,并将对象年龄设为1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度
  (默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数  -XX:MaxTenuringThreshold  来设置。

 4.对象动态年龄判断  

  当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了,
  例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年
  龄判断机制一般是在minor gc之后触发的。

 5. 老年代空间分配担保机制   

  年轻代每次minor gc之前JVM都会计算下老年代剩余可用空间如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象)就会看一个“-XX:-HandlePromotionFailure”(jdk1.8默认就设置了)的参数是否设置了
  如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次minor gc后进入老年代的对象的平均大小。如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full gc,对老年代和年轻代一起回收一次垃圾,
  如果回收完还是没有足够空间存放新的对象就会发生"OOM",当然,如果minor gc之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发full gc,full gc完之后如果还是没有空间放minor gc之后的存活对象,则也会发生“OOM”。

   

今天的分享就先到这里了,有不足的地方请各位看官评论区指出来,让我们共同进步,分享我们对技术的理解。

垃圾回收相关内容见下一篇文章分享。