您的位置 首页 java

Java虚拟机GC的根:识别堆空间中活跃对象,JVM内部实现优化引入

GC的根

垃圾回收的根和虚拟机运行时紧密结合,理解起来并不容易。

需要回答两个问题:哪些是垃圾回收的根?如何实现标记?

JVM 为例,JVM为了能执行 Java 代码,实现了一套完整的编译、解释、执行框架,其中编译是一个独立的模块,执行是另一个模块。

而GC的根既与执行框架相关,又与编译相关,除此之外,GC的根还与语言特性和JVM的实现相关。

在JVM中存在两种类型的根: 强根 弱根 。强根是GC的真正根,用于识别堆空间中的活跃对象;弱根并非用于识别活跃对象,只是为了支持语言特性(如Java的引用)或者JVM内部实现的优化而引入的。

强根

强根这个概念相对容易理解,这里使用线程栈来演示这个概念。假设JVM执行一段Java程序,如下所示:

现在来模拟一下JVM执行过程中内存的使用情况,在代码的地点一,内存布局如图2-14所示。

图2-14 地点一内存布局

其中图2-14中栈空间的使用通常在编译时就可以确定,堆空间通常是在运行时才能确定。每一个 局部变量 a、b、c、d在栈中都有一个槽位(slot)与之对应,这样在程序中才能访问到它们指向的对象或者数值。

这里稍微提示一下,代码d.f = c并不是将栈中c的值赋值给d.f,而是将c指向的堆地址赋值给d.f。

当代码执行到地点二时,内存布局如图2-15所示。

图2-15 地点二内存布局

此时因为变量作用域,变量d在栈中将无法访问(实际上该槽位被其他的变量使用),变量d因为已经死亡,其对应堆中的内存(图中灰色空间)也应该可以被回收重用。

基于栈变量可以找到堆空间中所有活跃的对象。当然,如果变量d在GC执行时死亡,在活跃对象的遍历过程中并不能知道变量d是否存在过,也无法知道变量d指向的内存空间。整个GC结束后只能得到所有活跃对象所占用的内存空间,所以追踪的GC算法都是管理活跃对象(将活跃对象赋值到新的空间,即复制算法,或者从整个空间中剔除活跃对象后,采用列表的方式管理自由空间),从而达到内存重用的目的。

当然实现层面可能还有更多细节需要考虑,例如在栈中一个槽位存放的值到底是指向堆空间的变量(即指针)还是一个立即数(在上述代码中变量a就是一个立即数),对于立即数对象,GC并不需要遍历(因为没有在堆空间中分配内存)。但是GC执行时并不知道槽位到底是一个地址还是一个立即数,如果做 不精确 的GC,可以把立即数也“当作”指针,只要立即数在堆空间的访问范围内,也会把对应的内存空间进行标记;如果做 精确 的GC,则必须区分立即数和指针,所以通常需要额外的信息来保存指针信息(例如使用额外的位图来描述栈空间的哪些槽位是指针),在GC执行时借助额外的信息就可以进行精确的回收。

经研究发现,通常不精确的GC和精确的GC相比,性能会有15%~40%的差距。

从栈变量作为根的例子可以看出,如果缺少某一个根,则必然会遗漏一些活跃对象,从而导致GC会访问非法内存。所以必须找到所有的强根并且逐一遍历,才能保证垃圾回收的正确性。

Java引用引入的弱根

Java语言中的引用主要指软引用(soft reference)、弱引用(weakreference)和虚引用(phantom reference)。

另外,Java中的 Finalize 也是通过引用实现的, JDK 定义了一种新的引用类型FinalReference,其处理和虚引用非常类似。

引用的处理和GC关系非常密切。在Java语言层面对于不同类型的引用有不同的定义,简单总结如下:

1)软引用: 声明为软引用的对象在垃圾回收时只有满足一些条件才会进行回收,这些条件 程序员 可以设置,比如通过参数SoftRefLRUPolicyMSPerMB设置软引用对象的存活时间。

2)弱引用: 在垃圾回收执行时,如果发现内存不足声明为弱引用的对象就会被回收。

3)虚引用: 使用虚引用需要定义一个引用队列,虚引用关联的对象在Java应用层面无法直接访问,而是通过引用 线程 (reference thread,这是一个Java应用的线程,JVM在启动时会生成该线程)处理引用队列来访问。所以虚引用对象的回收依赖于引用队列中的对象是否被执行,如果引用队列中的对象还没有被处理,则不能回收,否则就可以被GC回收。

4)Finalize: 如果Java的类重载了Finalize()函数,则需要通过Finalize线程( Finalizer Thread,这是一个Java应用的线程,JVM在启动时会生成该线程)处理。定义了Finalize()函数的对象类似于定义了虚引用,如果在GC执行过程中发现Finalize线程尚未执行对象的Finalize()函数,则对象不会被回收,否则对象就可以被回收。

可以发现Java语言中引用的处理和GC紧密相关。根据是否需要额外的线程执行额外的动作可以分为两类,对于这两类GC过程,处理方法有所不同:

1)软引用/弱引用: 在GC执行过程中,首先要通过强根扫描所有活跃对象,如果发现对象的元数据属于Java语言中的软引用/弱引用,则需要额外记录下来,在强根遍历结束后再根据GC的策略来决定是否回收引用对象占用的内存空间。

2)虚引用/Finalize引用: 在GC执行过程中,首先要通过强根扫描所有活跃对象,如果发现对象的元数据属于Java语言中的虚引用或者Finalize引用,则需要额外记录下来,然后将引用类型的对象单独保留起来,当GC结束后,引用线程处理过的对象就可以在下一次GC执行过程中进行回收。注意,定义了Finalize()函数的对象处理在对象生成期间就知道需要进行额外处理,所以生成的对象会自动添加到Finalize引用中。

从上面的描述中可以看出,当GC处理Java语言的引用特性时,需要额外地对引用对象进行处理,对于软引用/弱引用,在强根扫描结束以后就可以根78据策略进行回收;对于虚引用/Finalize引用,在本次GC时不能进行回收,通常需要在后续的GC过程中才能真正进行回收,且能否执行回收依赖于引用线程/Finalizer线程是否处理过对象,只有处理过的对象才能在后续的GC中被回收,如果对象没有处理过,JVM需要继续记录这些对象,并保持这些对象活跃。而这些对象明显不属于GC回收时识别的活跃对象,但是为了支持引用特性又必须将其记录下来,保持程序运行语义的正确性,所以JVM内部引入了弱根来记录这些对象。

JVM优化实现引入的弱根

在Java语言的发展过程中,JVM的研究者发现在JVM内部可以优化实现,从而节约内存或者提高程序执行的效率。为了达到这样的目的,JVM内部也需要引入一些弱根来保证程序运行的正确性。

这里以 字符串 为例来演示JVM的一个弱根。Java类库中 String类 提供了一个intern()方法用于优化JVM内存字符串的存储,intern()方法用来返回常量池中的某字符串。其目的是当Java程序中存在多个相同的字符串时可以共用一个JVM的底层对象表示,从而节约空间。代码片段如下:

String str1 = new String(“abc”);

String str2 = new String(“abc”);

str1.intern();

str2.intern();

在示例中,str1和str2都执行了intern()方法,JVM在执行时会优化底层的存储,可以简单地理解intern()方法的功能是:在JVM里面使用一个StringTable(使用 hash table 实现)存储字符串对象,如果StringTable中已经存在该字符串,则直接返回常量池中该对象的引用;否则,在StringTable中加入该对象,然后返回引用。

str1.intern()执行后,在StringTable中使用hash table存储这个 String 对象。因为str1对应的 字符数组 对象并不在StringTable中,所以它会被加入StringTable中。如图2-16所示,图中用圆表示对象(这里我们忽略外部的引用根信息)。

图2-16 intern()方法执行前后的内存示意图

当执行str2.intern()时,首先计算str2的 hash code,然后用 hash code 和str2的字符数组对象在StringTable查找是否已经存储了String对象,并且比较存储的String对象hash code与字符串数组是否相同,如果相同,则不需要再次把字符串放入StringTable中了,并且返回str1这个对象。

JVM在内部使用了StringTable来存储字符串intern的结果,其结构如图2-17所示。

图2-17 StringTable存储结构图

通过StringTable的方式方便共享字符串对象,但是会带来回收方面的问题。如果所有的共享变量都死亡,StringTable中的共享对象也应该释放。但什么时候可以回收或者释放StringTable占用的内存呢?在GC执行过程中,当强根遍历完成后,需要再次遍历StringTable,如果发现没有任何相关的引用,则StringTable中的共享对象可以释放,这个时候就可以回收了。可以看出,当GC的强根遍历完成后需要额外针对StringTable遍历来完成一些内存的释放,而StringTable和GC执行过程中对象的活跃性并无任何关系,仅仅是JVM内部设计带来的额外遍历,这样的根也称为弱根。

从上面的介绍可以看出,对于弱根,如果不进行遍历,则会导致一定程度的内存泄露,但是并不会影响Java程序正确地执行。为了保障GC执行的性能,在新生代回收中通常不回收这类弱根。当然由于JVM内存设计的复杂性,在一些新生代回收实现中也会处理这类弱根,其原因涉及对另外一些特性的支持的影响(例如类回收或者字符串去重等),这里不再展开介绍。

JVM中根的构成

JVM中根的构成非常复杂,根据程序执行的语义、语言特性的支持及JVM内部优化实现,可以将根划分为Java根、JVM根和其他根。

Java根用于找到Java程序执行时产生的对象,包括两类,分别为:类元数据对象,主要利用类加载器来跟踪Java程序运行时加载的类元数据对象。

Java对象,主要通过线程栈帧跟踪Java程序的活跃对象。

JVM根主要指JVM为了运行Java程序所产生的一些对象,这些对象可以简单地被认为是全局对象。主要有:

Universe,Java程序运行时需要一些全局对象,比如Java支持8种基本类型,这些基本类型的信息需要对象来描述(基本类型的描述信息作为全局对象是为了性能考虑),这些对象就存放在Universe中。

Monitor,全局监视器对象,对于Monitor对象主要是用于锁相关,可能存在只有Monitor对象引用到内存空间的对象,所以Monitor是JVM的根之一。

JNI ,JVM执行本地代码时使用API产生的对象,例如通过JNI API在堆中创建对象,这些对象只在JNI API中使用,所以需要单独管理这些对象。

JVMTI,使用JVM提供的接口用于调试、分析Java程序。使用JVMTI API时也会分配新的对象。

System Dictionary,JVM在设计类加载时,对于基本的类,比如Java中经常使用的基础类,会通过系统加载器加载这些类,而这些类在运行Java程序一直都需要,所以这些类被单独加载,单独标记。

Management,是JVM提供的内存管理API,用于JVM内存的统计信息,在使用这些API时需要创建Java对象,所以需要标记。

AOT,在JDK 9之后引入了提前编译。在AOT的编译过程中会把全局对象和编译优化的代码对象放在 可执行文件 中,当执行时会用到这些对象,所以在回收时需要标记。

其他根主要有:

语言特性的弱引用。

JVM弱根,例如管理Java中String中intern产生的对象、编译后代码等。

这些根共同构成了GC根集合,实际上根的确定和虚拟机运行时密切相关,而运行时又非常复杂,限于篇幅,本文无法对根详细介绍,有兴趣的读者可以参考其他文献。

需要注意的是,对于弱根的处理在不同的GC实现中有所不同,主要原因是弱根通常涉及内部资源的释放,整个流程耗时较多,在一些回收中会把弱根当作强根对待(即不释放弱根相关的内部资源),以加快GC的执行。

本文给大家讲解的内容是 Java虚拟机 和垃圾回收基础知识:GC的根

  1. 下篇文章给大家讲解的内容是JVM中垃圾回收相关的基本知识:安全点,解释+编译+本地+JVM内部并发线程进入安全点
  2. 感谢大家的支持!

文章来源:智云一二三科技

文章标题:Java虚拟机GC的根:识别堆空间中活跃对象,JVM内部实现优化引入

文章地址:https://www.zhihuclub.com/190006.shtml

关于作者: 智云科技

热门文章

发表回复

您的电子邮箱地址不会被公开。

网站地图