JVM 垃圾回收(GC)

JVM 垃圾回收(GC)

学习 JVM 发现挺有意思的,感觉都是知识干货,但是容易忘,为了准备秋招,本菜鸡只好把内容总结一遍,供各位看官点评点评,本文只涉及垃圾回收部分。

少侠莫慌,先上一张图压压惊
先上一张图压压惊

why – 为什么要了解垃圾回收

  • 排查各种内存溢出、内存泄漏的问题
  • 突破垃圾回收成为系统达到更高并发量的瓶颈

what1 – 哪些内存区域需要回收

  • 不需要回收的区域:程序计数器、虚拟机栈、本地方法栈
    这 3 个区域是线程私有,每个栈帧分配内存基本上是在类结构确定下来时就已知,内存分配和回收具备确定性,并且方法或线程结束时,内存自然也就回收了,不需要考虑回收问题
  • 需要回收的区域:堆、方法区
    Java 堆和方法区的内存分配是动态的,只有在程序运行期间才知道会创建哪些对象,需要关注的是这两部分内存回收

what2 – 哪些对象需要回收(对象存活判定算法)

引用计数算法

  • 原理:给对象添加一个引用计数器,每当有一个地方引用它时,计数器就加 1;当引用失效时,计数器值就减 1;任何时刻计数器为 0 的对象就是不可能再被使用的
  • 优点:实现简单、判定效率高
  • 缺点:存在对象之间循环引用的问题

可达性分析算法(GC Roots)

  • 原理:通过一系列的称为「GC Roots」的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的
  • GC Roots 对象种类
    • 虚拟机栈(栈帧中的本地变量表)中引用的对象
    • 方法区中类静态属性引用的对象
    • 方法区中常量引用的对象
    • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象

when – 什么时候回收

Minor GC 的触发条件

  • Eden 区域没有足够的空间时,发起一次 Minor GC

Full GC 的触发条件

  • 调用 System.gc() 时,系统建议执行 Full GC,但是不必然执行
  • 老年代空间不足时
  • 方法区空间不足时
  • 历次通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存时
  • 由 Eden 区、From Survior 区向 To Survior 区复制时,对象大小大于 To Survior 区可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小时

Minor GC 和 Full GC 的区别

  • 新生代GC(Minor GC)
    指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快

  • 老年代GC(Major GC / Full GC)
    指发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次的 Minor GC。 Major GC的速度一般会比 Minor GC 慢 10 倍以上。

how – 如何回收(垃圾收集算法)

标记 – 清除算法
– 该算法分为「标记」和「清除」两个阶段,首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
– 特点
– 效率不高:标记和清除两个过程的效率都不高
– 空间问题:标记清楚之后会产生大量不连续的内存碎片,空间碎片太多会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发一次垃圾收集动作。

复制算法(新生代采用的算法)
– 将可用内存按容量划分为大小相等的两块,每次使用其中的一块,当这一块的内存用完了,就将还存活的对象复制到另一块上面,然后把已使用过的内存空间一次清理掉
– 特点
– 优点:每次对整个半区进行回收,内存分配时不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
– 缺点:内存缩小为了原来的一半

标记 – 整理算法
– 该算法分为「标记」和「整理」两个阶段,其中「标记」阶段与「标记-清除」算法中的标记相同;在整理阶段不是直接对可回收对象进行清理,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存
– 特点
– 优点:避免了空间碎片,空间利用率提高
– 缺点:效率不高,标记和清除过程的效率低下

分代收集算法
– 将 Java 堆分为新生代和老年代,根据各个年代的特点采用适当的收集算法。
– 在新生代中,每次垃圾收集时都发现只有少量存活,选择使用复制算法,只需要付出少量存活对象的复制成本就可以完成收集
– 在老年代中,因为对象存活率高、没有额外空间进行分配担保,使用「标记-清理」或者「标记-整理」算法进行回收

垃圾收集器

衡量指标
– JVM 吞吐量
所谓吞吐量就是 CPU 用于运行代码的时间与 CPU 总消耗时间的比值,即吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)
– 停顿时间
一段时间内 JVM 运行代码的线程让渡给 GC线程执行垃圾回收而暂停用户代码执行的时长,在这段时间内没有应用程序是活动的
– 两者的关系
– 理想的 JVM 垃圾收集器是“吞吐量越高越好,停顿时间越短越好” ;
– 无法同时兼顾高吞吐量和低停顿时间。在选择 JVM 垃圾收集器时,我们必须确定我们切实可行的目标:一个 GC 算法只可能专注于最大吞吐量或最小停顿时间,或者尝试找到一个权衡两者这种的方案;
– 停顿时间越短就越适合与用户交互的程序,良好的响应速度能提升用户体验;而高吞吐量则可以高效率利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务;

Serial 收集器
– 特点
– 单线程收集器,只会使用一个 CPU 或一条收集线程去完成垃圾收集工作;
– 垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(又称为「Stop The World」);
– 优点是简单而高效(与其他收集器的单线层相比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然就可以获得最高的单线程收集效率;

  • 应用场景及参数设置
    • 场景:该收集器是 HotSpot 虚拟机运行在 Client 模式下的默认的新生代收集器适合运行在单 CPU 环境
    • 算法:堆内存年轻代采用“复制算法”;堆内存老年代采用“标记-整理算法”
    • 配置:+xx:UseSerialGC;年轻代采用Serial,老年代采用 Serial Old

ParNew 收集器
– 特点
– ParNew 收集器就是 Serial 收集器的多线程版本;
– 只能用于新生代;
– 多线程收集,并行;
– 在多 CPU 环境下,随着 CPU 的数量增加,它对于 GC; 时系统资源的有效利用是有益的。它默认开启的收集线程数与 CPU 的数量相同;
– ParNew 收集器在单 CPU 的环境中绝对不会有比 Serial 收集器有更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个 CPU 的环境中都不能百分之百地保证可以超越;

  • 应用场景及参数设置
    • 算法:堆内存年轻代采用“复制算法”
    • 配置:+xx:UseParNewGC;年轻代采用ParNew,老年代采用 Serial Old
      -XX:ParallerGCThreads;多 CPU 情况下面开启多少个线程来回收内存

Parallel Scavenge 收集器
– 特点
– 多线程收集器
– 只适用于新生代
– 自适应调节策略
– Parallel Scavenge收集器的目标是达到一个可控制的吞吐量
– Parallel Scavenge 收集器无法与 CMS 收集器配合使用

  • 应用场景及参数设置
    • 新生代:复制算法。设置参数:-XX:+UseParallelGC;
    • 老年代:使用多线程和“标记-整理”算法。设置参数:-XX:+UseParallelOldGC;
    • -XX:ParallelGCThreads=,并行 GC 线程数;
    • -XX:MaxGCpauseMillis,设置最大垃圾收集停顿时间;
    • -XX:GCTimeRatio,设置吞吐量大小;
    • -XX:+UseAdaptiveSizePolicy,这是一个动态调整各个代区的内存大小的开关参数,打开参数后,就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为 GC 自适应的调节策略(GC Ergonomics)

CMS 收集器
– 运作过程
– 初始标记
仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要“Stop The World”
– 并发标记
进行 GC Roots 追溯所有对象的过程,在整个过程中耗时最长
– 重新标记
为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。此阶段也需要“Stop The World”
– 并发清除
– 特点
– Concurrent Mark Sweep,基于“标记-清除”算法实现
– 各阶段耗时:并发标记/并发清除 > 重新标记 > 初始标记
– 对 CPU 资源非常敏感
– 标记-清除算法导致的空间碎片
– 并发收集、低停顿,因此 CMS 收集器也被称为并发低停顿收集器
– 无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次 Full GC 的产生
– 由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作;所以,从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。

  • 应用场景及参数设置
    • 当你的应用程序需要有较短的应用程序暂停,而可以接受垃圾收集器与应用程序共享应用程序时,你可以选择 CMS 垃圾收集器
    • -XX:+UseConcMarkSweepGC,使用 CMS 收集器
    • -XX:+UseCMSCompactAtFullCollection,Full GC 后,进行一次碎片整理,整理过程是独占的,会引起停顿时间变长
    • -XX:+CMSFullGCsBeforeCompaction,设置进行几次 Full GC 后,进行一次碎片整理
    • -XX:ParallelCMSThreads,设定 CMS 的线程数量(一般情况约等于可用 CPU 数量)

G1 收集器
– 运作过程
– 初始标记
仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要“Stop The World”
– 并发标记
进行 GC Roots 追溯所有对象的过程,可与用户程序并发执行
– 最终标记
修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录
– 筛选回收
对各个Region的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来指定回收计划
– 特点
– 面向服务端应用的垃圾收集器
– 并行与并发
– 分代收集
– 空间整合:整体上看来是基于“标记-整理”算法实现的,从局部(两个Region)上看来是基于“复制”算法实现的
– 可预测的停顿:G1收集器可以非常精确地控制停顿,既能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java的垃圾收集器的特征
– G1将整个Java堆(包括新生代、老年代)划分为多个大小相等的内存块(Region),每个 Region 是逻辑连续的一段内存,在后台维护一个优先列表,每次根据允许的收集时间,优先回收垃圾最多的区域

  • 应用场景及参数设置
    • -XX:MaxGCPauseMillis = 50 设置最大允许 GC 时间

内容补充

分代收集算法的原因
– 为什么JVM堆内存新生代选用“复制算法”?
– 在新生代中,由于大量的对象都是“朝生夕死”,也就是一次垃圾收集后只有少量对象存活,因此HotSpot JVM将堆内存划分成三块:Eden、Survior1、Survior2,内存大小分别是8:1:1。
– 分配内存时,只使用 Eden 和一块 Survior。例如,当发现 Eden+Survior1 的内存即将满时,JVM会发起一次MinorGC,清除掉废弃的对象,并将所有存活下来的对象复制到另一块Survior2 中。那么,接下来就用 Eden+Survior2 进行内存分配。通过这种方式,只需要浪费 10% 的内存空间即可实现带有压缩功能的垃圾收集方法,避免了内存碎片的问题。
– 但是,当一个对象要申请内存空间时,发现 Eden+Survior 中剩下的空间无法放置该对象,此时需要进行Minor GC,如果 Minor GC 过后空闲出来的内存空间仍然无法放置该对象,那么此时就需要将可用对象转移到老年代中,然后再将新对象存入 Eden 区,这种方式叫做“分配担保”。
– 为什么JVM堆内存老年代选用“标记-整理算法”?
– 老年代中的对象一般寿命比较长,因此每次GC时会有大量对象存活,因此如果选用“复制”算法,每次需要复制大量存活的对象,会导致效率很低。而且,在新生代中使用“复制”算法,当Eden+Survior中都装不下某个对象时,可以使用老年代的内存进行“分配担保”,而如果在老年代使用该算法,那么在老年代中如果出现装不下某个对象时,没有其他区域给他作分配担保。因此,老年代中一般使用“标记-整理”算法。

对象自我拯救
– 第一次标记:对象进行可达性分析后发现没有与 GC Roots 相连接的引用链,将进行第一次标记并进行一次筛选,判断对象是否覆盖了 finalize() 方法
– 若已覆盖该方法,并且该对象的 finalize() 方法还没有被执行过,那么就将改对象扔到 F-Queue 队列中
– 若没有覆盖 finalize() 方法或该对象已经执行过该方法,则进入「即将回收」的集合
– 第二次标记:虚拟机自动建立的、低优先级的 Finalizer 线程去执行 F-Queue 队列,实际上是去执行队列中对象的 finalize() 方法,GC会对 F-Queue 队列中的对象进行第二次小规模的标记,如果该对象重新与引用链上的任何一个对象建立关联 (比如把自己 ( this关键字 ) 赋值给某个类变量或对象的成员变量),第二次标记会将它移出「即将回收」的集合

引用的分类
– 强引用(Strong Reference)
我们平时所使用的引用就是强引用。 A a = new A(); 也就是通过关键字new创建的对象所关联的引用就是强引用。 只要强引用存在,该对象永远也不会被回收。
– 软引用(Soft Reference)
只有当堆即将发生OOM异常时,JVM才会回收软引用所指向的对象。 软引用通过SoftReference类实现。 软引用的生命周期比强引用短一些。
– 弱引用(Weak Reference)
只要垃圾收集器运行,软引用所指向的对象就会被回收。 弱引用通过WeakReference类实现。 弱引用的生命周期比软引用短。
– 虚引用(Phantom Reference)
虚引用也叫幽灵引用,它和没有引用没有区别,无法通过虚引用取得一个对象实例。 一个对象关联虚引用唯一的作用就是在该对象被垃圾收集器回收之前会受到一条系统通知。 虚引用通过PhantomReference类来实现。

本文为笔者整理的读书笔记,如有错误的地方麻烦指出,欢迎各位大佬指导。

                     笔者自己的公众号,待秋招之后开始写文章,欢迎关注。

笔者自己的公众号

参考来源:《深入理解 Java 虚拟机》、公众号:Java 大后端

i

发表评论

电子邮件地址不会被公开。 必填项已用*标注