服务部署在 Docker 中,启动后出现 Java heap space,重启仍会复现。但服务器任务管理器显示还有约 6 GB 空闲内存,本地与服务器同样是 16 GB 内存,本地运行却正常。

通过 Eclipse Memory Analyzer(MAT)分析堆转储后,发现一个调度线程持有约 99% 的堆内存。沿线程栈查到业务代码,原因是:领取任务时只需要 ID,查询却返回了所有候选任务的完整数据快照。

下面记录排查过程。截图保留原貌,补充命令供复用,不代表现场全部执行过。命令使用 PowerShell 语法,先把容器名替换为自己的服务:

$container = "your-java-container"

1. 先确定报的是哪一种 OOM

最初日志同时出现 PostgreSQL 通信异常、事务回滚失败和 Tomcat 异步超时处理异常,看起来像多个模块一起出了问题。

现场报错:数据库通信异常与 Java heap space

其中最关键的信息是:

java.lang.OutOfMemoryError: Java heap space

它说明 Java 堆无法分配新对象。先查堆内存不足的原因;数据库通信和回滚错误也可能是内存不足后的连带异常。

同时保留容器日志和退出状态,区分 Java 自己报错与进程被系统终止:

docker logs --timestamps --tail 500 $container
docker inspect --format '{{json .State}}' $container

结合 OOMKilled 和日志判断,不能只凭退出码 137 认定内存不足。本次日志明确报了 Java 堆内存不足。

2. 宿主机还有内存,为什么 Java 会内存不足

当时执行了三个检查:

docker stats --no-stream $container
docker inspect --format '{{.HostConfig.Memory}}' $container
docker exec $container printenv JAVA_OPTS

Docker 内存和 JVM 参数现场结果

结果分别是:

检查项

现场结果

能说明什么

Docker 内存使用量 / 上限

2.753 GiB / 7.626 GiB

采样时的容器用量及 Docker 报告的可用上限

HostConfig.Memory

0

未给这个容器单独设置该项内存硬限制,不代表它可以无限使用 Windows 内存

JAVA_OPTS

-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0

JVM 按其识别到的可用内存计算最大堆

服务器有 16 GB 内存,Docker 环境只报告约 7.6 GiB 可用,JVM 又从中分配堆内存。因此,Windows 还有空闲内存,Java 堆仍可能达到上限。

按截图粗算,最大堆约为 7.626 GiB × 75% ≈ 5.72 GiB,实际值以运行中的 JVM 为准。Java 进程还需要堆之外的内存,不能把 Docker 的全部额度都留给堆。参数说明

如果容器包含 JDK 诊断工具,可以检查实际应用进程:

docker exec $container jcmd -l

从结果中找到应用的 Java PID,再执行,以下 123 仅为示例:

docker exec $container jcmd 123 VM.flags
docker exec $container jcmd 123 GC.heap_info

查看 MaxHeapSize 和当前堆用量。精简镜像可能没有 jcmd;Java PID 也不一定是 1。另起 java -XX:+PrintFlagsFinal 查到的是新进程参数,应以实际应用进程为准。jcmd 文档

截图中的 2.753 GiB 是采样值,不能代表故障瞬间的峰值。Docker stats 各列含义

本地与服务器还要比较 JVM 参数、数据量、任务积压和并发情况。物理内存相同,不代表运行条件相同。

3. 重启无效,先保留堆转储

重启释放了进程内存,但数据库里的任务还在。调度器再次查询同一批大快照,问题就会重现。

此时需要分析故障现场的 .hprof 文件,也就是堆转储。它记录当时有哪些对象、被谁引用。

如果尚未启用自动转储,可以在 JVM 启动参数中追加:

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/app/data/heapdumps

启用后,发生 Java OOM 才会自动生成文件,正常启动不会生成;也可以用诊断工具手动导出。容器被系统直接杀掉时,JVM 可能来不及转储。

目录要提前创建、确保可写并预留磁盘空间。启动参数修改后需重启应用,无法补回之前的现场。转储参数说明

/app/data 在 Windows 的哪个位置

/app/data 是容器内路径,先查看挂载关系:

docker inspect --format '{{json .Mounts}}' $container

现场使用 Docker 命名卷挂载 /app/data,它不直接对应 Windows 普通目录。无论采用哪种挂载方式,都可以通过 Docker 复制:

docker exec $container ls -lh /app/data
docker exec $container ls -lh /app/data/heapdumps
New-Item -ItemType Directory -Force -Path D:\oom-investigation
docker cp ${container}:/app/data/heapdumps/java_pid123.hprof D:\oom-investigation\server-oom.hprof

替换成实际文件路径,并核对修改时间,避免分析旧故障。docker cp 支持从已停止的容器复制文件,本地父目录需要提前创建。命令说明

5 GB 文件是不是超过了 Docker 的限制

5 GB 文件占用的是磁盘。Docker 的 7.626 GiB 是内存上限,Java 堆还有单独的上限,三者不能直接比较。

分析服务器故障,应使用服务器当时的文件。本地文件即使大小相同,对象和调用栈也可能不同。

删除文件只释放磁盘,不能解决 OOM。清理前保存现场;转储目标文件重名也可能导致新文件写入失败。

4. 用 MAT 找到“谁把内存留住了”

用免费的 Eclipse MAT 离线打开服务器的 .hprof。内网提前准备 MAT 及其要求的 Java 运行时,装在分析电脑上即可,无需为 Docker 服务在宿主机另装 Java。

打开后生成 Leak Suspects 报告。分析大文件时,可能需要调大 MAT 的 MemoryAnalyzer.ini 中的 -Xmx,并为索引预留磁盘空间。

MAT 报告:调度线程持有约 99% 堆内存

报告中的关键信息是:

观察

现场证据

排查方向

主要持有者

scheduling-1 线程的局部变量

检查后台调度任务

保留大小

5,151,650,400 bytes,约 99.01%

绝大部分堆集中在这条引用链

主要对象类型

byte[]、byte[][]、org.postgresql.core.Tuple

检查 PostgreSQL 查询结果

聚集容器

ArrayList 及底层 Object[]

检查是否一次缓存了大量结果行

retained heap(保留堆大小)表示:这个对象不再被引用后,连同它独占持有的对象,能释放多少内存。这里要找的是谁留住了大批数据。

这张报告说明大量内存用于保存 PostgreSQL 查询结果,还不能证明驱动有内存泄漏。MAT 分析的是某一时刻的快照,接下来要查这些结果从哪里来。MAT 报告说明

5. 从驱动栈继续找到自己的业务代码

在报告中点击 See stacktrace 或 See stacktrace with involved local variables,查看持有这些对象的线程当时正在做什么。

MAT 线程栈:定位到 AbnormalDetectionRepository.claim

将完整调用栈压缩后,调用路径是:

AbnormalDetectionWorker.processNext
  → AbnormalDetectionRepository.claim
  → Exposed Query.iterator / executeQuery
  → Hikari ProxyPreparedStatement.executeQuery
  → PostgreSQL QueryExecutorImpl.processResults
  → PGStream.receiveTupleV3
  → OutOfMemoryError

从驱动和框架调用栈向上找,直到找到自己的代码。截图中的 AbnormalDetectionRepository.claim() 就是业务入口,负责从数据库领取待处理任务。排查自己的服务时,用同样的方法找到对应入口,再检查其中的查询。

6. 只取 ID 的代码,为什么读进来 5 GB

任务表中有一个 snapshot 字段,类型为 TEXT,保存任务执行所需的配置和数据,单条内容可能很大。

原代码使用 Kotlin 的 Exposed 数据库框架。简化表名后,关键逻辑如下:

val candidates = TaskTable
    .selectAll()
    .where {
        (TaskTable.status neq "DONE") and
            (TaskTable.availableAt lessEq now)
    }
    .orderBy(TaskTable.id to SortOrder.ASC)
    .map { it[TaskTable.id].value }

最后的 map 只取 ID,但它发生在结果返回之后。前面的 selectAll() 已经要求数据库返回所有字段,包括 snapshot;查询又没有限制行数,大量快照便一起进入驱动。

换成 SQL 就容易看清:业务需要 SELECT id,实际执行的却是包含大字段的 SELECT *。使用其他数据库框架时,也应检查最终生成的 SQL。

PostgreSQL 的 Java 驱动 pgJDBC 默认一次缓存全部查询结果,因此业务还没处理完 ID,就可能耗尽内存。默认缓存与分批获取说明

flowchart TD
    A[领取一个任务] --> B[查询所有候选任务的全部字段]
    B --> C[大量 snapshot 随结果返回]
    C --> D[JDBC 用 Tuple 和字节数组保存结果]
    D --> E[调度线程局部变量持有结果集]
    E --> F[Java 堆耗尽]

字段内容大小和返回行数相乘,结果可能很惊人。例如:

7,000 条任务 × 每条 700 KiB 快照 ≈ 4.67 GiB 原始内容

这是数量级示例,并非现场实测,还没计入数组和 JSON 解析等额外开销。截图中约 7,000 个 Tuple 和大批字节数组,也支持继续排查大结果集。

7. 为什么之前没问题,最近才报错

通过版本记录发现,这条查询早已存在。数据量小的时候,结果集还能放进堆里;随着数据增长或任务积压,同样的查询就可能耗尽内存。因此,最近出现故障不一定是最近修改了代码,还要对比任务数量、单条数据大小及内存配置。

8. 修复重点:控制读取字段和单次结果规模

这次修复围绕任务领取过程做了三件事:

  1. 候选扫描只查询 ID,固定本轮最大 ID,按游标每批最多读取 100 个编号。

  2. 锁定候选任务后,只读取状态、批次、租约等判断所需的小字段。

  3. 确认该任务可以领取,才单独读取并解析这一条 snapshot。

相关检查同样只查询必要字段,保留原有任务执行顺序和重试规则。

只加 LIMIT 100 仍可能读取 100 条大快照,因此要同时控制字段和行数。这里不需要候选任务的快照,直接不查询它,比调整驱动缓存更合适。

修改后重新部署,服务启动和后台任务运行恢复正常,未再出现本次 OOM 问题。