JVM(二)垃圾回收

2024 年 6 月 20 日 星期四(已编辑)
/
54
这篇文章上次修改于 2024 年 7 月 5 日 星期五,可能部分内容已经不适用,如有疑问可询问作者。

JVM(二)垃圾回收

引用

对象的访问方式有哪些?

Java程序会通过栈上的reference引用操作堆对象,访问方式由虚拟机决定,主流访问方式主要有句柄和直接指针。

  • 句柄:堆会划分出一块内存作为句柄池,reference中存储对象的句柄地址,句柄包含对象实例数据与类型数据的地址信息。
    • 优点是reference中存储的是稳定句柄地址,在GC过程中对象被移动时只会改变句柄的实例数据指针,而reference本身不需要修改。
  • 直接指针:堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference存储对象地址,如果只是访问对象本身就不需要多一次间接访问的开销。
    • 优点是速度更快,节省了一次指针定位的时间开销,HotSpot主要使用直接指针进行对象访问。

Java的引用有哪些类型?

JDK1.2后对引用进行了扩充,按强度分为四种:

  • 强引用:最常见的引用,例如Object obj=new Object()就属于强引用。只要对象有强引用指向且GC Roots可达(循环引用可能GC Roots不可达),在内存回收时即使濒临内存耗尽也不会被回收。
  • 软引用:弱于强引用,描述非必需对象。在系统将发生内存溢出前,会把软引用关联的对象加入回收范围以获得更多内存空间。用来缓存服务器中间计算结果及不需要实时保存的用户行为等。
  • 弱引用:弱于软引用,描述非必需对象。弱引用关联的对象只能生存到下次YGC(minor GC)前,当垃圾收集器开始工作时无论当前内存是否足够都会回收只被弱引用关联的对象。由于YGC具有不确定性,因此弱引用何时被回收也不确定。
  • 虚引用:最弱的引用,定义完成后无法通过该引用获取对象。唯一目的就是为了能在对象被回收时收到一个系统通知。虚引用必须与引用队列联合使用,垃圾回收时如果出现虚引用,就会在回收对象前把这个虚引用加入引用队列。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动

如何判断对象是否是垃圾?

  • 引用计数:在对象中添加一个引用计数器,如果被引用计数器加1,引用失效时计数器减1,如果计数器为0则被标记为垃圾。
    • 原理简单,效率高,但是在Java中很少使用,因为存在对象间循环引用的问题,导致计数器无法清零。
  • 可达性分析:主流语言的内存管理都使用可达性分析判断对象是否存活。基本思路是通过一系列称为GC Roots的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程走过的路径称为引用链,如果某个对象到GC Roots没有任何引用链相连,则会被标记为垃圾。可作为GC Roots的对象包括虚拟机栈和本地方法栈中引用的对象、类静态属性引用的对象、常量引用的对象。

哪些对象可以作为 GC Roots 呢?

类加载

  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器(java.lang.Class,java.lang.String)。

  • 虚拟机栈(栈帧中的局部变量表)中引用的对象
  • 本地方法栈(Native 方法)中引用的对象
  • 线程Thread对象

方法区

  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象

  • 监视器对象:所有被同步锁(synchronized关键字)持有的对象。

垃圾回收

有哪些GC算法?

GC(Garbage Collected)

  1. 标记-清除算法
  • 分为标记和清除阶段,首先从每个GC Roots出发依次标记有引用关系的对象,最后清除没有标记的对象。
    • 执行效率不稳定,如果堆包含大量对象且大部分需要回收,必须进行大量标记清除,导致效率随对象数增长而降低
    • 存在内存空间碎片化问题,会产生大量不连续的内存碎片,导致以后需要分配大对象时容易触发FullGC
  1. 标记-复制算法
  • 为了解决内存碎片问题,将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当使用的这块空间用完了,就将存活对象复制到另一块,再把已使用过的内存空间一次清理掉。主要用于进行新生代。
    • 实现简单、运行高效,解决了内存碎片问题
    • 代价是一次只能用到一半内存,浪费空间
    • 如果存活对象数量比较大,复制性能会变得很
    • HotSpot把新生代划分为一块较大的Eden和两块较小的Survivor,每次分配内存只使用Eden和其中一块Survivor。垃圾收集时将Eden和Survivor中仍然存活的对象一次性复制到另一块Survivor上,然后直接清理掉Eden和已用过的那块Survivor。HotSpot默认Eden和Survivor的大小比例是8:1,即每次新生代中可用空间为整个新生代的90%。
  1. 标记-整理算法
  • 老年代使用标记整理算法,标记过程与标记清除算法一样,但不直接清理可回收对象,而是让所有存活对象都向内存空间一端移动,然后清理掉边界以外的内存。
    • 标记清除与标记整理的差异在于前者是一种非移动式算法而后者是移动式的
    • 由于多了整理这一步,因此效率也不高,适合老年代这种垃圾回收频率不是很高的场景
  1. 分代收集算法

当前虚拟机的垃圾收集都采用分代收集算法,根据对象存活周期的不同将内存分为几块。一般将 Java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

  • 比如在新生代中,每次收集都会有大量对象死去,所以可以选择”标记-复制“算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。
  • 而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。

有哪些垃圾收集器?

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

JDK 默认垃圾收集器(使用 java -XX:+PrintCommandLineFlags -version 命令查看):

  • JDK 8:Parallel Scavenge(新生代)+ Parallel Old(老年代)
  • JDK 9 ~ JDK20: G1
  1. Serial

最基础的收集器,使用标记-复制算法、单线程工作,只用一个处理器或一条线程完成垃圾收集,进行垃圾收集时必须暂停其他所有工作线程。没有线程交互的开销(单线程效率高),Serial 收集器对于运行在客户端模式下的虚拟机来说是个不错的选择.

  1. ParNew

Serial的可并行多线程版本,除了使用多线程进行垃圾收集外其余行为完全一致。

ParNew是虚拟机在服务端模式的默认新生代收集器,一个重要原因是除了Serial外只有它能与CMS(真正意义上的并发收集器)配合。自从JDK9开始,ParNew加CMS不再是官方推荐的解决方案,官方希望它被G1取代。

并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态

并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个 CPU 上

  1. Parallel Scavenge

新生代收集器,基于标记-复制算法,是可并行的多线程收集器,与ParNew类似。

特点是它的关注点与其他收集器不同,Parallel Scavenge的目标是达到一个可控制的吞吐量,吞吐量就是处理器用于运行用户代码的时间与处理器消耗总时间的比值。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)

  1. Serial Old

Serial的老年代版本,单线程工作,使用标记-整理算法。

Serial Old是虚拟机在客户端模式的默认老年代收集器,用于服务端有两种用途:

  • JDK5及之前与Parallel Scavenge搭配。
  • 作为CMS失败预案。
  1. Parallel Old Parallel Scavenge的老年代版本,支持多线程,基于标记-整理算法。JDK6提供,注重吞吐量可考虑Parallel Scavenge加Parallel Old。

  2. CMS(Concurrent Mark Sweep)

    以获取最短回收停顿时间为目标,基于标记-清除算法,过程相对复杂,分为四个步骤:初始标记、并发标记、重新标记、并发清除。

    • 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要Stop The World
    • 并发标记:进行GC Roots Tracing可达性分析的过程,它在整个回收过程中耗时最长,不需要Stop The World,可以与用户线程并发。
    • 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要Stop The World。比初始标记时间长,比并发标记时间短。
    • 并发清除:清除掉判定为死亡的对象,不需要Stop The World,可以与用户线程并发。

    缺点:

    • 对处理器资源敏感,并发阶段虽然不会导致用户线程暂停,但会降低吞吐量(停顿后专心回收垃圾肯定更快)
    • 浮动垃圾"问题:并发清除“阶段,由于gc线程是与用户线程并发的,这个期间用户还会产生新的垃圾,所以一般会预留出一部分内存,不能等到老年代快满的时候才去收集,如果预留的内存不足以存放这部分浮动垃圾的话,就会出现Concurrent Mode Failure。出现这个错误之后,虚拟机将临时启用Serial Old来替代CMS
    • 基于标记清除算法,产生空间碎片
  3. G1(Garbage-First)

    兼顾吞吐量和停顿时间,G1依然还是采用了分代设计,但是其内存结构并不是简单的条带式划分,而是类似棋盘的一个个Region,每一个Region都可以是新生代,老年代,Eden空间,Survivor空间的角色。所以Region成为了垃圾收集的最小单元,每一次回收都会是Region的整数倍大小。

    Region之间是复制算法,但整体上实际可看作是标记-整理算法,有效避免内存碎片。

    开创了收集器面向局部收集的设计思路和基于Region的内存布局,主要面向服务端,最初设计目标是替换CMS。

    G1之前的收集器,垃圾收集目标要么是整个新生代,要么是整个老年代或整个堆。而G1可面向堆任何部分来组成回收集进行回收,衡量标准不再是分代,而是哪块内存中存放的垃圾数量最多,回收受益最大

    跟踪各Region里垃圾的价值,价值即回收所获空间大小以及回收所需时间的经验值,在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间优先处理回收价值最大的Region。这种方式保证了G1在有限时间内获取尽可能高的收集效率。

    G1运作过程:

    • 初始标记:标记GC Roots能直接关联到的对象,让下一阶段用户线程并发运行时能正确地在可用Region中分配新对象。需要STW但耗时很短,在Minor GC时同步完成。
    • 并发标记:从GC Roots开始对堆中对象进行可达性分析,递归扫描整个堆的对象图,耗时长但可与用户线程并发,不需要STW
    • 最终标记:需要短暂STW,处理并发阶段对象引用有变动的记录。
    • 筛选回收:对各Region的回收价值排序,根据用户期望停顿时间制定回收计划。需要STW,由多条收集线程并行完成。(可由用户指定期望停顿时间是G1的一个强大功能,但该值不能设得太低,一般设置为100-300ms)
  • Region里面存在的跨Region引用对象如何解决?
    • 使用记忆集,避免全堆作为GC Roots扫描,G1它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。

内存配与回收策略?

  1. 对象优先在Eden区分配:

大多数情况下对象在新生代Eden区分配,当Eden没有足够空间时将发起一次Minor GC。

  1. 大对象直接进入老年代:
  • 大对象指需要大量连续内存空间的对象,典型是很长的字符串或数量庞大的数组。大对象容易导致内存还有不少空间就提前触发垃圾收集以获得足够的连续空间。

  • HotSpot提供了-XX:PretenureSizeThreshold参数,大于该值的对象直接在老年代分配,避免在Eden和Survivor间来回复制.

  1. 长期存活对象进入老年代:

    虚拟机给每个对象定义了一个对象年龄计数器,存储在对象头。如果经历过第一次Minor GC仍然存活且能被Survivor容纳,该对象就会被移动到Survivor中并将年龄设置为1。对象在Survivor中每熬过一次Minor GC年龄就加1,当增加到一定程度(默认15)就会被晋升到老年代。对象晋升老年代的阈值可通过-XX:MaxTenuringThreshold设置。

  2. 动态对象年龄判定

    为了适应不同内存状况,虚拟机不要求对象年龄达到阈值才能晋升老年代,如果在Survivor中相同年龄所有对象大小的总和大于Survivor的一半(众数大于一半),年龄不小于该年龄的对象就可以直接进入老年代。

  3. 空间分配担保

    上面可以看出,Minor GC有可能会导致一大批对象从新生代进入老年代,那老年代如果放不下怎么办?

  • 每次Minor GC之前都得检查老年代的空间是否能容纳所有新生代对象
    • 如果可以那就安全。
    • 如果不可以,则虚拟机会先查看-X:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);
      • 如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小
        • 如果大于,将尝试进行一次Minor GC,尽管这次MinorGC是有风险的;
        • 如果小于,就进行Full GC
      • 如果不允许,那这时就要改为进行一次Full GC

参考: JavaGuide(Java学习&面试指南) | JavaGuide

使用社交账号登录

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...