JVM垃圾回收机制

142 阅读13分钟

概述

在c语言中我们无时无刻不需要关注一个问题,就是垃圾回收,不然一个不小心就会造成内存溢出导致程序崩溃,好在java简化了这个问题,使得java开发者只需要关注具体的业务逻辑而不需要关注垃圾回收,但是作为一个有上进心的java开发人员,怎么能不去了解一下jvm的垃圾回收机制呢?

什么时候会发生内存溢出?

以jdk1.8为例,可能会发生内存溢出的地方有三
1、元空间区域(MetaSpace)
造成元空间的内存溢出主要有两方面
(1)元空间默认参数过小,但是项目很大并且还依赖了很多外部的jar包的类
(2)使用动态生成类的技术,没有控制好的情况下,会呆滞生成的类过多将元空间区域塞满,引起内存溢出

2、每个线程的栈
例如不正确的使用递归调用

3、堆
(1)、系统承载高并发请求,因为请求量过大,导致大量的对象都是存活的,所以要放入新的对象放不下了,此时就会引起内存溢出系统崩溃
(2)、系统有泄漏的问题,就是莫名其妙产生了很多对象,结果对象都是存活的,没有及时取消他们的引用,导致触发GC还是无法回收,此时只能引发内存溢出,因为实在是放不下更多的对象了。

如何判断一个对象是否存活

判断一个对象是否存活的方法有两种
引用计数法
给每个对象设置一个引用计数器,当有引用时,计数器加一,引用失效时,计数器减一,当计数器为0时,就说明这个对象没有引用,也就是垃圾对象。但是,如果存在循环依赖,也就是a引用了b,b又引用了a,这种情况的话,计数器永远不可能为0,也就永远无法回收。所以,主流的虚拟机都不采用这个方法。

可达性分析
从一个GC Roots的对象向下搜索,如果一个像到GC Root是没有任何的引用链相连接时,说明此对象不可用

在java中可以作为GC Roots的对象有以下几种

  • 虚拟机栈中引用的对象
  • 方法区类静态属性引用的变量
  • 方法区常量池引用的对象
  • 本地方法JNI引用的对象

但一个对象满足上述条件的时候,不会马上被回收,还需要进行两次标记;第一次标记:判断当前对象是否有finalize()方法并且该方法没有被执行过,若不存在则标记为垃圾对象,等待回收;若有的话,则进行第二次标记;第二次标记将当前对象放入F-Queue队列,并生成一个finalize线程去执行该方法,虚拟机不保证该方法一定会被执行,这是因为如果线程执行缓慢或进入了死锁,会导致回收系统的崩溃;如果执行了finalize方法之后仍然没有与GC Roots有直接或者间接的引用,则该对象会被回收;

Java中的四种引用

强引用软引用虚引用弱引用

  • 强引用:就是普通对象引用关系
  • 软引用:用于维护一些可有可无的对象,当内存不足时,会回收软引用对象,如果回收了软引用对象仍然内存不足,则会抛出内存溢出异常
  • 虚引用:是一种形同虚设的引用,主要用来追踪对象被垃圾回收的活动
  • 弱引用:相比软引用,更加无用些,拥有更短的生命周期,jvm每次垃圾回收时,无论内存是否充足的,均会进行回收

Java中的垃圾回收算法

标记清除法
过程:
1、利用可达性分析遍历内存,把存活对象和垃圾对象进行标记
2、再次遍历,将所有标记对象进行回收

image.png

特点:效率低,标记和清除的效率均不高,清除完后会产生大量的不连续的空间切片,可能会导致程序需要分配大对象而找不到连续分配时触发一次gc

标记整理法
过程:
1、利用可达性分析,把存活对象和垃圾对象进行标记
2、将所有的存活对象向一端移动,将端边界以外的对象都回收掉

image.png

特点:适用于存活对象多,垃圾少的情况;无空间碎片产生

复制算法
将内存按照容量大小分为相等的两块,每次只使用一块,当一块使用完了,就将存活的对象移动到另一块上,然后在把使用过的内存空间移出
特点:不会产生空间碎片;内存使用率极低

分代收集算法
根据内存对象的存活周期不同,将内存划分成几块,java虚拟机一般将内存分成新生代和老生代,在新生代中,有大量对象死去和少量对象存活,所以采用复制算法,只需要付出少量存活对象的复制成本就可以完成收集;老年代中因为对象的存活率极高,没有额外的空间对他进行分配担保,所以采用标记清理或者标记整理算法进行回收;

主流的垃圾回收器

垃圾回收器主要分为以下几种:Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1

  • Serial:单线程收集器,收集垃圾时,需要STW,使用复制算法。
  • ParNew:多线程收集器,是Serial的多线程版本,同样在回收时需要STW
  • Parallel Scavenge:多线程收集器,新生代收集器,采用复制算法,虚拟机会根据系统的运行状态收集性能监控信息,动态设置这些参数,以提供最优停顿时间和最高的吞吐量
  • Serial Old:Serial收集器的老年代版本,单线程收集器,使用标记整理法
  • Parallel Old:是Parallel Scavenge收集器的老年代版本,使用多线程,标记-整理算法。
  • CMS:是一种以获得最短回收停顿时间为目标的收集器,标记清除算法,运作过程:初始标记,并发标记,重新标记,并发清除,收集结束会产生大量空间碎片;
  • G1:标记整理算法实现,运作流程主要包括以下:初始标记,并发标记,最终标记,筛选回收。不会产生空间碎片,可以精确地控制停顿;G1将整个堆分为大小相等的多个Region(区域),G1跟踪每个区域的垃圾大小,在后台维护一个优先级列表,每次根据允许的收集时间,优先回收价值最大的区域,已达到在有限时间内获取尽可能高的回收效率;

CMS

CMS(Concurrent Mark Sweep)并发标记清除,CMS是以最短回收停顿时间为目标,在垃圾收集时使得用户线程和GC线程并发执行,因此用户不会感到明显卡顿,CMS是老年代的垃圾回收器

CMS的回收过程

1、初始标记:主要是标记GC Root开始的下级对象(仅一层),这个过程会STW,但是跟GC Root 直接关联的下级对象不会很多,因此这个过程其实很快。
2、并发标记:根据上一步的结果,继续向下标记所有关联的对象,直到这条链上的最尽头。这个过程是多线程的,没有STW
3、重新标记:重新进行一次标记,因为第二步没有STW,所以在线程标记过程中,可能会产生新的垃圾
4、并发清除:清除阶段是删除已经标记好的死亡对象,由于不需要移动存活对象,所以这个阶段可以与用户线程同时并发进行,没有STW

CMS的问题

1、并发回收导致CPU资源紧张
在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程而导致应用程序变慢,降低程序总吞吐量。CMS默认启动的回收线程数是:(CPU核数 + 3)/ 4,当CPU核数不足四个时,CMS对用户程序的影响就可能变得很大。

2. 无法清理浮动垃圾:

在CMS的并发标记和并发清理阶段,用户线程还在继续运行,就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留到下一次垃圾收集时再清理掉。这一部分垃圾称为“浮动垃圾”。

3. 并发失败(Concurrent Mode Failure):

由于在垃圾回收阶段用户线程还在并发运行,那就还需要预留足够的内存空间提供给用户线程使用,因此CMS不能像其他回收器那样等到老年代几乎完全被填满了再进行回收,必须预留一部分空间供并发回收时的程序运行使用。默认情况下,当老年代使用了 92% 的空间后就会触发 CMS 垃圾回收,这个值可以通过 -XX : CMSInitiatingOccupancyFraction 参数来设置。

这里会有一个风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:Stop The World,临时启用 Serial Old 来重新进行老年代的垃圾回收,这样一来停顿时间就很长了。

4.内存碎片问题:

CMS是一款基于“标记-清除”算法实现的回收器,这意味着回收结束时会有内存碎片产生。内存碎片过多时,将会给大对象分配带来麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次 Full GC 的情况。

为了解决这个问题,CMS收集器提供了一个 -XX : +UseCMSCompactAtFullCollection 开关参数(默认开启),用于在 Full GC 时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,是无法并发的,这样停顿时间就会变长。还有另外一个参数 -XX : CMSFullGCsBeforeCompaction,这个参数的作用是要求CMS在执行过若干次不整理空间的 Full GC 之后,下一次进入 Full GC 前会先进行碎片整理(默认值为0,表示每次进入 Full GC 时都进行碎片整理)。

G1

G1(Garbage First)回收器采用面向局部收集的设计思路和基于Region的内存布局形式,是一款主要面向服务端应用的垃圾回收器。G1设计初衷就是替换 CMS,成为一种全功能收集器。G1 在JDK9 之后成为服务端模式下的默认垃圾回收器,取代了 Parallel Scavenge 加 Parallel Old 的默认组合,而 CMS 被声明为不推荐使用的垃圾回收器。G1从整体来看是基于 标记-整理 算法实现的回收器,但从局部(两个Region之间)上看又是基于 标记-复制 算法实现的。

G1 回收过程

  1. 初始标记(会STW):仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
  2. 并发标记:从 GC Roots 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理在并发时有引用变动的对象。
  3. 最终标记(会STW):对用户线程做短暂的暂停,处理并发阶段结束后仍有引用变动的对象。
  4. 清理阶段(会STW):更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,必须暂停用户线程,由多条回收器线程并行完成的。

JVM中一次完整的GC

先描述一下Java堆内存划分。

在 Java 中,堆被划分成两个不同的区域:新生代 ( Young )、老年代 ( Old ),新生代默认占总空间的 1/3,老年代默认占 2/3。 新生代有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1。

新生代的垃圾回收(又称Minor GC)后只有少量对象存活,所以选用复制算法,只需要少量的复制成本就可以完成回收。

老年代的垃圾回收(又称Major GC)通常使用“标记-清理”或“标记-整理”算法。

它们之间转化流程:

  • 对象优先在Eden分配。当 eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。

    • 在 Eden 区执行了第一次 GC 之后,存活的对象会被移动到其中一个 Survivor 分区;
    • Eden 区再次 GC,这时会采用复制算法,将 Eden 和 from 区一起清理,存活的对象会被复制到 to 区;
    • 移动一次,对象年龄加 1,对象年龄大于一定阀值会直接移动到老年代。GC年龄的阀值可以通过参数 -XX:MaxTenuringThreshold 设置,默认为 15;
    • 动态对象年龄判定:Survivor 区相同年龄所有对象大小的总和 > (Survivor 区内存大小 * 这个目标使用率)时,大于或等于该年龄的对象直接进入老年代。其中这个使用率通过 -XX:TargetSurvivorRatio 指定,默认为 50%;
    • Survivor 区内存不足会发生担保分配,超过指定大小的对象可以直接进入老年代。
  • 大对象直接进入老年代,大对象就是需要大量连续内存空间的对象(比如:字符串、数组),为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。

  • 老年代满了而无法容纳更多的对象,Minor GC 之后通常就会进行Full GC,Full GC 清理整个内存堆 – 包括年轻代和老年代