JVM的垃圾究竟是怎么回收的

477 阅读10分钟

这是我参与更文挑战的第17天,活动详情查看: 更文挑战

算法部分

复制算法

复制算法就是把内存区域划分为两块,然后只是用其中一块内存,等待使用的那块快要占满的时候,就把里面的存活对象一次性转移到另外一块内存区域,保证没有内存碎片,接着一次性的回收原来那块内存里的垃圾对象,再次空出来一块内存区域,两块内存就这么重复着循环使用。 缺点:内存分为相等的两块,每次使用其中一块,内存利用率低。

复制算法的优化:Eden区和Survivor区

在Java项目里,大多数的对象都是存活周期非常短的对象,可能被创建出来1毫秒后就没有引用成为垃圾对象了。可以想像一下,在新生代的垃圾回收,每次可能都有99%对象被回收了,1%存活下来的对象可能是一些长期存活的对象,或者是还没有被使用完的对象。 实际上新生代的复制算法,把新生代内存划分为了三块:1个Eden区,2个Survivor区。其中Eden占据80%,每一块Survivor占据10%。平时只有Eden区和其中一个Survivor被使用,相当于有90%的内存可以被使用。对象一开始都是被分配在Eden区,当Eden区快要占满的时候,触发垃圾回收,此时会把Eden区里存活的对象一次性转移到其中一块空着的Survivor区中,接着清空Eden区,然后再次分配新的对象到Eden区。如果下次Eden区再次被占满,触发垃圾回收,就会把Eden区和上次垃圾回收后存放存活对象的Survivor区内的存活对象,转移到另一块空着的Survivor区中,然后清空Eden区和之前的Survivor区。 对象在Eden区和其中一块Survivor区中,始终保持一块Survivor区是空的,就这样一直循环使用这三块内存。这么做最大的好处,就是只有10%的内存被闲置,90%的内存都被使用着。

标记清除算法

就是标记出存活的对象,然后再清除掉垃圾对象。 缺点:造成内存碎片。

标记整理算法

就是标记出存活的对象,此时存活对象可能是零散分布的,接着会让这些存活对象在内存里进行移动,移到一起,避免垃圾回收后造成碎片,然后再清除掉垃圾对象。

垃圾收集器

在新生代和老年代进行垃圾回收的时候,都是要使用垃圾回收器的,不同区域使用不同的垃圾回收器。

Serial和Serial Old

Serial和Serial Old分别用来回收新生代和老年代的垃圾。工作原理就是单线程运行,垃圾回收的时候会停止工作线程,然后进行垃圾回收。现在一般都不会使用。

ParNew和CMS

ParNew是用于新生代的垃圾回收器,CMS使用在老年代的垃圾回收器。它们都是多线程工作机制,性能更好,生产一般使用这种垃圾回收器。

G1

统一收集新生代和老年代,采用了更加优秀的算法和设计机制。是一种分代+分区收集的垃圾回收器。

这里顺便说一下“stop the world”现象。 大家有没有想过一个问题,就是在进行垃圾回收的时候,Java在运行期间还能继续创建对象吗?假设在运行期间,还允许创建新对象,那么在清除Eden区和一个Survivor区的时候,就会有新的对象被创建,这些新对象有的成为了垃圾对象有的还在被引用,那垃圾收集器要如何去回收呢?所以在垃圾回收的时候,是会停止工作线程,禁止创建新对象的,Java程序会出现短暂的“卡顿”,等待垃圾回收结束后再继续运行。这里的“卡顿”就是JVM最大的痛点,在垃圾回收的时候JVM会在后台进入“stop the world”状态。 Serial垃圾回收器就是用一个线程进行垃圾回收,然后此时暂停系统工作线程,所以我们在生产上不会使用。平时我们用在新生代的垃圾收集器一般都是ParNew,它针对服务器一般都是多核CPU做了优化,支持多线程垃圾回收,可以大幅度提升性能。CMS是专门用在老年代的垃圾回收器,是多线程运行的,它有自己特殊的一套机制和原理,尽可能的减少在垃圾回收中造成的“stop the world”的时间。 JVM迭代的演进,就是不断的在优化垃圾回收器的机制和算法,尽可能的降低垃圾回收过程对我们系统运行的影响。

ParNew垃圾收集器

在进行垃圾回收时,JVM停止了工作线程,就一个垃圾回收线程在运行,对于多核服务器,充分的使用CPU并行收集垃圾,可以成倍的提升性能。 新生代的ParNew主打的就是多线程垃圾回收机制。另一种Serial垃圾回收器主打的是单线程,它们两个都是回收新生代的,唯一区别就是单线程和多线程的区别,收集算法是一样的,都是采用复制算法。 系统启动时,如何指定使用ParNew垃圾收集器呢?使用参数“-XX:+UseParNewGC”,JVM启动的时候,新生代就是使用了ParNew垃圾收集器。 对于多核服务器,一旦我们指定了ParNew垃圾收集器,它默认给自己设计的垃圾回收线程数跟CPU的核数是一样的。当然我们也可以通过参数“-XX:ParallelGCThreads”来指定垃圾回收时的线程数。一般建议不用修改该参数。

CMS垃圾收集器

CMS垃圾回收机制

一般我们老年代选择的垃圾回收器是CMS垃圾回收器,它采用的是标记清除算法,这种方法最大的问题就是“碎片化”。当系统进入“stop the world”状态,然后采用“标记-清除”回收垃圾时,会导致系统卡死时间过长,所以CMS垃圾回收器采用的是垃圾回收线程和系统工作线程尽量同时执行的模式来处理的。 下面我们来分析一下CMS如何实现系统一边工作的同时进行垃圾回收?我们结合代码分析:

public class Test {
    private static Student student = new Student();
}
class Student{
    private Object object = new Object();
}

CMS在进行垃圾回收的时候一共分为4个阶段:

  • 初始标记:初始标记阶段会让工作线程全部暂停,进入“stop the world”状态。在这个阶段,仅仅会通过“student”这个类的静态变量代表的GC Roots,去标记出它直接引用的Student对象,这就是初始标记的过程。它不会去管Object这种对象,因为Object是被Student类的“object”实例变量引用的,之前说过,方法的局部变量和类的静态变量是GC Roots,但类的实例变量不是。
  • 并发标记:这个阶段会让系统随意创建对象,工作线程继续运行。在运行期间可能会创建新的存活对象,也可能会让部分存活对象失去引用变成垃圾。这个过程里,垃圾回收线程,会尽可能的对已有的对象进行GC Roots追踪。所谓GC Roots追踪,意思就是对类似“Object”之类的全部老年代里的对象,去看他们都被谁引用了?比如这里Object是被Student的实例变量引用了,接着看,Student对象被Test类里的静态变量引用了,那么此时可以认定Object是被GC Roots间接引用的,所以此时就不会回收它。这个阶段是对老年代所有对象进行GC Roots进行追踪的,是最耗时的。不过这个阶段是跟工作线程并发运行的,所以这个阶段不会对系统造成影响。
  • 重新标记:因为在并发标记阶段,一边标记垃圾对象和存活对象,一边系统在不停的运行创建新的对象,让老对象变成垃圾对象。所以第二阶段结束后,绝对会很多存活对象和垃圾对象是第二阶段没有标记出来的。此时进入第三个阶段,继续让系统停下来,再次进入“stop the world”状态。然后重新标记第二阶段里新创建的那些对象,以及一些已有对象可能失去引用变成垃圾的情况。重新标记阶段也是很快的,它其实就是对第二阶段中被系统程序运行变动过的少数对象进行标记。
  • 并发清理:这个阶段系统可随意运行,同时清理掉之前标记为垃圾的对象即可。这个阶段也是很耗时的,但是他也是并发运行的,所以不影响程序运行。 分析完CMS的垃圾回收机制后,我们发现他已经尽可能的进行优化了,最耗时的两个阶段:并发标记和并发清理都是和工作线程并发执行的。“stop the world”状态的两个阶段:初始标记和重新标记都是很快的,对系统的影响也很小。

并发回收导致资源紧张

CMS最大的问题,就是在垃圾回收的同时让工作线程继续运行,在并发标记和并发清理两个最耗时的两个阶段,垃圾回收线程和工作线程并行,会导致有限的CPU资源被垃圾回收线程占用了一部分。CMS默认启动的垃圾回收线程数是(CPU核数+3)/4,所以CMS并发回收机制的问题之一就在于占用了CPU资源。

Concurrent Mode Failure问题

在并发清理阶段,CMS是清理之前标记好的垃圾对象,但是这个阶段系统一直在运行,有一些对象会在此时进入老年代,并成为垃圾对象,这种垃圾对象是“浮动垃圾”。虽然它们成为了垃圾但是垃圾回收器不会回收它们,需要等到下次GC时才会回收。所以为了保证在CMS垃圾回收期间,还有一定的内存让对象进入老年代,一般会预留一些空间,CMS垃圾回收的触发时机,其中有一个就是老年代空间占用达到一定比例了,就自动GC。 “-XX:CMSInitiatingOccupancyFaction”参数就可以用来设置老年代占用多少比例的时候触发CMS垃圾回收,JDK1.6里默认的是92%。那么如果CMS并发清理期间,系统要放入老年代的对象大于了可用空间,就会发生“Concurrent Mode Failuer”。此时会自动使用“Serial Old”垃圾回收器代替CMS,就是强行进入“stop the world”重新长时间的GC Roots追踪,标记出垃圾,不允许新对象产生,然后再进行垃圾回收,结束后再恢复运行。

内存碎片问题

CMS采用“标记-清除”算法,这样势必会导致大量的内存碎片。如果碎片太多,导致找不到足够的空间来放置新对象,就会触发Full GC。实际上太多的内存碎片会导致更频繁的Full GC。CMS有一个参数是:“-XX:+UseCMSCompactAtFullCollection”,默认是打开的。它的意思是在Full GC之后,要再次进行“stop the world”,停止工作线程,进行碎片整理,空出大片连续的内存空间。还有一个参数是:“-XX:CMSFullGCsBeforeCompaction”,这个意思是执行多少次Full GC之后执行一次碎片整理,默认是0,即每次Full GC之后都进行碎片整理。