JVM常用总结2之垃圾回收

JVM常用总结2之垃圾回收

上篇文章中主要介绍了JVM的组成结构并且引出了垃圾回收,比如垃圾回收的判断,本文主要讲解常见的几种垃圾回收器以及垃圾回收算法.

java垃圾回收机制

java语言最显著的特点就是引入了垃圾回收机制,使程序员在写代码的时候不用考虑内存管理的问题,程序员不需要显示的去释放一个对象的内存,而是由java虚拟机来自行执行.在JVM中,有一个垃圾回收的线程,它是最低优先级的,正常情况下不会执行,只有在虚拟机空闲或者当前堆内存不足的情况下,才会触发执行.它可以有效的防止内存泄漏.

GC就是垃圾收集的意思,当程序员创建对象时,GC就开始监控这个对象的地址大小以及使用情况,当确定对象通过可达性分析算法是不可达时,GC就有责任回收这些对象的内存空间.当然也可以手动执行system.gc(),通知GC执行.

在java中,对象什么时候可以被垃圾回收

当对象对当前使用这个对应的应用程序变得不可触及的时候,也就是不可达的时候,这个对象就可以被回收了.

垃圾回收不会发生在永久代,除非是永久代满了或者超过了临界值,才会触发完全垃圾回收full gc.

垃圾回收算法

(1) 标记清除算法
标记存活的对象,统一回收所有未被标记的对象,也可以反着来,标记出所有需要回收的对象,在标记完成后统一回收标记的对象.它是最基础的收集算法,优点是实现简单,不需要对对象进行移动,但是有两个明显的问题:

  • 效率问题,如果需要标记的对象太多,效率不高
  • 空间问题,标记清除后会产生大量的不连续的碎片

(2) 复制算法
为了解决效率问题,复制算法是将内存大小分为大小相同的两块,每次使用其中的一块,当这一块的内存空间使用完后,遍历当前的内存域,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉,这样就使每次的内存回收都是对内存区间的一半做回收.

优点:按顺序分配内存即可,实现简单,不用考虑内存碎片
缺点:可用的内存空间为原来的一半,对象存活率高时会频繁的进行复制.

(3) 标记整理算法
在新生代中可以使用复制算法,但是在老年代中就不能选择复制算法了,因为老年代的对象存活率会很高,这样会有很多的复制操作,导致效率很低.标记清除算法可以用到老年代,但是它效率不高,而且容易产生大量的碎片.
标记整理算法是在标记可回收对象后,将所有的存活对象移动到内存的一端,让他们紧凑得的排列在一起,然后对边界以外的内存进行回收,回收后,已使用的内存和未使用的内存都是各自一边.

优点:解决了标记清除算法中的内存碎片问题
缺点:仍然需要对对象进行移动,一定程度上效率不高.

(4) 分代收集算法
当前的商业虚拟机都是采用了分代手机的垃圾收集算法,分代收集,就是根据对象的存活周期将内存划分为几块,一般包括年轻代,老年代和永久代,然后根据每个年代的特点选择合适的垃圾收集算法.
比如在新生代,每次收集都会有大量的对象死去,就可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集.
而老年代的对象存活率比较高,并且没有额外的空间给它进行分配,所以必须选择标记清除或者标记整理算法.

垃圾收集器

如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是对内存回收的具体实现.
如下图所示
垃圾收集器

其中用于回收新生代的收集器包括Serial,parNew,Parallel,回收老年代的垃圾收集器包括Serial old,Parallel Old,CMS,还有用户回收整个java堆的G1和ZGC收集器.

Serial收集器(-XX:+UseSerialGC -XX:+UseSerialOldGC)

Serial收集器是一个单线程的收集器,单线程不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾回收工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他的所有工作线程(stop the world),知道它收集结束.

新生代采用的是复制算法,老年代采用的是标记整理算法

Paraller收集器(-XX:+UseParallelGC -XX:+UseParallelOldGC)

parallel收集器其实就是serial收集器的多线程版本,默认的收集线程数跟CPU的核数相同.
它的关注点是吞吐量,能高效率的利用CPU.吞吐量=用户线程时间/(用户线程时间+GC线程时间),使用后台应用等对交互响应要求不高的场景.同样也会STW

新生代采用复制算法,老年代采用标记整理算法.

ParNew收集器(-XX:+UseParNewGC)

parNew收集器其实和paraller收集器很类似,区别主要是他可以和CMS收集器配合使用,新生代采用复制算法.

CMS收集器(-XX:+UseConcMarkSweepGC)

CMS收集器是一种以获取最短回收停顿为目标的收集器.它非常符合在注重用户体验的应用上使用.它使用的是标记清除算法,整个过程分为四个步骤:

  • 初始标记:暂时所有的其他线程(STW),并记录下GC ROOTS直接引用的对象,速度很快.
  • 并发标记:并发标记就是从GC ROOTS的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长,但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行,因为用户线程继续运行,可能会导致已经标记过的对象状态发生改变
  • 重新标记:重新标记时为了修正并发标记期间因为用户线程继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记的停顿时间稍长,远远比并发标记的时间段.主要用到了三色标记里的增量算法做重新标记.会STW
  • 并发清理:开启用户线程,同时GC线程开始对未标记的区域做清理,这个阶段如果有新增的对象会被标记为黑色,但是不做任何处理.
  • 并发重置:重置本次GC过程的标记数据.

它的主要优点是并发收集,低停顿,缺点是:

  • 对CPU资源敏感,会和服务抢资源
  • 无法处理浮动垃圾(在并发标记和并发清理阶段又产生了垃圾,只能等下一次GC操作)
  • 使用的标记清除算法会产生大量的空间碎片,当然可以通过设置参数-XX:+UseCMSCompactAtFullCollection让JVM在执行完标记清除后,再做整理
  • 执行过程中的不确定性,会存在上一次垃圾回收还未执行完,然后垃圾回收又被处罚的情况,特别是并发标记和并发清理阶段会出现一边回收,系统一边运行,也许还没回收完,就触发了full gc,此时会进入STW,用Serial old垃圾收集器来回收.

CMS相关的核心参数

  • -XX:+UseConcMarkSweepGC:启用cms
  • -XX:ConcGCThreads:并发的GC线程数
  • -XX:+UseCMSCompactAtFullCollection:FullGC之后做压缩整理(减少碎片)
  • -XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一次
  • -XX:CMSInitiatingOccupancyFraction: 当老年代使用达到该比例时会触发FullGC(默认是92,这是百分比)
  • -XX:+UseCMSInitiatingOccupancyOnly:只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction设定的值),如果不指定,JVM仅在第一次使用设定值,后续则会自动调整
  • -XX:+CMSScavengeBeforeRemark:在CMS GC前启动一次minor gc,降低CMS GC标记阶段(也会对年轻代一起做标记,如果在minor gc就干掉了很多对垃圾对象,标记阶段就会减少一些标记时间)时的开销,一般CMS的GC耗时 80%都在标记阶段
  • -XX:+CMSParallellnitialMarkEnabled:表示在初始标记的时候多线程执行,缩短STW
  • -XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短STW;

三色标记

在并发标记的过程中,因为用户线程还在使用,对象间的引用关系可能发生变化,多表和漏表的情况就有可能发生.
三色标记把gc roots可达性分析遍历对象过程中遇到的对象按照是否访问过这个条件标记为三种颜色

  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象所有的引用都被扫描过.黑色对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须再重新扫描,黑色对象不可能直接不经过灰色对象指向到某个白色对象
  • 灰色:表示对象已经被访问过,但是这个对象上至少存在一个引用还未被扫描过
  • 白色:表示对象还未被垃圾收集器访问过,显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,在分析结束阶段,如果仍然是白色的独享,即代表不可达.

G1收集器(-XX:UseG1GC)

G1是一款面向服务器的垃圾收集器,主要针对配备多颗处理器以及大容量内存的机器,以极高的效率满足GC停顿时间要求的同时,还具备高吞吐量的特征.它是基于标记整理算法,不会产生内存碎片,并且不同于其他的垃圾收集器,G1回收的范围是整个JAVA堆(包括新生代,老年代).

G1将java堆分为多个大小相等的独立区域(region),一般是不超过2048个.一般region的大小等于堆大小除region数量2048

G1保留了年轻代和老年代的概念,单不再是物理隔阂了,它们都可以是不连续的region集合.
默认年轻代对堆内存的占比为5%,在系统运行过程中会不停的给年轻代增加更多的region,但是最多新生代不会超过60%(默认,可以设置),年轻代中的eden,survivor和之前一样,按照8:1:1,一个region可能之前是年轻代,如果region进行了垃圾回收,可能又会变成老年代.

G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样,唯一不同的是对大对象的处理,G1有专门分配大对象的Region叫Humongous区,而不是让大对象直接进入老年代的Region中。在G1中,大对象的判定规则就是一个大对象超过了一个Region大小的50%,比如每个Region是2M,只要一个大对象超过了1M,就会被放入Humongous中,而且一个大对象如果太大,可能会横跨多个Region来存放。

Full GC的时候除了收集年轻代和老年代之外,也会将Humongous区一并回收。

G1收集器一次GC(主要指Mixed GC)的运作过程大致分为以下几个步骤

  • 初始标记(STW):暂停所有其他线程,并记录下gc roots能直接引用的对象,速度很快
  • 并发标记:与CMS的并发标记类似
  • 最终标记(STW):同CMS的重新标记
  • 筛选回收(STW):筛选回收阶段首先对各个region的回收价值和成本进行排序,根据用户所期望的GC停顿STW时间来指定回收计划,比如老年代有1000个region都满了,但是因为预期停顿时间,本次垃圾回收可能只能停顿200毫秒,通过之前的回收成本计算,本次回收可能需要回收其中的500个.回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个region中,不会像CMS那样回收完有很多垃圾碎片还需要整理一次.注意CMS回收阶段是跟用户线程并发执行的,G1因为内部实现太复杂并没有实现并发回收.

G1收集器在后台线程中维护了一个有限列表,每次根据允许的收集时间,优选选择回收价值最大的region.比如一个region,花100ms可以回收10M的垃圾,另外一个花50ms能回收20M的垃圾,在回收时间有限的情况下,当然选择后面一个region回收.

G1默认的停顿时间为200ms,当然可以设置,如果设置的停顿时间比较短,就会导致每次回收的垃圾占堆内存的很小一部分,导致垃圾慢慢积累,最终导致full gc

G1垃圾收集分类
  • YoungGC:YoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发Young GC

  • MixedGC:不是FullGC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发,回收所有的Young和部分Old(根据期望的GC停顿时间确定old区垃圾收集的优先顺序)以及大对象区,正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC

  • Full GC:停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批Region来供下一次MixedGC使用,这个过程是非常耗时的

什么场景适合使用G1
  • 50%以上的堆被存活对象占用
  • 对象分配和晋升的速度变化非常大
  • 垃圾回收时间特别长,超过1秒
  • 8GB以上的堆内存(建议值)
  • 停顿时间是500ms以内

JVM调优

JVM调优的工具

JDK自带了很多监控工具,最常用的是jvisualvm和jconsole,还有阿里开源的arthas

  • jvisualvm jdk自带的全能分析工具,可以分析内存快照,线程快照,程序死锁,监控内存的变化,gc变化等
  • jconsole 用于对JVM中的内存,线程和类进行监控

常用的JVM调优参数

  • -Xms2g: 初始化堆大小为2G
  • -Xmx2g: 堆最大内存为2G
  • -Xmn512M: 年轻代大小为512M
  • -Xss256K:线程栈内存大小为256k
  • -XX:NewRatio=4: 设置年轻和老年代的内存比例为1:4
  • -XX:SurvivorRatio=8: 设置新生代eden和survivor区的比例为8:2
  • -XX:+UseParNewGC: 指定使用parnew+serial old组合垃圾回收期
  • -XX:+UseParallelOldGC: 指定使用Parnew+ parnew old垃圾回收期
  • -XX:+UseConcMarkSweepGC:指定使用serial+cms垃圾回收期组合
  • -XX:+PrintGC:开启打印GC信息
  • -XX:+PrintGCDetail: 打印GC相信信息
  • -XX:+HeapDumpOnOutOfMemoryError:当内存溢出时打印dump信息
  • -XX:+HeapDumpPath = ./test.dump:dump文件打印输出位置

常用的JVM调优命令

  • jps: 查看java进程ID
  • jmap -histo pid > ./log.txt: 查看历史生成的实例,并存放到文件中
  • jmap -histo:live pid :查看当前存活的实例
  • jmap -heap pid: 查看堆信息
  • jmap -dump:format=b,file=test.hprof pid:查看堆内存信息,并输出到test.hprof文件中,可以导入jvisualvm中查看.
  • jstack pid:查找死锁
  • top -p pid:显示java进程的内存情况,按H可以获取每个线程的内存情况,找到内存和CPU最高的线程ID,转换为十六进制,执行jstack pid|grep -A 10 十六进制:获取这个线程所在行的后面10行堆栈信息
  • jinfo -flags pid:查看java应用程序的扩展参数
  • jinfo -sysprops pid:查看java系统参数
  • jstat [-命令选项] [vmid] [间隔时间毫秒] [查询次数]:查看堆内存各部分的使用信息
  • jstat -gc pid 1000 10:查询当前进程内存使用及GC压力情况,每个1秒查询一次,总共查询10次

JVM运行情况预估

jstat -gc pid [间隔时间毫秒] [查询次数]命令可以计算出如下一些关键数据,有了这些数据,就可以设置一些初始的JVM参数,比如堆内存大小,年轻代大小,eden和survivor比例,老年代大小,大对象的阈值,大龄对象进行老年代的阈值等.

根据这个命令,我们可以计算出年轻代增加速率,然后根据eden区大小,就可以计算出大概多久young gc一次.
同时我们可以重新设置间隔时间=young gc的时间,可以看出每次young gc中的eden区,survivor区,老年代的内存使用情况,从而可以推算出老年代对象的增长速率.

知道了老年代的增长速率,根据老年代空间大小,就可以计算出大概多久触发一次full gc.

JVM优化的思路就是尽量让每次young gc后存活的对象小于survivor区的50%,都留在年轻代里.尽量别让对象进入老年代,减少full gc的频率.

一些常用的JVM参数设置

1
2
Xms1536M -Xmx1536M -Xmn1024M -Xss256K -XX:SurvivorRatio=6  -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M 
-XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=92 -XX:+UseCMSInitiatingOccupancyOnly

GC日志详情

1
2
-Xloggc:./gc-%t.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps  -XX:+PrintGCTimeStamps -XX:+PrintGCCause  
-XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M
作者

Jonathan

发布于

2020-07-04

更新于

2020-12-05

许可协议