JVM | 垃圾回收篇

本篇主要讲解Java虚拟机的内存分配策略、垃圾回收算法以及相关的垃圾回收器。
建议阅读 [JVM | 快速入门] 后再继续此篇!

一、内存分配与回收策略

下图是Java堆内存的各个区域划分图,相信读者在 [JVM | 内存管理篇] 也已经熟悉了。
在这里插入图片描述

(一)内存分配的一般过程

步骤如下:

  1. 当我们新建一个对象实例的时候,Java虚拟机会优先在Eden区给我们分配一块内存。
  2. 如果Eden区内存不够,Java虚拟机则会进行一次Young GC来清理新生代的内存,将依旧存活的对象放入幸存者区(即from或to区,为什么要这两个等后面将垃圾回收算法你就懂了)。
  3. 如果幸存者区装不下我们所有的对象实例或者装到幸存者区的对象实例存活时间已经很久了,则将该部分对象实例放入老年代。至此Young GC完成。
  4. 如果老年代还是装不下我们的对象实例,则会触发Old GC进而出发Full GC进行垃圾回收。
  5. 如果Full GC之后发现还是装不下对象实例,则会抛出OOM异常。

上述流程如下虚线框所示:
在这里插入图片描述
那么虚线框外的流程是怎么回事呢,我们接下来讲解。

(二)大对象直接进入老年代

大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。

那么到底多大才算大对象呢?
其实如果我们不去设置虚拟机参数的话,其实只有当某个对象实例在Eden区装不下的时候它就是大对象。是不是很多读者没有想到这种情况。
当然我们也可以通过-XX:PretenureSizeThreshold参数来指定大对象的阈值。

(三)长期存活的对象将进入老年代

[JVM | 内存管理篇] 我们学过Java对象的内存布局,其中对象头有一部分记录的便是这个对象的分代年龄,如下图:
在这里插入图片描述

为什么要记录这个分代年龄,这个和分代垃圾回收算法息息相关。

年轻代中存活的都是生命周期比较短的对象,分出年轻代就是为了方便我们区清理这一部分对象。每进行一次Young GC,存活下来到幸存者区的对象的分代年龄就会加一,当分代年龄超过某一阈值时,说明该对象的生命周期是比较长的,因此对象就会被移动到老年代中。

这个阈值可以通过-XX:MaxTenuringThreshold进行设置,需要注意的是,分代年龄最高只能为15(可以思考下原因)。

(四)动态对象年龄判定

如果在Survivor区中相同年龄所有对象大小的总和大于Survivor区的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。

(五)空间分配担保

在发生Young GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Young GC可以确保是安全的。
如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。
如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Young GC,尽管这次Young GC是有风险的;
如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。

(六)TLAB相关说明

什么是TLAB?
从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。

TLAB的作用
多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。

TLAB相关说明
在这里插入图片描述

  • 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。
  • 在程序中,开发人员可以通过选项“-XX:UseTLAB”设置是否开启TLAB空间。
  • 默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。
  • 如上图所示,一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。

(七)对象一定分配到堆上吗?

在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(EscapeAnalysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。

什么是逃逸分析?
逃逸分析的基本行为就是分析对象动态作用域当一个对象在方法中被定义后,对象只在方法内部使用 则认为没有发生逃逸。
当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方。

基于逃逸分析的三种优化方式

  1. 栈上分配: 如果确定一个对象不会逃逸出方法之外,那就可以让这个对象在栈上分配内存,让对象所占用的内存空间随栈帧出栈而销毁。在一般应用中,不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集系统的压力将会小很多。
  2. 同步省略: 线程同步是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以消除掉。
  3. 标量替换: 在JIT编译阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,会把这个对象拆解成若干个其中包含的若于个成员变量来代替。这个过程就是标量替换。

二、如何判断对象可以回收

目前主要有两种方式:

(一)引用计数算法

原理: 给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。

缺陷: 无法解决循环引用的问题。假设 a 对象中有一个引用指向 b 对象,而 b 对象中又有一个引用指向 a 对象,除此之外这两个对象再无引用指向。在这种情况下,虽然两个对象都不需要被使用了,但是相互引用对方导致都无法回收。

(二)可达性分析算法

原理: 通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。
![在这里插入图片描述](https://img-blog.csdnimg.cn/20210314161849353.png =500x

(三)Java对于可达性分析算法的使用

1 Java中的 GC Roots 对象

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象。

2 Java中的引用分类

分为强软弱虚四种引用
可参考这篇文章:https://blog.csdn.net/weixin_45702700/article/details/114189817

3 判断是否回收(增加了 finalize 机制)

Java虚拟机真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调过,虚拟机将这两种情况都视为“没有必要执行”。

如果这个对象被判定为有必要执行 finalize() 方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。如果对象要在 finalize() 中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。

注意:任何一个对象的 finalize() 方法都只会被系统自动调用一次,如果对象面临下一次回收,它的 finalize() 方法不会被再次执行。

4 方法区的回收

主要回收两部分内容:废弃常量和无用的类。

  • 废弃常量: 与对象回收类似
  • 无用的类: 类需要同时满足下面3个条件才可以(仅仅是可以)进行回收
    • 该类所有的实例都已经被回收
    • 加载该类的ClassLoader已经被回收
    • 该类对应的java.lang.Class对象没有在任何地方被引用

相关参数: 是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看类加载和卸载信息,其中-verbose:class和-XX:+TraceClassLoading可以在Product版的虚拟机中使用,-XX:+TraceClassUnLoading参数需要FastDebug版的虚拟机支持。

必要性: 在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

二、垃圾回收算法

(一)标记-清除算法

原理: 首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

缺陷:

  • 效率问题:标记和清除两个过程的效率都不高
  • 空间问题:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

应用: CMS垃圾回收器

(二)复制算法

原理: 将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

缺陷: 内存的使用率只有原来的一半

应用: Java堆年轻代划分出两块幸存者区就是为了使用复制算法。Serial垃圾回收器、ParNew垃圾回收器、Parallel Scavenge垃圾回收器。

(三)标记-整理算法

原理: 标记过程仍然与“标记-清除”算法一样,然后让所有存活的对象都向一端移动,再直接清理掉端边界以外的内存

缺陷: 也是效率较低

应用: Serial Old垃圾回收器和Parallel Old垃圾回收器

(四)分代收集算法

原理: 根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。

而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。

三、垃圾回收器

垃圾回收器是Java内存回收的具体实现,主要分为以下几种
在这里插入图片描述

(一)Serial和Serial Old收集器

串行的垃圾回收器,Serial作用于年轻代,使用复制算法;Serial Old作用于老年代,使用标记整理算法。
工作过程如下:
在这里插入图片描述

(二)ParNew和Parallel Old收集器

Serial和Serial Old收集器的多线程版本
工作流程如下:
在这里插入图片描述

(三)Parallel Scavenge收集器

Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点
是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。

吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集
停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。

Parallel Scavenge收集器还有一个参数-XX:+UseAdaptiveSizePolicy。当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为GC自适应的调节策略。

(四)CMS收集器

第一款并发的垃圾收集器,工作流程如下:
在这里插入图片描述
初始标记(Initial-Mark)阶段:
在这个阶段中,程序中所有的工作线程都将会因为“Stop-the-World”机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出 GC Roots能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快。

并发标记(Concurrent-Mark)阶段:
从GC Roots的直接关联对象开始遍历整个引用链,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行

重新标记(Remark)阶段:
由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象(主要是防止误清除对象)的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。

注意这个过程并不会标记并发标记过程中新产生的垃圾,这也是CMS垃圾的缺陷,无法处理浮动垃圾。

并发清除(Concurrent-Sweep)阶段:
此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的

(五)G1收集器

介绍
G1垃圾回收器跟之前所有的垃圾回收器都有所不同,它可以兼顾年轻代和老年代的垃圾回收,这是因为G1垃圾回收器虽然还保留着分代收集的思想,但是他的年轻代和老年代并不是物理隔离的。

如下图所示,G1垃圾回收器的思想是将堆空间分为很多个小区域,每一块小区域既可以是年轻代的(Eden区和Survivor区)也可以老年代(Old区和专门存放大对象Humongous区)。
在这里插入图片描述
垃圾回收流程
由G1的分区可以看出它的垃圾回收不管是在年轻代还是老年代都会使用复制算法,这就大大提升了垃圾回收的效率,整体的回收流程如下图所示。
在这里插入图片描述
G1对于年轻代的垃圾回收跟前面讲过的年轻代垃圾回收器并无太大区别,它也是将Eden区和Survivor区所存活的对象移动到允许的空闲的Survivor区域,当对象达到了分代年龄或者Survivor区装不下就会晋升为老年代。

G1对于老年代的垃圾回收是在整个堆内存使用率达到45%才会触发并发标记过程,然后使用复制算法进行混合回收。

混合回收也不会立即将所有的垃圾都回收掉,而是有计划地对整个Java 堆中进行全区域的垃圾收集。G1 跟踪每一小块区域垃圾回收的价值(回收这块区域所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的区域。

如果混合回收失败也有可能会触发FullGC。

在这里插入图片描述
G1垃圾回收详细流程

年轻代:

  1. 扫描根:根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同RSet记录的外部引用作为扫描存活对象的入口。
  2. 更新RSet 处理dirty card queue( 见备注)中的card,更新RSet。此阶段完成后,RSet可以准确的反映老年代对所在的内存分段中对象的引用。
  3. 处理RSet:识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。
  4. 此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阀值会被会被复制到Old区中空的内存分段。如果Survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间。
  5. 处理Soft, Weak, Phantom, Final, JNI Weak等引用。最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。

老年代:

  1. 初始标记阶段:标记从根节点直接可达的对象。这个阶段是STW的,并且会触发一一次年轻代GC。
  2. 根区域扫描(Root Region Scanning) : G1 GC扫描Survivor区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在young GC之前完成。
  3. 并发标记(Concurrent Marking): 在整个堆中进行并发标记(和应用程序并发执行),此过程可能被young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
  4. 再次标记(Remark):由 于应用程序持续进行,需要修正上- .次的标记结果。是STW的。G1中采用了比CMS更快的初始快照算法: snapshot-at-the-beginning (SATB)。
  5. 独占清理(cleanup,STW): 计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是STW的。
    ➢这个阶段并不会实际上去做垃圾的收集
  6. 并发清理阶段:识别并清理完全空闲的区域。

Full GC:

  • G1的初衷就是要避免Full GC的出现。但是如果上述方式不能正常工作,G1会停止应用程序的执行(Stop-The-World),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。
  • 要避免Full GC的发生,一旦发生需要进行调整。什么时候会发生Full
    GC呢?比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到full gc,这种情况可以通过增大内存解决。
  • 导致G1 Full GC的原因可能有两个:
    • Evacuation的时候没有足够的to-space来存放晋升的对象;
    • 并发处理过程完成之前空间耗尽。

G1相关参数

-XX:+UseG1GC 手动指定使用G1收集器执行内存回收任务
-XX:G1HeapRegionSize 设置每个Region的大小。值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000。
-XX:MaxGCPauseMillis 设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)。默认值是200ms
-XX:ParallelGCThread 设置STW工作线程数的值。最多设置为8
-XX:ConcGCThreads 设置并发标记的线程数。将n设置为并行垃圾回收线程数(ParallelGCThreads)的1/4左右。
-XX:InitiatingHeapOccupancyPercent 设置触发并发GC周期的Java堆占用率阈值。超过此值,就触发GC。默认值是45。

G1优化建议
1 年轻代大小
避免使用-Xmn或-Xx:NewRatio等相关选项显式设置年轻代大小,固定年轻代的大小会覆盖暂停时间目标

2 暂停时间目标不要太过严苛
G1GC的吞吐量目标是90%的应用程序时间和10%的垃圾回收时间评估G1GC的吞吐量时,暂停时间目标不要太严苛。目标太过严苛表示你愿意承受更多的垃圾回收开销,而这些会直接影响到吞吐量。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值