【JVM学习笔记】内存回收与内存回收算法 就哪些地方需要回收、什么时候回收、如何回收三个问题进行分析和说明


【JVM学习笔记】JVM内存区域定义与内存结构
【JVM学习笔记】对象的创建过程、 对象的内存布局、 如何定位和使用对象


一、相关名词解释

垃圾收集常用名词

  • GC:Garbage Collection 垃圾收集
  • Partial GC:部分收集,不是完整的收集整个Java堆的垃圾,包含Mirror、Major、MixedGC
  • Mirror GC/Young GC:新生代收集,目标是新生代的垃圾收集
  • Major GC/Old GC:老年代收集,目标是老年代的垃圾收集
  • Mixed GC:混合收集,指目标是整个新生代以及部分老年代的垃圾收集(仅G1收集器有该行为)
  • Full GC:整堆收集,收集整个Java堆和方法区的垃圾收集
  • Reference Counting GC:引用技术式垃圾收集、或称直接式收集
  • Tracing GC:追踪式垃圾收集、或称间接式垃圾收集,以下介绍的所有垃圾收集算法都是追踪式垃圾收集





二、哪些地方需要回收

本地方法栈、虚拟机栈、程序计数器

这三块区域都是线程隔离的数据区,属于线程独有区域,随线程而生,随线程而灭。这几个区域的内存分配和回收都具备确定性,当方法结束或者线程结束时,内存自然就跟随着回收了。

这三块内存的回收,不涉及到堆和方法区,如果他们在堆或方法区(在创建对象、反射类型加载新的类型信息时)创建内容后结束运行了,那么那部分内容需要堆或方法区根据引用关系自行回收。



方法区

方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据,这个区域是线程共享的。

注:JDK7之后,HotSpot把放在永久代的字符串常量池、静态变量移至了Java堆中。因此运行时常量池和常量池的内存将不受制于方法区而是堆。

JVM不要求虚拟机在方法区中实现垃圾收集,事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在。由于方法区回收的判定条件较为苛刻,方法区垃圾收集的“性价比”通常也是比较低的。

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

扫描二维码关注公众号,回复: 14785000 查看本文章

回收方法区主要包含两类,回收废弃的常量和不再使用的类型。

  1. 判定废弃常量:
  • 如果一个常量池中的常量,已经没有任何字符串对象引用,且虚拟机中也没有其他地方引用这个字面量。

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


    关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。



Java堆

这个区域是垃圾回收的重点,它同样是线程共享的,任意线程都可以在堆中创建对象并使用该对象。


在后面的分代假说中会提到,绝大多数对象都是朝生夕灭的,为什么这么说呢?
举个例子,在目前的Spring项目中,所有的Bean都是被Spring容器管理的,只要Spring容器不被关闭这些bean对象将不会被释放,在经过一定次数的垃圾收集后,这些对象自然而然的会进入老年代。这些被容器管理和把持的对象看起来虽多,但是对比整个堆中的对象,却是九牛一毛。

堆中大部分的对象大都来自方法的执行也就是虚拟机栈,每个方法每次执行时都将会创建大量对象。在学习对象内存布局中,我们知道Hotpot虚拟机的栈使用的是直接指针的方式引用的堆中对象,所以为方法创建对象时,会在堆中先将对象创建好,然后将引用地址复制到本地方法栈中(基本类型是复制值,值传递和引用传递这里就不赘述了),以供本地线程调度使用。
在这里插入图片描述

当方法执行完毕后,大部分对象也就不再使用了,自然会被后续的新生代垃圾收集器直接回收。方法的执行会产生大量的对象,不计全局变量的情况下,最多也就只有入参和方法值会被其他方法利用,其内部所产生的其他对象在方法执行完毕后都将被回收。

下图是对象生命周期的典型分布:x轴是以分配的字节为单位衡量的对象生命周期。y轴上的字节数是具有相应生命周期的对象的总字节数。左边的尖峰表示在分配后不久可以回收(换句话说,已经“死亡”)的对象,垃圾回收器也是以此来进行的分代。
对象生命周期的典型分布





三、什么时候回收

要回收内存,我们需要知道,哪些内存可以被回收,这些可回收内存将在何时回收。

  • 要知道内存能否被回收,我们可以通过回收算法来分析决定。
  • 要知道内存何时被回收,那就是具体的VM实现来决定了。

1. 内存能否被回收

内存中的引用类型

无论通过引用计数还是可达性分析,判定对象存活都和引用离不开关系,因此就必须了解各种引用概念的强弱,如下四种引用强度依次逐渐减弱:

  • 强引用(Strongly Reference):最传统的引用的定义,是指代码中普遍存在的引用赋值(Object obj = new Object())。无论什么情况下,只要强引用关系还存在,垃圾收集器就永远不会回收被引用的对象。

  • 软引用(Soft Reference):用来描述一些还有用,但非必须的对象。只被软引用关联的对象,在系统即将发生OOM异常时,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还是没有足够内存,这时才会抛出OOM异常。使用SoftReference类实现软引用。

  • 弱引用(Weak Reference):也是用来描述非必须的对象,强度更低,与软引用不同,被弱引用关联的对象,只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论内存是否足够都会回收只被弱引用关联的对象。使用WeakReference类实现弱引用。

  • 虚引用(Phantom Reference):称为幽灵引用、幻影引用。是最弱的一种引用关系,它完全不会对其生存时间构成影响,也不法通过虚引用来取得一个对象实例。其唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。使用PhantomReference类实现虚引用。



引用计数算法

描述:在对象中添加一个对象引用计数器,每当有一个地方引用时计数器加一,当引用失效时,计数器减一。很多脚本语言用的都是该算法(如python)。

优点:原理简单,判定效率很高。

缺点:会占用一定的额外空间用以计数;该算法有很多额外的情况需要考虑,意味着需要大量额外的处理才能正常工作,比如对象之间的相互引用问题。



可达性分析算法

描述:通过一系列GC Roots的根对象作为起始节点集,从这个节点开始,根据引用关系向下搜索,搜索过程所走过的路程称为引用链(Reference Chain),如果某个对象到GC Roots间没有任何引用链(从GC Roots到这个对象不可达时),则证明这个对象是不可能的再被使用的。

目前主流的商用程序语言,都是通过可达性分析算法来判定对象是否存活。

在这里插入图片描述

GC Roots包括如下几种固定对象

  • 在虚拟机栈中,引用的对象(栈帧中的本地变量表,譬如正在运行方法中的参数、局部变量、临时变量等)

  • 在本地方法栈中JNI引用的对象(Native方法)

  • 在方法区中,类静态属性引用的对象(譬如Java类中的引用类型静态变量)

  • 在方法区中,常量引用的对象(譬如字符串常量池里的引用)

  • 在虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(OOM NullPE),类加载器等

  • 所有被同步锁持有的对象(synchronized关键字)

  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

  • 其他临时性加入的对象(可能用于分代收集和局部回收)



2. 内存何时被回收(Hotspot)

当我们通过上方的算法确定一些内存是可以回收的时候,VM也不是立马就把这些内存回收,事实上gc的发生是由具体的垃圾收集器来决定的。根据不同的内存区域,垃圾收集器会进行不同的回收策略以决定一个切确的时间进行GC。因此回收的数据实质上要在分析特定的垃圾收集器才能知晓,比如一些收集器会在Eden区满、方法区内存不足、老年代内存不足等时才会主动触发GC操作。





四、如何回收

垃圾收集需要权衡的目标——当我们确定了哪些内存可以回收,并在特点的时间进行回收时,就会遇到确切的内存回收问题:

  • 在内存回收时,当我们对整个内存进行回收时,JVM将会暂停程序工作以保证gc的顺利运行,当堆内存越大,这个停顿时间越长,整个程序暂停的时间也越长。
  • 如果为了避免回收整个内存带来的长时间暂停,通过增加收集部分内存频率的话,那么又会使得CPU经常被利用在垃圾回收上,从而降低了整个程序的吞吐量。

因此如何平衡最大暂停时间和应用程序吞吐量的关系,这就是垃圾回收器将要处理的问题,每种回收器都是权衡各种利弊后做出的取舍问题。



最大暂停时间:

  • 暂停时间是垃圾收集器停止应用程序并清理会恢复不再使用的空间的持续时间。
  • 最大暂停时间的目的是限制这些暂停的最长时间。由垃圾收集器维护平均暂停时间和该平均时间的方差。平均值是从执行开始计算的,但经过了加权,以便最近的暂停计算更重。如果暂停时间的平均值加上方差大于最大暂停时间目标,则垃圾收集器认为目标没有达到。
  • - XX:MaxGCPauseMillis=<nnn>:最大暂停时间的目标可以通过该参数指定,垃圾收集器将以此为目标,将调整Java堆大小和其他与垃圾收集相关的参数,以使垃圾收集暂停时间小于该毫秒。注意,这些调整可能会导致垃圾收集器更频繁地发生,从而降低应用程序的总体吞吐量。

吞吐量:

  • 吞吐量的目标是根据收集垃圾所花费的时间和垃圾收集之外所花费的时间(称为应用程序时间)来衡量的。
  • 用于垃圾收集的时间是年轻代和老代收集的总时间的总和。如果吞吐量目标没有达到,则增加代(新生代、老年代)的大小,以增加应用程序在集合之间运行的时间。
  • -XX:GCTimeRatio=<number>。垃圾收集时间与应用程序时间的比率为1 / (1 + number)。例如,-XX:GCTimeRatio=19设置垃圾收集总时间的1/20或5%的目标。

对于一些虚拟机来说:如果吞吐量和最大暂停时间目标已经满足,那么垃圾收集器将减少堆的大小,直到其中一个目标(总是吞吐量目标)无法满足。然后解决未实现的目标。

1. 垃圾回收理论

通过之前的内容,我们了解到,大部分对象都是朝生夕灭的,只有少部分对象才能存活很长时间,那么每次进行垃圾回收时,直接对整个内存进行回收就变得不合理了,所以我们应该根据对象的生命周期来决定我们应当如何对其进行回收。

Generational Collection——分代收集理论

该理论奠定多款垃圾收集器的一致设计原则——即收集器应将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。

该理论建立在两个假说上:

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的
  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象越难以消亡

基于这两个假说,垃圾收集器划分了新生代和老年代这两个区域并分别对其进行垃圾回收。这两个假说也产生了新的问题,当对新生代进行垃圾回收时,发现新生代对象引用了老年代对象,那这类对象的回收又当如何,如下解说便是对该问题提出的另一假说:

  • 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用即弱分代区域的对象引用强分代区域的对象,相对于同代引用来说仅占极少数
  • 卡表card table 可以用于解决跨代引用问题



JVM堆中的分代

  • Young Generation:新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放
  • Old Generation:老年代,经历多次垃圾收过程的对象,经历次数以具体的垃圾收集算法定



垃圾回收算法的类型

从如何判定对象消亡的角度,分为两类:
引用计数式垃圾收集(Reference Counting GC)或称直接垃圾收集
追踪式垃圾收集(Tracing GC)或称间接垃圾收集



2. 垃圾回收算法

标记-清除算法 Mark-Sweep

描述:算法分为标记、清除两个部分,根据算法标记出所有需要回收的对象,在标记完成后,统一回收被标记的对象。也可以反过来,标记存活的对象,统一删除未被标记的对象。
标记-清除

缺点

  • 执行效率不稳定。如果Java堆中包含大量对象,且大部分都是需要回收的,这时必须进行大量的标记和清除,导致标记和清除的执行效率都随对象数量增长而降低。

  • 内存空间碎片化问题。标记和清除之后会产生大量的不连续的内存碎片,空间碎片太多可能导致以后分配较大对象时无法找到足够的连续空间而不得不提前触发另一次垃圾回收操作。

适用范围:适合在老年代进行垃圾回收,比如CMS收集器就是采用该算法进行回收的。



标记-复制算法 Mark-Copying

描述:又称复制算法。它先将可用的内存按容量划分为大小相同的两块,每次只是用其中的一块。当这块内存用完了,就将还存活着的对象复制到另一块上面,然后把已经使用过的内存空间一次清理掉。

标记-复制

优点

  • 这种算法不用考虑空间碎片的复杂情况,只要移动堆顶指针,按顺序分配内存即可。

  • 这种算法实现简单,运行高效。

缺点

  • 在对象存活率较高时,就要进行较多的复制操作,效率将会降低。

  • 此外,如果不想浪费50%的空间,就需要有额外的空间进行分配担保。

改进
由于新生代都是朝生夕死的,所以不需要1:1划分内存空间,可以将内存划分为一块较大的Eden和两块较小的Suvivor空间。每次使用Eden和其中一块Survivor。当回收的时候,将Eden和Survivor中还活着的对象一次性地复制到另一块Survivor空间上,最后清理掉Eden和刚才使用过的Suevivor空间。其中Eden和Suevivor的大小比例是8:1。
缺点是需要老年代进行分配担保,如果第二块的Survovor空间不够的时候,需要对老年代进行垃圾回收,然后存储新生代的对象,这些新生代当然会直接进入来老年代。

适用范围:适合新生代区进行垃圾回收。serial new,parallel new和parallel scanvage收集器,就是采用该算法进行回收的。



标记-整理算法 Mark-Compact

描述:分为标记和整理两个阶段:首先标记出所有需要回收的对象,让所有存活的对象都向空间的一端移动,然后直接清理掉端边界以外的内存。与标记-清除算法的差异在于,前者是一种非移动式的回收算法,后者是移动式的。

标记-整理

缺点

  • 移动存活对象并更新所有引用这些对象(由于内存进行了移动,那么引用该对象的指针也会发生变化)的地方都会进行一次更新,且这种对象移动操作必须全程暂停用户应用程序才能进行。如果存活对象过多,那么这次操作的负担势必很大

回收后是否移动对象:

  • 根据缺点的权衡:是否移动对象都存在弊端,移动则内存回收时更复杂,不移动则内存分配时更复杂。
  • 从垃圾收集的停顿时间来看:不移动对象停顿时间更短,甚至不停顿
  • 从整个程序的吞吐量来看:移动对象更划算。因为内存分配和访问相比垃圾收集频率要高得多。不移动对象则在这部分的耗时增加,总吞吐量仍然是下降的。

适用范围:适合老年代进行垃圾收集,Parallel Old(针对parallel scanvange gc的) gc和Serial old收集器就是采用该算法进行回收的。





总结

本文主要记录了垃圾回收的部分过程分析,主要对什么内存需要回收、何时回收、如何回收进行了阐述,说明了各种垃圾回收的标准、衡量指标和垃圾回收的回收算法。

这篇文章更偏向于理论知识,并未对各种垃圾回收器进行说明和阐述,我将会在新文中详细的描述各种收集器的功能作用、适用场景、启用和VM参数设置并对对各种收集器进行对比分析。





参考

深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)周志明
java 8 doc
Java HotSpot VM
Java Virtual Machine Technology
Java HotSpot Equivalents of Exact VM Flags
Java Platform, Standard Edition HotSpot Virtual Machine Garbage Collection Tuning Guide

猜你喜欢

转载自blog.csdn.net/HO1_K/article/details/127852494