JVM之垃圾回收机制全解

发表于:2020-12-11 10:21

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:柯南道尔的江户川    来源:掘金

  垃圾回收是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节点未触及持有的对象,但一个无法触及持有的对象有可能在某个时间下使自己复活。
  对象的可触及性的三种状态:
  可触及的
  可复活的(finalize()函数)
  不可触及的(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对象)--> 并发标记 --> 预清理(准备及控制停顿时间)--> 重新标记 --> 并发清除 --> 并发重置
  **优点:**并发收集、低停顿。
  缺点:
  CMS对CPU资源敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低
  CMS无法处理浮动垃圾,可能会出现“Concurrent Mode Failure(并发模式故障)”失败而导致Full GC产生
  CMS容易出现大量空间碎片。当空间碎片过多,将会给大对象分配带来很大的麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC
  老年代垃圾回收过程中,如果出现资源不够用,则会强制进行老年代串行回收,应用暂停时间更长,影响更大
  **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毫秒的时间片段内。
  五、调优思路
  调优前瞻
  尝试多种垃圾回收器,G1并不是最好的。
  并发不等于并行,垃圾回收的过程实际上有两步:启动GC周期和GC自身运行,这是不同的两件事。并发针对的是GC周期,而并行针对GC算法自身。
  平均事务时间不是最需要被关注的指标,有可能用户正好经历了那个长时间GC的场景,那将是毁灭性的。
  GC调优并不能解决所有的事。如果程序修改程度大,那应该优先优化架构及代码。
  GC日志并不会对性能造成太大的影响,在GC未被优化之前,开启GC日志是有必要的。
  降低新对象的分配率可以改善GC的运行状况:粗略地把系统中的对象分为三种:长命(long-lived)对象,对它们我们一般做不了什么;中等寿命(mid-lived)对象,最大的问题可能出现在这;短命(short-lived)对象,它们的释放和回收通常都很快,在下个GC周期来临时就会消失。
  调优思路
  理解应用需求和问题。
  掌握GC的状态。
  思考选择的GC是否符合我们的应用特征。
  分析确认需要调整的参数。
  验证调优。
  GC一般合理表现
  分析结果显示Full GC耗时在0.1-0.3秒以内的话,一般不需要花费额外的时间做GC调优。然而如果Full GC耗时达到1-3秒甚至10秒以上,就需要立即对系统进行GC调优 。
  Minor GC执行迅速(50毫秒以内)
  Minor GC执行不频繁(间隔10秒左右一次)
  Full GC执行迅速(1秒以内)
  Full GC执行不频繁(间隔10分钟左右一次)
  六、参数调优
  PS回收器
  -Xms (初始堆内存) and -Xmx (最大堆内存)
  如果知道应用程序需要多少堆才能正常工作,那么可以将-Xms和-Xmx设置为相同的值。如果不知道,那么JVM将首先使用初始堆大小,然后自动增长,直到它找到堆使用和性能之间的平衡。
  建议将**-Xms和-Xmx**设置为一样,因为JVM在计算如何扩容/缩容时,也会消耗资源。
  -XX:GCTimeRatio=
  吞吐量:垃圾收集时间与应用程序时间的比率设置为1/(1+),默认值是99%(垃圾收集时间的1%)
  -XX:MaxGCPauseTimeMillis
  设置最大垃圾回收时间停顿时间
  -XX:UseAdaptiveSizePolicy
  自适应模式:新生代大小,eden区与survivor区的比例,晋升老年代的对象年龄等参数会被自动调整,那么SurvivorRatio参数设置将会失效。
  优先级保证:暂停时间>吞吐量>堆空间。如果不设置初始堆内存和最大堆内存,则初始堆大小为物理内存的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
  可以使jstat [-命令选项] [vmid] [间隔时间/毫秒] [查询次数]查看堆内存使用情况。
  gc的监控可以使用JDK自带的jvisualvm或者jconsole
  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,以后有机会再去研究分享。

  本文内容不用于商业目的,如涉及知识产权问题,请权利人联系博为峰小编(021-64471599-8017),我们将立即处理
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号