Loading

JVM 二 垃圾清理

概述

对于栈,它其中的变量都是跟随方法结束就被销毁的,而且它们所需要的大小在运行期就能确定,所以垃圾清理不会考虑这其中的内容。

垃圾清理主要考虑堆中的内容,因为这里面的内容更加多变,其中的对象大小,对象有多少,它们之间的依赖关系都是需要运行时才能确定,所以这部分需要垃圾回收机制进行动态管理。

引用计数算法

引用计数算法是最基本的垃圾回收算法机制,它很简单,它为每个对象添加一个计数器,当一个对象被引用一次,计数器加一,当对象失去一个引用,计数器减一。当计数器归零时,就代表该对象没有引用了,再也不会被访问到,那么它就是可以被清理的。

但是它无法应对循环引用,就是如下:

obj1.a = obj2
obj2.a = obj1

obj1 = null
obj2 = null

上面的案例中,显然我们永远无法访问到obj1和obj2了,但是它们两个由于在互相引用,它们的计数器永远不可能清零。

可达性分析算法

大部分Java垃圾清理器都没有使用引用计数算法,而是使用一个更加复杂但更可靠的可达性分析算法。

可达性分析算法首先会维护一个GC Roots对象列表,这些对象是根对象,就是项目中所有的其他对象都是在根对象下被一层一层引用的。用图论的知识来说,如果一个对象到任何GC Roots对象都是不可达的,那么就是无法引用到了。

像object5的那个引用链都是已死亡的对象,再也不可能被引用到,就可以被清理了。

Java技术体系中,固定有四种对象是可以作为GC Roots的对象。

  1. 虚拟机栈帧中引用的对象
  2. 方法区中静态属性引用的对象
  3. 方法区中常量引用的对象(如字符串常量池中的引用)
  4. 本地方法栈JNI(本地方法)引用的对象
  5. JVM中的内部引用,如基本数据类型对应的Class对象,一些常驻的异常对象(NullPointException、OutOfMemoryError),还有系统类加载器
  6. 所有被同步锁持有的对象
  7. 反应Java虚拟机内部情况的JMXBean,JVMTI中注册的回调,本地代码缓存等。

其实这么多内容中和我们有关系的大概也就1,2,3,6,仔细想想,它们是应该作为GC Roots对象,因为所有的引用关系都是从它们中发出的。

除了这些内容,不同的垃圾收集器还可能临时加入其他对象到GCRoots中。

再谈引用

JDK1.2之前,一个对象只有未被引用和被引用两种状态,这显然有点狭隘。因为很多时候我们需要一些对象当内存空间还足够时仍然存在于内存中。这时候就需要更加细致的区分引用状态。

JDK1.2之后引用被区分为四种

  1. 强引用,就是代码中的Object obj = new Object()这种操作,只要强引用关系还在,Java虚拟机就永远不清理这个对象
  2. 软引用,代表一些留着可能有用但是不是必须留着的对象,OutOfMemoryError触发之前,会把这些对象列进回收范围进行第二次回收,如果回收后还没有足够的内存才触发OutOfMemoryError
  3. 弱引用,和软引用差不多,但是它只能存活到下一次垃圾回收操作,下次垃圾回收发生,它们就会被清理
  4. 虚引用,无法通过虚引用获得一个实例,虚引用也不会对对象的生存时间产生影响。虚引用的作用就是当对象被清理时发出一个系统通知。

finalize

一个对象已经确定要被清理了之后也不是直接清理,而是先标记,然后去找它有没有覆盖finalize方法和finalize方法是否没有被执行过。如果两个条件都满足,把它放进F-Queue中,JVM有一个低优先级的线程去调度它们的finalize方法。而且,JVM不会等待finalize执行完成,是否执行完完全听天由命。

如果你想你的对象不被清理,那么你可以在finalize方法中与任意引用链上的对象建立连接。稍后,虚拟机就会对F-Queue中的对象进行二次标记,如果你这个连接建立成功了,你这个对象就会逃脱被清理的命运。

例如下面的例子,第一次被清理时,在finalize方法中与静态变量连接,逃脱了被清理的命运。

public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;

    public void isAlive() {
        System.out.println("Yes, iam still alive.");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed...");
        SAVE_HOOK = this;
    }

    public static void main(String[] args) throws InterruptedException {
        SAVE_HOOK = new FinalizeEscapeGC();
        SAVE_HOOK = null;
        System.gc();
        // 因为执行finalize的线程优先级很低,所以等待一会
        Thread.sleep(500);
        SAVE_HOOK.isAlive();

        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(500);
        SAVE_HOOK.isAlive();
    }
}

当然,任何对象只能通过finalize自救一次,当这个对象的finalize被调用过之后,JVM下次将不会再去调用它,直接清理。所以第二次这个对象没能逃脱被清理的命运。

不建议使用finalize方法,它已经被官方废弃。

方法区中的回收

JVM并未要求方法区中一定实现回收,因为方法区的回收性价比很低。

方法区中存储的都是常量和类型信息,常量信息还好说,类型信息是否可以被清理的判定条件极其严苛,并且通常回收不了太多内存。

但在使用目前这种大量应用动态代理、反射、字节码生成的框架中,需要一个能够对方法区进行回收的垃圾清理器。

垃圾回收算法

分代收集理论

大部分垃圾清理器都是基于分代收集理论构建的,两个假说支撑了分代收集理论体系。

  1. 弱分代假说,绝大多数对象都是朝生夕灭的
  2. 强分代假说,熬过越多次垃圾回收的对象就越难以消亡

确实是这样,大部分对象我们用完就扔,而且越是那种迟迟都不会被放开引用的对象越是会伴随整个程序的生命周期。所以基于这两个假说,可以对堆内存进行分代管理,“新生代”中的对象都是那些朝生熄灭的对象,而“老年代”中的对象都是熬过了很多次垃圾回收的对象。这样一来,垃圾回收器就可以把关注点放在如何保留少量难以消亡的对象,而不是如何标记大量即将被回收的对象。

根据分代,又衍生出了MinorGCMajorGC以及FullGC这些对于不同区域进行垃圾回收的算法,这些算法根据不同区域中对象生存时间特征的不同采用不同的算法以提高垃圾收集器的工作效率。

跨代引用假说

新生代和老年代并不是孤立的两个分区,新生代的对象很有可能被老年代的引用,这时候,除了遍历整个GCRoots外,还要扫描整个老年代中的对象,这多费劲啊。

所以有了第三条假说:

  • 跨代引用相对于同代引用来说仅占少数

仔细考虑下确实嘛,一般贯穿整个系统的对象都不会去引用那些我们创建的用完即走的对象。而且就算存在了,那么老年代中的对象不消亡,被它引用的新生代在熬过很多次GC之后,也会晋升到老年代,这样跨代引用变成了同代引用。

这样,我们就不必为少量的跨代引用而扫描整个老年代,很多垃圾回收器的做法是在新生代中创建一块区域,记录老年代中发生了跨代引用的部分,并且在GC发生时将这部分中的对象放到GCRoots中进行扫描。

PartialGC

PartialGC是指不对整个内存区域直接进行清理,而是清理其中的一部分,下面是一些PartialGC分类

  1. MinorGC,只对新生代进行清理
  2. MajorGC,只对老年代进行清理(但是大部分垃圾收集器器都不会单独收集老年代,一般都是用它进行整堆收集)
  3. MixedGC,混合收集,对新生代和老年代一起执行收集(目前只有G1收集器有这个行为)
  4. FullGC,对整个堆进行收集

标记-清除算法

标记-清除算法是最简单的垃圾收集算法,其原理就是先标记出要收集的对象,然后清理它们。

这个算法简单,也有很多问题,首先,运行效率极其不稳定,如果要收集的对象很多,那么就需要很长一段时间来标记。其次清理后会产生很多内存碎片,如上图。

标记-复制算法

标记-复制算法解决了当要回收大量对象时效率低下和容易产生内存碎片的问题。

标记复制算法将内存区域划分成两块,先将所有对象存储在其中一块,当要执行回收时,会把存活的对象复制到另一块区域然后直接清理掉之前的区域。只针对存活对象这一思路在大多数对象都要被回收的时候效率很高(新生代就是这一特性),并且复制操作能够解决内存碎片问题。

但是问题又来了,这直接给我他妈整没一半内存啊。

但是根据弱分代假说,并且根据很多可靠的研究表明,新生代中98%的对象熬不过第一次回收,所以我们根本不用以1:1的方式分配这两个空间。

很流行的做法,包括HotSpot虚拟机中的一些垃圾收集器都在使用的做法就是将新生代也分为几个区,分别是较大的Eden区(伊甸园区)和两块较小的Survivor区(幸存者区),分配空间时使用其中的一块Eden区和一块Survivor区,当进行清理时,将Eden区和Survivor区的存活对象复制到另一块Survivor区,清理掉之前的两块分区,然后下次分配空间使用这个被复制到的Survivor区和Eden区。

HotSpot虚拟机默认的三个分区的大小是8:1,就是Eden占用80%,两个幸存者各自占用10%,也就是说实际我们可用的空间是整个新生代空间的90%。

还有一个问题待解决,保留的Survivor空间如果真的存不下上一次幸存的对象咋办,有个保留机制,就是让那些对象直接进入老年代。

标记-整理算法

标记-复制算法适合新生代中对象的特征,但是对于老年代就不是那么合适了,因为老年代中的对象很难以消亡。

标记整理算法也比较简单,就是将所有存活的对象向内存的一端移动,然后直接清理掉剩余部分。

但是移动对象,尤其是在老年代这种每次存活的对象数量都很多的情况下,会极其耗时,并且移动对象的过程中由于会覆盖之前的内存结构,必须停止用户线程对内存的访问,这种完全暂停用户线程的行为被戏称为"Stop the World"。

如果不移动对象,那么,就必须允许一个对象可以存储在非连续的内存空间中,这样才不会有碎片问题,这就需要维护一个类似分区表的东西,但用户频繁与内存交互,如果在访问内存这一阶段上添加额外的操作成本,那么会直接影响到整个应用的吞吐量。也就是说虽然垃圾收集器的效率高了,但是整个程序的性能下降了。如果一个垃圾收集器想要关注吞吐量方面或者用户线程延时方面,那么就应该选择不同的垃圾收集算法。

还有一个办法就是,虚拟机暂时使用标记-清除算法,容忍碎片的存在,直到碎片化程度已经严重影响内存分配再使用一次标记-整理算法。

HotSpot垃圾清理算法细节实现

这部分是理论性极强的,而且我也不保证我的理解没有产生偏差,推荐还是去读原书,而且原书也说了这部分知识并不是必须。直接看下一个部分也行。

根节点枚举

我们都知道可达性分析算法(即判断一个对象是否仍被引用)是从GC Roots集合的引用链中开始查找的,GC Roots大概都是栈中引用,方法区中的静态引用和常量等,现在的Java程序中,这些内容蛮多的,方法区动辄几百上千兆,完全遍历GC Roots的所有引用链是不太现实的。

而且在枚举这些根节点的时候一定要停止用户线程,如果用户线程还在继续操作,继续向引用链中添加内容,那垃圾收集器永远无法保证可达性分析的正确性。虽然现在查找引用链的过程在很多垃圾收集器中都可以做到与用户线程并发执行了,但是还是要在一个能保证一致性的快照中进行。

大部分虚拟机都会维护一个OopMap来保存哪些地方存放着引用的,所以就不需要真正一个不落的去遍历GC Roots引用链。因为类加载时虚拟机就能够得到这些信息。

安全点

虽然OopMap可以加速引用链查找这一过程,但是OopMap中的引用关系也可能被用户线程改变,而能够改变OopMap中引用关系的指令很多,不可能在每条指令下都记录。

取而代之的办法是当程序运行到某些位置时停下,然后虚拟机去记录这些信息,这些位置就是安全点。也就是当虚拟机想要进行垃圾回收,那么必须等待所有用户线程都到达安全点之后才能进行。

安全点的选定不能够太密集,太密集就增加了虚拟机的工作量和内存负荷,也不能太少,这样就会让垃圾不能及时得到回收,积攒在内存中。安全点的选定以程序是否会长时间执行为基准,所以大概循环,方法调用,异常处理这些部分都会作为安全点。

如何在虚拟机需要回收垃圾时让所有线程都走到最近的安全点呢?两种办法,一种是虚拟机主动停止所有用户线程,如果有线程不在安全点上就恢复它的执行,直到它走到安全点上,这种暴力的方式称为抢先式中断。另一种JVM中广泛使用的方式叫主动式中断,温柔很多,当虚拟机需要回收垃圾时会设定一个标记,用户线程执行时会主动轮询这个标记,这个轮询操作的效率很高,如果发现标记为真就执行到附近的安全点主动挂起。

安全区域

嘶,如果虚拟机需要回收垃圾的时候,有的用户线程处于sleep或blocked状态,那么它就没能力走到安全点上,虚拟机自然不能破坏这种阻塞,让它执行到安全点上,那么就只能认为这种状态的线程在一段时间内不会进行操作,也不会改变引用关系,所以它们被看作安全区域,虚拟机不会管它们。

记忆集和卡表

之前说过如果存在跨代引用,比如新生代中的对象被老年代引用的话就需要一个数据结构来记录这些引用。这个数据结构就是记忆集。

卡表是记忆集的一种实现方式,先从逻辑上把内存分为若干卡页,每一个卡页占用固定的空间,一般是2^N的字节数,卡表中表示的就是这些卡页所代表的内存块(因为卡表都是固定大小,所以只需要知道起始地址就能通过数组下标算出对应卡页的内存范围),一旦某个卡页中出现了跨代引用,就将这个卡页变脏,也就是将数组中对应的元素置为1。这样当垃圾收集时只要过滤出卡表中为1的元素,并把对应的卡页中的对象放到GC Roots中扫描。

写屏障

谁来把卡表变脏?卡表不会自己变脏。

HotSpot通过写屏障技术将卡表变脏,就是在引用类型字段赋值时的一个AOP切面,提供在写入前执行额外的动作和写入后执行额外的动作。

在切面中产生的性能损耗和扫描整个老年代比起来要低得多。

并发的可达性分析

开局就没看懂,略。

经典垃圾收集器

“经典”一词指在JDK 7 Update 4之后和JDK 11正式发布之前HotSpot虚拟机中内置的垃圾收集器。

上图是概览图,中间的横线区分了新生代收集器和老年代收集器,两个收集器之间的连线代表二者可以协同工作。

为什么有这么多收集器呢?因为它们各自采用的垃圾回收算法以它们关注的点不同,所以它们适用于不同的场景。

Serial收集器

看名字就能看出它是一个单线程收集器,在垃圾收集器在收集垃圾时采用单线程之外,也说明它在垃圾收集时必须完全停顿用户线程。

下图是Serial收集器的运行流程图

嘶???看起来挺垃圾的,是不是它一无是处呢?不是,它有自己的适用场景。

在桌面应用场景和微服务场景中,分配给单个程序的内存不会很大,要收集的垃圾少了,回收时的用户线程停顿时间就短,完全在可以接受的范围内。并且它由于算法简单,在要回收工作量不特别大的时候效率自然比其他复杂的庞大的垃圾回收器快。

举个例子,效率更高的插入排序在数据量少的情况下的效率往往不如冒泡排序。

ParNew收集器

同样,看名字也就能看出来它是一个多线程的收集器。它就是Serial的多线程版本。它使用多条线程进行垃圾回收新生代,除此之外它的所有细节和Serial没有区别。

所以它挺鸡肋的,但是当CMS收集器出现的时候,由于CMS作为老年代收集器,只能与Serial和ParNew配合,而Serial又不太适合服务端的场景,所以ParNew成为了CMS打开后的默认新生代收集器。

但是CMS后来也落寞了,所以ParNew也随之落寞。

Parallel Scavenge收集器

Parallel Scavenge也是一款基于标记-复制算法的新生代收集器,它的特色在于它专注于让程序整体达到一个可控制的吞吐量,而不是着眼于缩短用户线程停顿时间。

Parallel Scavenge的-XX: MaxGCPauseMillis用于指定最大的用户线程停顿时间,-XX: GCTimeRatio用于直接设置吞吐量。

不要天真的以为世界上真的存在魔法,你只需要设置很小的-XX: MaxGCPauseMillis就会让用户停顿时间很短,然后提升程序效率,它变短了,单次收集的空间就小了(也就是把新生代调小),新生代空间小了,收集的频率就频繁了,所以短的用户停顿时间时用更低的吞吐量来换取的。

Parallel Scavenge有一个-XX: +UseAdaptiveSizePoicy,它是一个自动调节参数,动态调整吞吐量和最大停顿时间。

Serial Old

Serial收集器的老年代版本

Parallel Old收集器

Parallel Scavenge的老年代版本。因为在JDK6以前,如果使用Parallel Scavenge,那么就只有Serial Old与之协同工作,但Serial Old不适用于服务端的场景,所以Parallel Scavenge在服务端的表现也就不太好。

Parallel Old就是用来打破这一场景的。

CMS收集器

CMS(Concurrent Mark Sweep)收集器致力于获得最短的用户线程停顿时间。因为Java最流行的服务端应用程序都是比较看重响应时间的,所以用户线程停顿越短越好。它是一个老年代收集器。

CMS基于标记清除算法实现,它的运作过程分为四个步骤

  1. 初始标记
  2. 并发标记
  3. 重新标记
  4. 并发清除

初始标记和重新标记还是需要暂停用户线程,不过初始标记只是记录一下GC Roots能直接关联到的对象,速度很快。并发阶段才是从GCRoots直接关联到的对象开始遍历整个对象关联关系图的过程,这阶段才是真的耗时。既然允许与用户线程并发执行,那么就必然会造成用户在这个过程中修改关系图的结果,所以重新标记阶段就是用来遍历这些产生变动的对象,这个时间要比初始标记长一些,但仍比并发标记短。并发清除阶段由于采用了标记-清除算法,不需要移动对象,所以仍然可以与用户线程并发执行。

由于清除阶段也是与用户线程同时执行的,那么可能会在清除阶段产生一些新的需要被清理的对象,这些对象只能等到下一次被清理,它们被称为浮动垃圾。同样由于与用户线程同时执行,所以必须预留一部分空间给用户线程,不能等到老年代满了再执行清理,这样新对象将无法进入老年代。默认情况下这个值是当老年代达到68%就开始清理。JDK6中这个值变成了92%,可以通过-XX: CMSInitiatingOccupancyFraction来设置。

如果真的发生了老年代中无法分配足够的空间给新对象,那么就会产生一次“并发失败”,这时虚拟机就会冻结所有用户线程,临时启用Serial Old来进行老年代收集,所以这个值应该谨慎设定。

再有就是由于是并发程序,所以天生对处理器资源敏感,它可能不适合运行在处理器资源非常紧缺的环境下。

最后一个缺点就是使用标记-清除算法会产生内存碎片,在无法分配空间时会调用一次FullGC整理内存。

Garbage First收集器

Garbage First就是G1收集器,主要面向服务端应用的一个全区收集器。

G1收集器着眼于一个新视角,就是支持在指定一个长度为M毫秒的时间片段内,耗费在垃圾回收上的时间不超过N毫秒。

这样就需要改变整个内存区域的结构划分,以前的收集器要么就是针对整个新生代,要么就是整个老年代,要么就是整个Java堆,G1把内存分为一大堆Region(区域),可以清理其中的任意一个Region,依据就是哪个Region中垃圾数量最多,回收效益最高。随之而来的模式称作MixedGC

Region之间的大小相同,并且可以使用任意的,甚至不连续的Region扮演新生代或老年代的角色,G1会根据它们的角色不同采用不同的清理算法。除此之外,还分出了一个特殊的区,叫Humongous区,用来存储大对象,G1认为只要大小超过一个Region的一半的对象就被称作大对象,这些对象可以用多个Region存储。G1大多数时间把它们看作老年代处理。

初始标记就是标记GC Roots能直接关联的对象,并发标记就是并发的去遍历那么多对象关系链,最终标记采用SATB算法来扫描并发标记过程中产生的变化,筛选回收阶段,对各个Region的回收价值和成本进行排序,根据用户期望的停顿时间来选择不同的回收计划。然后把决定回收的Region中的存活对象复制到其他Region中,清除掉原Region。

同样,不能异想天开的降低延时的值,延时设置的太低就会导致垃圾堆积在内存中无法被及时清理,最后产生FullGC。

Shenandoah收集器

Shenandoah不是Oracle的官方收集器,所以受到了官方的排挤,只能在OpenJDK中使用,但它比官方的更加强大。

它的野心很大,就是要在任何堆内存大小都将垃圾收集的用户线程停顿时间降低在10ms以内。

它支持在整理阶段也与用户线程并发。

如下是基于延时的垃圾收集器比较数据

ZGC收集器

是一个血统纯正的Oracle发布的低延时收集器。

不学了学不动了以后再学。可能单用一篇文章介绍Shenandoah和ZGC。

未完...

posted @ 2021-09-02 18:57  yudoge  阅读(171)  评论(0编辑  收藏  举报