【垃圾回收】原来是这样!我懂了

本文涉及的产品
日志服务 SLS,月写入数据量 50GB 1个月
简介: 本文主要介绍 java性能分析 之 垃圾收集

Java 与其他解释性语言相比,有自己的内存管理机制也是它的一大优点。因为,在 Java性能调优领域中,GC 也是一个很重要的存在。咱们话不多说,撸起袖子开干!


文章大纲


垃圾收集概述


现代 JVM 的类型繁多,最主流的四个垃圾收集器分别是:Serial 收集器(常用于单 CPU)Throughput(Paralle)收集器Concurrent收集器(CMS)G1 收集器。它们的性能特征各异。


Java 最诱人的特征之一是不需要显式地管理对象的生命周期;我们在需要时创建对象,当对象不再被使用时,会由 JVM 在后台自动进行回收。


垃圾收集中有两个关键步骤:


  1. 查找不再使用的对象


  • 引用计数法
  • 可达性分析


  1. 释放这些对象所管理的内存


  • 垃圾收集器回收


一丶分代垃圾收集器


在 JVM 中会根据情况将堆划分成不同的代:新生代老年代。其中新生代又被进一步地划分为不同的区段,分别为Eden空间和Survivor空间。其中Survivor又分为Survivor toSurvivor from两部分。


堆分配


新生代是堆的一部分,对象首先在新生代中分配。新生代填满时,垃圾收集器会暂停所有的应用线程,回收新生代空间。不再使用的对象会被回收,仍然在使用的对象会被移动到其他地方。这种操作称为 Minor GC。采用这种设计的两个优势:


  • 由于新生代仅仅是堆的一部分,与处理整个堆相比,处理新生代的速度更快,这就意味着应用线程停顿的时间会更短。


  • 在于新生代对象分配的方式。对象分配于Eden空间(占据了新生代空间的绝大多数)。垃圾收集时新生代空间被清空,Eden空间中的对象要么被移走,要么被回收;所有存活的对象要么被移动到另一个 Survivor 空间,要么被移到老年代。由于所有的对象都被移走,相当于新生代进行垃圾回收时自动地进行了一次压缩整理。


所有的垃圾收集算法在对新生代进行垃圾回收时都存在 “时空停顿” 现象(STW:stop-the-world)*


对象不断地被移动到老年代,最终老年代也会被填满,JVM 需要找出老年代中不再使用的对象,并对它们进行回收。而这便是垃圾收集算法差异最大的地方。简单的垃圾收集算法直接停掉所有的应用线程,找出不再使用的对象,对其进行回收,接着对堆空间进行整理。这个过程被称为 Full GC,通常导致应用线程长时间的停顿。


:有没有可能在应用运行的同时找出不再使用的对象?


:答案是有的!  CMS和G1 收集器就是用过这种方式进行垃圾收集的。由于它们不需要停止应用线程就能找出不再用的对象,CMS 和 G1 收集器被称为 Concurrent 垃圾收集器。同时,由于它们将停止应用程序的可能降到了最小,也被称为低停顿收集器。但是使用CMS 和 G1 的代价就是应用程序会消耗更多的CPU。CMS 和 G1 收集也可能遭遇长时间的 Full GC 停顿。


因此我们应该考虑以下因素:


  • 单个请求会受停顿时间的影响——不过其受 Full GC长时间停顿的影响更大。如果目标是要尽可能地缩短响应时间,那么选择使用 Concurrent 收集器更合适。


  • 如果平均响应时间比最大响应时间更重要(譬如 90%的响应时间),采用 Throughput 收集器通常就能满足要求。


  • 使用 Concurrent 收集器来避免长的停顿时间也有其代价,这会消耗额外的 CPU


因此我们为批量应用选择垃圾收集器可以遵循以下原则:


  • 如果 CPU 足够强劲,使用 Concurrent 收集器避免发生 Full GC 停顿可以让任务运行得更快。


  • 如果 CPU 有限,那么 Concurrent 收集器额外的 CPU 消耗会让批量任务消耗更多的时间。


小结:


  1. 所有的 GC 算法都将堆划分成了 老年代新生代


  1. 所有的 GC 算法在清理新生代对象时,都使用了 “时空停顿”(STW)方式的垃圾收集方法,通常这是一个能较快完成的操作。


二丶GC 算法


  • Serial 垃圾收集器


Serial 垃圾收集器是四种垃圾收集器中最简单的一种。Serial 收集器使用单线程 清理堆的内容。使用 Serial 收集器,无论是进行 Minor GC还是Full GC,清理堆空间时,所有的应用线程都会暂停。进行 Full GC时,它还会对老年代空间的对象进行压缩。


我们可以通过-XX:+UserSerialGC标志可以启用Serial收集器。在Serial 收集器作为默认收集器的系统上,如果需要关闭Serial收集器,可以通过指定另一种垃圾收集器来实现。


  • Throughput垃圾收集器


Thoughput 收集器是 Server级虚拟机(多 CPU 的 Unix机器)的默认收集器。Thoughput 收集器使用多线程 回收新生代空间,Minor GC 的速度比使用 Serial 收集器快得多。处理老年代时Thoughput也是使用多线程 方式。


由于Thoughput收集器是使用多线程,因此也称为Parallel 收集器。Thoughput收集器在Minor GCFull GC 时会暂停所有的应用线程,同时在 Full GC过程中会对老年代空间进行压缩整理。由于在大多数使用的场景,它已经是默认的收集器。


在 JDK 1.7之后可以通过-XX:+UseParallelGC-XX:+UserParallelOldGC标志开启这个功能。


  • CMS 收集器


CMS 收集器设计的初衷是为了消除Throughput 收集器和Serial 收集器在Full GC 周期中的长时间停顿。CMS收集器在Minor GC时会暂停所有的应用线程,并以多线程的方式进行垃圾回收。


CMS 改用新的算法来收集新生代对象:-XX:+useParNewGC


CMS收集器在Full GC 时不再暂停所有应用线程,而是使用若干个后台线程定期地对老年代空间进行扫描,及时回收其中不再使用的对象。应用线程只在Minor GC 以及后台线程扫描老年代时发生极其短暂的停顿。应用程序线程停顿的总时长与使用Throughput 收集器比起来短得多


  • G1 垃圾收集器


G1 垃圾收集器(垃圾优先收集器)的设计初衷是为了尽量缩短处理超大堆(大于4GB)时产生的停顿。G1收集算法将堆划分为若干个区域(Region),不过它依旧属于分代收集器。G1垃圾收集算法和其他收集算法一样,这些操作也是利用多线程的方式来操作的。


G1收集器属于Concurrent收集器:老年代的垃圾收集工作由后台线程完成,大多数的工作不需要暂停应用线程。由于老年代被划分到不同的区域,G1收集器通过将对象从一个区域复制到另一个区域,完成对象的清理工作。G1收集器实现了堆的压缩整理(至少是部分的整理)。因此,使用G1收集器的堆不太容易发生碎片化——虽然这种问题无法避免。


CMS收集器一样,避免Full GC的代价就是消耗额外的CPU周期

我们可以通过-XX:+UserG1GC来启动垃圾收集器,默认值是关闭的。


Java中提供了一种机制让应用程序强制进行GC,就是System.gc()方法,但是这并不是一种友好的方法。调用这个方法会触发Full GC,应用程序线程会因此而停顿相当长的一段时间。


小结:


  1. 这四种垃圾收集算法分别采用了不同的方法来缓解GC对应用程序的影响。


  1. Serial收集器常用于仅有单CPU可用以及当其他程序会干扰 GC的情况(通常是默认值)。


  1. Througput收集器在其他的虚拟机上是默认值,它能最大化应用程序的总吞吐量,但是有些操作可能遭遇较长的停顿。


  1. CMS收集器能够在应用线程运行的同时并行地对老年代的垃圾进行收集。如果CPU的计算能力足以支撑后台垃圾收集线程的运行,该算法能避免应用程序发生Full GC


  1. G1收集器也能在应用线程运行的同时并发地对老年代的垃圾进行收集,在某种程序上能够减少发生Full GC的风险。G1的设计理念使得它比CMS更不容易遭遇Full GC


三丶选择GC算法


GC算法的选择一方面取决于应用程序的特征,另一方面取决于应用的性能目标。

如果有额外的CPU处理能力,那么使用Concurrent 收集器将极大地提升应用程序的性能。这里的关键在于我们能否提供足够的CPU给Concurrent 收集器的线程进行后台的处理工作。


使用Througput收集器处理应用程序线程的批量任务能最大程度地利用CPU的处理能力,通常能获得更好的性能。


如果批量任务并没有使用机器上所有可用的CPU资源,那么切换到Concurrent收集器往往能取得更好的性能。


CMS 收集器 和 G1 收集器该如何选择?


:一般情况下,堆空间小于4GB时,CMS 收集器的性能比 G1收集器好。CMS使用的算法比G1简单,因此在比较简单的环境中(譬如堆的容量很小的时候),它运行得更快。使用大型堆或巨型堆时,由于 G1收集器可以分割工作,通常它比CMS收集器表现更好。回收任何对象之前,CMS收集器后台线程必须扫描完整个老年代空间。如果堆还未填满之前,CMS收集器的后台线程就停止了堆的扫描,直接回收对象,CMS收集器就不得不回退,暂停所有的应用线程,进行FullGC操作。G1收集器采用了不同的方式来处理这个问题,它将老年代划分成不同的区域。能够更加容易地使用多个线程分担扫描老年代空间的任务。


小结:


  1. 选择Concurrent 收集器时,如果堆较小,推荐使用CMS收集器。


  1. G1的设计使得它能够在不同的分区(Region)处理堆,因此它的扩展性更好,比CMS更易于处理超大堆的情况。


GC调优基础


一丶调整堆的大小


与其他性能一样,选择堆的大小其实是一种平衡。如果分配的堆过于小,程序的大部分时间可能都消耗在GC上,没有足够的时间去运行应用程序的逻辑。但是,简单粗暴地设置一个特别大的堆也不是解决问题的方法。GC停顿消耗的时间取决于堆的大小,如果增大堆的空间,停顿的持续时间也会变长。这种情况下,停顿的频率会变得更少,但是它们持续的时间会让程序的整体性能变慢。


因此调整堆的大小时首要的原则就是永远不要将堆的容量设置得比机器的物理内存还大,另外,如果同一台机器上运行着多个 JVM 实例,这个原则适用于所有堆的总和。除此之外,你还需要为 JVM 自身以及机器上其他的应用程序预留一部分的内存空间:通常情况下,对于普通的操作系统,应该预留至少1个G的内存空间。


堆的大小由 2 个参数值控制:分别是初始值(-Xms N)设置和最大值(-Xms N)。堆的大小具有初始值和最大值的这种设计让 JVM 能够根据实际的负荷情况更灵活地调整 JVM 的行为。如果 JVM 发现使用初始的堆大小,频繁地发生 GC,它就会尝试增大堆的空间,直到 JVM 的GC 的频率回归到正常的范围,或者直到堆大小增大到它的上限值。

注意: 即使你显式地设置了堆的最大容量,还是会发生堆的自动调节:初始时堆以默认的大小开始运行,为了达到根据垃圾收集算法设置的性能目标,JVM 会逐步增大堆的大小。将堆的大小设置得比实际需要更大不一定会带来性能损耗:堆并不会无限地增大,JVM 会调节堆的大小直到满足 GC 的性能目标。


小结:


  1. JVM 会根据其运行的机器,尝试估算合适的最大,最小堆的大小。


  1. 除非应用程序需要比默认值更大的堆,否则在进行调优时,尽量考虑通过调整 GC 算法的性能目标,而非微调堆的大小来改善程序性能。


二丶 代空间的调整


一旦堆的大小确定下来,JVM 就需要决定分配多少堆给新生代空间,多少堆给老年代空间:如果新生代分配得比较大,垃圾收集发生的频率就比较低,从新生代晋升到老年代的对象也更少。但是这样子老年代分配的空间就比较少,会频繁地触发Full GC


  • 设置新生代与老年代的空间占用比率

-XX:NewRatio=N


  • 设置新生代空间的初始大小

-XX:NewSize=N


  • 设置新生代空间的最大大小

-XX:MaxNewSize=N


  • 将 NewSize 和 MaxNewSize 设定为同一个值的快捷方法

-XmnN


小结:


  1. 整个堆范围内,不同代的大小划分是由新生代所占用的空间控制的。


  1. 新生代的大小会随着整个堆大小的增大而增长,但这也只是随着整个堆的空间比率波动变化的(依据新生代的初始值和最大值)


三丶永久代和元空间的调整


JVM 载入类的时候,它需要记录这些类的元数据。从终端用户的角度来看,这些只是一些“书签”信息。这部分数据被保存在一个单独的堆空间中。在 Java 7里,这部分空间被称为 永久代,在 Java 8中,它们被称为元空间


永久代元空间 并不完全一样,Java 7中,永久代还保存了一些与类数据无关的杂项对象;这些对象在Java 8中挪到了普通的堆空间内。由于元空间的默认大小是没有限制的,因此在 Java 8的应用可能由于元空间被填满而耗尽内存,导致OOM。


永久代 虽然被称为 “永久代”。到时保存在永久代空间中的数据并不能永久保存。因此元空间这个名称更为贴切。保存在永久代的类会像其他的对象一样会经历垃圾回收。在应用服务器中,这是一种非常普遍的现象,每次有新的应用部署,应用服务器都会创建新的类加载器。


小结:


  1. 永久代或元空间保存着类的元数据(并非类本体的数据)。它以分离的堆的形式存在。


  1. 典型应用程序在启动后不需要载入新的类,这个区域的初始值可以依据所有类都加载后的情况设置。使用优化的初始值能够加速启动的过程。


  1. 开发中的应用服务器(或者任何需要频繁重新载入类的环境)上经常能碰到由于永久代或与元空间耗尽触发的Full GC,这时老的元数据会被丢弃回收。


四丶控制并发


Serial收集器之外几乎所有的垃圾收集器使用的算法都基于多线程。启动的线程数由-XX:ParallelGCThread=N参数控制。这个参数值会影响到下面这些操作:


  • 使用-XX:+UseParallelGC收集新生代空间


  • 使用-XX:+UseParallelOldGC收集老年代空间


  • 使用-XX:+UseParNewGC收集新生代空间


  • CMS 收集器的 “STW” 阶段


  • G1收集器的 “STW” 阶段


小结:


  1. 几乎所有的垃圾收集器算法中基本的垃圾回收线程数都依据机器上的COU数据计算而出。


  1. 多个 JVM 运行于同一台物理机上时,依据公式计算出的线程数可能过高,必须进行优化(减少)。


五丶自适应调整


根据调优的策略,JVM 会不断地尝试,寻找优化性能的机会,所以在 JVM 的运行过程中,堆、代以及Survivor空间的大小都可能发生变化。自适应调整在两方面能提供重要的帮助:


  • 小型应用程序不需要再为指定了过大的堆而担心,有了自适应调整之后,这种类型的应用程序不再需要额外花费精力去调优,平台默认的配置就能确保他们不会使用大量的内存。


  • 很多应用程序不需要担心它们堆的大小,如果需要使用的堆的大小超过了平台的默认值,它们可以放心地分配更大的堆,而不用关系其他的细节。 JVM 会自动调整堆 和 代 的大小。


使用-XX:-UseAdaptiveSizePolicy可以在全局范围内关闭自适应调整功能(默认情况下,这个标志是开启的),开启该标志后,一旦发生垃圾回收,GC 的日志会包含垃圾回收时不同的代进行空间调整的细节信息。


小结:


  1. JVM 在堆的内部如何调整新生代及老年代的百分比是由自适应调整机制控制的。


  1. 通常情况下,我们应该开启自适应调整,因为垃圾回收算法依赖于调整后的代的大小来达到它停顿时间的性能目标。


  1. 对于已经精细调优过的堆,关闭自适应调整能够获得一定的性能提升。


垃圾回收工具


观察垃圾回收对应用程序的性能影响最好的方法就是尽量熟悉垃圾回收的日志,垃圾回收日志中包含了程序运行过程中的每一次垃圾回收操作。


开启GC的日志功能


  • -verbose:gc


  • -XX:+PrintGC


  • -XX:+PrintGCDetails


这两个任意一个都能创建基本的GC日志(默认情况下的GC日志功能是关闭的)

通常情况下使用基本的GC日志很难诊断垃圾回收时发生的问题,我们可以使用下面两个指令判断几次GC操作之间的时间


  • -XX:+PrintGCTimeStamps


  • -XX:PrintGCDateStamps


小菜与你小结


  • 对任何一个Java应用程序而言,垃圾收集的性能都是其构成整体性能的关键一环。虽然对大多数的应用程序来说,调优的工作仅仅是选择合适的垃圾收集算法,或者在需要的时候,增大应用程序的堆空间。


  • 自适应调整让 JVM 能够自动地调整它的行为,使用给定的堆,提供尽可能好的性能。


  • 更复杂的应用往往需要额外的调优,尤其是针对特定GC算法的调优。


相关实践学习
日志服务之使用Nginx模式采集日志
本文介绍如何通过日志服务控制台创建Nginx模式的Logtail配置快速采集Nginx日志并进行多维度分析。
目录
相关文章
|
3月前
|
算法 Java
G1垃圾回收器
G1垃圾回收器
|
6月前
|
算法 Java
垃圾收集-垃圾收集算法
垃圾收集-垃圾收集算法
35 0
垃圾收集-垃圾收集算法
|
存储 算法 JavaScript
V8如何进行垃圾回收的
V8如何进行垃圾回收的
39 0
|
算法 Java 程序员
【垃圾回收器】
【垃圾回收器】
|
存储 前端开发 算法
V8 如何进行垃圾回收?
V8 如何进行垃圾回收?
99 0
|
算法 Java
详解gc(垃圾回收)机制六:分代垃圾回收
详解gc(垃圾回收)机制六:分代垃圾回收
207 0
详解gc(垃圾回收)机制六:分代垃圾回收
|
算法 Java UED
17-垃圾回收器(二)
17-垃圾回收器(二)
97 0
|
监控 算法 Oracle
17-垃圾回收器(四)
17-垃圾回收器(四)
191 0
|
算法 Java API
17-垃圾回收器(一)
17-垃圾回收器(一)
157 0
|
存储 算法 Oracle
17-垃圾回收器(三)
17-垃圾回收器(三)
84 0