努力成为面试高手06:JVM 垃圾回收

目录

内存分配与回收原则

对象优先在 Eden 区分配

大对象直接进入老年代

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

主要进行 gc 的区域

空间分配担保

死亡对象判断方法

引用计数法

引用类型总结

强引用(StrongReference)

软引用(SoftReference)

弱引用(WeakReference)

虚引用(PhantomReference)

判断废弃常量

判断无用类

垃圾收集算法

标记-清除算法

复制算法

标记-整理算法

分代收集算法

垃圾收集器

Serial(串行)收集器

ParNew 收集器

Parallel Scavenge 收集器

Serial Old 收集器

Parallel Old 收集器

CMS 收集器

G1 收集器

ZGC 收集器

内存分配与回收原则

对象优先在 Eden 区分配

大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。运行添加的参数:-XX:+PrintGCDetails

观察运行结果即可。

大对象直接进入老年代

大对象是指需要大量连续内存的对象(如长字符串、大数组)。JVM 为了提升效率,会让某些大对象直接进入老年代,而不是先放在新生代。这是一种优化策略,目的是减少新生代发生频繁垃圾回收带来的开销。不同垃圾回收器对大对象的处理方式不同:

  • G1 回收器会根据堆区域大小和设定阈值决定是否直接分配到老年代。

  • Parallel Scavenge 回收器没有固定阈值,由 JVM 根据堆内存使用情况动态决定。

比如在下面的这段代码运行结果中:

JVM 首先尝试在年轻代分配,但是 Eden 区空间不足,年轻代总容量只有 4MB,远小于分配的对象大小;由于对象过大,JVM 采用了直接分配到老年代的策略;因为对象直接进入了老年代,避免了 Eden 区空间不足时的 Minor GC。这正好验证了当 Eden 区没有足够空间时,JVM 会采取相应措施(在此案例中是通过直接分配到老年代来避免 Minor GC)。

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

JVM 通过一个“年龄计数器”来追踪每个对象的存活时间。对象每在新生代经历一次垃圾回收(GC)并且存活下来,年龄就增加1岁。当它的年龄达到一定阈值(默认 15 岁)时,就会被晋升到老年代。

基本规则是:大部分对象首先在 Eden 区创建。经历一次 Minor GC 并存活后,会被移到 Survivor 区,此时年龄设为1。之后每熬过一次 Minor GC,年龄就加 1 。当年龄超过 -XX:MaxTenuringThreshold 参数设定的值(默认15)时,进入老年代。

JVM 并不会总是严格遵循最大年龄阈值。为了更高效地利用内存,它会动态计算。具体做法是:从年龄 1 开始累加 Survivor 区中同龄对象的总大小,当某个年龄的累计大小超过了 Survivor 区容量的 50%(该比例可通过 -XX:TargetSurvivorRatio 调整)时,就会取【这个年龄】和【MaxTenuringThreshold】中较小的那个值,作为本次 GC 后新的晋升阈值。这使得 JVM 能在内存使用率高时,提前晋升一些对象,避免 Survivor 区被撑满。

主要进行 gc 的区域

JVM 的垃圾回收(GC)主要分为两大类:部分收集(只清理一部分内存区域)和整堆收集(清理整个堆内存)。我们常说的 Minor GC / Young GC 就是部分收集,而 Full GC / Major GC 就是整堆收集。不同的 GC 行为由不同的条件触发。

部分收集 (Partial GC):只清理一部分内存。包括:

  • 新生代收集(Young GC): 只清理新生代。

  • 老年代收集(Old GC): 只清理老年代(较特殊,如 CMS 回收器)。

  • 混合收集(Mixed GC): 清理整个新生代和部分老年代( G1 回收器特有)。

整堆收集 (Full GC)是清理整个堆内存(包括新生代、老年代等)。他们的触发条件也有所不同:

  • Young GC:最普遍,当新生代的 Eden 区被填满时就会触发。

  • Full GC:情况更复杂,通常在老年代空间不足、手动调用 System.gc() 或方法区(Perm Gen/MetaSpace)满时触发。

空间分配担保

在触发 Young GC 之前,JVM 会先检查老年代剩余的连续空间是否足够存放新生代所有存活的对象。如果不够,那么这次 Young GC 就可能“升级”为一次 Full GC ,先整体清理出足够空间,这是一种安全措施。不过,像 Parallel Scavenge 这种回收器会有优化,它在 Full GC 前会先尝试进行一次 Young GC 来减少工作量。

这个机制可以理解为一次“风险预估”。在 Minor GC 前,JVM 会检查老年代的剩余空间:

  • 理想情况:如果老年代剩余空间 > 年轻代所有对象的总和,那么放心进行 Minor GC。

  • 风险情况:如果老年代剩余空间不足,但只要大于之前每次 Minor GC 后晋升到老年代对象大小的平均值,JVM会“冒险”尝试一次 Minor GC(因为实际情况通常不会所有对象都存活)。

  • 保守情况:如果连平均大小都比不过,说明晋升风险很高,那么就会直接触发一次 Full GC 来彻底清理空间。

在 JDK 6 Update 24 之后,规则简化了,直接使用“老年代空间 > 年轻代总对象 或 大于历史晋升平均值”作为判断依据,不再需要手动设置风险参数。

死亡对象判断方法

堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。

引用计数法

引用计数法是一种内存管理方法,通过给每个对象维护一个计数器来记录有多少地方引用它。当计数器变为 0 时,就表示这个对象不再被使用,可以被回收。但这种方法最大的问题是无法识别循环引用,导致无法回收已经不再使用的对象。引用计数法规则很简单:

  • 被引用时,计数器 +1;

  • 引用失效时,计数器 -1;

  • 计数器为 0 时,对象可被回收。

但它有个致命缺陷:循环引用问题;就下面这个代码里的 objA 和 objB 互相引用,即使它们已经没用了,计数器也不为 0,垃圾回收器就无法回收它们,造成内存泄漏。

public class ReferenceCountingGc {
    Object instance = null;
    public static void main(String[] args) {
        ReferenceCountingGc objA = new ReferenceCountingGc();
        ReferenceCountingGc objB = new ReferenceCountingGc();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
    }
}

可达性分析算法是Java虚拟机判断对象是否可被回收的核心算法。它的思路是:从一些被称为 “GC Roots” 的根对象出发,向下搜索,如果某个对象和 GC Roots 之间没有任何引用链相连,就说明这个对象已经“到死不活”,可以被回收了。可达性分析算法的过程是:

  • 把一些特定的对象(如栈帧中的局部变量、静态变量等)当作起点,这些就是 GC Roots。

  • 从 GC Roots 开始,像走关系网一样,能找到的对象就是“可达的”,是存活对象;找不到的就是“不可达的”,需要被回收。

  • 如下图,Object 6-10 虽然自己抱团,但和 GC Roots 这个大圈子断了联系,所以会被回收。

不可达的对象不会被立即回收,虚拟机会对它们进行两次标记:

  • 第一次标记:筛选出那些有必要执行 finalize() 方法的对象。

  • 第二次标记:被选中的对象会被放入一个队列,如果它在执行 finalize() 方法时成功“自救”(比如把自己重新赋值给某个静态变量),就能活下来;否则就被真正回收。

注意:finalize() 方法非常不推荐使用,效果不可靠且影响性能,在新版JDK中已被标记为“弃用”,了解即可,千万别在代码里用它。

引用类型总结

Java中的引用就像你给一个对象贴的标签,标签的“粘性”决定了这个对象什么时候会被垃圾回收器扔掉。JDK1.2 之后,引用分成了四种:强引用(StrongReference)、软引用(SoftReference)、弱引用(WeakReference)和虚引用(PhantomReference)。

强引用(StrongReference)

强引用实际上就是程序代码中普遍存在的引用赋值,这是使用最普遍的引用,其代码如下:

String strongReference = new String("abc");

如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。

软引用(SoftReference)

如果一个对象只具有软引用,那就类似于可有可无的生活用品。软引用代码如下:

// 软引用
String str = new String("abc");
SoftReference<String> softReference = new SoftReference<String>(str);

如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。

软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JVM 就会把这个软引用加入到与之关联的引用队列中。

弱引用(WeakReference)

如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用代码如下:

String str = new String("abc");
WeakReference<String> weakReference = new WeakReference<>(str);
str = null; //str变成软引用,可以被收集

弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。

弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。

虚引用(PhantomReference)

"虚引用"顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。虚引用代码如下:

String str = new String("abc");
ReferenceQueue queue = new ReferenceQueue();
// 创建虚引用,要求必须与一个引用队列关联
PhantomReference pr = new PhantomReference(str, queue);

虚引用主要用来跟踪对象被垃圾回收的活动,虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。

当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。

特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生

判断废弃常量

判断一个常量是否是“废弃常量”,主要看它是否还有被引用。如果常量池中的某个常量(比如字符串"abc")已经没有任何对象引用它,那么它就算是废弃常量,在垃圾回收时可能会被清理掉。

  • JDK1.7前:运行时常量池在方法区(永久代)。

  • JDK1.7:字符串常量池移到堆中。

  • JDK1.8:方法区实现改为元空间,字符串常量池仍在堆中。

判断无用类

判断一个类是不是“无用的类”,需要同时满足三个条件:这个类所有的实例都被回收、加载它的类加载器也被回收,并且它的 Class 对象没有被任何地方引用。即使满足条件,虚拟机也只是“可以”回收它,而不是一定会回收。

垃圾收集算法

标记-清除算法

标记-清除算法是最基础的垃圾回收算法,分为“标记”和“清除”两阶段:先标记出所有存活对象,然后一次性清理掉未被标记的对象。这个算法主要问题是效率不高,且清理后会产生内存碎片。

  • 执行过程:

  1. 标记阶段:遍历所有对象,将存活的对象标记出来;

  2. 清除阶段:回收所有未被标记的对象。

  • 主要缺点:

  1. 效率较低,标记和清除两个步骤都比较耗时;

  2. 会产生大量不连续的内存碎片,影响后续内存使用。

复制算法

为了解决标记-清除算法的效率和内存碎片问题,复制(Copying)收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

虽然改进了标记-清除算法,但依然存在下面这些问题:

  • 可用内存变小:可用内存缩小为原来的一半。

  • 不适合老年代:如果存活对象数量比较大,复制性能会变得很差。

标记-整理算法

标记-整理(Mark-and-Compact)算法是根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。

分代收集算法

分代收集算法根据对象存活时间长短,将 Java 堆划分为新生代和老年代。新生代存活对象少,适合用复制算法快速回收;老年代存活率高,需采用标记-清除或标记-整理算法。这样分代能针对不同特点选择最适合的回收策略,提升垃圾收集效率。

垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。可以通过 java -XX:+PrintCommandLineFlags -version 命令查看JDK 默认垃圾收集器:

  • JDK 8: Parallel Scavenge(新生代)+ Parallel Old(老年代)

  • JDK 9 ~ JDK22: G1

Serial(串行)收集器

Serial(串行)收集器是一种单线程垃圾收集器,它在进行垃圾回收时必须暂停所有用户线程(称为"Stop The World")。虽然会造成停顿,但由于没有多线程交互开销,它在单线程环境下简单而高效,特别适合 Client 模式的客户端应用。

  • 工作方式:单线程垃圾回收,回收时暂停所有其他线程

  • 算法使用:

    • 新生代:标记-复制算法

    • 老年代:标记-整理算法

  • 优缺点:

    • 优点:实现简单,单线程效率高

    • 缺点:回收时会产生用户可感知的停顿

ParNew 收集器

ParNew 收集器是 Serial 收集器的多线程版本,它使用多个线程并行进行垃圾回收,但在回收时仍然需要暂停所有用户线程(Stop The World)。它是 Server 模式下虚拟机的首选新生代收集器,特别适合与 CMS 收集器配合工作。

  • 基本特点:Serial 收集器的多线程版本,核心算法和行为与 Serial 相同

  • 工作方式:

    • 新生代:多线程并行执行标记-复制算法

    • 老年代:配合 Serial Old 使用标记-整理算法

Parallel Scavenge 收集器

Parallel Scavenge 收集器是一款注重吞吐量优化的多线程垃圾收集器,它使用标记-复制算法,目标是在单位时间内尽可能提高 CPU 用于运行用户代码的效率。与其他关注降低停顿时间的收集器不同,它更看重系统的整体执行效率。且是 JDK1.8 默认收集器。

  • 设计目标:最大化系统吞吐量(用户代码运行时间/总CPU时间)

  • 工作特点:

    • 多线程并行垃圾收集

    • 新生代使用标记-复制算法,老年代使用标记-整理算法

  • 独特优势:

    • 提供丰富的调优参数来平衡停顿时间和吞吐量

    • 支持自适应调节策略,可自动优化GC性能

  • 适用场景:适合后台运算、批处理等对吞吐量要求高的任务

Serial Old 收集器

Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。

Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是以最短回收停顿时间为目标的垃圾收集器,它首次实现了垃圾收集线程与用户线程基本同时工作的并发收集。不过需要注意,CMS 已在 Java 9 被标记为过时,Java 14 中被移除。

  • 工作流程(四个阶段):

    • 初始标记:短暂停顿,标记根对象

    • 并发标记:GC 线程与用户线程同时工作

    • 重新标记:修正并发标记期间的变化

    • 并发清除:清理垃圾,与用户线程并发执行

  • 优缺点:

    • 优点:低停顿,适合用户体验敏感的应用

    • 缺点:CPU资源敏感、会产生内存碎片、无法处理"浮动垃圾"

G1 收集器

G1 把堆内存分成多个区域(Region),优先回收“垃圾最多”的区域,从而尽量减少程序停顿时间。它支持多核 CPU 并行处理,能在不停止程序太久的情况下完成垃圾回收。从 JDK 9 开始,G1 成了默认的垃圾回收器。G1 的特点有:

  • 并行并发:能用多个 CPU 同时工作,减少程序卡顿。

  • 分区管理:把堆分成小块(Region),按优先级回收。

  • 可预测停顿:用户可以设定“最多停多久”,G1 会尽量满足。

  • 空间整理:回收后不会留下碎片,内存更整齐。

回收步骤:

  1. 初始标记:短暂停一下,标记根对象。

  2. 并发标记:边运行边标记所有可达对象。

  3. 最终标记:再停一下,处理剩下的引用。

  4. 筛选回收:选“垃圾最多”的区域回收,复制存活对象到新地方。

ZGC 收集器

ZGC 是一款致力于将垃圾回收停顿时间控制在极低水平(毫秒级)的新一代垃圾收集器,其最大特点是停顿时间不随堆内存增大而增加,非常适合大内存应用场景。它在 Java 11 中作为实验功能引入,并已在后续版本中持续优化成熟。

  • 主要特点:

    • 基于标记-复制算法,但做了重大改进

    • 支持超大堆内存(最大 16TB)

    • 停顿时间极短,但会牺牲部分吞吐量

  • 启用方式:

    • 标准 ZGC: -XX:+UseZGC

    • 分代 ZGC: -XX:+UseZGC -XX:+ZGenerational

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值