Java 中的内存泄漏通常发生在哪些场景?

内存泄漏是指在程序运行过程中,已经不再使用的内存却没有被及时释放或回收,导致系统中的可用内存逐渐减少,最终可能导致系统性能下降,甚至引发系统崩溃。内存泄漏通常发生在程序中某些对象持续占用内存空间但又无法被正确释放的情况下。

产生内存泄漏的场景

未关闭的资源

FileInputStream fis = null;
try {
fis = new FileInputStream("example.txt");
// 读取文件内容
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
// 未在finally块中关闭文件流
// 应该在此处调用fis.close()以释放资源
}

未正确释放对象引用

private static List<Object> list = new ArrayList<>();

public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
Object obj = new Object();
list.add(obj); // 每次循环创建新对象并添加到list中
}
// 此处未调用list.clear()或将list置为null,导致list中的对象无法被释放
}

线程池导致的内存泄漏

private static ExecutorService executor = Executors.newFixedThreadPool(10);

public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
executor.execute(() -> {
// 执行任务
});
}
// 程序结束后未调用executor.shutdown(),线程池资源未被释放
}

监听器和回调函数未取消注册

private static List<ActionListener> listeners = new ArrayList<>();

public static void main(String[] args) {
ActionListener listener = e -> {
// 处理事件
};
listeners.add(listener); // 添加监听器

// 程序运行结束后未取消注册监听器,导致listener对象无法被释放
}

循环引用

两个或多个对象之间相互引用,导致彼此无法被回收。

内存泄漏工具无法检测的场景

有些情况下,内存泄漏工具无法检测到内存泄漏,例如:

1)使用匿名对象。

2)使用内部类或匿名类。

避免内存泄漏的建议

1)使用合理的变量作用域。

2)避免使用全局变量。

3)及时清理不使用的对象。

4)使用 try-with-resources 语句来关闭资源。

5)避免循环引用。

6)使用内存泄漏工具来检测和解决内存泄漏问题。

如何在 Java 中进行内存泄漏分析?

先确认是否真的发生了内存泄漏,即观察内存使用情况。

利用 jstat 命令(jstat -gc <pid> <interval in ms> )来观察 gc 概要信息,如果发现 GC 后内存并没有明显的减少且还是持续增加持续触发 gc,那说明内存泄漏的概率很大。

此时可以利用 jmap(jmap -dump:format=b,file=heapdump.hprof <pid>)生成 heap dump,然后将其导入 Eclipse MAT 或者 VisualVM 工具内进行分析,通过大量内存的占用可以找到对应的对象。

通过对象找到对应的代码分析,确认是否可能存在内存泄漏的场景,最终修复代码,解决内存泄漏的问题。

什么是垃圾回收GC?

Java的垃圾回收(Garbage Collection, GC)是Java虚拟机(JVM)自动管理内存的一种机制。它的主要目的是自动回收不再使用的对象所占用的内存,以防止内存泄漏和优化内存使用。

为什么要进行垃圾回收?

垃圾回收是对于 堆(Heap) 而言的,我们创建的对象或数组一般都是在 Heap 堆中。我们不能无限制的创建对象,同时不是所有对象都是需要一直存活,如果不进行垃圾回收,则内存迟早会被耗尽,因此及时的垃圾回收是有必要的。

GC 的分类

针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:

部分收集 (Partial GC):

  • 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
  • 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
  • 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。

整堆收集 (Full GC):收集整个 Java 堆和方法区。

堆空间的基本结构

Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,Java 自动内存管理最核心的功能是 内存中对象的分配与回收。

Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)

从垃圾回收的角度来说,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆被划分为了几个不同的区域,这样我们就可以根据各个区域的特点选择合适的垃圾收集算法。

在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:

  1. 新生代内存(Young Generation)
  2. 老生代(Old Generation)
  3. 永久代(Permanent Generation)

下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代。

堆内存结构

JDK 8 版本之后 PermGen(永久) 已被 Metaspace(元空间) 取代,元空间使用的是直接内存

为什么要这样分?

因为不同对象的生命周期不一样,大部分对象朝生夕死,而少部分一直存在堆中,所以按照存活时间分区管理更加高效。

也因为不同分区的生命周期不同,所以可以采用不同的清除算法来优化处理,像新生代的对象“死亡率”比较高,因此标记复制比较合适(大部分对象都消失了,把存活的复制到一边,死亡的全部清理即可)

而老年代的对象存活时间比较长,因此标记清除即可(存活对象比较多,整理或复制耗时比较长)

且分区后可以减少 GC 暂停的时间,你想想每次处理一个堆的数据,还是将堆分区处理来的快?

总而言之,分区是为了更高效地管理不同生命周期的对象

为什么 Java 新生代被划分为 S0、S1 和 Eden 区?

因为新生代对象朝生夕死的特性,适合复制算法。按正常思路将新生代一分为二,划两块区域,每次只使用其中一个,GC 后将存活的复制到另一个区域,然后清理老区域非存活对象,这样替换使用两块区域可以避免内存碎片的存在。

但如果一分为二的话,空间利用率只有一半了(每次分配对象只能占据一半的内存大小),这样不太划算。

基于这点,定义了三个区域,Eden 区和两个 Survivor 区,Eden 区 + 1 个 Survivor 区可以比二分之一大,提升利用率,默认 Eden 占 80% ,一个 Survivor 占 10%。

然后利用两个 Survivor 来交替接收 gc 后存活的对象。

比如当前用 Eden + s0 两块区域,gc 的时候将存活的对象拷贝至 s1,然后清理 Eden 和 s0,接着使用 Eden + s1 作为新的对象分配区域。

后面 gc 后,把存活的对象拷贝至 s0,就这样往复使用两个 Survivor 区即可,这种划分手段就提升了内存的利用率。

并且程序可以根据自身的特性调整 Eden 区和 Survivor 区的比例,默认 8:1:1。

如果单个 Survivor 放不下 GC 存活的对象怎么办?

老年代兜底

也就是说如果 Survivor 放不下存活的对象,那么超出的对象直接晋升到老年代。

如果老年代剩余的空间也放不下这些存活的对象怎么办?

如果是 CMS 垃圾回收器,则会触发 CMS 回收。如果 CMS 回收不足以回收足够的空间,会触发 Full GC(Serial Old 回收器)。

如果是 G1 垃圾回收器则会触发 Mixed GC。

内存分配和回收原则

对象优先在 Eden 区分配

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

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。

大对象直接进入老年代的行为是由虚拟机动态决定的,它与具体使用的垃圾回收器和相关参数有关。大对象直接进入老年代是一种优化策略,旨在避免将大对象放入新生代,从而减少新生代的垃圾回收频率和成本。

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

为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。

大部分情况,对象都会首先在 Eden 区域分配。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间(s0 或者 s1)中,并将对象年龄设为 1(Eden 区->Survivor 区后对象的初始年龄变为 1)。

对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

垃圾回收

死亡对象判断方法

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

给对象中添加一个引用计数器:

  • 每当有一个地方引用它,计数器就加 1;
  • 当引用失效,计数器就减 1;
  • 任何时候计数器为 0 的对象就是不可能再被使用的。

这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间循环引用的问题。

对象之间循环引用

这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。

下图中的 Object 6 ~ Object 10 之间虽有引用关系,但它们到 GC Roots 不可达,因此为需要被回收的对象。

可达性分析算法

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

  • 虚拟机栈(栈帧中的局部变量表)中引用的对象
  • 本地方法栈(Native 方法)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 所有被同步锁持有的对象
  • JNI(Java Native Interface)引用的对象

被标记为垃圾的对象一定会被回收吗

即使在可达性分析算法中不可达的对象,也并非是“非死不可”,这时候它们暂时处于 “缓刑” 阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。

  • 第一次标记:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记;
  • 第二次标记:第一次标记后接着会进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。在 finalize() 方法中没有重新与引用链建立关联关系的,将被进行第二次标记。

第二次标记成功的对象将真的会被回收,如果对象在 finalize() 方法中重新与引用链建立了关联关系,那么将会逃离本次回收,继续存活。

垃圾收集算法

标记 - 清除算法

标记-清除(Mark-and-Sweep)算法分为“标记(Mark)”和“清除(Sweep)”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。

它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题:

  1. 效率问题:标记和清除两个过程效率都不高。
  2. 空间问题:标记清除后会产生大量不连续的内存碎片。

标记-清除算法

复制算法

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

复制算法

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

  • 可用内存变小:可用内存缩小为原来的一半。
  • 不适合老年代:如果存活对象数量比较大,复制性能会变得很差。

标记 - 整理算法

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

标记-整理算法

由于多了整理这一步,因此效率也不高,适合老年代这种垃圾回收频率不是很高的场景。

分代收集算法

根据对象存活周期的不同将内存划分为几块,一般是新生代和老年代,新生代基本采用复制算法,老年代采用标记整理算法。

  • 新生代默认的空间占比总空间的 1/3;
  • 老生代的默认占比是 2/3。

分代的垃圾回收策略,是基于这样一个事实:不同的对象的生命周期是不一样的。

因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。

分代垃圾回收采用分治的思想,进行代的划分,把不同生命周期的对象放在不同代上,不同代上采用最适合它的垃圾回收方式进行回收。

新生代使用的是复制算法,新生代里有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1,它的执行流程如下:

  • 把 Eden + From Survivor 存活的对象放入 To Survivor 区;
  • 清空 Eden 和 From Survivor 分区;
  • From Survivor 和 To Survivor 分区交换,From Survivor 变 To Survivor,To Survivor 变 From Survivor。
  • 每次在 From Survivor 到 To Survivor 移动时都存活的对象,年龄就 +1,当年龄到达 15(默认配置是 15)时,升级为老生代。大对象也会直接进入老生代。

老生代一般使用标记整理的执行算法:

  • 当空间占用到达某个值之后就会触发全局垃圾收回。

以上这些循环往复就构成了整个分代垃圾回收的整体执行流程。

垃圾收集器

虽然我们对各个收集器进行比较,但并非要挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。试想一下:如果有一种四海之内、任何场景下都适用的完美收集器存在,那么我们的 HotSpot 虚拟机就不会实现那么多不同的垃圾收集器了。

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

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

新生代垃圾收集器

Serial 收集器

  • 单线程收集器,适合小型应用和单处理器环境。
  • 触发 Stop-The-World(STW)操作,所有应用线程在 GC 时暂停。
  • 适用场景:适用于单线程应用和客户端模式。

ParNew 收集器

  • 是 Serial 收集器的多线程版本,能够并行进行垃圾收集。
  • 与 CMS 收集器配合使用时,通常会选择 ParNew 收集器作为新生代收集器。
  • 适用场景:适用于多处理器环境,通常配合 CMS 收集器使用。

Parallel Scavenge 收集器(吞吐量优先)

  • 也称为 “吞吐量收集器”,追求最大化 CPU 时间的利用率。
  • 并行处理新生代垃圾回收,适合大规模后台任务处理,注重吞吐量而非延迟。
  • 适用场景:适用于大规模运算密集型后台任务,适合对吞吐量要求较高的场景。

老年代垃圾收集器

Serial Old 收集器

  • Serial 收集器的老年代版本,使用标记-整理(Mark-Compact)算法进行垃圾回收。
  • 适用场景:适合单线程环境和低内存使用场景,通常配合 Serial 收集器一起使用。

Parallel Old 收集器

  • Parallel Scavenge 收集器的老年代版本,使用多线程并行标记-整理算法
  • 适用场景:适合大规模并行计算的场景,适用于高吞吐量要求的任务。

CMS(Concurrent Mark-Sweep)收集器

  • 并发标记-清除收集器,追求低延迟,减少 GC 停顿时间。
  • 使用并发标记和清除算法,适合对响应时间有较高要求的应用。
  • 缺点:可能会产生内存碎片,并且在并发阶段可能会发生 Concurrent Mode Failure,导致 Full GC。

要是 CMS 运行期间预留的内存无法满足程序分配新对象的需要(当minor GC进行时,旧生代所剩下的空间小于Eden区域+From区域的空间,或者在CMS执行老年带的回收时有业务线程试图将大的对象放入老年带,导致CMS在老年带的回收慢于业务对象对老年带内存的分配), 就会出现一次“并发失败”(Concurrent Mode Failure) ,就意味着此时 JVM 将继续采用 Stop-The-World 的方式来进行 Full GC,这种情况下,CMS 就没什么意义了。 所以参数 -XX:CMSInitiatingOccupancyFraction 设置得太高将会很容易导致大量的并发失败产生, 性能反而降低, 用户应在生产环境中根据实际应用情况来权衡设置。

  • 适用场景:适用于对响应时间要求高的应用,如 Web 服务和电商平台。

G1(Garbage First)收集器

  • 设计用于取代 CMS 的低延迟垃圾收集器,能够提供可预测的停顿时间。
  • 通过分区来管理内存,并在垃圾收集时优先处理最有价值的区域,避免了 CMS 的内存碎片问题。
  • 适用场景:适合大内存、多 CPU 服务器应用,尤其在延迟和响应时间敏感的场景中表现出色。

ZGC(Z Garbage Collector)收集器

  • 低停顿、高吞吐量的垃圾收集器,停顿时间一般不会超过 10 毫秒。
  • 适用场景:适用于需要管理大堆内存且对低延迟要求极高的应用。

关系

它们之间的关系(连线代表可以搭配使用):

总结:

  • 新生代垃圾回收器一般采用的是复制算法,复制算法的优点是效率高,缺点是内存利用率低;
  • 老年代回收器一般采用的是标记-整理的算法进行垃圾回收。

安全点是什么?

  1. 从线程的角度,安全点是代码执行中的一些特殊位置,当线程执行到这些特殊的位置,如果此时在GC,那么在这个地方线程会暂停,直到GC结束。
  2. GC的时候要挂起所有活动的线程,因此线程挂起,会选择在到达安全点的时候挂起。
  3. 安全点这个特殊的位置保存了线程上下文的全部信息。说白了,在进入安全点的时候打印日志信息能看出线程此刻都在干嘛。

有关安全点的详细说明,请移步:
JVM源码分析之安全点safepoint
[Java JVM] Hotspot GC研究- GC安全点 (Safepoint&Stop The World)

什么是STW(stop the world)

等待所有用户线程进入安全点后并阻塞,做一些全局性操作的行为。

什么时候会STW?(换句话说什么时候会触发进入安全点?)

  • Garbage collection pauses(垃圾回收)
  • JIT相关,比如Code deoptimization, Flushing code cache
  • Class redefinition (e.g. javaagent,AOP代码植入的产生的instrumentation)
  • Biased lock revocation 取消偏向锁
  • Various debug operation (e.g. thread dump or deadlock check) dump 线程

如何对 Java 的垃圾回收进行调优?

GC 调优这种问题肯定是具体场景具体分析,但是在面试中就不要讲太细,大方向说清楚就行,不需要涉及具体的垃圾收集器比如 CMS 调什么参数,G1 调什么参数之类的。

GC 调优的核心思路就是尽可能的使对象在年轻代被回收,减少对象进入老年代。

具体调优还是得看场景根据 GC 日志具体分析,常见的需要关注的指标是 Young GC 和 Full GC 触发频率、原因、晋升的速率、老年代内存占用量等等。

比如发现频繁会产生 Full GC,分析日志之后发现没有内存泄漏,只是 Young GC 之后会有大量的对象进入老年代,然后最终触发 Ful GC。所以就能得知是 Survivor 空间设置太小,导致对象过早进入老年代,因此调大 Survivor 。

或者是晋升年龄设置的太小,也有可能分析日志之后发现是内存泄漏、或者有第三方类库调用了 System.gc 等等。

反正具体场景具体分析,核心思想就是尽量在新生代把对象给回收了。

基本上这样答就行了,然后就等着面试官延伸了。

如果线上遇到了OOM,你该如何排查?如何解决?哪些方案?

为什么会 OOM

OOM 全称 “Out Of Memory”,表示内存耗尽。当 JVM 因为没有足够的内存来为对象分配空间,并且垃圾回收器也已经没有空间可回收时,就会抛出这个错误

为什么会出现 OOM,一般由这些问题引起

  1. 分配过少:JVM 初始化内存小,业务使用了大量内存;或者不同 JVM 区域分配内存不合理
  2. 代码漏洞:某一个对象被频繁申请,不用了之后却没有被释放,导致内存耗尽

内存泄漏:申请使用完的内存没有释放,导致虚拟机不能再次使用该内存,此时这段内存就泄露了。因为申请者不用了,而又不能被虚拟机分配给别人用

内存溢出:申请的内存超出了 JVM 能提供的内存大小,此时称之为溢出

内存泄漏持续存在,最后一定会溢出,两者是因果关系

常见的 OOM

比较常见的 OOM 类型有以下几种

java.lang.OutOfMemoryError: PermGen space

Java7 永久代(方法区)溢出,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。每当一个类初次加载的时候,元数据都会存放到永久代

一般出现于大量 Class 对象或者 JSP 页面,或者采用 CgLib 动态代理技术导致

我们可以通过 -XX:PermSize-XX:MaxPermSize 修改方法区大小

java.lang.StackOverflowError

虚拟机栈溢出,一般是由于程序中存在 死循环或者深度递归调用 造成的。如果栈大小设置过小也会出现溢出,可以通过 -Xss 设置栈的大小

虚拟机抛出栈溢出错误,可以在日志中定位到错误的类、方法

java.lang.OutOfMemoryError: Java heap space

Java 堆内存溢出,溢出的原因一般由于 JVM 堆内存设置不合理或者内存泄漏导致

如果是内存泄漏,可以通过工具查看泄漏对象到 GC Roots 的引用链。掌握了泄漏对象的类型信息以及 GC Roots 引用链信息,就可以精准地定位出泄漏代码的位置

如果不存在内存泄漏,就是内存中的对象确实都还必须存活着,那就应该检查虚拟机的堆参数(-Xmx 与 -Xms),查看是否可以将虚拟机的内存调大些

小结:方法区和虚拟机栈的溢出场景不在本篇过多讨论,下面主要讲解常见的 Java 堆空间的 OOM 排查思路

Dump 文件分析

Dump 文件是 Java 进程的内存镜像,其中主要包括 系统信息虚拟机属性完整的线程 Dump所有类和对象的状态 等信息

当程序发生内存溢出或 GC 异常情况时,怀疑 JVM 发生了 内存泄漏,这时我们就可以导出 Dump 文件分析

JVM 启动参数配置添加以下参数

  • -XX:+HeapDumpOnOutOfMemoryError
  • -XX:HeapDumpPath=./(参数为 Dump 文件生成路径)

上面配置是在应用抛出 OOM 后自动导出 Dump,或者可以在 JVM 运行时导出 Dump 文件

JvisualVM 分析

Dump 分析工具有很多,相对而言 JvisualVMJProfilerEclipse Mat,使用人群更多一些。下面以 JvisualVM 举例分析 Dump 文件

列举两个常用的功能,第一个是能看到触发 OOM 的线程堆栈,清晰得知程序溢出的原因

第二个就是可以查看 JVM 内存里保留大小最大的对象,可以自由选择排查个数

点击对象还可以跳转具体的对象引用详情页面

文中 Dump 文件较为简单,而正式环境出错的原因五花八门,所以不对该 Dump 文件做深度解析

注意:JvisualVM 如果分析大 Dump 文件,可能会因为内存不足打不开,需要调整默认的内存

总结回顾

线上如遇到 JVM 内存溢出,可以分以下几步排查

  1. jmap -heap 查看是否内存分配过小
  2. jmap -histo 查看是否有明显的对象分配过多且没有释放情况
  3. jmap -dump 导出 JVM 当前内存快照,使用 JDK 自带或 MAT 等工具分析快照

如果上面还不能定位问题,那么需要排查应用是否在不断创建资源,比如网络连接或者线程,都可能会导致系统资源耗尽。

参考链接

JVM垃圾回收详解(重点) | JavaGuide

Java 与 JVM — 八股文 (interview-points.readthedocs.io)

某团面试:如果线上遇到了OOM,你该如何排查?如何解决?哪些方案?-CSDN博客

【JVM基础09】——垃圾回收-对象什么时候可以被垃圾回收器回收?_jvm垃圾回收器啥时候回收-CSDN博客

CMS产生的问题:promotion failed和concurrent mode failure,及解决方案-CSDN博客