Java/面试题:Java 的垃圾回收(GC)

Java/面试题:Java 的垃圾回收(GC)

在 JVM 中,一个对象被判断为垃圾的标准是:没有其他对象引用。 # 在实现上,判断对象是否为垃圾的算法有两种: - 引用记数法 - 通过判断对象的被引用数量来判断对象是否可以被回收 - 每个对象实例都有一个引用计数器,被引用则 +1,完成引用则 -1 - 任何引用计数为 0 的对象实例都可以被当做垃圾 - 优点:实现简单,执行效率高,程序执行受影响较小 - 缺点:无法检测出循环引用的情况,导致内存泄露 - 可达性分析算法 - 通过判断对象的引用链是否可达来决定对象是否可以被回收,是从离散数学中的图论引入的,通过 GC Roots 为起始点开始搜索作为引用链,如果一个对象没有与任何引用链相连,从图论的角度来说,这个对象就是不可达的,被标记为垃圾 - 可以作为 GC Roots 的对象 - 虚拟机栈中引用的对象(栈帧中而本地变量表,也就是在方法中 new 的对象的引用) - 方法区中常量引用的对象 - 方法区中类静态属性引用的对象 - 本地方法栈中JNI(Native 方法)的引用对象 - 活跃线程的引用对象

谈谈你了解的垃圾回收算法

  • 标记-清除算法(Mark and Sweep)
    • 标记:从 GC Roots 开始进行扫描,对可达的存活对象进行标记
    • 清除:对堆内存从头到尾开始线性遍历,回收不可达对象的内存
    • 由于标记-清除算法不需要移动存活的对象,只需要回收垃圾,因此垃圾回收后会造成大量不连续的内存碎片,可能会造成以后在分配较大的对象时,无法找到足够大的连续内存,这又会引起新一次的垃圾回收,直到 OOM。

  • 年轻代一般使用复制算法(Copy)
    • 分为 Eden 区和两个 Survivor 区
    • 新的对象都是在 Eden 区创建
    • 当 Eden 区的内存不够创建新的对象时,就将 Eden 区存活的对象复制到一个Survivor 区,然后清空 Eden 区。
    • 适用于对象存活率低的场景(年轻代)。内存分配时不用考虑内存碎片等情况,在 Eden 区顺序分配内存,简单高效(年轻代每次回收时都只有 10% 左右的对象存活)。
    • 在应对对象存活率较高的场景时,效率会变低,因为每次 GC 都需要从 Eden 区复制较多的对象到 Survivor 区。而且如果不像浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都存活的极端情况
  • 老年代一般使用标记-整理算法(Compacting)
    • 标记:从 GC Roots 开始进行扫描,对可达的存活对象进行标记
    • 清除:移动所有存活对象,且按照内存地址一次排列,然后将末端地址以后的内存全部回收
    • 标记-整理算法实在标记-清除算法的基础上,将对象进行了移动,成本更高,但是解决了内存碎片的问题
    • 优点是:避免内存的不连续性,解决了内存碎片的问题,不用设置两块内存互换
    • 适用于对象存活率高的场景
现代 JVM 普遍采用分代收集算法(Generational Collector),分类有 Minor GC(年轻代) 和 Major GC(针对老年代。由于 Major GC 一般会伴随着 Minor GC,所以又被称为 FULL GC) - 年轻代:尽可能快速地收集掉那些生命周期短的对象 - 包括 Eden 区和两个 Survivor 区(一个称为 from 区,一个称为 to 区,这两个 Survivor 区不是固定的,会随着垃圾回收的过程而互换)。如果一个对象在年轻代没有足够内存分配,则会直接分配在老年区 - 每一次 Minor GC,会把 Eden 区和 from 区中存活的对象复制到 to 区,然后清空把 Eden 区和 from 区,把 from 区变为 to 区,to 区变为 from 区。年轻代中存活对象的年龄会 +1,当对象年龄达到 15 时,下一次Minor GC 会把年龄为 16 的对象移动到老年区(对象在年轻代存活的最大年龄可以通过 -XX:MaxTenuringThreshold 参数设置) - 对象如何晋升到老年代 - 经历一定 Minor GC 次数依然存活的对象(年龄超过 15) - Survivor 区放不下的对象 - 新生成的大对象(可以通过参数 -XX:PretenuerSizeThreshold 设置大对象的大小阈值) - 常用的调优参数 - -XX:SurvivorRatio:Eden 和 Survivor 的比值,默认为 8:1 - -XX:NewRatio:老年代和年轻代的比例,默认为 2 - -XX:MaxTenuringThreshold:对象从年轻代晋升到老年代经过 GC 次数的最大阈值

Java 堆中的内存分配


- 老年代:存放生命周期较长的对象 - Full GC 一般比 Minor GC 慢,但执行频率低 - Full GC 触发条件 - 老年代空间不足 - 永久代空间不足(针对 JDK7 以及 JDK7 之前的版本,JDK8 之后去掉了永久代,取而代之的时元空间,而元空间使用的是 Native 内存,减少了 Full GC 的频率,减少负担,提升 JVM 效率) - CMS GC 时出现 promotion failed,concurrent mode failure - 统计得到的 Minor GC 晋升到老年代的平均大小大于老年代的剩余空间(HotSpot 每次 Minor GC 都会统计晋升到老年代的对象的平均大小) - 在代码里调用 System.gc() - 使用 RMI 来进行 RPC 管理的 JDK 应用,每小时执行一次 Full GC。

Stop the World - 任何一种 GC 算法中都会发生 - JVM 由于由于要执行 GC 而暂停应用程序的执行 - 多数 GC 优化的目的,都是通过减少 Stop the World 发生的时间来提高程序性能,实现高吞吐,低停顿

SafePoint - 在分析可达性时,所有的程序运行状态都必须被冻结,分析过程中对象引用关系不会发生变化的点 - 产生 SafePoint 的地方:方法调用,循环跳转,异常跳转 - 安全点的数量要适中(太少会增加每次 GC 等待的时间,太多会影响应用程序性能)

JVM 的运行模式 - Server:启动慢,运行稳定后性能高 - Client:启动快,运行稳定后性能不如 Server 高 - 通过java -version命令查看 JVM 运行模式,如下图


# 年轻代垃圾收集器 - Serial 收集器(-XX:+UseSerialGC,复制算法,在 JDK1.3 之前时年轻代垃圾收集的唯一选择) - 单线程收集,进行垃圾收集时,必须暂停所有工作线程 - 简单高效,仍然是 Client 模式下默认的年轻代收集器,因为在客户端为 JVM 分配的内存一般不会很大,GC停顿时间很短,可以接受 - ParNew 收集器(-XX:+UseParNewGC,复制算法) - 是 Serial 收集器的多线程版本 - 单核执行效率不如 Serial,在多核下才有优势 - 可以和 CMS 收集器配合工作 - Parallel Scavenge 收集器(-XX:+UseParallelGC,复制算法) - 比起关注用户线程停顿时间,更关注系统的吞吐量(运行用户代码时间/(运行用户代码时间 + GC 时间)) - 适合后台运算,不需要太多交互的情况,是 Server 模式下默认的年轻代收集器 - 可以使用自适应内存调节策略(-XX:+UseAdaptiveSizePolicy),把内存管理的调优任务交给虚拟机完成 # 老年代垃圾收集器 - Serial Old 收集器(-XX:+UseSerialOldGC,标记-整理算法) - 单线程收集,进行垃圾收集时,必须暂停所有工作线程 - 简单高效,仍然是 Client 模式下默认的老年代收集器 - Parallel Old 收集器(-XX:+UseParallelOldGC,标记-整理算法) - 多线程,吞吐量优先 - CMS 收集器(-XX:+UseConcMarkSweepGC,标记-清除算法。极短的用户线程停顿时间,更适合对象存活时间较长的情况,会出现内存空间碎片) - 初始标记:从 GC Roots 开始标记(Stop the World) - 并发标记:并发追溯标记,程序不会停顿 - 并发预清理:查找并发标记阶段从年轻代晋升到老年代的对象 - 重新标记:暂停虚拟机,重新扫描 CMS 堆中剩余的对象(Stop the World) - 并发清理:清理垃圾对象,程序不会停顿 - 并发重置:重置 CMS 收集器的数据结构 - G1 收集器(-XX:UseG1GC,复制 + 标记-整理算法,年轻代和老年代均可使用) - 并发和并行 - 分代收集 - 空间整合 - 可预测的停顿 - 将整个 Java 堆内存划分为多个大小相等的Region,虽然还保留新生代和老年代的概念,但是新生代和老年代不再是物理隔离的一整块内存,它们都是由 Region 组成的集合。一个 Region 可以是年轻代,也可以是老年代,在运行过程中可能发生变化。

垃圾收集器之间的联系


评论