在GC执行的过程中,如果发现对象位于引用链路中,就需要将对象进行标记。标记状态说明对象活跃,后续GC执行时根据标记状态移动活跃对象或者将不活跃对象回收。
另外,在GC的执行过程中可能会存在一种情况,即多个对象同时指向一个对象。对于这种情况,在移动式的GC算法中需要特别处理。如图2-2所示,对象1和对象3都引用了对象2。
图2-2 多个对象引用一个对象
在移动式GC算法中,需要把对象1、对象2和对象3都移动到新的空间中,同时对象1、对象2和对象3都只能移动一次。由于对象1和对象3都指向对象2,因此在处理对象1和对象3的成员变量时,对象2可以被处理2次,但是只有一次是真正的转移对象,另外一次不能转移对象。
为了保证对象在GC过程中只移动一次,通常需要记录对象移动前后的映射关系,当对象尚未移动时可以移动对象,当对象已经移动,则直接使用移动后的对象,不需要再次移动对象。这说明在GC执行过程中需要记录额外的信息,记录信息的方式有多种,例如:可以为每一个对象分配额外的内存,该内存可以记录上述映射关系信息,也可以在标记时建立标记位图描述对象的活跃情况,在对象转移时使用转移信息表记录对象转移前后的地址信息。这些实现信息记录的方法在JVM中都有体现,其本质和GC的实现有关。在早期的GC实现中倾向于将信息记录在对象头中,其主要原因是当访问对象时就可以获得相关信息,而不需要进行额外的内存访问;而最新的垃圾回收实现因为算法的复杂性,可能需要借助额外的数据结构才能保证GC的正确性。
前面提到JVM实现了OOP和Klass机制模拟运行时和编译时使用的对象和类。在前面已经介绍了Klass实例化对象的内存布局。这里简单地看一下Java对象在JVM内部的表示,如图2-3所示。
图2-3 JVM对象头示意图
根据JVM源码的注释,针对标记对象头信息在32位JVM中用32bit来描述,这32bit的组合使用情况如表2-1所示。
表2-1 对象头信息
另外,在源代码中还可以看到一个Promoted的状态,Promoted指的是对象从新生代晋升到老生代时,正常的情况下需要对这个对象头进行保存,主要原因是如果发生晋升失败,则需要重新恢复对象头。如果晋升成功,那么这个保存的对象头就没有意义。所以为了提高晋升失败时对象头的恢复效率,设计了promo_bits,这其实是重用了加锁位(包括偏向锁)。实际上只有在以下3种情况下才需要保存对象头:
1)使用了偏向锁,并且偏向锁被设置(偏向锁在JDK 17中被移除,原因是在部分场景中使用偏向锁存在性能问题)。
2)对象被加锁。
3)为对象设置Hash_code。
值得一提的是,目前JVM正在优化对象的存储情况,因为额外的对象头实际上导致了内存的利用率降低。据有关论文研究,Java应用中的对象头大概浪费了5%~15%的内存空间。目前JVM提出了Lilliput(小人国项目)用于优化对象头额外的内存浪费,更多信息可以关注项目官网 。