JVM垃圾回收- GC算法:实现

Posted zackstang

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM垃圾回收- GC算法:实现相关的知识,希望对你有一定的参考价值。

 GC算法:实现

上面我们介绍了GC算法中的核心概念,接下来我们看一下JVM里的具体实现。首先必须了解的一个重要的事实是:对于大部分的JVM来说,两种不同的GC算法是必须的,一个是清理Young Generation的算法,另一种是清理Old Generation的算法。

在JVM里有各种各样的这种内置算法,如果你没有特别指定GC算法,则会使用一个默认的、适应当前平台(platform-specific)的算法。接下来我们会解释每种算法的工作原理。

下面的列表提供了一个快速的预览,关于哪些算法可能被结合使用。不过需要注意的是,它仅适用于Java 8,对应Java 8 之前的版本,可能稍有不同。

技术分享图片

如果上表看起来很复杂,不要慌。在实际使用中,基本可以归结为上面表中标粗的部分。剩下的不是被弃用,就是不被支持,或是在实际场景中不实用。所以,下面我们仅仅会讨论下面的几种组合:

  1. Serial GC for both the Young and Old generation
  2. Parallel GC for both the Young and Old generation
  3. Parallel New for Young + Concurrent Mark and Sweep (CMS) for the Old Generation
  4. G1 in case of which the generation are not separated between the Young and Old

 

Serial GC

这种垃圾回收器在Young Generation使用mark-copy,在Old Generation使用mark-sweep-compact。正如它的名字一样,这两种收集器均是单线程的收集器,无法与当前的任务并行工作。这两种收集器均会触发stop-the-world pauses,暂时停止所有应用线程。

这种GC算法无法使用当前主流硬件上多核CPU的优点,不管有多少可用的CPU核数,JVM在GC阶段仅会使用一个核。可以通过指定以下配置应用此机制:

java -XX:+UseSerialGC com.company.testclass

这个选项仅推荐给:

  1. JVM中仅有几百MB的堆大小
  2. 运行的环境是单核CPU

对于大部分的服务端部署来说,很少会使用这种模式,因为大部分服务端的应用一般部署在多核平台,并不适合Serial GC的使用场景,会造成服务器资源的浪费。接下来我们看一下如果使用Serial GC 的话,那GC 收集器的日志会是什么形式。首先我们在JVM下开启GC日志:

-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps

日志的输出类似以下内容:

2015-05-26T14:45:37.987-0200: 151.126: [GC (Allocation Failure) 151.126: [DefNew: 629119K->69888K(629120K), 0.0584157 secs] 1619346K->1273247K(2027264K), 0.0585007 secs] [Times: user=0.06 sys=0.00, real=0.06 secs]

2015-05-26T14:45:59.690-0200: 172.829: [GC (Allocation Failure) 172.829: [DefNew: 629120K->629120K(629120K), 0.0000372 secs]172.829: [Tenured: 1203359K->755802K(1398144K), 0.1855567 secs] 1832479K->755802K(2027264K), [Metaspace: 6741K->6741K(1056768K)], 0.1856954 secs] [Times: user=0.18 sys=0.00, real=0.18 secs]

这小部分GC日志可以提供我们很多信息,JVM内部当时发生了什么。具体地说,这部分日志片段反应了两轮GC,一个是清理Young Generation,另一个是清理整个堆。我们首先分析第一个在Young Generation发生的GC。

 

Minor GC

下面的日志片段包括了GC清理Young Generation时的一些信息:

2015-05-26T14:45:37.987-02001:151.1262:[GC3(Allocation Failure4) 151.126: [DefNew5:629119K->69888K6(629120K)7, 0.0584157 secs]1619346K->1273247K8(2027264K)9,0.0585007 secs10][Times: user=0.06 sys=0.00, real=0.06 secs]11

 

1. 2015-05-26T14:45:37.987-0200 : GC事件发生的时间

2. 151.126 : 相对于JVM的启动时间,GC事件发生的时间,以秒为单位

3. GC: GC类型的标志,用于区别是 Minor GC 还是 Full GC。这次显示的是一次 Minor GC

4. Allocation Failure :GC发生的原因。在这个日志中,表示的是是由于Young Generation 里的任何区域均无法满足一个(对某个数据结构的)空间分配

5. DefNew :使用的 GC 收集器名称。这个缩略名表示的是单线程的、mark-copy、stop-the-world 垃圾回收器,用于清理Young Generation

6. 629119K->69888K :Young Generation的使用情况,分为 GC 前以及 GC 后

7. (629120K) :Young Generation 的总大小

8. 1619346K->1273247K :堆内存使用的总大小,分为GC前与GC后

9. (2027264K) :堆内存总可用大小

10. 0.0585007 secs :GC事件的持续总长时间,以秒为单位

11. [Times: user=0.06 sys=0.00, real=0.06 secs] :GC事件的持续时间,从三种不同的类别衡量:

A. user:在GC 阶段,GC 线程消耗的整个CPU时间

B. sys:OS 调用消耗的时间,或是等待系统事件的时间

C. real:应用停止的时间。因为 Serial GC 一直使用的是单线程,所以这里 real time 等于 user 与 system 时间的总和

 

从上面的片段,我们可以精确地了解到在 GC 事件时,JVM内部的内存消耗情况。在这次回收前,heap 使用了总共 1,619,346K 大小的内存,其中 Young Generation 一共占了 629,120K 内存。基于此,我们可以计算出 Old Generation 使用量为 990,227K内存。

另一方面,我们也可以看到,在回收之后,Young Generation 的使用量降了 559,231K,但是整个heap 的使用量仅降了346,099K,由此可以推测出,有 213,132K 的对象从 Young Generation 被提升到了 Old Generation。

这次 GC 事件前后,内存的分布,也可以通过下图表示:

 技术分享图片

 

Full GC

在讨论了第一个 Minor GC 事件后,我们再来看看第二个 Full GC 事件日志:

2015-05-26T14:45:59.690-02001: 172.8292:[GC (Allocation Failure) 172.829:[DefNew: 629120K->629120K(629120K), 0.0000372 secs3]172.829:[Tenured4: 1203359K->755802K 5(1398144K) 6,0.1855567 secs7] 1832479K->755802K8(2027264K)9,[Metaspace: 6741K->6741K(1056768K)]10 [Times: user=0.18 sys=0.00, real=0.18 secs]11

 

1. 2015-05-26T14:45:59.690-0200 :GC事件开始的时间

2. 172.829 : 相对于JVM的启动时间,GC事件发生的时间,以秒为单位

3. [DefNew: 629120K->629120K(629120K), 0.0000372 secs :类似上一个例子(由于 Allocation Failure触发的一个minor GC),这次对 Young Generation 的回收也是同样由 DefNew 回收器完成。它将 Young Generation的使用量由 629,120K 降为 0。需要注意的是:这里的日志打印有问题,由于一个存在bug的行为,导致它打印的日志为 Young Generation 使用为满的状态。这次回收耗时 0.0000372 秒

4.Tenured :清理 Old 空间时使用的 GC 收集器名称。这里 Tenured 表示GC使用了一个单线程的、stop-the-world、mark-sweep-compact 垃圾回收器

5. 1203359K->755802K :在 GC 事件前后,Old Generation 使用的空间大小

6. (1398144K) :Old Generation 空间的总共大小

7. 0.1855567 secs :清理 Old Generation 的时间

8. 1832479K->755802K :清理 Young 以及 Old Generation 前后,整个 heap 使用的内存大小

9. (2027264K) :JVM 可用的 heap 大小

10. [Metaspace: 6741K->6741K(1056768K)] :类似 Metaspace 空间回收的信息,正如日志打印的,这次回收中,没有Metaspace的垃圾被回收

11. [Times: user=0.18 sys=0.00, real=0.18 secs] :GC事件的持续时间,从三种不同的类别衡量:

A. user:在GC 阶段,GC 线程消耗的整个CPU时间

B. sys:OS 调用消耗的时间,或是等待系统事件的时间

C. real:应用停止的时间。因为 Serial GC 一直使用的是单线程,所以这里 real time 等于 user 与 system 时间的总和

 

Full GC 与 Minor GC 的不同点显而易见:在 GC 事件中,除了对 Young Generation 做了垃圾回收外,Old Generation 与 Metaspace 也被做了清理。在这个例子中,在 GC 事件前后,内存的分布可如下如表示:

 技术分享图片

 

 

Parallel GC

这种GC收集器的组合(对 Young 与 Old 使用的两种 GC收集器),在 Young Generation 中使用 mark-copy,在Old Generation中使用mark-sweep-compact。对 Young 与 Old Generation的收集均会触发 stop-the-world 事件,暂停应用的所有线程,以运行 GC。两个收集器均会以多线程的方式运行 mark-copy / mark-sweep-compact,所以它的名字为 ‘Parallel’。使用并行的方式,可以明显减少GC的时间。在GC时,使用多少个线程也可以通过参数指定:-XX:ParallelGCThreads=NNN。默认的值是:机器的CPU核数。在启动JVM时使用以下任一配置即可启用ParallelGC:

java -XX:+UseParallelGC com.company.MyClass

java -XX:+UseParallelOldGC com.company.MyClass

java -XX:+UseParallelGC -XX:+UseParallelOldGC com.compay.MyClass

 

Parallel 垃圾收集器适用与多核机器,所以如果你的主要目标是为了提高吞吐,则Parallel GC是一个较好的选择。可以获得高吞吐是由于此方法高效地使用了系统资源:

  1. 在收集过程中,所有CPU 核均会并行回收垃圾,所以应用暂停时间会更短
  2. 在GC轮数之间,不会有收集器消耗任何资源

另一方面,由于在GC中所有的阶段在运行时不可被打断,所以在你的应用线程暂停时,这些收集器仍容易受到long pause的影响。所以,如果Latency是你需要优先考虑的目标,则你可以考虑下一个垃圾收集器组合。

下面我们看一下使用Parallel GC时,日志输出的信息。下面是一个 minor 和一个 major GC 的日志:

2015-05-26T14:27:40.915-0200: 116.115: [GC (Allocation Failure) [PSYoungGen: 2694440K->1305132K(2796544K)] 9556775K->8438926K(11185152K), 0.2406675 secs] [Times: user=1.77 sys=0.01, real=0.24 secs]

2015-05-26T14:27:41.155-0200: 116.356: [Full GC (Ergonomics) [PSYoungGen: 1305132K->0K(2796544K)] [ParOldGen: 7133794K->6597672K(8388608K)] 8438926K->6597672K(11185152K), [Metaspace: 6745K->6745K(1056768K)], 0.9158801 secs] [Times: user=4.49 sys=0.64, real=0.92 secs]

 

Minor GC

第一条表示了在 Young Generation里发生的一个GC事件:

2015-05-26T14:27:40.915-02001: 116.1152:[GC3(Allocation Failure4)[PSYoungGen5: 2694440K->1305132K6(2796544K)7]9556775K->8438926K8(11185152K)9, 0.2406675 secs10][Times: user=1.77 sys=0.01, real=0.24 secs]11

1. 2015-05-26T14:27:40.915-0200:GC事件发生的时间

2. 116.115 : 相对于JVM的启动时间,GC事件发生的时间,以秒为单位

3. GC: GC类型的标志,用于区别是 Minor GC 还是 Full GC。这次显示的是一次 Minor GC

4. Allocation Failure :GC发生的原因。在这个日志中,表示的是是由于Young Generation 里的任何区域均无法满足一个(对某个数据结构的)空间分配

5. PSYoungGen:使用的 GC 收集器名称。这里表示的是一个并行的、mark-copy、stop-the-world 垃圾回收器被用于清理Young Generation

6. 2694440K->1305132K:Young Generation的使用情况,分为 GC 前以及 GC 后

7. (2796544K):Young Generation 的总大小

8. 9556775K->8438926K :堆内存使用的总大小,分为GC前与GC后

9. (11185152K):堆内存总可用大小

10. 0.2406675 secs:GC事件的持续总长时间,以秒为单位

11. [Times: user=1.77 sys=0.01, real=0.24 secs] :GC事件的持续时间,从三种不同的类别衡量:

A. user:在GC 阶段,GC 线程消耗的整个CPU时间

B. sys:OS 调用消耗的时间,或是等待系统事件的时间

C. real:应用停止的时间。对于 Parallel GC 来说,它的值应该接近于(user time + system time)/ GC 收集器使用的 CPU 线程数。在这个例子中,GC 收集器使用的是 8 个线程。不过需要注意的是,由于一些活动并不会被并行执行,所以它的真实值会超过一定的比率。

 

从上面的日志可以看到,在 GC 事件前,整个 heap 中消耗的内存为 9,556,775K,其中 Young Generation 消耗了2,694,440K,也就是说 Old Generation 使用了 6,862,335K。在 GC 后,Young Generation 的使用量降了 1,389,308K,但是整个 heap 的使用量仅降了 1,117,849K。也就是说,有 271,459K 从 Young Generation 提升到了 Old Generation。

技术分享图片

Full GC

下面我们继续看下一行 GC 日志,看看 GC 是如何清理整个 heap 内存的:

2015-05-26T14:27:41.155-02001:116.3562:[Full GC3 (Ergonomics4)[PSYoungGen: 1305132K->0K(2796544K)]5[ParOldGen6:7133794K->6597672K 7(8388608K)8] 8438926K->6597672K9(11185152K)10, [Metaspace: 6745K->6745K(1056768K)] 11, 0.9158801 secs12, [Times: user=4.49 sys=0.64, real=0.92 secs]13

1-3 省略

4. Ergonomics:GC 事件发生的原因。这里表示 JVM 的内部功效决定这时候需要做垃圾回收

5. [PSYoungGen: 1305132K->0K(2796544K)]:与之前的例子类似,一个名为“PSYoungGen”的、并行的、mark-copy、stop-the-world GC 回收器被用于清理 Young Generation。Young Generation 的使用情况由 1,305,132K 降为了 0,因为一般一次 Full GC 经常会将 Young GC 完全清理掉。

6. ParOldGen:用于清理 Old Generation 的收集器类型。在这个例子中,一个名为 ParOldGen 的、并行的、mark-sweep-compact、stop-the-world 垃圾回收器被用于清理 Old Generation。

7. 7133794K->6597672K :在 GC 事件前后,Old Generation 使用的空间大小

8. (8388608K) :Old Generation 空间的总共大小

9. 8438926K->6597672K:清理 Young 以及 Old Generation 前后,整个 heap 使用的内存大小

10. (11185152K) :JVM 可用的 heap 大小

11. [Metaspace: 6745K->6745K(1056768K)] :类似 Metaspace 空间回收的信息,正如日志打印的,这次事件中,没有Metaspace的垃圾被回收

12. 0.9158801 secs:GC 事件持续的时间

13. [Times: user=4.49 sys=0.64, real=0.92 secs] GC事件的持续时间,从三种不同的类别衡量:

A. user:在GC 阶段,GC 线程消耗的整个CPU时间

B. sys:OS 调用消耗的时间,或是等待系统事件的时间

C. real:应用停止的时间。对于 Parallel GC 来说,它的值应该接近于(user time + system time)/ GC 收集器使用的 CPU 线程数。在这个例子中,GC 收集器使用的是 8 个线程。不过需要注意的是,由于一些活动并不会被并行执行,所以它的真实值会超过一定的比率。

 

同样,Full GC 与 Minor GC 的区别较为明显:除了清理 Young Generation,Old Generation 与 Metaspace 也会被清理。在这个例子中,在 GC 事件前后,内存的分布可如下如表示:

技术分享图片

 

References:

https://plumbr.io/handbook/garbage-collection-algorithms-implementations

 

以上是关于JVM垃圾回收- GC算法:实现的主要内容,如果未能解决你的问题,请参考以下文章

JVM垃圾回收的“三色标记算法”实现,内容太干

JVM中的GC垃圾回收

JVM详解——垃圾回收算法(补充)

JVM垃圾收集器

JVM垃圾收集器

JVM学习笔记GC——JAVA预言的垃圾回收