Java垃圾回收总结

时间:2023-03-08 21:46:49
Java垃圾回收总结

基本概念

垃圾回收器(Garbage Collector )是JVM非常重要的一个组成部分,主要用于自动化的内存管理。相比手动的内存管理,自动化的内存管理大大简化了程序员的开发难度并且更加安全,避免了各种如内存泄露,悬空引用等问题。GC职责是:分配内存,确保被可达的对象保留在内存中,将不可达的对象的内存进行回收利用。寻找和回收不可达对象的过程被称为垃圾回收,这些操作都都是为了维护一个大的内存池(通常叫堆)。GC在分配内存时候的难点在于如何能快速在堆中找到一块未被使用的合适大小的空间(在多线程环境下更加复杂),而在回收的时候要考虑内存碎片化的问题。同时,GC本身也是一个耗费时间和资源的操作,特别是在堆分配的过大的时候,虽然减少了GC的次数,但是执行一次GC可能要耗费很长的时间,如何减少对应用程序的性能影响也是一个重要考量。

可达对象(存活对象)

可达对象是GC识别出来的不能回收的对象,所有被GC Root直接或者间接引用的对象都是可达对象。

STW (Stop-The-World)

Stop-The-World是指在进行垃圾回收的时候,将Java应用程序进行完全停顿,显然这对应用程序的执行影响比较大。到目前为止,所有的垃圾回收都需要STW,即使是并发收集器也有些阶段需要STW,只需时间比较短,Java垃圾收集器不断改进都是为了降低STW的时间。

快速内存分配

在一块大而连续的内存上进行内存分配是非常快速的,只需要使用指针碰撞技术即可快速获得一块可用内存。在多线程情况下,内存分配必须保证是线程安全的,如果使用加锁类来处理的话,性能显然会比较底下。

HotSpot使用了Thread-Local Allocation Buffers (TLABs)技术来进行内存分配,也就是为每个线程分配一个Buffer,每个线程都在自己的Buffer上进行内存分配,这样就可以在不加锁的情况下使用指针碰撞来分配内存了,只有在TLAB满了以后才需要加锁以获取一个新的TLAB。TLAB技术可能会带来一定的内存浪费,但是控制在1%的Eden空间以下。

内存碎片(压缩,不压缩,复制)

当垃圾收集器确认内存中哪些是存活对象,哪些是需要回收的对象后,它可以对内存进行压缩,将所有存活对象移动到连续的内存中,剩下的连续内存就是可用内存了。进行压缩后内存分配效率将提升,分配一块内存只需要将一个指针移动到下一个有效内存的起始点。不压缩的情况是针对需要收集的对象内存做一个标记清除而不进行任何位置调整,这样就使内存中产生了许多空洞,也就是内存碎片了,导致内存分配比较耗时,因为可能需要搜索整个堆以找到一个可用的空洞。还有一种基于复制的算法,复制操作需要额外的内存空间和时间,但是解决了内存碎片的问题。

分代收集

分带收集是指将内存分成几个代,不同的代存放不同年龄的对象,通常分成两个代:青年代和老年代。分代后,不同的代可以使用不同的算法进行垃圾收集,每种算法都可以针对性的对这个代进行优化。分代收集基于对多种编程语言的观察得到的假设:

  • 大部分分配出来的对象存活时间不长,基本在年轻代就被回收了
  • 存在少量的老年代指向年轻代的引用

年轻代的收集执行的非常频繁并且快速有效,因为年轻代空间通常比较小并且大都是未被引用的对象,年轻代对象在经历指定次数的回收后依然存活会提升到老年代。老年代通常空间比较大,回收频率比较低,但是回收一次的耗时比较长。

HotSpot将内存分成3代:年轻代,老年代,永久代。通常对象在年轻代上进行首次分配,老年代存放在年轻代上经历了一定次数回收后依然存活的对象。有些大的对象可能直接被分配在老年代。永久代存放对象的描述信息如类和方法,永久代也受垃圾收集器管理。

年轻代分成3个区域:Eden和两个Survivor(From和To)。通常对象在Eden上进行分配。在经历了一次或多次年轻代的垃圾收集后依然存活的对象会被移动到Survivor的From区。

垃圾收集类型

当年轻代的被填满时会发生Minor GC。当老年代或永久代被填充满后会发生Full GC(有时也叫Major GC),Full GC会导致每个代都进行一次回收,通常年轻代先进行收集,然后对老年代和永久代进行收集。有时在先执行Minor GC后老年代的空间可能容纳不了年轻代晋升过来的存活对象。这种情况下除CMS以外的所以收集器都会使用老年代的收集算法对整个堆进行一次回收(CMS不支持对年轻代进行回收)。

HotSpot可用的垃圾收集器

HotSpot虚拟机包含了3类垃圾收集器,分别是串行收集器,并行收集器,并发收集器。

串行收集器(Serial Collector)

串行收集器可应用于年轻代和老年代,单线程 + STW。

对于年轻代,使用复制算法,在执行串行回收时,Eden中存活的对象被复制到Survivor的To区中,有些Eden中的大对象可能不适合放到To中,会被直接放到老年代中。Survivor的From区中年轻对象也会被复制到To中,而达到老年年龄的对象会被复制到老年区。如果To区满了,剩余在Eden和From中的所有存活对象都会被直接复制到老年区。在年轻代执行完后,Eden和From都完全变空了,只有To中包含存活对象,这时候,需要将From和To交换角色。

串行收集器在老年代和永久代上使用了标记-清除-压缩(mark-sweep-compact)算法。在标记阶段,收集器将标记所有的存活对象,在清除阶段,收集器扫描整个堆,将垃圾对象进行清除回收,最后执行压缩,将所有存活对象移动到老年代内存空间的起始位置(永久代同样),压缩完成后所有的空闲空间形成了一个大的连续块。

对于大多数对停顿时间不那么敏感的程序可以选择使用,比如客户端(client-style)桌面或者控制台程序,串行回收器是client应用的默认收集器,可以使用-XX:+UseSerialGC显式启用。

并行收集器(Parallel Collector,Throughput Collector)

在经历了多个Java版本后,并行收集器可以说是最混乱的一个了,各个Java版本对他的命名,参数,行为定义等都可能不一样,官方各个时期文档对此描述有时候也会有冲突,GC Log给出的命名又和官方文档命名不一致,而年轻代和老年代的区分也使得整体变得更加复杂。总得来说,并行收集器就是使用多线程并行进行垃圾回收的统称,在年轻代使用复制算法,在老年代使用标记-清除-压缩算法。了解并行收集器,最好结合JVM参数和Java版本来进行。

-XX:+UseParallelGC
  • Java 6:只指对年轻代进行并行收集,而老年代还是单线程的。
  • Java 7:和Java6一致。
  • Java 8:对年轻代进行并行收集,同时默认启用了并行压缩功能,也就是说老年代也是并行收集的了。
-XX:+UseParallelOldGC

Java 6,7,8版本都一样,年轻代和老年代都是并行收集。这个收集器的官方叫法是:并行压缩收集器(Parallel Compacting Collector),相比并行收集器,主要就是为老年代设计了新的并行收集算法,这是一个真正的全部并行的垃圾收集器。

-XX:+UseParNewGC
  • Java6:年轻代并行,老年代串行。
  • Java7:年轻代并行,老年代启用CMS收集器(并发)。
  • Java8:废除。

可以使用参数-XX:ParallelGCThreads=<N>来并行线程的数量,默认在Cpu核心数大于8的时候线程数量大约是5/8的Cpu核数,小于8则下降到了5/16。多线程同时进行Minor GC的时候,在年轻代对象进行到老年代的时候可能会产生内存碎片,降低线程数量可以降低碎片影响。

并发标记清除收集器(Concurrent Mark-Sweep (CMS) Collector, Low-Latency Collector)

CMS追求更短的STW时间,相比并行,引入了并发阶段,使得GC线程能在并发阶段和应用程序的代码一起执行而无需停顿。CMS只能应用到老年代,所以需要配合串行或者并行收集器一起使用,默认情况下选择CMS后,年轻代使用并行收集器。

CMS大部分时间和应用程序一起并发执行。分如下几个步骤:

  • 初始标记:先进行一次短暂的停顿,然后识别所有可以直达(directly reachable)的存活对象。
  • 并发标记:这个阶段将基于上一步的直达对象识别出所有可传递可达( transitively

    reachable)的存活对象。这个阶段不需要停顿,而是并发执行,带来的问题就是这个期间随着应用程序的运行,可能有新的存活对象没有被标记到。
  • 重新标记:遍历所有在并发标记阶段发生过变化的对象,重新标记停顿的时间比初始标记要长,这个阶段使用多线程并行来提升效率。所有的存活对象在这个阶段被标记。
  • 并发清除:回收所有被识别出来的垃圾对象。
不压缩带来的问题

CMS是不进行压缩整理的收集器,这样虽然节省了时间,但是由于空闲内存块不连续,也就无法使用指针碰撞来分配内存了,而是使用了一组空闲链表,将所有的空闲空间链接起来,当需要分配内存的时候,会基于要分配的空间大小选择一个链表进行搜索,以获取一个可用空间。因此在老年代上进行内存分配的操作代价更大,同时年轻代垃圾回收也受影响,晋升到老年代花费时间变长。

浮动垃圾

由于内存紧张触发CMS后,在并发标记阶段,应用程序还可以继续运行(不需要STW),这期间可能需要继续申请新的内存给新对象,在并发清除阶段,可能又有新的垃圾产生,这些垃圾未被进行标记,需要到下一次GC才能被清除,这样的垃圾称为浮动垃圾。这也导致了CMS需要使用更多的内存,通常需要准备20%的空间来给这些浮动垃圾。

并发模式失败

CMS并不像其他收集器那样在内存变满的时候执行,而是试图提前执行以便在内存满之前执行完成,如果在内存填满之前或者在老年代上无法分配请求的内存时,整个应用程序会被停顿,CMS会退化成并行垃圾回收,这种情况成为并发模式失败,需要调整参数以避免这种情况。可以通过参数-XX:CMSInitiatingOccupancyFraction=<N>来控制CMS的启动时机,N表示老年代的百分比。同时CMS采用了一些预测机制,如基于每次并发收集花费的时间和老年代填充满需要的时间来判断CMS的启动时机。

增量模式

增量模式是指将CMS的并发阶段的任务增量式的完成。这种模式通过定期的暂停并发任务将CPU资源还给应用程序,以减少长时间的并发对应用程序造成的影响。这项工作主要是在年轻代GC之间将任务划分成小的时间片来完成。这个特性主要用在那些对低停顿敏感但是Cpu核数不多的情况下。启用增量模式:–XX:+CMSIncrementalMode

如果追求更短的停顿时间并且能将CPU资源从应用程序共享给GC(并发阶段,CMS会用掉应用程序的CPU周期),可以选择使用CMS。通常对于那些运行在双核或者多核CPU机器上并且拥有较大老年代的应用程序来说,CMS会更加适合,比Web Server。在单核Cpu并且老年代大小适当的情况下也可以考虑使用CMS。启用CMS:-XX:+UseConcMarkSweepGC

G1垃圾收集器 (Garbage-First Garbage Collector)

G1目前我暂时还没有在实际环境中使用,将使用一篇单独的博客来记录。