• 0

  • 476

JVM之垃圾回收机制全解

1个月前

垃圾回收是Java体系中最重要的组成部分之一,其提供了一套全自动的内存管理方案,要想掌握这套管理方案,就必须了解垃圾回收器的工作原理。本文介绍了垃圾回收的概念,算法,垃圾回收器及我在工作中遇到的一些关于GC的优化实例。

首先大致了解下JVM的主要组成部分:

一、heap内存划分

年轻代

年轻代分三个区。一个Eden区,两个Survivor区(from Survivor(s0)区和to Survivor(s1)区)。 大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到Survivor区(两个中的一个),当这个Survivor区满时,此区的存活对象将被复制到另外一个Survivor区,当这个Survivor去也满了的时候,从第一个Survivor区复制过来的并且此时还存活的对象,将被复制“年老区(Tenured)”。  

年老代

在年轻代中经历了N次((ParNew默认15))垃圾回收后仍然存活的对象,就会被放到年老代中。年轻代放不下的大对象直接进入老年代。 

tip:对象动态年龄计算规则 虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold(默认15次)才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

持久代

用于存放静态文件,如今Java类、方法等 JDK1.8中,永久代已经从java堆中移除,String直接存放在堆中,类的元数据存储在meta space中,meta space占用外部内存,不占用堆内存。

二、GC回收算法

标记清除算法

标记清除分为两个阶段,标记阶段(标记从根节点开始的所有可达对象,未标记即未被引用)和清除阶段。缺点:两个阶段效率都很低;回收后内存空间不连续,产生碎片多,易导致提前GC。 

复制算法

内存等分两块,相互复制存活的对象后清洗垃圾 缺点:内存利用率低。

标记压缩法

先标记,然后存活的向一段移动,清理存活端标记以外的内存。(老年代使用,无需需要第二块相同的内存) 优缺点:无内存碎片,但是耗时。 

分代算法

复制算法(新生代使用) ,标记压缩法和标记清除法(老年代使用)。卡表(数据结构,一个比特位的集合),用来表示老年代对象是否持有新生代对象的引用,新生代无需再花时间确认对象是否被持有,可以加快新生代回收的速度。  

分区算法

将整个堆空间划分为连续不同的小的空间,独立管理,独立回收。  

引用和可触及的强度

对象的引用和可触及的强度分为4个级别 

强引用:任何时候都不会被系统回收,亦可能会引起OOM。 

StringBuffer str = new StringBuffer("juejin");
复制代码

软引用:GC不一定回收,但堆空间不足时会被回收。OOM之前一定会回收,所以软引用不会引起OOM。 使用SoftReference创建的对象。 

SoftReference<User> userSoftReference = new SoftReference<User>(u);
复制代码

弱引用:发现即回收。使用WeakReference创建的对象。使用PhantomReference创建的对象。 

WeakReference<User> userWeakReference = new WeakReference<User>(u);
复制代码

 虚引用:随时可回收。 

PhantomReference<User> userPhantomReference = new PhantomReference<User>(u);
复制代码

三、分代垃圾回收

垃圾回收基本思想在于如何判断对象的可触及性。根据标记清除算法,可以扫描出root节点未触及持有的对象,但一个无法触及持有的对象有可能在某个时间下使自己复活。

对象的可触及性的三种状态:

  1. 可触及的 
  2. 可复活的(finalize()函数) 
  3. 不可触及的(finalize()函数只能调用一次)

young space 采用复制算法

old space 使用标记清除或者标记清理

Tip1:对象优先在Eden去分配,大的对象直接进入老年代,长期存活对象进入老年代。

四、垃圾回收器

串行回收器

        单线程GC,启动时会停止应用,适用于配置小的服务器(1C2G),基本已弃用。

并行回收器PS(吞吐量优先)

        JDK1.6~1.8默认使用。垃圾线程并行,启动时应用会等待(STW)

Stop The World  
why?
1、为了让垃圾回收器可以正常切高效执行。
2、保证了系统某个瞬间的一致性。
3、有益于垃圾回收器更好地标记垃圾对象。

   PS的新生代回收器有两个:

  1. ParNew回收器:多线程执行垃圾回收。                                                                       PS的线程数量可以用-XX:ParallelGCThreads指定。当CPU<8时,ParallelGCThreads的值=CPU,CPU>8时,ParallelGCThreads的值=3+((5*CPU_count)/8)。适用于交互较弱的场景。(JDK1.8以上已经被删除)
  2. Parallel回收器:与ParNew一样是多线程独占式。但其特点是关注系统的吞吐量                                       (吞吐量:花费在垃圾收集时间和花费在应用时间的占比)                                                 使用方法:-XX:+UseParallelGC(设置老年代-XX:+UseParallelOidGC)

并发回收器(响应时间优先)

与并行回收器不相同的是,并发收集器是非独占式,在进行垃圾回收的时候应用程序也可以运行,并行GC前会额外触发新生代的GC。

    Concurrent Mask Sweep(CMS)

    过程:初始标记(标记root对象)--> 并发标记 --> 预清理(准备及控制停顿时间)--> 重新标记 --> 并发清除 --> 并发重置

    优点:并发收集、低停顿。

    缺点: 

  1. CMS对CPU资源敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低
  2. CMS无法处理浮动垃圾,可能会出现“Concurrent Mode Failure(并发模式故障)”失败而导致Full GC产生
  3. CMS容易出现大量空间碎片。当空间碎片过多,将会给大对象分配带来很大的麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC
  4. 老年代垃圾回收过程中,如果出现资源不够用,则会强制进行老年代串行回收,应用暂停时间更长,影响更大

    G1(Garbage-First)

JDK1.7正式使用,且使用了全新的算法,看起来有取代CMS的趋势。G1保留了分代的概念,但是从堆结构上看,分代内存并不是连续的。如图:

 

G1在并行性和并发性的基础上,可以同时兼顾年轻代和年老代,还可以进行空间整理,每次GC之后会自动进行碎片整理,减少碎片空间。最后还有可预见性,G1可以选取部分区域进行内存回收。

       过程:1)初始标记(标记root对象)(eden区会被清空) 2)根区域扫描 3)并发标记 4)重新标记 5)独占清理 (计算各个区域存活对象和GC回收比例)6)并发清理

混合回收:在年轻代满时,触发年轻代收集;随着老年代内存增长,当到达IHOP阈值-XX:InitiatingHeapOccupancyPercent(老年代占整堆比,默认45%)时,G1开始准备收集老年代空间。首先经历并发标记周期,识别出垃圾占比较高的老年代分区。但随后G1并不会马上开始一次混合收集,而是让应用线程先运行一段时间,等待触发一次年轻代收集。在这次STW中,G1将保准整理混合收集周期。接着再次让应用线程运行,当接下来的几次年轻代收集时,将会有老年代分区加入到CSet中,即触发混合收集,这些连续多次的混合收集称为混合收集周期(Mixed Collection Cycle)

       特点: 

  1. 并行于并发:G1能充分利用CPU多核,使用多个CPU来缩短stop-The-World停顿时间。 
  2. 分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。 
  3. 空间整合:与CMS的“标记--清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。 
  4. 可预测的停顿:这是G1相对于CMS的另一个大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内。

五、调优思路

调优前瞻

  1. 尝试多种垃圾回收器,G1并不是最好的。
  2. 并发不等于并行,垃圾回收的过程实际上有两步:启动GC周期和GC自身运行,这是不同的两件事。并发针对的是GC周期,而并行针对GC算法自身。 
  3. 平均事务时间不是最需要被关注的指标,有可能用户正好经历了那个长时间GC的场景,那将是毁灭性的。 
  4. GC调优并不能解决所有的事。如果程序修改程度大,那应该优先优化架构及代码。
  5. GC日志并不会对性能造成太大的影响,在GC未被优化之前,开启GC日志是有必要的。
  6. 降低新对象的分配率可以改善GC的运行状况:粗略地把系统中的对象分为三种:长命(long-lived)对象,对它们我们一般做不了什么;中等寿命(mid-lived)对象,最大的问题可能出现在这;短命(short-lived)对象,它们的释放和回收通常都很快,在下个GC周期来临时就会消失。

调优思路

  1. 理解应用需求和问题。 
  2. 掌握GC的状态。
  3. 思考选择的GC是否符合我们的应用特征。
  4. 分析确认需要调整的参数。
  5. 验证调优。

GC一般合理表现

分析结果显示Full GC耗时在0.1-0.3秒以内的话,一般不需要花费额外的时间做GC调优。然而如果Full GC耗时达到1-3秒甚至10秒以上,就需要立即对系统进行GC调优 。

  1. Minor GC执行迅速(50毫秒以内) 
  2. Minor GC执行不频繁(间隔10秒左右一次) 
  3. Full GC执行迅速(1秒以内) 
  4. Full GC执行不频繁(间隔10分钟左右一次)

六、参数调优

PS回收器

-Xms (初始堆内存) and -Xmx (最大堆内存)

如果知道应用程序需要多少堆才能正常工作,那么可以将-Xms和-Xmx设置为相同的值。如果不知道,那么JVM将首先使用初始堆大小,然后自动增长,直到它找到堆使用和性能之间的平衡。

建议将-Xms和-XX:MaxGCPauseMillis设置为,因为JVM在计算如何扩容/缩容时,也会消耗资源。

-XX:GCTimeRatio=

吞吐量:垃圾收集时间与应用程序时间的比率设置为1/(1+),默认值是99%(垃圾收集时间的1%)

-XX:MaxGCPauseTimeMillis

设置最大垃圾回收时间停顿时间

-XX:UseAdaptiveSizePolicy

自适应模式:新生代大小,eden区与survivor区的比例,晋升老年代的对象年龄等参数会被自动调整

       优先级保证:暂停时间>吞吐量>堆空间。如果不设置初始堆内存和最大堆内存,则初始堆大小为物理内存的1/64,最大内存为1/4,年轻代大小为堆内存的1/3

CMS回收器

-XX:+UseConcMarkSweepGC 开启CMS

并发线程数:(ParallelGCThreads+3)/4。也可用通过-XX:ConcGCThreads或者-XX:ParallelCMSThreads手工设置

-XX:CMSInitiatingOccupancyFraction

因为并发性质,所以CMS不会等到堆饱和时才进行垃圾回收。默认值为老年代占用率68%,通过此参数设置

-XX:CMSFullGCsBeforeCompaction

内存压缩:设定多少次之后GC回收之后对内存进行一次压缩,默认0

-XX:CMSClassUnloadingEnable

开启之后Perm区满了之后的还会触发一次Full GC

G1回收器

**-XX:+UseG1GC  **启用G1

-XX:NewSize(最小年轻代) -XX:MaxNewSize(最大年轻代)

-XX:MaxGCPauseMillis=(默认200ms)GC最大暂停时间

如果设置了-Xmn,则MaxGCPauseMillis会失效

-XX:MinHeapFreeRatio=40  **-XX:MaxHeapFreeRatio=70  **空闲堆占比

GC后,如果发现空闲堆内存占到整个预估堆内存的40%,则放大堆内存的预估最大值,但不超过固定最大值

-XX:ParallelGCThreads=

GC停顿时候的并行的GC收集线程数:-XX:ParallelGCThreads=根据虚拟机所在的主机的可用CPU线程数来计算的:如果CPU少于8个这个值就是cpu的数量,否则,就等于cpu数量*5/8。每个停顿开始的时候,最大的GC线程数还受限于最大的堆内存,G1的每个线程能使用的最大堆内存是由-XX:HeapSizePerGCThread来设置的,默认8M

-XX:ConcGCThreads=

应用并发执行的GC线程数,默认是-XX:ParallelGCThreads/4

**-XX:G1HeapRegionSize=**

region的大小:整个堆大概有2048个region,region的大小可以在1-32M之间,必须是2的次方。调整之后会影响分配对象的大小及停顿时间

-XX:G1MaxNewSizePercent

可分配的最大对象的大小,必须配合参数-XX:+UnlockExperimentalVMOptions使用,并且只能加在其后才能生效

七、tools

  1. 可以使jstat [-命令选项][vmid] [间隔时间/毫秒][查询次数]查看堆内存使用情况。
  2. gc的监控可以使用JDK自带的jvisualvm或者jconsole
  3. gc.log日志分析可以使用免费在线分析工具gceasy:blog.gceasy.io/

八、针对项目经验集

这几种GC收集器相比之下,只要JDK版本在1.7u4及以上,推荐使用G1收集器。JDK1.7,1.8都默认使用PS
注意容器项目,容器设置的JVM配置内存大小不能大于容器内存大小,否则参数配置无效

调优实例

        压测表现:压测时压力不上去,服务器消耗未满载,但是增加并发数服务器资源并不能充分利用。且压的时间长了TPS会有断崖式下降,TPS和相应时间非常不稳定。

        TPS与响应时间:

        资源监控:

        图片不是特别清楚,app集群CPU消耗46%,内存消耗37%

        分析堆内存使用情况: 

       了解到机器配置为4C8G,调整了下heap堆大小,又手动设置了年轻代大小,经过多轮次调优复测情况如下(-Xmx7g -Xms7g -XX:NewSize=3g -XX:MaxNewSize=3g):  

        TPS与响应时间:性能提升了100%以上,响应时间缩短30%左右

        资源监控:

        app集群CPU消耗65%,内存消耗50%

PS:JDK11中新推了一款新的垃圾回收器ZGC,只能用四个字描述“超乎想象”,我们公司目前还是JDK8的标准版本,暂时未升级到JDK11,以后有机会再去研究分享。

后续计划发布还是关于JVM相关:

JVM关键参数、OOM、类装载等知识点......

最后附上GC官方文档:docs.oracle.com/en/java/jav…

免责声明:文章版权归原作者所有,其内容与观点不代表Unitimes立场,亦不构成任何投资意见或建议。

程序员

476

相关文章推荐

未登录头像

暂无评论