java中的垃圾回收机制

本文深入探讨了JVM垃圾回收的关键概念和技术细节,包括可达性分析、安全点机制、对象头标记等基础知识。介绍了HotSpot JVM的内存管理策略、各种垃圾回收算法如复制算法、标记-紧凑算法等,以及多种垃圾收集器如Serial、ParNew、CMS、G1、ZGC的特点和应用场景。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

垃圾回收可以说是JVM最关键的知识点。

基础知识

  • 可达性分析
    在jvm中,做为GCRoots的对象:
  1. 虚拟机栈(栈桢中的本地变量表)中的引用的对象
  2. 方法区中的类静态属性引用的对象
  3. 方法区中的常量引用的对象
  4. 本地方法栈中JNI的引用的对象
  • 安全点
    为什么需要安全点,是因为,新建一个对象的操作并不是原子性的。需要分配内存然后把引用存到例如线程的本地变量表中。如果在刚完成新建一个对象,但是对象的引用还在寄存器中,此时线程终止,则刚刚建好的对象就会作为不可达对象被删除。
    安全点的选择既不能太少,让GC等待太长,也不能太多,增加运行负荷;安全点的选择以是否让程序长时间执行的特征;一般在方法调用;循环跳转;异常跳转等才会产生安全点。
    GC中STW的操作,必须等到所有线程都进入安全点才行。

  • 对象
    每个对象都有一个对象头(MarkWord)。执行标记的时候就是将标记加入对象头。

JVM的分代管理策略

HOTSPOT JVM内存管理采取分代的策略:

在这里插入图片描述

  • 年轻代(Young Gen)
    中间包含一个Eden Space和两个Suvivor Space(S0,S1)
    JVM为了加快GC回收速度,会在Eden区为每一个线程分配一个很小的TLAB空间,用于存储线程独享的小对象。需要打开-XX:+UseTLAB来启动这一机制。另外,这和逃逸分析栈上分配不是一回事。逃逸分析是直接进行栈上分配。

  • 老年代(Tenured Gen)

  • 持久代(Perm Gen)/元数据区。
    虽然号称持久代,但实际上也可以被垃圾收集。

JDK7之前,所谓的Permanet Generation内存区域其实包含了两个部分:

  • 方法区,存储class文件,具体包括:类的方法,类的名称,常量池。方法区是JVM规范里写出要实现的。方法区( Method Area) 是可供各条线程共享的运行时内存区域。
  • Internded String

JDK7中,开始了减轻持久代的问题。做了如下工作。

  • 符号引用(Symbols)转移到了native heap
  • 字面量(interned strings)转移到了java heap
  • 类的静态变量(class statics)转移到了java heap。

常量池指的是在编译期被确定,并被保存在已编译的.class文件中的一些数据。包含以下内容

  • 代码中所定义的各种基本类型
  • 构造函数和普通方法的字节码内容

Oracle JDK8的HotSpot VM去掉“持久代”,以“元数据区”(Metaspace)替代之。

区别就是原来的持久代直接属于GC heap(受GC管理。)即GC HEAP =JAVA HEAP + PERM。移到Metaspace的原因是PermGen很难调整。PermGen中类的元数据信息在每次FullGC的时候可能会被收集,但成绩很难令人满意。而且应该为PermGen分配多大的空间很难确定,因为PermSize的大小依赖于很多因素,比如JVM加载的class的总数,常量池的大小,方法的大小等。

Metaspace VM利用内存管理技术来管理Metaspace。这使得原来由不同的垃圾收集器来处理类元数据的工作,现在仅仅由Metaspace VM自己进行管理。Metaspace背后的一个思想是,类和它的元数据的生命周期是和它的类加载器的生命周期一致的。也就是说,只要类的类加载器是存活的,在Metaspace中的类元数据也是存活的,不能被释放。对其讨论已经超出了本文的范畴。

废弃永久代意味着我们永远告别了java.lang.OutOfMemoryError: PermGen。理论上Metaspace的最大大小取决于操作系统,但是也可以设置参数来进行限制。

JVM垃圾回收的策略

  • 复制(Copying)算法
    Eden/S0/S1三个区更合理,空间比例为Eden:S0:S1==8:1:1
  1. Eden+S0可分配新生对象;
  2. 对Eden+S0进行垃圾收集,存活对象复制到S1。清理Eden+S0。一次新生代GC结束。
  3. Eden+S1可分配新生对象;
  4. 对Eden+S1进行垃圾收集,存活对象复制到S0。清理Eden+S1。二次新生代GC结束。
  5. goto 1。
  • 标记-紧凑(Mark-Compact)
  1. 标记:标记可回收对象(垃圾对象)和存活对象。
  2. 紧凑(也称“整理”):将所有存活对象向内存开始部位移动,称为内存紧凑(相当于碎片整理)。完毕后,清理剩余内存空间。
  • 标记-清除(Mark-Sweep)算法
  1. 先判定对象是否可回收,对其标记。
  2. 统一回收(简单地删除对垃圾对象的内存引用)。
    优点:简单直观容易实现和理解。缺点:效率不高,内存空间碎片化。

JVM提供的几种垃圾收集器

通过JVM启动参数来确定该使用哪种垃圾收集器。

Serial/Serial Old

最古老的收集器,是一个单线程收集器,用它进行垃圾回收时,必须暂停所有用户线程。Serial是针对新生代的收集器,采用Copying算法;而Serial Old是针对老生代的收集器,采用Mark-Compact算法。优点是简单高效,缺点是需要暂停用户线程。Serial至今仍然是新生代默认的垃圾处理器。

ParNew

Seral/Serial Old的多线程版本,使用多个线程进行垃圾收集。
默认和CMS搭配的年轻代收集器。

[GC (Allocation Failure) [ParNew: 48254K->6783K(61440K), 0.6221046 secs] 48254K->57192K(198016K), 0.6221474 secs] [Times: user=1.05 sys=0.11, real=0.62 secs] 
Parallel Scavenge

新生代的并行收集器,回收期间不需要暂停其他线程,采用Copying算法。该收集器与前两个收集器不同,主要为了达到一个可控的吞吐量。
JDK7和JDK8中默认垃圾收集器。

##注意多核CPU的real可能会小于sys+user。
[GC (Allocation Failure) [PSYoungGen: 47508K->8176K(59904K)] 47508K->45648K(196608K), 0.1189809 secs] [Times: user=0.29 sys=0.05, real=0.12 secs] 

如果说非要说和ParNew有什么区别,就是paralel scavenge可以设置最大停顿时间的。他的优化也是以减少停顿时间为目的的。而ParNew是以最大吞吐为目的的。

Parallel Old

Parallel Scavenge的老生代版本,采用Mark-Compact算法和多线程。
JDK8老年代默认。

CMS(适用于老年代)

Current Mark Sweep收集器是一种以最小回收时间停顿为目标的并发回收器,因而采用Mark-Sweep算法。算法牺牲吞吐量为代价来获得最短回收停顿时间。

##实际上是 "ParNew" + "CMS" + "Serial Old"。
##如果发生concurrent mode failure,CMS就会变成Serial Old,从而显著增加停顿时间。
-XX:+UseConcMarkSweepGC

concurrent mode failure是在执行CMS GC的过程中同时有对象要放入老年代,而此时老年代空间不足造成的。

##在一次CMS的阶段中,突然一次ParNew发起的年轻代清理产生了对象要放入老年代,
##此时,老年代空间不足,触发了concurrent mode failure
##终止CMS过程,使用Serial Old,最终造成了2.88s的停滞。
[GC (Allocation Failure) [ParNew: 32720K->6782K(61440K), 0.2004625 secs][CMS[CMS-concurrent-abortable-preclean: 0.485/0.696 secs] [Times: user=1.26 sys=0.03, real=0.69 secs] 
 (concurrent mode failure): 413819K->378732K(414620K), 2.6777082 secs] 417417K->378732K(476060K), [Metaspace: 8656K->8656K(1056768K)], 2.8795875 secs] [Times: user=2.97 sys=0.18, real=2.88 secs]

##promotion failed是在进行Minor GC时,Survivor Space放不下,对象只能放入老年代,而此时老年代也放不下造成的。promotion failed会导致Concurrent mode failure。
106.641: [GC 106.641: [ParNew (promotion failed): 14784K->14784K(14784K), 0.0370328 secs]106.678: [CMS106.715: [CMS-concurrent-mark: 0.065/0.103 secs] [Times: user=0.17 sys=0.00, real=0.11 secs]
(concurrent mode failure): 41568K->27787K(49152K), 0.2128504 secs] 52402K->27787K(63936K), [CMS Perm : 2086K->2086K(12288K)], 0.2499776 secs] [Times: user=0.28 sys=0.00, real=0.25 secs]


一次CMS包含如下阶段

在这里插入图片描述

  • 初始标记(STW initial mark) 暂停应用
    做两个事情
    1)从GC Roots遍历可直达的老年代对象;
    2)遍历被新生代存活对象所引用的老年代对象。
  • 并发标记(Concurrent marking)
    并发标记阶段的主要工作是,通过遍历第一个阶段(Initial Mark)标记出来的存活对象,继续递归遍历老年代,并标记可直接或间接到达的所有老年代存活对象。
    由于在并发标记阶段,应用线程和GC线程是并发执行的,因此可能产生新的对象或对象关系发生变化,例如:
	新生代的对象晋升到老年代;
	直接在老年代分配对象;
	老年代对象的引用关系发生变更;
	等等。

对于这些对象,需要重新标记以防止被遗漏。为了提高重新标记的效率,本阶段会把这些发生变化的对象所在的Card标识为Dirty,这样后续就只需要扫描这些Dirty Card的对象,从而避免扫描整个老年代。

  • 并发预清理(Concurrent precleaning)

在并发预清洗阶段,将会重新扫描前一个阶段标记的Dirty对象,并标记被Dirty对象直接或间接引用的对象,然后清除Card标识。

  • 重新标记(STW remark) 暂停应用
    因此,需要有一个stop-the-world的阶段来完成最后的标记工作,这就是重新标记阶段(CMS标记阶段的最后一个阶段)。主要目的是重新扫描之前并发处理阶段的所有残留更新对象。

主要工作:

遍历新生代对象,重新标记;(新生代会被分块,多线程扫描)
根据GC Roots,重新标记;
遍历老年代的Dirty Card,重新标记。这里的Dirty Card,大部分已经在Preclean阶段被处理过了。
  • 并发清理(Concurrent sweeping)
并发清理阶段,主要工作是清理所有未被标记的死亡对象,回收被占用的空间。


  • 并发重置(Concurrent reset)

典型日志如下

//初始标记(STW initial mark)
//STW(快速),扫描和根对象直接关联的对象。
[GC (CMS Initial Mark) [1 CMS-initial-mark: 97625K(136576K)] 121111K(198016K), 0.0190166 secs] [Times: user=0.02 sys=0.00, real=0.02 secs] 
//并发标记(Concurrent marking)。和用户线程一同进行,继续扫描可达对象
[CMS-concurrent-mark-start]
[CMS-concurrent-mark: 0.608/0.620 secs] [Times: user=0.76 sys=0.06, real=0.62 secs] 
//并发预清理(Concurrent precleaning),查找所有在并发标记阶段进入老年代的对象
[CMS-concurrent-preclean-start]
[CMS-concurrent-preclean: 0.151/0.449 secs] [Times: user=0.83 sys=0.06, real=0.45 secs] 
[CMS-concurrent-abortable-preclean-start]
[CMS-concurrent-abortable-preclean: 0.498/0.951 secs] [Times: user=2.19 sys=0.07, real=0.95 secs] 
//重新标记(STW remark)。对并发预清理的对象判断是否可达,作出标记(短暂STW)
[GC (CMS Final Remark) [YG occupancy: 7825 K (61440 K)][Rescan (parallel) , 0.1830990 secs][weak refs processing, 0.0000214 secs][class unloading, 0.0011215 secs][scrub symbol table, 0.0011397 secs][scrub string table, 0.0002518 secs][1 CMS-remark: 260692K(261316K)] 268518K(322756K), 0.1858328 secs] [Times: user=0.46 sys=0.00, real=0.19 secs] 
//并发清理(Concurrent sweeping)
[CMS-concurrent-sweep-start]
[CMS-concurrent-sweep: 0.067/0.067 secs] [Times: user=0.16 sys=0.01, real=0.07 secs] 
//并发重置(Concurrent reset)
[CMS-concurrent-reset-start]
[CMS-concurrent-reset: 0.008/0.008 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 
  • -XX:SurvivorRatio 新生代和老年代的比值。一般算成1:8。显然,新生代越大,晋升到老年代的就越少,可以提高CMS对老年代回收的性能。(相应的等于说降低了新生代的回收性能)
  • -XX:+UseParNewGC, -XX:+UseConcMarkSweepGC 这俩几乎是默认搭配了
  • -XX:ParallelGCThreads, -XX:ParallelCMSThreads 这俩的线程数量
  • -XX:MaxTenuringThreshold 新生代晋升为老年代的年龄阈值
  • -XX:+UseCMSCompactAtFullCollection CMS的时候不可避免会造成内存碎片。所以在不得不进行Serail Old Full GC的时候应该顺便进行内存压缩。
  • -XX:+UseCMSInitiatingOccupancyOnly , -XX:CMSInitiatingOccupancyFraction 这俩一般一起使用,表示老年代使用多少比例后开始进行CMS回收。如果不使用UseCMSInitiatingOccupancyOnly,那么JVM会在他认为合适的时候自动触发
  • -XX:+CMSClassUnloadingEnabled 表示CMS同时对永久代也进行垃圾回收。
G1

G1(Garbage First)收集器技术的前沿成果,是面向服务端的收集器,能充分利用CPU和多核环境。是一款并行与并发收集器,它能够建立可预测的停顿时间模型。g1收集器是一个面向服务端的垃圾收收集器,适用于多核处理器、大内存容量的服务端系统。 它满足短时间gc 停顿的同时达到一个高的吞吐量。G1的处理阶段类似于CMS。目标也和CMS一致。
比如阿里内部的ZenGC就是基于G1的
G1的改变还是革命性的,打破了年轻代和老年代的连续分区(但是仍然区分来年代和新生代)。G1的收集也不区分年轻代和老年代。
在这里插入图片描述
一般每个region大小预设为1M或者即使M。如果对象大于半个reagion。那就直接放在O块。如果大于一个region,那么分配多个连续的O块。每个region维护一个

  • RSET 本region引用其他region的。
  • CSET 本次GC需要清理的垃圾集合

完整的过程如下:

  • 对于YGC,仍然是复制算法
  • 对于Old区的GC,在G1中被称为Mix GC(就是Old区GC 顺便完成YGC)
    • 初次标记。除了标记GCRoot,还标记GCRoot所在的region。STW。根据找到的rootRegion,找到所有和rootRegion相关的region。
    • 并发标记。遍历上步骤中所有找到的region
    • 重新标记。SATB算法。STW。https://www.pianshen.com/article/74861031970/ 这个算法还是蛮复杂的。其实对我们来说,没有找到一个需要清理的对象并无伤大雅,完全可以下次清理,但是把一个有效对象标记成垃圾清理就是完全不可接受了。这个算法可以看作是在新的region数据结构下的一种优化。
    • 清理。复制算法。STW。只选择垃圾较多的区域进行。所谓Garbage-First。活的越久的老年代被选中进行回收的可能性越高。

用户在使用G1的时候,可以指定期望的暂停时间。G1将尽量在期望暂停时间内做更多的事。既然叫做【Garbage-First】,就是要在并发标记和最终标记时,找到最高收益(也就是最多垃圾)的分区进行回收。回收的方式是将存活的对象移动到其他的分区,相当于同时完成紧凑的工作。

在这里插入图片描述

一些常见的调优参数

  • XX:InitiatingHeapOccupancyPercent。默认45%。当整个堆的使用比超过这个值的时候,就会开始Concurrent Marking Cycle Phases,也就是提前触发了一个GC周期。这个值越低,GC越频繁,越高,越容易出现Full GC(Serail old清理)。
  • XX:G1HeapWastePercent。在上述并发标记后,计算可被回收的垃圾的比值。默认5%。如果超过这个值,那么就会开始mixed gc。这个GC有助于更多的回收老年代。
ZGC

JDK11中推出的垃圾处理器。oracle的JDK11收费,一般大家都用OpenJDK11。宣传上说停顿时间不大于1ms。这可以大幅度提高一些互联网应用的999线。

可以堪称是G1的进一步优化。

在这里插入图片描述

直接在老年代分配对象

1、分配的对象大小大于eden space。适合所有收集器。

2、eden space剩余空间不足分配,且需要分配对象内存大小不小于eden space总空间的一半,直接分配到老年代,不触发Minor GC。适合-XX:+UseParallelGC、-XX:+UseParallelOldGC,即适合Parallel Scavenge。

3、大对象直接进入老年代,使用-XX:PretenureSizeThreshold参数控制,适合-XX:+UseSerialGC、-XX:+UseParNewGC、-XX:+UseConcMarkSweepGC,即适合Serial和ParNew收集器。

JVM的清理过程

(1)minorGC:
清理Eden,S0,S1

(2)majorGC
清理老年代
http://www.importnew.com/15820.html
事实上majorGC和full gc的定义十分令人困惑,建议忽略major GC的说法。重点从什么GC对什么空间做了什么入手。

(3)FullGC
清理所有堆空间。full gc的耗时事件往往非常长。

[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(798208K)] [ParOldGen: 2623370K->2622981K(2796544K)] 2623370K->2622981K(3594752K), [Metaspace: 8908K->8761K(1056768K)], 17.6905044 secs] [Times: user=58.44 sys=0.75, real=17.69 secs] 

如下操作可能会触发Full GC:

  1. 旧生代空间不足
    旧生代空间只有在新生代对象转入及创建为大对象、大数组时才会出现不足的现象,当执行Full GC后空间仍然不足,则抛出如下错误:
    java.lang.OutOfMemoryError: Java heap space
    为避免以上两种状况引起的FullGC,调优时应尽量做到让对象在Minor GC阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组。

  2. Permanet Generation空间满
    PermanetGeneration中存放的为一些class的信息等,当系统中要加载的类、反射的类和调用的方法较多时,Permanet Generation可能会被占满,在未配置为采用CMS GC的情况下会执行Full GC。如果经过Full GC仍然回收不了,那么JVM会抛出如下错误信息:
    java.lang.OutOfMemoryError: PermGen space
    为避免Perm Gen占满造成Full GC现象,可采用的方法为增大Perm Gen空间或转为使用CMS GC。

  3. CMS GC时出现promotion failed和concurrent mode failure
    对于采用CMS进行旧生代GC的程序而言,尤其要注意GC日志中是否有promotion failed和concurrent mode failure两种状况,当这两种状况出现时可能会触发Full GC。
    promotionfailed是在进行Minor GC时,survivor space放不下、对象只能放入旧生代,而此时旧生代也放不下造成的;concurrent mode failure是在执行CMS GC的过程中同时有对象要放入旧生代,而此时旧生代空间不足造成的。
    应对措施为:增大survivorspace、旧生代空间或调低触发并发GC的比率,但在JDK 5.0+、6.0+的版本中有可能会由于JDK的bug29导致CMS在remark完毕后很久才触发sweeping动作。对于这种状况,可通过设置-XX:CMSMaxAbortablePrecleanTime=5(单位为ms)来避免。

  4. 统计得到的Minor GC晋升到旧生代的平均大小大于旧生代的剩余空间
    这是一个较为复杂的触发情况,Hotspot为了避免由于新生代对象晋升到旧生代导致旧生代空间不足的现象,在进行Minor GC时,做了一个判断,如果之前统计所得到的Minor GC晋升到旧生代的平均大小大于旧生代的剩余空间,那么就直接触发Full GC。
    例如程序第一次触发MinorGC后,有6MB的对象晋升到旧生代,那么当下一次Minor GC发生时,首先检查旧生代的剩余空间是否大于6MB,如果小于6MB,则执行Full GC。
    当新生代采用PSGC时,方式稍有不同,PS GC是在Minor GC后也会检查,例如上面的例子中第一次Minor GC后,PS GC会检查此时旧生代的剩余空间是否大于6MB,如小于,则触发对旧生代的回收。

所有GC都会触发STW。

方法区的内存回收

方法区的垃圾回收主要回收两部分内容:1. 废弃常量。2. 无用的类。既然进行垃圾回收,就需要判断哪些是废弃常量,哪些是无用的类。

如何判断废弃常量呢?以字面量回收为例,如果一个字符串“abc”已经进入常量池,但是当前系统没有任何一个String对象引用了叫做“abc”的字面量,那么,如果发生垃圾回收并且有必要时,“abc”就会被系统移出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。

如何判断无用的类呢?需要满足以下三个条件

  1. 该类的所有实例都已经被回收,即Java堆中不存在该类的任何实例。

  2. 加载该类的ClassLoader已经被回收。

  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

对于哪些需要动态加载卸载外部代码的类,显然你需要保证三点

  • 使用单独的CL加载外部代码,卸载时将CL销毁
  • 卸载时销毁所有需要的代码引用
  • 不要允许外部应用加载过于复杂的类

FAQ

  • java.lang.OutOfMemoryError:GC overhead limit exceeded
    java内存溢出,简单而言就是内存不足了,因为是error,按照java异常机制最初设计直接程序就应该完蛋了。显然,这种情况不利于保存现场,所以,JVM定义了一个提前预测的内存不足的机制,并抛出该error。
    你可以用catch(throwable e)来catch住该错误,但是,建议你只进行保存现场的的工作,然后退出程序。

JVM参数优化

如果是因为CMS产生碎片过多,导致大对象无法在老年代分配,从而引起concurrent mode failure,可以指定在CMS在运行若干次标记清除算法后运行一次标记整理算法。

-XX:UseCMSCompactAtFullCollection -XX:CMSFullGCBeforeCompaction=5
##也就是CMS在进行5次Full GC(标记清除)之后进行一次标记整理算法,从而可以控制老年代的碎片在一定的数量以内,甚至可以配置CMS在每次Full GC的时候都进行内存的整理。

如果是突然的大对象无法在老年区分配,则思路还有提前触发老年区的垃圾收集,以保证老年区总有足够的空间。解决这个问题的通用方法是调低触发CMS GC执行的阀值,CMS GC触发主要由CMSInitiatingOccupancyFraction值决定,默认情况是当旧生代已用空间为68%时,即触发CMS GC,在出现concurrent mode failure的情况下,可考虑调小这个值,提前CMS GC的触发,以保证旧生代有足够的空间。

调大新生代的S0,S1区域的值适合那些对象大而且瞬间生死的应用。避免使用老年区装载对象。

一些反例代码

如果现在互联网和传统软件业有什么不同的话,那么就是什么人都能写代码,而且完全没有必要的培训,事先设计和事后验证。所以,我们可以有很多神奇的代码,令人哭笑不得。

  • 例子一 线程池的滥用。作者似乎是考虑到读取大文件,所以采用Scanner类。没有字符串拼接,使用String和StringBuffer都无所谓。但是最关键的!作者忘记了线程池的工作队列!试想以下,程序跑起来后,文件读取速度会非常的快,而网络IO则相对很慢,线程池的消费速度如果赶不上生产速度,那么所有任务会堆积在工作队列中,会导致大量的内存溢出。
            inputStream = new FileInputStream(fileName);
            sc = new Scanner(inputStream, "UTF-8");
            while (sc.hasNextLine()) {
                final String innerRow = sc.nextLine();
                exec.execute(new Runnable() {
                    public void run() {
                        if (count.incrementAndGet() % 10000 == 0) {
                            logger.info(count + " certs have been scanned..");
                        }
                        String key = innerRow.split("-")[0];
                        producer.send("UMETRIP_PUT_HBASE_TOPIC", innerRow, key);
                        logger.info(innerRow);
                    }
                });
            }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值