判断对象是否可回收
在 Java 虚拟机(JVM)的垃圾回收机制中,判断对象是否可回收是垃圾回收过程的重要环节。常见的判断方法有引用计数算法和可达性分析算法
引用计数算法
- 原理:给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1;当引用失效时,计数器值就减 1;任何时刻计数器为 0 的对象就是不可能再被使用的,即可以被回收。
- 优缺点
-
- 优点:实现简单,判定效率高,在大部分情况下它都是一个不错的算法。
- 缺点:很难解决对象之间相互循环引用的问题。比如上述代码中,
obj1
和obj2
相互引用,即使它们已经没有外部引用指向它们,但它们的引用计数器值都不为 0,根据引用计数算法,它们不会被回收,但实际上它们已经不可能再被访问到,应该被回收。
可达性分析算法
- 原理:可达性分析算法以一系列被称为 “GC Roots” 的根对象作为起始点,从这些节点开始向下搜索,搜索过程所走过的路径被称为 “引用链”。如果某个对象到 GC Roots 之间没有任何引用链相连,也就是从 GC Roots 到该对象不可达,那么就证明此对象不可能再被使用,可以被判定为可回收对象。
- 可作为 GC Roots 的对象
-
- 虚拟机栈(栈帧中的本地变量表)中引用的对象:比如方法中的局部变量所引用的对象。
- 方法区中类静态属性引用的对象:例如类的静态变量引用的对象。
- 方法区中常量引用的对象:如字符串常量池中的引用。
- 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象。
- 优缺点
-
- 优点:可以有效地解决引用计数算法中循环引用的问题,是目前主流的 Java 虚拟机所采用的判断对象是否可回收的算法。
- 缺点:实现相对复杂,需要进行大量的对象遍历和图的可达性分析,性能开销较大。
引用类型对对象回收的影响
在 Java 中,除了强引用外,还有软引用、弱引用和虚引用,不同的引用类型对对象的回收时机有不同的影响:
- 强引用:是最常见的引用类型,如
Object obj = new Object();
这种引用方式。只要强引用存在,对象就不会被回收。即便系统内存不足,JVM 会抛出 OutOfMemoryError 异常,而不会回收强引用指向的对象。 - 软引用:用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。可以使用
SoftReference
类来实现软引用。 - 弱引用:也是用来描述非必需对象的,但是它的强度比软引用更弱一些。被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。可以使用
WeakReference
类来实现弱引用。弱引用常用于解决内存泄漏问题,特别是在一些需要临时关联对象的场景中。例如,在 Java 的 ThreadLocal 类中,就使用了弱引用来避免内存泄漏。ThreadLocal 对象在使用完后,如果没有及时清理,可能会导致内存泄漏。通过使用弱引用,当 ThreadLocal 对象没有其他强引用时,垃圾回收器可以及时回收它。 - 虚引用:也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。可以使用
PhantomReference
类来实现虚引用。虚引用主要用于在对象被回收时进行一些额外的清理工作,例如管理直接内存。在 Java 中,ByteBuffer 类使用直接内存,当 ByteBuffer 对象被回收时,可以通过虚引用来释放对应的直接内存,避免内存泄漏。
垃圾收集算法
在 Java 虚拟机(JVM)中,垃圾收集算法用于确定如何回收不再使用的对象,释放内存空间。常见的垃圾收集算法有标记 - 清除算法、标记 - 整理算法、复制算法和分代收集算法
标记 - 清除算法(Mark - Sweep)
- 算法原理:该算法分为 “标记” 和 “清除” 两个阶段。首先标记出所有需要回收的对象,标记完成后,统一回收掉所有被标记的对象;也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
- 优缺点
-
- 优点:实现简单,不需要额外的空间开销。
- 缺点:执行效率不稳定,如果堆中包含大量需要被回收的对象,标记和清除过程的效率会比较低;会产生大量不连续的内存碎片,可能导致后续在分配较大对象时,无法找到足够的连续内存而提前触发新的垃圾收集动作。
标记 - 整理算法(Mark - Compact)
- 算法原理:标记过程与 “标记 - 清除” 算法一样,但后续步骤不是直接对可回收对象进行清除,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
- 优缺点
-
- 优点:解决了标记 - 清除算法存在的内存碎片问题,为后续对象分配提供了连续的内存空间。
- 缺点:标记 - 整理算法的效率低于标记 - 清除算法,因为需要移动存活对象,这涉及到大量的对象复制操作,会增加垃圾回收的时间开销。
复制算法(Copying)
- 算法原理:将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
- 优缺点
-
- 优点:实现简单,运行高效,由于只需要移动指针,按顺序分配内存即可,所以分配内存的效率很高;不会产生内存碎片。
- 缺点:将内存缩小为原来的一半,空间利用率较低;如果存活对象较多,复制操作的开销会比较大。
- 实际应用:在新生代中,由于大部分对象的生命周期较短,存活对象较少,所以常用复制算法。实际的 JVM 实现中,并不是将新生代内存严格划分为大小相等的两块,而是将新生代分为一个较大的 Eden 区和两个较小的 Survivor 区,每次使用 Eden 区和其中一个 Survivor 区,当进行垃圾回收时,将 Eden 区和使用的 Survivor 区中存活的对象复制到另一个 Survivor 区中,然后清理 Eden 区和使用过的 Survivor 区。
分代收集算法(Generational Collection)
- 算法原理:根据对象存活周期的不同将内存划分为几块,一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用 “标记 - 清除” 或者 “标记 - 整理” 算法来进行回收。
- 优缺点
-
- 优点:结合了不同垃圾收集算法的优点,根据对象的存活特性选择合适的算法,提高了垃圾回收的效率和性能。
- 缺点:需要对堆内存进行分代管理,增加了垃圾回收系统的复杂性。
垃圾回收器
- Serial 收集器
-
- 特点:单线程的收集器,在进行垃圾回收时,必须暂停其他所有的工作线程,直到它回收结束。
- 适用场景:Client 模式下的虚拟机(如桌面应用程序),因为这类应用所需内存相对较小,使用单线程的 Serial 收集器可以减少内存管理的开销。
- ParNew 收集器
-
- 特点:Serial 收集器的多线程版本,除了使用多线程进行垃圾回收外,其余行为包括控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一样。
- 适用场景:在 Server 模式下,它是很多虚拟机在新生代的首选收集器,因为它可以和 CMS(Concurrent Mark Sweep)收集器配合工作。
- Parallel Scavenge 收集器
-
- 特点:也是一款新生代收集器,使用复制算法的多线程收集器。它的目标是达到一个可控制的吞吐量,吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)。
- 适用场景:适合那些对吞吐量要求较高的应用,比如后台运算而不需要太多交互的任务。
- Serial Old 收集器
-
- 特点:Serial 收集器的老年代版本,同样是单线程收集器,使用 “标记 - 整理” 算法。
- 适用场景:主要用于 Client 模式下的虚拟机。另外,在 Server 模式下,它可以作为 CMS 收集器发生失败时的后备预案。
- Parallel Old 收集器
-
- 特点:Parallel Scavenge 收集器的老年代版本,使用多线程和 “标记 - 整理” 算法。
- 适用场景:与 Parallel Scavenge 收集器配合使用,组成吞吐量优先的垃圾回收组合,适合对吞吐量要求较高的应用。
- CMS(Concurrent Mark Sweep)收集器
-
- 特点:以获取最短回收停顿时间为目标的收集器,基于 “标记 - 清除” 算法实现。整个过程分为初始标记、并发标记、重新标记和并发清除四个步骤,其中初始标记和重新标记需要 “Stop The World”,但停顿时间相对较短。
- 适用场景:适用于那些对响应时间要求较高的应用,比如 Web 应用,因为它可以减少垃圾回收时的停顿时间,提高用户体验。
- G1(Garbage - First)收集器
-
- 特点:面向服务端应用的垃圾收集器,将整个 Java 堆划分为多个大小相等的独立区域(Region),跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。
- 适用场景:适用于大内存、多处理器的机器,能够在满足高吞吐量的同时,实现较为可控的停顿时间。
- ZGC(Z Garbage Collector)
-
- 特点:一款可伸缩的、低延迟的垃圾收集器,支持 TB 级别的堆内存,停顿时间不超过 10ms。它采用了染色指针和读屏障技术,实现了并发的标记、转移和重定位操作。
- 适用场景:适用于对延迟非常敏感的大型应用程序,如大型分布式系统、实时数据处理系统等。
- Shenandoah 收集器
-
- 特点:与 ZGC 类似,目标是实现几乎全部并发的垃圾回收过程,以达到极低的停顿时间。它通过在回收过程中使用转发指针来解决对象移动的问题。
- 适用场景:同样适用于对响应时间要求极高、需要快速响应的应用场景。
垃圾回收器的组合使用
不同的垃圾回收器可以相互组合使用,以满足不同应用的需求。例如,在 JDK 8 及以前,ParNew 收集器可以和 CMS 收集器配合使用,新生代使用 ParNew 收集器,老年代使用 CMS 收集器;Parallel Scavenge 收集器可以和 Parallel Old 收集器配合使用,实现吞吐量优先的垃圾回收。
垃圾回收器的选择策略
选择合适的垃圾回收器需要考虑多个因素,如应用的内存大小、吞吐量要求、响应时间要求等。一般来说,如果应用对吞吐量要求较高,且可以容忍一定的停顿时间,可以选择 Parallel Scavenge + Parallel Old 组合;如果应用对响应时间要求较高,希望停顿时间尽可能短,可以选择 CMS 或 G1、ZGC、Shenandoah 等低延迟的收集器。
面试题
基础概念类
1. 什么是 JVM 内存回收(GC)?
JVM 内存回收(Garbage Collection,GC)是 Java 虚拟机(JVM)提供的一种自动内存管理机制。它的主要作用是自动回收不再使用的对象所占用的内存空间,避免内存泄漏,使得开发者无需手动管理内存的分配和释放,从而提高开发效率和程序的健壮性。
2. 为什么需要 GC?
在早期的编程语言(如 C、C++)中,程序员需要手动管理内存的分配和释放,这容易导致内存泄漏(忘记释放内存)和悬空指针(释放后继续使用)等问题。而 Java 通过 GC 机制,自动识别并回收不再使用的对象,减少了内存管理的复杂性,让开发者可以更专注于业务逻辑的实现。
.3. JVM 的内存结构主要包括:
- 方法区(Method Area) :存储类信息、常量池、静态变量、JIT编译后的代码等。
- 堆(Heap) :存放对象实例,GC主要管理的区域,通常分为新生代(Eden+Survivor)和老年代。
- 虚拟机栈(JVM Stack) :线程私有,每个方法执行都会创建栈帧(Frame),存储局部变量、操作数栈、方法出口等信息。
- 本地方法栈(Native Method Stack) :为Native方法服务,类似于JVM栈。
- 程序计数器(PC Register) :存储当前线程正在执行的字节码指令地址。
内存区域与对象存活判定类
1. JVM 内存区域中哪些区域会发生垃圾回收?
- JVM 内存区域主要包括程序计数器、虚拟机栈、本地方法栈、堆和方法区。
- 其中,程序计数器、虚拟机栈和本地方法栈随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着入栈和出栈操作,每个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,不需要进行垃圾回收。
- 而堆和方法区则是垃圾回收的主要区域,堆中存放着几乎所有的对象实例,方法区中存放着类的元数据等信息,这些区域的内存分配和使用情况较为复杂,需要 GC 来管理。
2. 如何判断一个对象是否可以被回收?
常见的判断对象是否可以被回收的算法有两种:
- 引用计数算法:给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1;当引用失效时,计数器值就减 1;任何时刻计数器为 0 的对象就是不可能再被使用的,可以被回收。但这种算法存在循环引用的问题,即两个对象相互引用,导致它们的引用计数器都不为 0,但实际上它们已经没有其他外部引用,无法被访问,却不能被回收。
- 可达性分析算法:通过一系列称为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的,可以被回收。可以作为 GC Roots 的对象包括:虚拟机栈(栈帧中的本地变量表)中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中 JNI(即 Native 方法)引用的对象。
垃圾回收算法类
1. 请简要介绍常见的垃圾回收算法。
- 标记 - 清除算法(Mark - Sweep) :分为 “标记” 和 “清除” 两个阶段。首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。该算法的缺点是会产生大量不连续的内存碎片,当后续需要分配较大对象时,可能会因无法找到足够的连续内存而触发新的垃圾回收。
- 标记 - 整理算法(Mark - Compact) :标记过程与 “标记 - 清除” 算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。这种算法避免了内存碎片的问题,但移动对象的操作会带来一定的性能开销。
- 复制算法(Copying) :将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。但这种算法的缺点是可用内存缩小为了原来的一半。
- 分代收集算法(Generational Collection) :当前商业虚拟机的垃圾收集都采用 “分代收集” 算法,它根据对象存活周期的不同将内存划分为几块,一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用 “标记 - 清除” 或者 “标记 - 整理” 算法来进行回收。
2. 复制算法中,新生代为什么要分为 Eden 区、Survivor 区?
新生代采用复制算法,但如果将内存平均分成两块,会导致可用内存减少一半,浪费严重。因此,将新生代分为一个较大的 Eden 区和两个较小的 Survivor 区(一般比例为 8:1:1)。每次使用 Eden 区和其中一个 Survivor 区,当发生 Minor GC 时,将 Eden 区和使用的 Survivor 区中存活的对象复制到另一个 Survivor 区,然后清理 Eden 区和使用过的 Survivor 区。这样,只有 10% 的内存被闲置,大大提高了内存利用率。而且经过多次 Minor GC 后仍然存活的对象会被晋升到老年代,进一步优化了内存管理。
1. 新生代和老年代的GC策略有何不同?
新生代GC(Minor GC):
- 采用复制算法(Survivor + Eden)。
- 频率较高,但回收速度快。
- 发生在Eden区满时。
老年代GC(Major GC/Full GC):
- 采用标记-整理算法或标记-清除。
- 回收速度较慢,伴随STW(Stop The World)。
- 触发条件:老年代满、CMS空间不足、System.gc()、Metaspace扩展等。
垃圾收集器类
1. 请介绍几种常见的垃圾收集器。
- Serial 收集器:最基本、发展历史最悠久的收集器,它是一个单线程的收集器,在进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。它采用复制算法,主要用于新生代的垃圾回收,简单高效,适合单 CPU 环境下的小型应用。
- ParNew 收集器:Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为包括算法、Stop - The - World、对象分配规则、回收策略等都与 Serial 收集器完全一样。它是许多运行在 Server 模式下的虚拟机中首选的新生代收集器,因为它可以与 CMS 收集器配合使用。
- Parallel Scavenge 收集器:也是一个新生代收集器,同样采用复制算法和多线程收集方式。它的目标是达到一个可控制的吞吐量,吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)。它适合那些对吞吐量要求较高、对停顿时间要求不高的场景,如后台运算等。
- CMS(Concurrent Mark Sweep)收集器:以获取最短回收停顿时间为目标的收集器,基于 “标记 - 清除” 算法实现。它的运作过程相对复杂,分为初始标记、并发标记、重新标记、并发清除四个阶段。其中初始标记和重新标记阶段需要 Stop - The - World,但这两个阶段的时间相对较短,而并发标记和并发清除阶段可以与用户线程并发执行,因此整体的停顿时间较短,适合对响应时间要求较高的应用,如 Web 应用。
- G1(Garbage First)收集器:一款面向服务端应用的垃圾收集器,它将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region(不需要连续)的集合。G1 收集器能建立可预测的停顿时间模型,它会根据用户设定的允许停顿时间,优先回收价值最大的 Region(垃圾最多的区域),避免了对整个堆进行扫描,提高了垃圾回收的效率。
2. CMS 收集器有哪些缺点?
- 会产生内存碎片:由于采用 “标记 - 清除” 算法,CMS 收集器在回收后会产生大量不连续的内存碎片,可能导致后续大对象无法分配到足够的连续内存,从而提前触发 Full GC。
- 对 CPU 资源敏感:CMS 收集器在并发标记和并发清除阶段虽然可以与用户线程并发执行,但会占用一部分 CPU 资源,可能会导致应用程序的性能下降,尤其是在 CPU 资源紧张的情况下。
- 浮动垃圾问题:在并发标记阶段,由于用户线程还在继续运行,可能会产生新的垃圾对象,这些垃圾对象在本次 GC 中无法被回收,只能留到下一次 GC 处理,这就是浮动垃圾。浮动垃圾可能会导致在 CMS 运行期间预留的内存不足以应对新产生的对象,从而触发一次 Full GC。
性能调优类
1. 如何进行 JVM 内存回收的性能调优?
- 合理设置堆内存大小:根据应用程序的特点和运行环境,合理调整堆内存的初始大小(-Xms)和最大大小(-Xmx),避免频繁的垃圾回收和内存溢出问题。一般建议将 -Xms 和 -Xmx 设置为相同的值,以避免堆内存动态调整带来的性能开销。
- 选择合适的垃圾收集器:根据应用程序的类型和性能需求,选择合适的垃圾收集器。例如,对于对响应时间要求较高的 Web 应用,可以选择 CMS 或 G1 收集器;对于对吞吐量要求较高的后台运算应用,可以选择 Parallel Scavenge 收集器。
- 调整新生代和老年代的比例:通过调整 -XX:NewRatio 参数,可以改变新生代和老年代的内存比例。对于创建对象频繁的应用,可以适当增大新生代的比例,减少对象晋升到老年代的概率,从而减少 Full GC 的次数。
- 监控和分析垃圾回收情况:使用工具(如 VisualVM、jstat、jmap 等)监控 JVM 的内存使用情况和垃圾回收情况,分析垃圾回收的频率、停顿时间等指标,找出性能瓶颈并进行针对性的优化。
2. 什么情况下会触发 Full GC?
- 老年代空间不足:当新创建的对象无法在新生代分配内存,需要晋升到老年代,但老年代没有足够的空间容纳这些对象时,会触发 Full GC 来回收老年代的内存。
- 永久代(元空间)空间不足:在 Java 8 之前,永久代用于存储类的元数据等信息,当永久代空间不足时,会触发 Full GC。在 Java 8 及以后,永久代被元空间取代,当元空间不足时也可能触发 Full GC。
- 显式调用 System.gc () :虽然调用
System.gc()
并不一定会立即触发 Full GC,但它会向 JVM 发出垃圾回收的建议,JVM 可能会在合适的时机进行 Full GC。 - CMS 收集器的并发模式失败:在 CMS 收集器进行并发标记和并发清除阶段,如果老年代的剩余空间不足以应对新产生的对象,会导致并发模式失败,从而触发 Full GC 并使用 Serial Old 收集器进行垃圾回收。
- 堆中分配大对象:当需要分配一个大对象(如数组),且该对象无法在新生代和老年代的剩余空间中分配时,会触发 Full GC。