JVM中GC的基本概念

有的时候,你很爱一个人,其实只是你自己的事,像一出独角戏。到最后,最感动的人,不过是你自己

Posted by yishuifengxiao on 2022-02-22

在JAVA8中,内存被分为一下几部分

image-20220222103951534而在java8之前,内存区域划分大致如下所示

img在java8中,去掉了最右侧的Perm区域,换成了“元数据”区。

一 GC的分类

在日常开发过程中,我们接触最多的概念如下:Minor GC、Young GC、Full GC、Old GC、Major GC、Mixed GC。

这些概念的大致含义如下:

  • Partial GC(局部 GC): 并不收集整个 GC 堆的模式

    • Young GC: 只收集 Young Gen 的 GC,Young GC 还有种说法就叫做 Minor GC
    • Old GC: 只收集 old gen 的 GC,只有垃圾收集器 CMS 的 concurrent collection 是这个模式
    • Mixed GC: 收集整个 Young Gen 以及部分 old gen 的 GC,只有垃圾收集器 G1 有这个模式
  • Full GC: 收集整个堆,包括新生代,老年代,永久代(在 JDK 1.8 及以后,永久代被移除,换为 metaspace 元空间)等所有部分的模式

Mixed GC 是 G1 中特有的概念,其实说白了,主要就是说在 G1 中,一旦老年代占据堆内存的 45%(-XX:InitiatingHeapOccupancyPercent:设置触发标记周期的 Java 堆占用率阈值,默认值是 45%。这里的Java 堆占比指的是 non_young_capacity_bytes,包括 old + humongous),就要触发 Mixed GC,此时对年轻代和老年代都会进行回收。Mixed GC 只有 G1 中才会出现。

二 Minor GC

2.1 Minor GC特点

从年轻代空间(包括 Eden 和 Survivor 区域)回收内存被称为 Minor GC。这一定义既清晰又易于理解。但是,当发生Minor GC事件的时候,有一些有趣的地方需要注意到:

  1. 当 JVM 无法为一个新的对象分配空间时会触发 Minor GC,比如当 Eden 区满了。所以分配率越高,越频繁执行 Minor GC。
  2. 内存池被填满的时候,其中的内容全部会被复制,指针会从0开始跟踪空闲内存。Eden 和 Survivor 区进行了标记和复制操作,取代了经典的标记、扫描、压缩、清理操作。所以 Eden 和 Survivor 区不存在内存碎片。写指针总是停留在所使用内存池的顶部。
  3. 执行 Minor GC 操作时,不会影响到永久代从永久代到年轻代的引用被当成 GC roots,从年轻代到永久代的引用在标记阶段被直接忽略掉。
  4. 与普遍看法相反,所有 Minor GC 都会触发 stop-the-world 暂停,从而停止应用程序线程。 对于大多数应用程序,暂停的长度在延迟方面可以忽略不计。 如果 Eden 中的大多数对象都可以被视为垃圾并且永远不会复制到幸存者/旧空间,则这是正确的。 如果情况正好相反,并且大多数新生对象都不符合 GC 条件,那么 Minor GC 暂停开始花费更多时间。

所以 Minor GC 的情况就相当清楚了——每次 Minor GC 会清理年轻代的内存。

在 JVM 规范中,没有强制要求方法区必须实现垃圾回收,很多人习惯将方法区称为“永久代”。

2.2 Minor GC触发机制

当年轻代满时就会触发Minor GC,这里的年轻代满指的是Eden代满。

Survivor满不会引发GC。

在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”,不管怎样,都会保证名为To的Survivor区域是空的。也就是说从一次Minnor GC的开始到结束,“TO”区域最终都要成为空的。

Minor GC会一直重复这样的过程,直到“To”区被填满即Eden区存活的对象和from区存活的对象很多了,被复制到to区域时,to区域一下子接收装不下了,则“To”区被填满时,就不会再进行角色互换变成from了,而是“To”区被填满之后,会将所有对象移动到年老代中,则“To”区是空的了,即“To”区变成空有两种方式,一是对象从from移动到to区后,角色互换,为空的区域from变成to,to就变成空了的;二是Eden和from中未达到15岁的对象两者加起来太多,移动到to区填满了,则把填满了to区的对象移动到老年代,此时eden区和from区对象变少了,to区也没经过角色互换,变成空的。

有个问题,to区域被填满了,to区中的有的对象年龄还没被复制15次,也会被移动到年老代中吗?

答:Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。默认情况下,如果对象年龄达到15岁,就会移动到老年代中。

一般来说,大对象会被直接分配到老年代,所谓的大对象是指需要大量连续存储空间的对象,最常见的一种大对象就是大数组,比如:

byte[] data = new byte[4*1024*1024]

这种一般会直接在老年代分配存储空间。

2.3 对象年龄

虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1。对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁)时,就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold (阈值)来设置。

三 MajorGC

当老年代满时会触发MajorGC,只有CMS收集器会有单独收集老年代的行为,其他收集器均无此行为。而针对新生代的MinorGC,各个收集器均支持。总之,单独发生收集行为的只有新生代,除了CMS收集器,都不支持单独回收老年代。

发生在老年代的GC ,基本上发生了一次Major GC 就会发生一次 Minor GC。并且Major GC 的速度往往会比 Minor GC 慢 10 倍。

3.1 Major GC 触发机制

  1. 对于一个大对象,我们会首先在Eden 尝试创建,如果创建不了,就会触发Minor GC
  2. 随后继续尝试在Eden区存放,发现仍然放不下
  3. 尝试直接进入老年代,老年代也放不下
  4. 触发 Major GC 清理老年代的空间
  5. 放的下 成功
  6. 放不下 OOM

四 Full GC

大家应该注意到,目前,这些术语无论是在 JVM 规范还是在垃圾收集研究论文中都没有正式的定义。但是我们一看就知道这些在我们已经知道的基础之上做出的定义是正确的,Minor GC 清理年轻带内存应该被设计得简单:

  • Major GC 是清理老年代。
  • Full GC 是清理整个堆空间—包括年轻代和老年代。

很不幸,实际上它还有点复杂且令人困惑。首先,许多 Major GC 是由 Minor GC 触发的,所以很多情况下将这两种 GC 分离是不太可能的。另一方面,许多现代垃圾收集机制会清理部分永久代空间,所以使用“cleaning”一词只是部分正确。

这使得我们不用去关心到底是叫 Major GC 还是 Full GC,大家应该关注当前的 GC 是否停止了所有应用程序的线程,还是能够并发的处理而不用停掉应用程序的线程。

4.1 常见GC分析

这种混乱甚至内置到 JVM 标准工具。下面一个例子很好的解释了我的意思。让我们比较两个不同的工具 Concurrent Mark 和 Sweep collector (-XX:+UseConcMarkSweepGC)在 JVM 中运行时输出的跟踪记录。

第一次尝试通过 jstat 输出:

1
my-precious: me$ jstat -gc -t 4235 1s

得到输出内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
Time S0C    S1C    S0U    S1U      EC       EU        OC         OU       MC     MU    CCSC   CCSU   YGC     YGCT    FGC    FGCT     GCT   
5.7 34048.0 34048.0 0.0 34048.0 272640.0 194699.7 1756416.0 181419.9 18304.0 17865.1 2688.0 2497.6 3 0.275 0 0.000 0.275
6.7 34048.0 34048.0 34048.0 0.0 272640.0 247555.4 1756416.0 263447.9 18816.0 18123.3 2688.0 2523.1 4 0.359 0 0.000 0.359
7.7 34048.0 34048.0 0.0 34048.0 272640.0 257729.3 1756416.0 345109.8 19072.0 18396.6 2688.0 2550.3 5 0.451 0 0.000 0.451
8.7 34048.0 34048.0 34048.0 34048.0 272640.0 272640.0 1756416.0 444982.5 19456.0 18681.3 2816.0 2575.8 7 0.550 0 0.000 0.550
9.7 34048.0 34048.0 34046.7 0.0 272640.0 16777.0 1756416.0 587906.3 20096.0 19235.1 2944.0 2631.8 8 0.720 0 0.000 0.720
10.7 34048.0 34048.0 0.0 34046.2 272640.0 80171.6 1756416.0 664913.4 20352.0 19495.9 2944.0 2657.4 9 0.810 0 0.000 0.810
11.7 34048.0 34048.0 34048.0 0.0 272640.0 129480.8 1756416.0 745100.2 20608.0 19704.5 2944.0 2678.4 10 0.896 0 0.000 0.896
12.7 34048.0 34048.0 0.0 34046.6 272640.0 164070.7 1756416.0 822073.7 20992.0 19937.1 3072.0 2702.8 11 0.978 0 0.000 0.978
13.7 34048.0 34048.0 34048.0 0.0 272640.0 211949.9 1756416.0 897364.4 21248.0 20179.6 3072.0 2728.1 12 1.087 1 0.004 1.091
14.7 34048.0 34048.0 0.0 34047.1 272640.0 245801.5 1756416.0 597362.6 21504.0 20390.6 3072.0 2750.3 13 1.183 2 0.050 1.233
15.7 34048.0 34048.0 0.0 34048.0 272640.0 21474.1 1756416.0 757347.0 22012.0 20792.0 3200.0 2791.0 15 1.336 2 0.050 1.386
16.7 34048.0 34048.0 34047.0 0.0 272640.0 48378.0 1756416.0 838594.4 22268.0 21003.5 3200.0 2813.2 16 1.433 2 0.050 1.484

这个片段是 JVM 启动后第17秒提取的。基于该信息,我们可以得出这样的结果,运行了12次 Minor GC、2次 Full GC,时间总跨度为50毫秒。通过 jconsole 或者 jvisualvm 这样的基于GUI的工具你能得到同样的结果。

1
java -XX:+PrintGCDetails -XX:+UseConcMarkSweepGC eu.plumbr.demo.GarbageProducer

得到的内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
3.157: [GC (Allocation Failure) 3.157: [ParNew: 272640K->34048K(306688K), 0.0844702 secs] 272640K->69574K(2063104K), 0.0845560 secs] [Times: user=0.23 sys=0.03, real=0.09 secs] 
4.092: [GC (Allocation Failure) 4.092: [ParNew: 306688K->34048K(306688K), 0.1013723 secs] 342214K->136584K(2063104K), 0.1014307 secs] [Times: user=0.25 sys=0.05, real=0.10 secs]
... cut for brevity ...
11.292: [GC (Allocation Failure) 11.292: [ParNew: 306686K->34048K(306688K), 0.0857219 secs] 971599K->779148K(2063104K), 0.0857875 secs] [Times: user=0.26 sys=0.04, real=0.09 secs]
12.140: [GC (Allocation Failure) 12.140: [ParNew: 306688K->34046K(306688K), 0.0821774 secs] 1051788K->856120K(2063104K), 0.0822400 secs] [Times: user=0.25 sys=0.03, real=0.08 secs]
12.989: [GC (Allocation Failure) 12.989: [ParNew: 306686K->34048K(306688K), 0.1086667 secs] 1128760K->931412K(2063104K), 0.1087416 secs] [Times: user=0.24 sys=0.04, real=0.11 secs]
13.098: [GC (CMS Initial Mark) [1 CMS-initial-mark: 897364K(1756416K)] 936667K(2063104K), 0.0041705 secs] [Times: user=0.02 sys=0.00, real=0.00 secs]
13.102: [CMS-concurrent-mark-start]
13.341: [CMS-concurrent-mark: 0.238/0.238 secs] [Times: user=0.36 sys=0.01, real=0.24 secs]
13.341: [CMS-concurrent-preclean-start]
13.350: [CMS-concurrent-preclean: 0.009/0.009 secs] [Times: user=0.03 sys=0.00, real=0.01 secs]
13.350: [CMS-concurrent-abortable-preclean-start]
13.878: [GC (Allocation Failure) 13.878: [ParNew: 306688K->34047K(306688K), 0.0960456 secs] 1204052K->1010638K(2063104K), 0.0961542 secs] [Times: user=0.29 sys=0.04, real=0.09 secs]
14.366: [CMS-concurrent-abortable-preclean: 0.917/1.016 secs] [Times: user=2.22 sys=0.07, real=1.01 secs]
14.366: [GC (CMS Final Remark) [YG occupancy: 182593 K (306688 K)]14.366: [Rescan (parallel) , 0.0291598 secs]14.395: [weak refs processing, 0.0000232 secs]14.395: [class unloading, 0.0117661 secs]14.407: [scrub symbol table, 0.0015323 secs]14.409: [scrub string table, 0.0003221 secs][1 CMS-remark: 976591K(1756416K)] 1159184K(2063104K), 0.0462010 secs] [Times: user=0.14 sys=0.00, real=0.05 secs]
14.412: [CMS-concurrent-sweep-start]
14.633: [CMS-concurrent-sweep: 0.221/0.221 secs] [Times: user=0.37 sys=0.00, real=0.22 secs]
14.633: [CMS-concurrent-reset-start]
14.636: [CMS-concurrent-reset: 0.002/0.002 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

基于这些信息,我们可以看到12次 Minor GC 后开始有些和上面不一样了。没有运行两次 Full GC,这不同的地方在于单个 GC 在永久代中不同阶段运行了两次:

1、最初的标记阶段,用了0.0041705秒也就是4ms左右。这个阶段会暂停“全世界( stop-the-world)”的事件,停止所有应用程序的线程,然后开始标记。

2、并行执行标记和清洗阶段。这些都是和应用程序线程并行的。

3、最后 Remark 阶段,花费了0.0462010秒约46ms。这个阶段会再次暂停所有的事件。

4、并行执行清理操作。正如其名,此阶段也是并行的,不会停止其他线程。

所以,正如我们从垃圾回收日志中所看到的那样,实际上只是执行了 Major GC 去清理老年代空间而已,而不是执行了两次 Full GC。

如果你是后期做决 定的话,那么由 jstat 提供的数据会引导你做出正确的决策。它正确列出的两个暂停所有事件的情况,导致所有线程停止了共计50ms。但是如果你试图优化吞吐量,你会被误导的。清 单只列出了回收初始标记和最终 Remark 阶段,jstat的输出看不到那些并发完成的工作。

4.2 Full GC触发机制

  • 调用System.gc时,系统建议执行Full GC,但是不必然执行
  • 老年代空间不足
  • 方法区空间不足,类卸载(类卸载三个条件)
  • 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
  • 由Eden区、survivor space1(From Space)区向survivor space2(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

空间担保原则

每次 ygc 时预估这次 ygc 要往老年代放多少东西,如果要往老年代存放的对象大小大于老年代大小,则不进行 ygc,改为进行 full gc

当永久代满时也会引发Full GC,会导致Class、Method元信息的卸载。

五 GC全流程分析

img

img 1、左5型,绕过了空间担保(绕过紫色框),直接进行了Young GC。不用空间担保,意味着Young GC后,即使新生代所有的对象都存活,也都可以放入老年代,所以只会进行Young GC

  2、img锚型,空间担保成功(跨越紫色框),先Young GC,后Old GC(也可能不进行)。空间担保成功,意味着Young GC后,大概率老年代空间够晋升对象用,不需要进行Old GC,那么先进行Young GC吧,Young GC过程中会有一部分对象晋升到老年代,这时候有两种情况:

  (情况1)、发现老年代空间不够,只能转而进行 Old GC ,然后接着YoungGC。

  (情况2)、老年代空间够,不用Old GC

  3、img中5少横型,空间担保失败(分叉于紫色框),先Old GC ,后Young GC。空间担保失败,意味着Young GC后,大概率老年代空间不够晋升对象用,需要进行Old GC。那么先进行Old GC吧,Old GC完成后进行Young GC(大家别忘记为什么要进行Old GC,是为了更好的进行Young GC,免得先进行Young GC到一半才发现空间不够用,再来Old GC)

六 避免频繁的Full GC

  • 避免定义过大的对象(数组)
  • 避免将过大对象定义为静态变量