2、对象创建与内存分布

1、对象创建

创建对象流程图

当遇到关键字new指令时,Java对象创建过程便开始,整个过程如下:

步骤1:类加载检查

  1. 检查 该new指令的参数 是否能在 常量池中 定位到一个类的符号引用
  2. 检查 该类符号引用 代表的类是否已被加载、解析和初始化过

如果没有,需要先执行相应的类加载过程

  • 检查常量池中是否有即将要创建的这个对象所属的类的符号引用;若常量池中没有这个类的符号引用,说明这个类还没有被定义!抛出ClassNotFoundException;若常量池中有这个类的符号引用,则进行下一步工作;
  • 进而检查这个符号引用所代表的类是否已经被JVM加载;若该类还没有被加载,就找该类的class文件,并加载进方法区;若该类已经被JVM加载,则准备为对象分配内存;
  • 根据方法区中该类的信息确定该类所需的内存大小; 一个对象所需的内存大小是在这个对象所属类被定义完就能确定的!且一个类所生产的所有对象的内存大小是一样的!JVM在一个类被加载进方法区的时候就知道该类生产的每一个对象所需要的内存大小。

步骤2:为对象分配内存

  • 虚拟机将为对象分配内存,即把一块确定大小的内存从 Java 堆中划分出来,对象所需内存的大小在类加载完成后便可完全确定
  • 内存分配 根据 Java堆内存是否绝对规整 分为两种方式:指针碰撞 & 空闲列表

Java堆内存 规整:已使用的内存在一边,未使用内存在另一边 Java堆内存 不规整:已使用的内存和未使用内存相互交错

方式1:指针碰撞(规整)

如果内存是绝对规整的,即左右两边分别是已占用内存和闲置内存,中间有分界点的指针指示器,那么内存分配仅仅在于指针的移动,这种分配方式叫做“指针碰撞”。

那么,分配对象内存=把指针向未使用内存移动一段与对象大小相等的距离

方式2:空闲列表(不规整

如果内存不规整,即已使用和空闲的内存交错分布,那么虚拟机必须维护一个列表,记录哪些内存可用。创建对象时从列表中找到一块足够大的空间划分给对象使用,同时更新列表记录。这种分配方式称为“空闲列表”

对象分配内存会存在线程不安全问题
对象创建在虚拟机中是非常频繁的操作,即使仅仅修改一个指针所指向的位置,在并发情况下也会引起线程不安全。如,正在给对象A分配内存,指针还没有来得及修改,对象B又同时使用了原来的指针来分配内存

解决线程不安全有两种方案:
1、同步处理分配内存空间的行为
虚拟机采用CAS(Compare and Swap,多线程操作中只有一个线程能成功,其他线程被通知竞争失败)配上失败重试的方式保证操作的原子性

2、把内存分配的动作按线程分在不同的空间中进行,这样可以很大程度避免在并发情况下频繁创建对象造成的线程不安全

每个线程在Java堆中预先分配一小块内存,各个线程的内存分配发生在自己的TLAB区域内,只有TLAB用完需要分配新的空间时才需要同步锁定,即每个线程在堆中都会有私有的分配缓冲区(TLAB),分配内存的时候在当前线程的TLAB上分配,只有旧的TLAB用完才给新的TLAB时才需要同步锁定。虚拟机是否使用TLAB,可通过-XX:+/-UseTLAB参数设定。

步骤3: 将内存空间初始化为零值

内存分配完成后,虚拟机需要将分配到的内存空间初始化为零(不包括对象头):内存空间分配完成后会初始化为0(不包括对象头)保证了Java代码中不赋初值就可以使用。

  1. 保证了对象的实例字段在使用时可不赋初始值就直接使用(对应值 = 0)
  2. 如使用本地线程分配缓冲(TLAB),这一工作过程也可以提前至TLAB分配时进行。

步骤4: 对对象进行必要的设置

  • 接下来就是填充对象头,把对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象GC分代年龄等信息存入对象头。JVM根据当前运行状态的不同设置不同的对象头。
  • 执行new指令后执行init方法后(初始化)才算一份真正可用的对象创建完成。

2、对象的内存分布

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

1 对象头区域

此处存储的信息包括两部分:

对象自身的运行时数据

  • 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等
  • 该部分数据被设计成1个非固定的数据结构以便在极小的空间存储尽量多的信息(会根据对象状态复用存储空间)

对象类型指针

  • 即对象指向它的类元数据的指针(++方法区加载的类++)
  • 虚拟机通过这个指针来确定这个对象是哪个类的实例

特别注意

如果对象是数组,那么在对象头中还必须有一块用于记录数组长度的数据
因为虚拟机可以通过普通Java对象的元数据信息确定对象的大小,但是从数组的元数据中却无法确定数组的大小。

2 实例数据区域

存储的信息:对象真正有效的信息 即代码中定义的字段内容

3 对齐填充区域

存储的信息:占位符 占位作用
因为对象的大小必须是8字节的整数倍
而因HotSpot VM的要求对象起始地址必须是8字节的整数倍,且对象头部分正好是8字节的倍数。
因此,当对象实例数据部分没有对齐时(即对象的大小不是8字节的整数倍),就需要通过对齐填充来补全。

3、对象的访问定位

问:建立对象后,该如何访问对象呢?实际上需访问的是 对象类型数据 & 对象实例数据

答:Java程序 通过 栈上的引用类型数据(reference) 来访问Java堆上的对象 由于引用类型数据(reference)在 Java虚拟机中只规定了一个指向对象的引用,但没定义该引用应该通过何种方式去定位、访问堆中的对象的具体位置

所以对象访问方式取决于虚拟机实现。目前主流的对象访问方式有两种:
句柄访问、直接指针访问

1、句柄访问

句柄访问方式,会在Java堆中划分一块内存作为句柄池,Java栈中的reference存储对象所对应句柄的地址,而句柄中包含了对象的实例数据的地址和类型数据的地址信息。其实这是一个二级访问的过程。

优点:当对象移动时,reference保存的句柄地址不用改变,只需要修改句柄中实例数据的地址信息。

缺点:速度慢,两次指针定位开销大。

2、直接访问

直接访问,reference中存放的就是对象在堆中的实际地址。

优点:速度快,直接定位,因而hotspot采用此种方式。

比较:使用句柄的最大好处是reference中存储的是稳定的句柄地址,在对象移动(GC)是只改变实例数据指针地址,reference自身不需要修改。直接指针访问的最大好处是速度快,节省了一次指针定位的时间开销。如果是对象频繁GC那么句柄方法好,如果是对象频繁访问则直接指针访问好。

4、对象分配

新生代 GC (Minor GC):发生在新生代的垃圾回收动作,频繁,速度快。

老年代 GC (Major GC / Full GC):发生在老年代的垃圾回收动作,出现了 Major GC 经常会伴随至少一次 Minor GC(非绝对)。Major GC 的速度一般会比 Minor GC 慢十倍以上。

1、对象优先在Eden区分配

大多数情况,对象在新生代Eden区分配内存

当Eden区没有足够空间时,将发起一次Minor GC。

2、大对象直接进入老年代

大对象:如很长的字符串和数组。

大对象直接进入老年代可以节省大量的复制开销。

程序员应尽量避免”短命大对象“,”短命大对象“容易引起频繁的GC。

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

当对象进入Survivor区,设置年龄为1,在Survivor区每熬过一次GC,年龄加1。当年龄到某个值(默认15),将被晋升到老年代。

4、动态对象年龄判定

如果Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,大于等于该年龄的对象可直接进入老年代。

这样可以防止Survivor区大量复制和减小空间分配担保的可能性。

5、空间分配担保

进行Minor GC之前,会检查老年代最大可用连续空间是否大于新生代所有对象空间。如果是,那么这次Minor GC是安全的。否则,检查是否允许担保失败:

允许:比较老年代最大可用连续空间是否大于历次晋升到老年代的平均值,如果大于,则尝试Minor GC,显然这是有风险的。如果小于,或者设置不允许冒险,则要先进行一次Full GC。

6、如果老年代的对象需要引用新生代的对象,会发生什么呢?

为了解决这个问题,老年代中存在一个 card table ,它是一个512byte大小的块。所有老年代的对象指向新生代对象的引用都会被记录在这个表中。当针对新生代执行GC的时候,只需要查询 card table 来决定是否可以被回收,而不用查询整个老年代。这个 card table 由一个write barrier 来管理。write barrier给GC带来了很大的性能提升,虽然由此可能带来一些开销,但完全是值得的。