二Java中的垃圾回收

Posted dabokele

tags:

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

  在垃圾回收中,标记清除(Mark and Sweep)是最重要的一个思想。但是想要在实际场景中应用这一思想,还是需要进行一些调整的。本文接下来就通过简单的示例来分析JVM是如何保证安全持续的分配对象的。

一、碎片内存区域整理

  JVM进行垃圾回收的目的是想要重复使用内存中那些不再被使用的对象所占的存储空间。这些不可用对象在内存中一般都不会是连续分布的,所以它们使用的空间也是比较零散的,而这种零散将会导致以下两个问题:
  
- 内存中的写操作将会变得更加耗时,因为在写的时候需要花时间去找到一块足够的内存碎片
- 当创建一个新对象时,JVM会分配给该对象一块连续的内存区域。所以,如果所有的内存碎片大小都不足以容纳该对象时,就会抛出一个无法分配内存空间的错误。

  为了避免上述两个问题,JVM在垃圾回收时除了进行标记清除之外,还会有一步内存整理操作。内存整理会将所有仍然在使用的对象在内存中重新分配,使用连续的一块内存空间。对碎片内存的整理过程如下图所示,
  
  

二、对象分代理论

  在前面提到过,在进行垃圾回收时会将整个应用系统暂停,并且当需要进行回收的对象越多,这个暂停时间也会越长。为了尽量缩短这个暂停时间,我们可以选择对少部分空间进行清理。研究表明,

  • 大部分对象很快就不再使用了
  • 并且对象也不会在很长时间内都可用

      基于上面这两个现象,可以提出一个简单的分代理论。在JVM中的对象可以划分为新生代(Young Generation)和老年代(Old Generation),老年代也可以成为老年区(Tenured)。
      
      

      从上图中可以看出,大部分的对象很快就不再使用,随着时间的推移,最后仅有少部分对象还继续存活。

      将JVM划分为两个年代区后,就可以对不同区域采用不同的垃圾回收策略来进行垃圾回收了。但是这也不是说这样就没有问题了,比如说有可能在新生代区域中有一个对象和老年代区域中的某个对象互相引用,那么由于这两个对象始终有引用的存在,在垃圾回收时就无法将这两个实质无用的对象进行回收。

      分代理论并不能很好的解决垃圾回收时的很多问题,比如,GC算法对于那些很快就不可用的对象,以及可能会存活很久的对象的处理效果都很好,但是对那些存活时间不长不短的对象的回收效果就会很不好。

三、内存区域划分

  下图中所展示的对JVM堆内存区域的划分和对象分代有点类似,需要注意的是要搞清楚垃圾回收在不同内存区域时采取的是什么策略。不同GC算法某区域的垃圾回收实现细节可能并不相同,但是大体思路基本上还是一致的。
  
  
  

  随着对象的生命周期增加,该对象所处区域会逐渐后移。上图中将内存区域划分为五个片段,分别是一个Eden区,两个Survivor区,这三个区域组成一个Young区。一个Tenured区,以及一个Permgen区。接下来的文章中,我们称Eden区为新生区,Survivor区为存活区,而Eden区和Survivor区共同组成新生代,Tenured称为老年代,Permgen称为永久代。

1、Eden区(新生区)

  新生区 是对象生成时所在的区域,即新建一个对象时,首先会在内存中的新生区域为其分配空间。由于可能会有多线程同时生成新对象,所以新生区 会进一步划分成一个或多个TLAB(Thread Local Allocation Buffer, 线程自有的内存分配区域)区。一个线程可以在对应的TLAB区域直接创建若干个对象,避免了多线程时的锁消耗。当一个TLAB区域的内存不足以继续创建对象时,接下来就会使用新生代中划分的一片共享区域。新生区 中TLAB和共享内存区域的划分如下图所示,
  
  
  
  如果在共享区域中仍然没有足够的内存进行分配,就会触发新生区中的一次垃圾回收。如果新生区垃圾回收后,内存还是不足,那么接下来就触发老年代中的垃圾回收动作了。

  当Eden区进行垃圾回收时,GC会将所有从root可达的对象标记为存活。

  前面提到,在JVM中可能会存在一种跨区域引用的情况,即可能在其他年代区域中会有对象引用了Eden区中的某对象。这种情况会破坏上面的内存区域划分策略。面对这种情况,JVM使用了一种叫做卡片标记(card-marking)的方法。从本质上来说,JVM只需要标记出Eden区域中那些被老年区对象所引用的脏对象的大致位置。可以参考Nitsan的博客查看更多信息。

  标记阶段完成后,在Eden区域中的所有存活对象将会被复制到存活区中。此时Eden区就被清空了,可以进行下一轮的对象生成。这一步被称为是标记和复制(Mark and Copy):存活的对象被标记,然后将它们复制(不是移动)到存活区中。

2、Survivor区(存活区)

  紧随Eden区之后有两个Survivor区,分别命名为from和to。这两个Survivor区总是有一个处于empty状态,并且处于empty状态的Survivor区称为to区,另一个Survivor区称为from区。

  在新生代(上图中的整个Young区域)垃圾回收时,新生代中(包括Eden区和from区)的所有存活对象将会复制到to区中。

  

  在新生代垃圾回收过程中,不再使用的对象将会在下次垃圾回收时清除,而一直存活的对象会在两个Survivor区中来回复制。当某个对象足够老时,这个对象将会被升级到老年代中。

  那么如何判断一个对象的年龄呢?在每发生一次GC时,那些仍然存活的对象的年龄就会增长一次,当某个对象的年龄增长到一个阈值时,这个对象就会被认定为足够老,可以升级到老年代了。

  这个年龄阈值是可以由参数进行设置的,该参数是-XX:MaxTenuringThreshold。如果设置-XX:MaxTenuringThreshold=0,那么每次新生代GC时,对象不再在from和to区之间进行复制,而是存活对象直接进入老年代。该参数默认值是15,即发生15次GC,如果某个对象仍然存活,则该对象会被升级到老年代。

  需要注意,有另一种情况会提前将新生代中的存活对象升迁到老年代。如果在GC时发现Survivor区已经无法容纳所有的存活对象时,也会有对象会被升迁到老年代中。

3、Old区(老年代)

  老年代的垃圾回收过程会更加复杂,这一部分区域更大,并且其中的对象可能存活的概率也更高。即在GC时在大量对象中可能只有少部分会被回收。

  老年代的GC要比新生代GC的频率低得多。由于该区域中的大多数对象在GC时都可能存活,所以老年代中不会进行标记复制(Mark and Copy)来整理碎片空间,而是通过移动对象来整理。基本上是按照如下步骤进行,

  • 将存活的对象通过标记为(marked bit)进行标记
  • 删除所有不再使用的对象
  • 将存活的对象进行复制,从按照老年代空间开始处依次存放
      

4、PermGen区(永久代)

  在Java 8之前的JVM版本中,还有一个永久代区域。这个区域一般用于存放元数据,比如类定义信息,内部化的字符串(internalized strings)等。在实际应用中很难确定这片区域需要使用多少空间,因此该区域很可能会抛出java.lang.OutOfMemoryError: Permgen space的错误信息。除非该报错明确的是由于内存泄漏导致,否则就需要通过调整永久代区域大小来解决这个问题。调整永久代区域内存大小的方式如下,

java -XX:MaxPermSize=256m com.mycompany.MyApplication

5、元数据区(Metaspace)

  由于很难去确定永久代的空间大小调节,所以在Java 8中已经去除了永久代的概念,新增了一个元数据区。

  在Java 8中,类定义信息都保存在元数据区中。元数据区使用的是本地内存(native memory),并且不会有Java堆中对象的引用。这样的话,元数据区的大小就由运行Java进程的机器上的本地内存大小决定了。这样就可以尽可能的避免出现java.lang.OutOfMemoryError: Permgen space报错。

  但是如果不加限制的使用本地内存的话,可能会影响到本机的内存交换或者导致本机内存分配失败。在这种情况下,最好还是对元数据区可使用的本地内存大小进行一定的限制,方法如下

java -XX:MaxMetaspaceSize=256m com.mycompany.MyApplication

四、Minor GC vs Major GC vs Full GC

  GC主要有三种形式,Minor GC,Major GC以及Full GC。在本节中,会对这三种GC事件进行分析。

1、Minor GC

  新生代(Young区)中进行的垃圾回收事件被称为Minor GC。在理解Minor GC事件时,需要注意以下几点,

  • 当JVM无法为新生成对象分配内存空间时就会触发Minor GC,比如Eden区满时。所以新生成对象的频率越高,越容易导致Minor GC。
  • 在Minor GC过程中,不会对永久代区域进行处理。所有新生代区域中对象被永久代区域中某些对象引用的对象,都直接当成是GC roots过来的引用。从新生代区域指向永久代区域的引用,在标记过程中会被忽略。
  • 需要注意的是Minor GC是会导致应用线程全部暂停的(stop-the-world)。对大多数应用来说,Minor GC导致的暂停可以忽略不计,因为Eden区中的对象大部分都是垃圾,在Minor GC时仅仅只有少量的对象会被复制到Survivor区或者Old区。

2、Major GC vs Full GC

  新生代发送的GC被称为Minor GC,那么老年代区域发生的GC就被称为Major GC,新生代和老年代同时发生的GC被称为Full GC。

  按照上面的定义,想要区分开Major GC和Full GC是有点困难的,或者说Major GC与Full GC之间的划分并没有那么明显。许多Major GC都是由Minor GC触发的。另外,对于一些GC算法,比如G1算法,在垃圾回收时只会清理部分内存空间。

  所以,我们完全无需纠结于到底如何区分Major GC和Full GC。我们只需要关注GC是否会暂停应用线程,或者GC是否可以与应用线程并行执行。

  下面通过一个实例来进行分析,这个实例中使用的是并发标记清除垃圾收集器。(-XX:+UseConcMarkSweepGC)

  观察jstat工具的输出

my-precious: me$ jstat -gc -t 4235 1s
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来获取。

  接下来我们再看看本次JVM启动后的垃圾回收日志,需要打印垃圾回收日志可以通过参数-XX:+PrintGCDetails来控制,

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。这一过程的执行步骤如下,

  • 初始标记过程,耗时 0.0041705秒。这一过程暂停了整个应用线程。
  • 标记和预清理过程。这一过程与应用线程并发执行。
  • 重新标记过程,耗时 0.0462010。这一过程再次暂停了整个应用线程。
  • 清除过程。这一过程也是并发执行,没有暂停应用线程。

      所以,在垃圾回收日志中我们获取到的实际情况是,系统并不是真正的执行了两次Full GC操作,而是仅执行了一次Major GC来清理老年代空间。这里Major GC导致的两次暂停时间加起来刚好等于上面日志中Full GC导致的暂停时间。

      如果只关注系统延迟的话,使用jstat工具获取到的信息就足够进行调优决策了。而GC日志数据会清楚的列举出总共50毫秒的暂停时间都消耗在哪些阶段。如果需要调整系统吞吐量的话,jstat获取的信息就会有误导性了,因为它只列举出了GC过程中stop-the-world的总时间,将GC过程中并发执行的操作给隐藏起来了。而实际上,GC过程中的并发操作过程也会影响到应用的运行。

以上是关于二Java中的垃圾回收的主要内容,如果未能解决你的问题,请参考以下文章

JVM | 垃圾回收

Java中的垃圾回收算法详解

java---垃圾回收

js中的内存泄漏

JVM 垃圾回收算法和垃圾回收器

二Java中的垃圾回收