jvm专题系列——详解垃圾回收器、常用jvm参数、性能监控工具以及调优案例

摘要

  本篇博客接着jvm专题系列—详解垃圾回收机制及其算法讲解,看懂此篇博客需要你对jvm内存结构,垃圾回收机制和算法有一定了解,所以推荐没有了解的朋友可以先看一下上篇jvm的博客,大神请无视。言归正传,有了充实的理论基础,便要开始运用于实践当中,本篇博客主要讲解jvm垃圾回收器的分类和选择,常用jvm参数,性能监控工具以及调优实战,带你一步步揭开jvm的神秘面纱。

jvm垃圾回收器

  我们知道,jvm堆内存分为新生代和老生代,新生代采用复制算法,老生代采用标记-清除或者标记-整理算法来收集和清理垃圾,关于算法的具体实现便是接下来要讲解的垃圾回收器。
  jvm垃圾回收器目前主要有7种:serial收集器、parnew收集器、parallel scavenge收集器、serial old 收集器、parallel old收集器、cms收集器、g1收集器。7种垃圾回收器做进一步划分可以分为:
  新生代收集器:Serial、ParNew、Parallel Scavenge
  老生代收集器:CMS、Serial Old、Parallel Old
  整堆收集器: G1
  新生代收集器只能收集新生代的垃圾,老生代收集器只能收集老生代的垃圾,而整堆收集器G1新老通吃,值得一提的是,G1收集器将整个Java堆划分为多个大小相等的独立区域(Region)。虽然也保留了新生代、老年代的概念,但新生代和老年代不再是相互隔离的,他们都是一部分Region(不需要连续)的集合,所以G1收集器的堆内存结构跟我们之前介绍的结构区别是很大的,它标记了每个Region所属的区域,然后再对其进行垃圾回收。
  下图为jvm垃圾回收器图示:
垃圾回收器图示
  如上图,连线表示可以搭配使用。你也许会好奇为什么cms不能和perallel scavenge搭配使用,cms为什么可以跟serial old搭配使用,parallel old为什么只能和parallel sacvenge搭配使用。首先回答第一个问题:parallel scavenge没有使用其他Gc通用的Gc框架,导致两者无法搭配,至于为什么没有使用同一个框架,这完全是人为原因,跟技术没有关系(也许未来可以实现两者共用吧)。第二个问题:二者其实并非搭配使用,cms收集器(并发收集器)采用的是标记-清除算法,与用户线程并发运行,所以会产生浮动垃圾(标记完垃圾之后产生的垃圾),当cms运行期间预留内存无法满足程序需要的时候,便会启动后备方案,使用serial old来进行收集(标记-整理),释放内存空间。第三个问题:parallel old跟parallel scavenge一样,为并行收集器,不能跟serial和parnew的原因同问题一。
  在这里有一个概念:并行收集器和并发收集器,可以把用户线程作为参照物,跟用户线程一起运行称之为并发,没有用户线程参与称之为并行。

Serial收集器

  Serial收集器是最原始的一款垃圾收集器,也称之为串行收集器,顾名思义,它是单线程运行的,而且不止如此,它在收集垃圾的时候,会暂停其他所有的工作线程,直到收集结束,被称之“stop the world”。想象一下,比如你在看电影,每看五分钟需要暂停几秒钟,这显然是令人难以接受的。serial收集器的运行流程如下:
serial运行流程
  对于"stop the world"这种不良体验,虚拟机的开发者表示非常理解,但也心存委屈:“你妈妈为你打扫房间的时候,肯定会让你老老实实坐着或者出去待着,如果一边打扫,你一边扔纸屑,那房间永远也无法打扫干净。”这听起来很有道理,并且事实的确如此,同时虚拟机开发团队也在为消除或减少内存回收而导致的停顿而一直努力着!

ParNew收集器

  ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程收集垃圾之外,其他行为基本和Serial收集器的实现完全一样。ParNew收集器只有在多核CPU的环境下才能发挥出它的优势(多线程收集速度快,停顿时间缩短),如果是单核CPU它甚至不如Serial收集器的效果好(单核CPU的线程切换导致额外开销)。它的运行流程如下:
ParNew运行流程

Parallel Scavenge收集器

  Parallel Scavenge与ParNew类似,也是一款并行多线程收集器,相比于ParNew,它的目标则是达到一个可控制的吞吐量(吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)),如果虚拟机总共运行了100分钟,垃圾收集花了1分钟,那么吞吐量变为100-1/100=99%。
  停顿时间越短越适合与用户进行交互的程序,良好的响应速度可以提升用户体验,而高吞吐量则是可以高效利用cpu,主要用于在后台运算不需要进行用户交互的任务。
  Parallel Scavenge提供了两个参数来控制吞吐量:-XX:MaxGCPauseMillis(控制停顿时间(jvm尽量不超过设置的时间),单位ms),-XX:GCTimeRatio(吞吐量大小,大于0小于100),但你千万不要以为把停顿时间的参数设小,吞吐量参数设大就可以让垃圾收集的速度变快,停顿时间的缩短是靠牺牲吞吐量和新生代空间来换取的:系统把新生代调小,比如由1000兆调节为700兆,收集700兆的空间速度必然比1000兆快,但是相应的收集频率会增高,原来10s收集一次,每次停顿100ms,现在需要5s收集一次,每次停顿70ms(相当于10s停顿140ms),停顿时间确实下降了,但是吞吐量也降了下来。
  所以,Parallel Scavenge也被称为“吞吐量优先收集器”,此收集器还有一个参数:-XX:+UseAdaptiveSizePolicy,打开这个参数之后,不需要我们再额外设置新生代的大小以及新生代eden和survivor的比例等参数了,jvm会根据当前系统的运行情况动态调整这些参数以提供最合适的停顿时间和吞吐量,这种调节方式称为GC自适应的调节策略,同时这也是Parallel Scavenge和ParNew的重要区别之一。

Serial Old收集器

  Serial Old跟Serial一样是一个单线程收集器,使用“标记-整理”算法。他可以作为CMS收集器的后备军,运行流程同Serial收集器运行流程。

Parallel Old收集器

  Parallel Old收集器跟Parallel Scavenge一样是一个并行多线程收集器,采用“标记-整理”算法。它与Parallel Scavenge搭配适用于增加吞吐量以及CPU资源敏感的场合。工作流程如下:
parallel old 工作流程

CMS收集器

  CMS收集器全称Concurrent Mark Sweep,主打并发收集,低停顿,适用于B/S系统的服务端,我们熟知的淘宝网站使用的便是CMS收集器,它的收集器线程可以跟用户线程一起工作,这也是与并行收集器所不同的地方,运行流程如下:
CMS运行流程
  由上图可见,尽管CMS可以跟用户线程一起运行,但它同样也无法避免“stop the world”,之前的博客讲过可达性原则,即在初始标记和重新标记验证对象死活的时候也会引起工作线程的停顿,只是停顿的时间较短。CMS收集器是一款非常优秀的垃圾回收器,但它也存在以下缺点:
  1.对CPU资源敏感。事实上,面向并发设计的程序对CPU资源都较为敏感,在并发阶段,他虽然不会使用户线程停顿,但是也会因为占用了一部分CPU资源而使应用程序变慢,总吞吐量就会降低。
  2.无法处理浮动垃圾,可能出现“Concurrent Mode Failure”导致另一次Full Gc(收集老生代成为Full Gc)。由于CMS在并发清理阶段用户线程依然运行着并不断产生垃圾,这部分垃圾出现在重新标记之后,所以在本次Gc中无法清理,这部分垃圾就称为浮动垃圾。CMS在垃圾收集的时候用户线程仍在运行,所以他不能向其他收集器一样等到老生代几乎填满再进行回收,需要预留一部分空间供并发时的程序使用,可以通过:-XX:CMSInitIatingOccupancyFaction的参数值来调节触发收集的百分比,一般不需要特意动它。如果预留空间无法满足程序运行的需要,那么就会出现Concurrent Mode Failure,这个时候就轮到Serial Old收集器登场了,jvm会临时使用Serial Old来重新对老年代进行垃圾收集,这同时也就意味着系统停顿时间变长,所以此参数设置过高容易引起大量Concurrent Mode Failure,反而降低性能!
  3.产生大量内存碎片。CMS利用的是标记-清除算法来进行垃圾收集(比标记-整理快),这必然会不可避免的产生内存碎片,内存碎片过多时,就算剩余空间很足,但是无法找到连续的内存空间去分配新来的大对象,就会不得不提前触发Full GC。我们可以通过开启XX:UseCMSCompactAtFullCollection参数来解决此问题(默认开启),这样CMS在顶不住要进行Full GC时会对内存碎片进行合并整理,但这也会使得停顿时间变长(内存整理无法并发执行)。通过XX:CMSFullGCsBeforeCompaction可以设置执行多少次不合并整理的Full Gc后,执行一次带合并整理的Full Gc,默认为0,即每次进入Full Gc时都会进行碎片整理。

G1收集器

  G1收集器是当今收集器技术发展的最前沿成果之一,它是一款面向服务端应用的垃圾收集器。
  G1收集器具备以下特点:
  1.并行和并发
  2.分代收集
  3.空间整合:从整体看它基于标记-整理算法,从局部(两个Region之间)来看则是基于复制算法,这意味着它在运行的时候不会产生内存碎片,有利于程序长时间执行,不会因为分配大对象找不到连续的空间而提前触发Full Gc
  4.可预测停顿:可以让使用者明确指定在一个长度为M毫秒的时间片段内,垃圾回收的时间不超过N毫秒。运行流程如下:
G1运行流程
  虽然G1收集器有诸多优点,但它的应用案例却少之又少,而且也缺乏与之相关的性能测试,但相信在未来G1会是最终的胜利者,我们可以一直观望!如果你的收集器目前没有什么问题,那么大可以维持现状,如果你的应用追求的是吞吐量,那么G1并不会为你带来什么特别的好处。

最好的垃圾回收器 ?

  看到这里,我想你应该会明白不存在什么最好的垃圾回收器,选择什么回收器需要我们根据实际的业务场景来确定,如果追求低停顿,可以考虑ParNew+CMS组合,如果追求高吞吐量,可以考虑Parallel Scavenge+Parallel Old组合,单核CPU下还可以考虑最经典的Serial组合!

常用的jvm参数

  通过jvm参数设置可以让我们实现对jvm的个性化定制,提高系统性能。使用参数只需要在java命令后面加上就可以,例如 java -Xmx100m hello,在eliplse和idea中同样可以很方便的进行设置,设置方法自行百度。

堆参数

堆参数
  jdk8永久代已经废弃,替换为Metaspace(本地内存),相应参数可以自行百度,默认值大约为4096M,一般的应用来说足够了。堆参数中可以适当把年轻代的内存设置的大一些,可以有效减少Full Gc的次数,提升系统的响应速度。

回收器参数

回收器参数
  通过上表的参数可以指定使用的垃圾回收器,后面会介绍常用的回收器参数组合。

常用参数

常用参数
  如上表,后面的几个参数可以打印GC日志,另外通过Xloggc:log/gc.log可以指定gc日志的位置,查看垃圾回收的情况,同时在OOM的时候可以在指定路径生成dump文件,方便我们可以使用性能监控工具分析查看。其中,xss参数值得注意,它是为每个线程所分配的内存大小,一般来说不会超过2兆,所以,xss设置的越大,可运行的线程总数就越少,但相应的每个线程栈的深度也就越深,不容易发生栈溢出,反之容易发生栈溢出,这也就是为什么有的公司严禁使用递归的原因,因为它会一直不停压栈。一般来说,jvm默认的大小基本已经够用,不需要再特别去设置。

回收器常用组合

回收器常用组合
  如上表,第二和第三种组合使用最为广泛!

性能监控工具

  要想更进一步的分析jvm的运行情况,一款好的监控工具显得格外重要,幸运的是,jdk本身就自带了许多优秀的小工具,就在其bin目录下,如jps(相信大家熟知,打印jvm进程信息),jstat(查看运行时信息),jinfo(查看和修改虚拟机配置),jmap(生成dump文件)等等,当然最有名的当属jvisualvm,它几乎把jvm所有的工具命令整合并用图形化界面的方式为我们展现了出来,堪称业界良心!值得注意的是,这些小工具本身并不大,小的几十k,大的也不过几百k,实际上它们都只是一个壳子,真正的方法实现都封装进了tools.jar当中,有兴趣的朋友可以反编译看一下其中的源码实现。
  在使用jvisualvm之前,我们以tomcat为例,随便启动一个tomcat应用,便于一会去监控tomcat的进程信息。
启动tomcat
  如上图,我成功的启动了tomcat,并且使用jps命令查看到了tomcat的进程id为875(Bootstrap为tomcat的启动应用),然后我用jstat命令打印出了5行jvm进程在200ms内的运行时信息(感兴趣的朋友可以百度以下每列的具体含义),注意有一列为MC,jdk1.8之前是PC,MC代表Metaspace(本地内存),PC为永久代分配的内存,这也说明了jdk1.8已然废弃了永久代。
  回归正题,让我们运行jvisualvm(环境变量配置的没有问题直接敲命令即可成功启动),如下:
jvisualvm
  双击tomcat便可以监控其进程状态,如下:
tomcat监控
  这里可以载入dump文件,我们可以通过查看类的实例数来查看实例创建的个数,如果某个实例个数过多或占用内存过大那么可以考虑发生了内存泄漏(无效引用得不到及时释放造成内存空间浪费):
在这里插入图片描述
  jvisualvm还可以安装插件帮助我们更加方便的去监控jvm,一个很受欢迎的插件便是visual gc,我们可以去访问https://visualvm.github.io/pluginscenters.html选择对应的版本下载,然后在工具——》插件菜单中进行安装。
  安装完成后重启,效果如下:
visual gc
  好了,常用的功能就是这些,远程连接请自行百度,教程很多,下面我们来看一个优化案例。

调优案例

  由于环境限制,在本地复现问题相对来说比较困难,所以在这里主要通过第一人称故事的方式来进行讲解。
  我们公司为客户做了一套数据利用系统供用户多维度查询数据并导出生成excel,下面是系统的具体配置:
  服务器:centos7一台
  内存:64G
  jdk版本:1.8
  web服务器:springboot内置tomcat
  客户规模:一千人左右
  讲道理,一千人使用,这种服务器配置可以说是非常奢侈了,所以感觉上是完全不会有问题的。但是后来客户却反应说偶尔系统在导出excel的时候会有较长时间的卡顿,最长的时候甚至长达半分钟才能有所响应,于是便迅速介入调查。
  首先考虑是不是sql有问题,但是卡顿的情况是偶尔发生,平时反应速度很快,所以暂时排除了这种可能。
  然后去询问运维人员有没有进行服务器维护相关的操作,得到的答案是否定的,这就让人陷入了困扰之中。
  没办法,这种问题只能从jvm上找原因了,于是使用了jvisualvm来进行系统监控,当我看到堆内存的时候吓了一跳,足足设置了40g,后来经过询问,原来部署程序的哥们不想浪费宝贵的服务器资源,所以故意把堆内存设置的很大。相信我们都有优化eclipse或idea的体验,默认的xmx比较小,当我们调大之后eclipse或idea的速度明显就快了很多,所以哥们想到了这一点,毫不留情的把系统堆内存设置到了40g。
  其实到这里问题已经比较明显了,大概率是进行Full GC的时候由于堆内存过大而需要耗费大量时间(stop the world)导致系统停顿时间过长,因此用户便无法获得及时响应,后来我通过输出gc日志发现果然是Full GC引起的问题,40g的内存,一次消耗半分钟也不足为奇。那么为什么会发生Full GC呢?原因其实很简单,客户在导出excel的时候生成的workbook对象需要封装大量的数据,所以属于大对象,会被直接分配到老生代,随着时间推移,大对象越积越多,老生代内存不够用时,自然要触发Full GC。值得一提的是,如果确认程序中不会有大对象的产生,那么可能对象都没有进入老生代的机会,这种场景下主要在新生代中进行垃圾回收(minor gc),速度是非常快的,系统就会“飞起来”,但一般不会有这种理想的情况。
  卡顿的原因定位了,优化的方法也显而易见,最简单的方法:调小堆内存即可!后来除了调小堆内存,还做了以下优化:
1.调小堆内存到4g
2.单机部署6个节点,使用nginx负载均衡,提高cpu的利用率
3.使用ParNew+CMS收集器组合(jdk默认为Parallel Scavenge+Parallel Old组合)
  优化过后,问题成功得到了解决。
  通过此案例也许会对你有所启发,很多人认为单机部署一个应用程序独占一台服务器是最好的选择,因为这样不会有其他程序抢占cpu资源,实际上这也不是绝对的,具体情况还需要具体分析。另外如果对jvm不够了解,不要贸然设置参数,否则可能会留下很大的坑,默认的往往不会有太大的问题。
  说到底,jvm调优只是一个辅助手段,大部分情况下往往都是代码层面的问题,通过优化代码,擅用设计模式,优化数据库结构,合理建立索引等方式照样会让系统稳定流畅运行。

小结

  本篇博客的内容到这里就结束了,希望能对你有所启发,之后我会介绍jvm类加载器相关的知识并且手动实现一个简易的热部署插件。感谢您的观看,再见!

发布了26 篇原创文章 · 获赞 99 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/m0_37719874/article/details/103801893