浅谈JVM中如何自动回收内存

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第7天,点击查看活动详情

对于从事C、C++程序开发的开发人员来说, 在内存管理领域, 他们既是拥有最高权力的“皇帝”,又是从事最基础工作的劳动人民;既拥有每一个对象的“所有权”,又担负着每一个对象生命从开始到终结的维护责任。

而对于Java程序员来说, 在虚拟机自动内存管理机制的帮助下,不再需要为每一个new操作去写配对的delete/free代码, 因此,不容易出现内存泄漏和内存溢出问题。今天,我们来聊聊JVM垃圾收集器是如何来回收死去(不可能再被任何途径使用)的对象。

通常,垃圾收集需要完成的三件事情:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

哪些内存需要回收?

之前文章讲述了Java内存运行时区域的各个部分(程序计数器、 虚拟机栈、 本地方法栈、Java堆和方法区等),其中程序计数器、 虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭, 栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。 每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由即时编译器进行一些优化,但在基于概念模型的讨论里,大体上可以认为是编译期可知的), 因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题, 当方法结束或者线程结束时, 内存自然就跟随着回收了。

而Java堆和方法区这两个区域则有着很显著的不确定性: 一个接口的多个实现类需要的内存可能会不一样, 一个方法所执行的不同条件分支所需要的内存也可能不一样, 只有处于运行期间, 我们才能知道程序究竟会创建哪些对象, 创建多少个对象, 这部分内存的分配和回收是动态的。 因此,垃圾收集器所关注的正是这部分内存该如何管理。

什么时候回收内存?

在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着, 哪些已经“死去”了。

浅述判断对象是否存活的两种算法:引用计数与可达性分析

从如何判定对象消亡的角度出发,垃圾收集算法可以划分为 “引用计数式垃圾收集”(Reference Counting GC)“追踪式垃圾收集”(Tracing GC) 两大类, 这两类也常被称作“直接垃圾收集”和“间接垃圾收集”。

引用计数算法(直接垃圾收集)

引用计数算法:在对象中添加一个引用计数器, 每当有一个地方引用它时, 计数器值就加一; 当引用失效时, 计数器值就减一; 任何时刻计数器为零的对象就是不可能再被使用的。

比如:Python语言主要的内存管理手段就是引用计数机制。引用计数算法(Reference Counting) 虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。同时,引用计数方法有一个最大的优点,即实时性,任何内存,一旦没有指向它的引用,就会立即被回收。而其他的垃圾收集技术必须在某种特殊条件下(比如内存分配失败)才能进行无效内存的回收。

引用计数机制所带来的维护引用计数的额外操作与Python运行中所进行的内存分配和释放,引用赋值的次数是成正比的,这一点,相对于主流的垃圾回收技术,比如标记清除(Mark-Sweep)、停止复制 (Stop-Copy) 等方法相比,是一个弱点,因为这些技术所带来的额外操作基本上只与待回收的内存数量有关。为了与引用计数机制搭配,在内存的分配和释放上获得最高的效率,例如,在Python中,设计了大量的内存池机制,比如PyIntobjectPyStringobjectPyDictobjectPyListobject等等都有与各种对象相关的内存池机制。这些大量使用的面向特定对象的对象内存池机制正是为了竭力弥补引用计数机制的软肋。

如果说执行效率还仅仅是引用计数机制的一个软肋的话,那么很不幸,引用计数还存在着一个致命的弱点,这一点虽然看似很小,然而其存在却几乎宣判了引用计数机制在垃圾收集技术中的“死刑”。也正是由于这一致命的弱点,使得狭义的垃圾收集研究从来没有将引用计数包含在内,这个致命的弱点就是循环引用

因此,在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存,主要原因是这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作。

可达性分析算法(间接垃圾收集)

可达性分析算法:通过一系列称为“GC Roots”的根对象作为起始节点集, 从这些节点开始, 根据引用关系向下搜索, 搜索过程所走过的路径称为“引用链”(Reference Chain) ,如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时, 则证明此对象是不可能再被使用的。

如下图所示, 对象object 5、 object 6、 object 7虽然互有关联, 但是它们到GC Roots是不可达的,因此它们将会被判定为可回收的对象。

image.png

在Java技术体系里面, 固定可作为GC Roots的对象包括以下几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象, 譬如,各个线程被调用的方法堆栈中使用到的参数、 局部变量、 临时变量等。
  • 在方法区中类静态属性引用的对象, 譬如,Java类的引用类型静态变量。
  • 在方法区中常量引用的对象, 譬如,字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象
  • Java虚拟机内部的引用,如,基本数据类型对应的Class对象, 一些常驻的异常对象(比如NullPointExcepitonOutOfMemoryError)等, 还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMXBeanJVMTI中注册的回调、本地代码缓存等。

除了这些固定的GC Roots集合以外, 根据用户所选用的垃圾收集器以及当前回收的内存区域不同, 还可以有其他对象“临时性”地加入, 共同构成完整GC Roots集合。在分代垃圾回收收集器局部垃圾回收收集器(Partial GC)中 ,如果只针对Java堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用, 这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去, 才能保证可达性分析的正确性。

JDK中四种对象引用类型

无论是通过引用计数算法判断对象的引用数量, 还是通过可达性分析算法判断对象是否引用链可达, 判定对象是否存活都和“引用”离不开关系

在JDK 1.2版之前, Java里面的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址, 就称该reference数据是代表某块内存、 某个对象的引用。 一个对象在 这种定义下只有“被引用”或者“未被引用”两种状态。这种定义粒度比较粗,当我们希望能描述一类对象: 当内存空间还足够时, 能保留在内存之中, 如果内存空间在进行垃圾收集后仍然非常紧张, 那就可以抛弃这些对象。这种定义方式就显得无能为力。

因此,在JDK 1.2版之后, Java对引用的概念进行了扩充, 将引用分为强引用(Strongly Reference) 、 软引用(Soft Reference) 、 弱引用(Weak Reference) 和虚引用(Phantom Reference) 4种, 这4种引用强度依次逐渐减弱。具体请参考我的另一篇博客:JDK中四种对象引用类型

如何回收内存?

即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段, 要真正宣告一个对象死亡, 至少要经历两次标记过程:

  • 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链, 那它将会被第一次标记
  • 随后进行一次筛选, 筛选的条件是此对象是否有必要执行finalize()方法( 假如对象没有覆盖finalize()方法, 或者finalize()方法已经被虚拟机调用过, 那么虚拟机将这两种情况都视为“没有必要执行”)。
  • 如果这个对象被判定为确有必要执行finalize()方法, 那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行, 但并不承诺一定会等待它运行结束。这样做的原因是, 如果某个对象的finalize()方法执行缓慢, 或者更极端地发生了死循环, 将很可能导致F-Queue队列中的其他对象永久处于等待, 甚至导致整个内存回收子系统的崩溃,finalize()方法是对象逃脱死亡命运的最后一次机会。
  • 稍后收集器将对F-Queue中的对象进行第二次小规模的标记, 如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可, 譬如把自己(this关键字) 赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱, 那基本上它就真的要被回收了。

注意:

任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行。虽然finalize()方法可以用来拯救对象,但是大家尽量避免使用它。因为它的运行代价高昂, 不确定性大, 无法保证各个对象的调用顺序, 如今已被官方明确声明为不推荐使用的语法。

方法区垃圾回收

有些人认为方法区(如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的, 《Java虚拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集,事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK 11时期的ZGC收集器就不支持类卸载),方法区垃圾收集的“性价比”通常也是比较低的

在Java堆中, 尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收70%至99%的内存空间, 相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。

方法区的垃圾收集主要回收两部分内容: 废弃的常量不再使用的类型

回收废弃常量与回收Java堆中的对象非常类似。 举个常量池中字面量回收的例子, 假如,一个字符串“java”曾经进入常量池中, 但是当前系统又没有任何一个字符串对象的值是“java”, 换句话说, 已经没有任何字符串对象引用常量池中的“java”常量, 且虚拟机中也没有其他地方引用这个字面量。 如果在这时发生内存回收, 而且垃圾收集器判断确有必要的话, 这个“java”常量就将会被系统清理出常量池。 常量池中其他类(接口) 、 方法、 字段的符号引用也与此类似。

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了

需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、 JSP的重加载等,否则通常是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是 和对象一样, 没有引用了就必然会回收。

关于是否要对类型进行回收, HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClass-Loading-XX:+TraceClassUnLoading查看类加载和卸载信息,其中:-verbose: class-XX: +TraceClassLoading可以在Product版的虚拟机中使用, -XX: +TraceClassUnLoading参数需要FastDebug版的虚拟机支持。

在大量使用反射、 动态代理、 CGLib等字节码框架, 动态生成JSP以及OSGi这类频繁自定义类加载器的场景中, 通常都需要Java虚拟机具备类型卸载的能力, 以保证不会对方法区造成过大的内存压力。

总结

本文讲述了JVM垃圾收集器如何帮助Java程序员自动回收内存。通常情况下,垃圾收集需要完成的三件事情:

首先,我们需要知道哪些内存需要回收?我们谈到线程私有的内存会随着线程的结束而释放内存。而线程公有的内存,如Java堆和方法区这两个区域,则有着很显著的不确定性,是垃圾收集器所关注的内存区域。

然后,我们谈到什么时候回收内存?通过引用计数与可达性分析判断是否已经死去。对于引用计数算法,一旦没有指向它的引用,就会立即被回收。而可达性分析通常在某种特殊条件下(比如内存分配失败)才能进行无效内存的回收。而主流的Java虚拟机通常选用可达性分析算法来管理内存。

最后,我们讲述了如何回收内存?在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段, 要真正宣告一个对象死亡, 至少要经历两次标记过程。同时,我们也提到了方法区也需要垃圾回收,并且在大量使用反射、 动态代理、 CGLib等字节码框架, 动态生成JSP以及OSGi这类频繁自定义类加载器的场景中, 通常都需要Java虚拟机具备类型卸载的能力, 以保证不会对方法区造成过大的内存压力。

猜你喜欢

转载自juejin.im/post/7083665800925544484