方法执行完了,对象为什么还占着内存?

资深 Java 面试到 Agent 开发工程师 · 第 10 集

因为方法里的局部变量消失了,其他地方却可能还留着这个对象的引用。比如方法把数据放进一个长期存在的 List,方法返回以后,List 里的数据仍在。只要这个 List 还能被程序访问到,里面的数组就会被 GC 保留下来,即使业务早已不用这些数据。

1. 方法已经返回,List 还在变大

下面的程序每调用一次 load(),就创建一个约 1 MiB 的字节数组,放进 List:

java 复制代码
import java.util.ArrayList;
import java.util.List;

public class MemoryDemo {
    private static final List<byte[]> CACHE = new ArrayList<>();

    static void load() {
        byte[] data = new byte[1024 * 1024];
        CACHE.add(data);
    }

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            load();
        }
    }
}

保存为 MemoryDemo.java,编译后用小堆运行:

sh 复制代码
javac MemoryDemo.java
java -Xms32m -Xmx32m MemoryDemo

-Xms 是初始堆大小,-Xmx 是最大堆大小。这是限制实验用量的配置,不是服务的推荐值。在本地 JDK 8 实验中,程序没能存满 100 份数组,就抛出了 OutOfMemoryError: Java heap space。具体能存多少份,还受数组头、List 和 JVM 其他堆内对象影响。

前面的多次 load() 已经正常返回,数组却仍然留在堆里。它们没被回收,是因为 CACHE 保存着它。final 只限制 CACHE 不能改指向另一个 List,List 里仍然可以一直添加元素。

2. JVM 的内存区域,各自负责什么

刚才报错里的"堆",只是 JVM 运行时内存的一部分。线程执行到了哪条指令、方法调用到哪一层、创建了哪些对象,需要分别记录。JVM 规范把这些运行时数据分到下面几个区域。

区域 谁使用 保存什么、有什么用
程序计数器 每个线程各有一份 执行 Java 方法时,记录当前字节码指令的位置;执行 native 方法时,这个值未定义
JVM 栈(虚拟机栈) 每个线程各有一份 每次方法调用建立一个栈帧,记录参数、局部变量、计算中的中间结果等;方法结束时移除这次调用的帧
本地方法栈 通常随线程分配,按实现安排 服务于 native 方法,例如通过 JNI 执行的 C/C++ 代码;是否提供、是否单独设置,由 JVM 实现决定
Java 堆 线程共享 保存类实例和数组;本例的 List、List 内部数组和 byte[] 都属于这里的对象
方法区 线程共享 保存每个类的字段、方法描述、方法代码和运行时常量池等类相关数据

load() 调用一次,对应一次方法执行;new byte[...] 创建一个数组。方法执行记录和创建出来的数组分属不同区域,所以结束这次调用,不会顺便删除数组。

运行时常量池属于方法区,用来保存类运行时需要的常量,以及指向类、字段、方法的引用信息。例如字节码调用一个方法,要能找到它对应的目标。它不是另外一块与堆、栈并列的第六区域。

在 HotSpot 21 中,类的描述信息主要存放在 Metaspace(元空间),使用 Java 堆之外的本地内存。方法区是规范定义,Metaspace 是类元数据的一种具体存放方式,两者范围并不完全相同。static 也不表示"相关对象都在元空间":本例 CACHE 指向的 List 和里面的数组,仍然是堆对象。

图里的区域也不是进程内存的全部。HotSpot 还需要保存编译后机器码的 Code Cache(代码缓存);直接缓冲区的数据也可以位于 Java 堆外。-Xmx 只限制 Java 堆,不能限制这些部分加起来的总量。

年轻代、老年代则是某些收集器在堆内部采用的分代方式,不是额外的 JVM 运行时区域。具体怎样分代、怎样回收,取决于收集器和 JDK 版本。

3. 栈里的变量和堆里的对象,分别发生了什么

执行 load() 时,它的栈帧会保存局部变量。栈帧里还包含操作数栈,字节码计算时临时使用的数据会放在这里。data 保存数组的引用,数组本身属于堆中的对象。调用 CACHE.add(data) 后,List 的内部数组也保存了同一个引用,并没有把整份数据复制进去。

方法返回后,这次调用的栈帧被移除,但 List 中的引用仍在。CACHE 是静态字段,不随某次 load() 调用一起消失。这段程序运行时,仍然可以通过它访问 List 和里面的数组。

图展示的是理解本例所需的引用关系。JVM 实际执行时还可能优化局部变量和对象分配,所以不能只根据变量是否出了作用域判断对象能否回收。本例中的数组仍被 List 保存,结论不受影响。

4. GC 怎么判断一个对象还能不能回收

JVM 从一些仍然活跃的引用出发,沿对象之间的引用继续找。在线程执行中仍需要的局部引用、JNI 全局引用等,可以成为查找起点;这些起点称为 GC Roots。静态字段引用的对象也可能经由仍存活的类被找到。

本例可以沿下面的关系找到数组:

text 复制代码
仍存活的类 → 静态字段 CACHE → List → 内部 Object[] → byte[]

这叫可达性分析。只要还有这样的强引用路径,GC 就会保留对应对象。内存不够时多做几次 GC,也不能把 CACHE 里还留着的数组删掉。

这里讨论普通强引用对象。软引用、弱引用等有额外规则。本例没有使用它们。

两个对象互相引用,也不一定会一直活着。如果从 GC Roots 出发已经找不到它们,整个环仍可回收。因此,Java 内存泄漏不必表现为循环引用;一个静态 List 不断留下业务早已不需要的对象,就足以耗尽堆。

5. 删除哪条引用,才真的有用

在 load() 最后写 data = null,只会断开局部变量这一条引用,List 里的那条还在。要释放这批不再需要的数据,应当从保存它的容器里删除。

如果整个批次都用完了,可以执行 CACHE.clear()。OpenJDK 21 的 ArrayList.clear() 如下:

java 复制代码
public void clear() {
    modCount++;
    final Object[] es = elementData;
    for (int to = size, i = size = 0; i < to; i++)
        es[i] = null;
}
源码里的名字或动作 这里做了什么
elementData,局部变量 es List 存元素的内部数组;es 只是方便访问它
size 当前元素数量;这里先改为 0
to 和 i to 留住原来的元素数量,i 从 0 开始遍历这些位置
es[i] = null 擦掉内部数组里的元素引用;只改 size,这些引用仍可能留着
modCount++ 记录这次修改,供迭代器检查并发修改等情况使用

clear() 没有直接释放那些字节数组,也没有释放 List 自己的内部数组。它清除了元素引用;如果别处也没有强引用,字节数组就满足后续回收的条件。JVM 什么时候执行 GC、回收后是否把内存归还操作系统,是后续的事,不能用进程内存是否立刻下降来判断 clear() 有没有生效。

整个缓存还在使用时,不能为了降内存把它全部清空。需要在业务完成时删除对应条目,或者事先限制最多留多少数据。例如,只保留最近四份数据。把原来的 CACHE 字段换成 RECENT,并用下面的 load() 替换原方法;这个版本供单线程调用:

java 复制代码
private static final Deque<byte[]> RECENT = new ArrayDeque<>();

static void load() {
    byte[] data = new byte[1024 * 1024];
    remember(data);
}

static void remember(byte[] data) {
    if (RECENT.size() == 4) {
        RECENT.removeFirst();
    }
    RECENT.addLast(data);
}

这段需导入 java.util.Deque、java.util.ArrayDeque。每份固定 1 MiB 时,容器最多保留四份,持续输入不会让保留量一直增加;输入的新数组和容器本身还会占额外空间。实际数据大小不固定,还要限制总字节数。多线程调用时,判断数量、移除、添加必须作为整体受到保护;单把容器换成并发集合,不能保证这个数量上限。

现象 应当改哪里
缓存条目只增不删 明确淘汰、过期或业务结束后的删除规则;期限和容量按需求确定
异步任务排队太多,每项都带着请求数据 限制排队,并处理拒绝和过期任务;第 9 集的超时也不能自动清除所有排队引用
数据本来就必须同时驻留,堆不够 降低同时处理量,改成分批/流式处理,或评估可用内存后调整堆大小

System.gc() 只是请求 JVM 尝试回收,不能切断这些引用。对持续增长的缓存只调大 -Xmx,通常只是让问题晚一点出现。文件、连接等资源则按各自约定及时 close() 或归还;不能等 Java 对象被回收以后才处理。

6. 工作中怎么确认是哪一处留住了对象

服务内存上涨时,先看上涨的是哪项指标。堆已用量、提交给堆的内存、整个 Java 进程的内存不是同一项。进程还要容纳线程栈、Metaspace、直接内存等内容,-Xmx 不是进程总内存上限。

在负载接近、回收范围相同的几轮 GC 后,如果留下的对象越来越多,就继续看哪类对象在增长,以及谁引用着它们。只比较年轻代回收后的老年代占用,会受正常对象晋升影响;一次内存峰值也可能只是请求量增加,单凭曲线上升不能定为泄漏。

Heap dump 是当时堆中对象及引用关系的快照,可以用 Eclipse MAT 等工具打开。分析时,可以在 Dominator Tree(支配树)里按 Retained Heap(保留大小)排序,找出留住大量内存的对象,再查看它到 GC Roots 的路径。保留大小包括对象自身,以及只有通过它才能继续被访问到的那些对象所占的堆内存。例如,List 自己不大,但它留住的字节数组可能占了大部分堆。

对于本例,byte[] 占用大,但仅知道数组多还没找到原因。沿引用路径看到 ArrayList 和 MemoryDemo.CACHE,才知道该检查 CACHE.add 之后有没有删除。修复后,以接近的输入量和运行时长复测,确认条目数量受到限制、相同回收范围的 GC 后存活量不再随调用次数一直增长。不要在生产环境直接照搬本节的小堆耗尽实验。

HotSpot 可以在堆溢出时自动保存快照,启动参数示例:

sh 复制代码
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heap.hprof

路径要可写、留出足够磁盘空间,并避免文件名冲突。主动生成 dump 也可能使服务停顿,先评估实例流量与可用副本。dump 可能包含请求内容和用户数据,按内部数据要求存放和分析。

常见面试题怎么答?

1. JVM 运行时内存区域有哪些?堆和栈有什么区别?

按 JVM 规范,主要有程序计数器、JVM 栈、本地方法栈、堆和方法区,运行时常量池属于方法区。程序计数器记录当前执行位置;JVM 栈通过栈帧保存每次 Java 方法调用的局部变量和计算过程,本地方法栈支持本地方法执行。这些与各自线程的执行有关。堆由线程共享,用于类实例和数组;方法区也是共享的,保存类相关数据。HotSpot 的 Metaspace 用于类元数据,位于本地内存,不能直接当作整个规范方法区。

"局部变量在栈、对象在堆"可以帮助理解,但引用也能存在于对象字段或数组中;不能据此断言对象一定随方法返回而回收,也不能忽略 JIT 优化。

2. Java 有 GC,为什么还会内存泄漏?循环引用能回收吗?

业务不用的对象,只要还被活的强引用路径留住,GC 就会继续保留它。没有清理规则的长期容器是一个例子;应从引用路径找到持有者,修改删除或淘汰逻辑。循环引用本身不会阻止可达性分析回收,从 GC Roots 已经不可达的普通对象环可以回收。判断泄漏需要结合业务是否仍需要这些对象,不能只凭对象多或发生 OOM。

3. OutOfMemoryError 和 StackOverflowError 有什么区别?

StackOverflowError 常见于递归调用过深,所需 JVM 栈超过允许范围。OutOfMemoryError 不只有堆不足:可能是 Java heap space、Metaspace,也可能是无法创建本地线程。最后一种还要检查线程数量和系统、容器的资源限制。直接加大 -Xmx 不会解决无限递归,也不等于扩大了 Metaspace 或容器的内存限额。

读完自测

1. load() 末尾增加 data = null,会解决本例的堆溢出吗?

参考要点:不会。CACHE 的内部数组还留着引用。应删除不需要的数据或限制保留量,并确认没有其他持有者;final 不会限制元素数量。

2. 调用了 clear(),操作系统看到的进程内存没下降,说明清理失败了吗?

参考要点:不能这样判断。先确认条目已清空、其他引用已释放,再在接近的运行条件下比较 GC 后的存活量。元素满足回收条件、实际被 GC 回收、JVM 把内存归还操作系统,是不同阶段。List 内部数组还可能保留容量;进程还有堆外的其他内存。

3. 服务限制了堆最大 1 GiB,为什么进程仍可能超过 1 GiB?

参考要点:-Xmx 只限制 Java 堆,还要考虑线程栈、类元数据、直接内存、JIT 代码和 JVM 本身的开销。结合堆、线程数和本地内存数据定位;容器总限额需要给这些部分留空间,不凭进程超出 -Xmx 就判定堆泄漏。

参考资料

规范按 Java SE 21、源码按 OpenJDK jdk-21+35 说明;文中兼容 Java 8 的示例使用 Zulu 8 验证。

相关推荐
用户77712985091243 分钟前
常见的同义方法的区别:增加类和删除类
java·后端
十年Java程序媛1 小时前
Java this 与 super 详解|继承高频踩坑 + 面试题整理
java·spring boot·面试
用户7771298509121 小时前
Stream流式使用
java
旋律4141 小时前
List、Set、Map:三个装数据的容器,到底啥区别?怎么遍历?
java·后端
Wang's Blog1 小时前
Java 项目实战: 交友项目 - 回顾和今日内容介绍
java·开发语言·交友
工具派1 小时前
不重写一行老代码:给 Spring Boot 系统加一层 AI 能力
java·人工智能·spring boot
大雄很用心1 小时前
Java 数据结构基础:栈、堆与 Deque
java
牛战士coding1 小时前
用户点了停止生成,上游还在烧 token 吗?取消传播链路拆解
java·人工智能
初心未改HD1 小时前
DSH Skill开发实战:SKILL.md是给LLM看的操作手册,不是给人看的README,踩了4个坑
java·大数据·开发语言