垃圾收集器与回收策略
JAVA 虚拟机收集垃圾的区域:
【垃圾回收主要是指方法区和堆内存的回收,这些区域的内存是变化的。其它区域的内存跟随方法的结束或者线程的结束而自动回收】
程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。
而Java堆和方法区则不一样,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存,“内存”分配与回收也仅指这一部分内存。
垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(即不可能再被任何途径使用的对象)。
判断对象是否已经死去的方法主要有引用计数法和可达性分析算法。
引用计数法就是通过计数器来计算,当对象被引用时计数器加1,引用失效时计数器就减去1。但是这对于有循环引用的对象,通过该算法管理内存就不太可靠。
可达性分析算法的思路是是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。如图所示,对象object 5、object 6、object 7虽然互相有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。
在Java语言中GC Roots的对象包括:
虚拟机栈(栈帧中的本地变量表)中引用的对象
方法区中类静态属性引用的对象、方法区中常量引用的对象
本地方法栈中JNI(即一般说的Native方法)引用的对象
方法区的回收:
在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,而永久代的垃圾收集效率远低于此。永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。回收废弃常量与回收Java堆中的对象非常类似。没有任何对象引用常量池中的常量可被回收。
判断常量池比较容易,但是判断类是否无用就需要同时满足如下三种情况才能被认为是无用的常量类:
该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。
加载该类的ClassLoader已经被回收。
该类对应的java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
垃圾回收算法:
标记清除算法(Mark-Sweep):
主要分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
它的主要不足有两个:
一个是效率问题,标记和清除两个过程的效率都不高;
另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
复制算法(Copying):
将可用内存划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半。
【一个Eden,两个Survivor,比例一般为8:1:1,可用空间为90%,10%的空间用来垃圾回收时复制对象所用,当然如果这10%不够用,可以找老年代进行分配担保】
研究表明,新生代中的对象98%是“朝生夕死”的,所以并不需要按照1∶1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor[插图]。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上(在Survivor上的对象如果什么达到了15,也会迁移到老年代),最后清理掉Eden和刚才用过的Survivor空间,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也就是每次新生代中可用内存空间为整个新生代容量的90% (80%+10%),只有10%的内存会被“浪费”。
标记-整理算法:
【老年代的对象存活率很高,如果采取一半一半的复制算法,那么就近乎于来回复制。为了避免复制,同时又想消除碎片内存的影响,可以采取适用于老年代的标记整理算法】
标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
分代收集算法:
【新生代采用复制算法,老年代采取标记—清理或者标记整理】
当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。
枚举根节点:
【枚举根节点的过程非常耗时,主要体现在查找对象的引用,为了减少遍地查找的时间,通过OopMap的数据结构来存储对象引用的信息】
可达性分析从GC Roots节点找引用链这个操作为例,可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,现在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,那么必然会消耗很多时间。
另外,可达性分析对执行时间的敏感还体现在GC停顿上,因为这项分析工作必须在一个能确保一致性的快照中进行——这里“一致性”的意思是指在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果准确性就无法得到保证。这点是导致GC进行时必须停顿所有Java执行线程(Sun将这件事情称为“Stop TheWorld”)的其中一个重要原因,即使是在号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。
目前的主流Java虚拟机使用的都是准确式GC,所以当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得知哪些地方存放着对象引用。在HotSpot的实现中,是使用一组称为OopMap的数据结构来达到这个目的的,在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。
垃圾收集器:
新生代收集器:都采用复制算法实现。
老年代收集器:CMS采用标记—清除算法;Serial Old采用标记—整理算法; Parallel Old采用 标记—整理算法。
Serial收集器:
新生代收集器,需要“Stop The World”。对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
Serial/Serial Old 收集器
ParNew收集器:
Server模式下的虚拟机中首选的新生代收集器。除了Serial收集器外,目前新生代收集器只有它能与CMS收集器配合工作。
ParNew/Serial Old收集器
Parallel Scavenge收集器:
Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器。
Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。(谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间 +垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%)
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。GCTimeRatio参数的值应当是一个大于0且小于100的整数,也就是垃圾收集时间占总时间的比率,相当于是吞吐量的倒数。如果把此参数设置为19,那允许的最大GC时间就占总时间的5%(即1 /(1+19)),默认值为99,就是允许最大1%(即1 /(1+99))的垃圾收集时间。
自适应调整策略:Parallel Scavenge收集器有一个参数-XX:+UseAdaptiveSizePolicy值得关注。这是一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomics)。
Serial Old收集器:
Serial Old是Serial收集器的老年代版本,主要用于JDK 1.5以及之前的版本中与Parallel Scavenge收集器搭配使,另一种用途就是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。
Parallel Old收集器:
Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。
Parallel Scavenge/Parallel Old
CMS收集器:
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。
过程:
初始标记(CMS initial mark):只是标记一下GC Roots能直接关联到的对象,速度很快
并发标记(CMS concurrent mark):进行GC Roots Tracing的过程
重新标记(CMS remark):修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短
并发清除(CMS concurrent sweep)
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
优点:并发收集、低停顿
缺点:1 在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。
2 CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。(浮动垃圾:由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。)
3 CMS是一款基于“标记—清除”算法实现的收集器,收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。
G1收集器
G1具备如下特点:
并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
分代收集:与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。
空间整合:与CMS的“标记—清理”算法不同,G1从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
可预测的停顿:这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。
操作过程:
初始标记(Initial Marking)
并发标记(Concurrent Marking)
最终标记(Final Marking)
筛选回收(Live Data Counting and Evacuation)
G1收集器和CMS收集器的区别:
CMS特点:并发,低停顿
缺点:对CPU非常敏感,无法处理浮动垃圾,内存碎片过多时,会产生full gc
G1特点: 是一款面向服务端应用的垃圾收集器,并行与并发,分代收集,空间整合,可预测的停顿。
空间整合:由于G1使用了独立区域(Region)概念,G1从整体来看是基于“标记-整理”算法实现收集,从局部(两个Region)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片。
可预测的停顿:这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用这明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒)
内存回收策略:
新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
老年代GC(Major GC / Full GC):指发生在老年代的GC,出现了MajorGC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。
大对象直接进入老年代:虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制。
长期存活的对象将进入老年代:虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次MinorGC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。
垃圾回收相关参数:
CMS收集算法详细概述:
CMS收集器有3种基本的操作,分别是:
• CMS收集器会对新生代的对象进行回收(所有的应用线程都会被暂停);
• CMS收集器会启动一个并发的线程对老年代空间的垃圾进行回收;
• 如果有必要,CMS会发起Full GC。
JVM会依据堆的使用情况启动并发回收。当堆的占用达到某个程度时,JVM会启动后台线程扫描堆,回收不用的对象。扫描结束的时候,堆的状况就像这幅图中最后一列所描述的情况一样。请注意,如果使用CMS回收器,老年代空间不会进行压缩整理:老年代空间由已经分配对象的空间和空闲空间共同组成。新生代垃圾收集将对象由Eden空间挪到老年代空间时,JVM会尝试使用那些空闲的空间来保存这些晋升的对象。
初始标记:Gc Roots直接关联对象 ,年轻代指向老年代的对象
2 并发标记:从Gc Roots向下追溯,比较所有可达的对象
3 并发预处理:由于并发标记阶段,用户线程与垃圾回收线程是并行处理的,有些对象可能从新生代晋升到老年代; 有些大对象直接分配到了老年代;或者老年代或新生代的引用发生了变化。针对这些现象,在并发预处理阶段进行识别。
4 重新标记:重新标记」阶段会Stop The World,这个过程的停顿时间其实很大程度上取决于上面「并发预处理」阶段(可以发现,这是一个追赶的过程:一边在标记存活对象,一边用户线程在执行产生垃圾)
5 并发清除:这个过程,还是有可能用户线程在不断产生垃圾,但只能留到下一次GC 进行处理了,产生的这些垃圾被叫做“浮动垃圾”。
1.空间需要预留:CMS垃圾收集器可以一边回收垃圾,一边处理用户线程,那需要在这个过程中保证有充足的内存空间供用户使用。
如果CMS运行过程中预留的空间不够用了,会报错(Concurrent Mode Failure)即并发模式失效,这时会启动 Serial Old垃圾收集器进行老年代的垃圾回收,会导致停顿的时间很长。
并发模式失效与晋升失败:
发生并发模式失效往往是由于CMS不能以足够快的速度清理老年代空间。
1 初始时老年代空间中对象是一个接一个整齐有序排列的。当老年代空间的占用达到某个程度(默认值为70%,XX:+UseCMSInitiatingOccupancyOnly
来设置)时,并发回收就开始了。由于在并发执行期间,用户线程依然在运行,如果应用在向老年代申请空间时,CMS收集器计算发现老年代没有足够的空闲空间可以容纳这些晋升对象,CMS收集器就会发生并发模式失效,此时会临时启用Serial Old收集器来重新进行老年代收集,这会导致停顿时间更长.
2 新生代做Minor GC的时候,老年代没有足够的空间用来存放晋升的对象,则会报promotion faileds;
-
晋升失败: 进行Minor GC时,survior space放不下,对象只能放入旧生代,而此时旧生代也放不下造成的.
-
永久代空间(Java8的元空间)耗尽: 默认情况下CMS不会对永久代进行收集,一旦永久代空间耗尽,就会触发FullGC
解决办法:
• 想办法增大老年代空间,要么只移动部分的新生代对象到老年代,要么增加更多的堆空间。
• 以更高的频率运行后台回收线程。
• 使用更多的后台回收线程。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了