3. 内存不足错误:OutOfMemoryError 及解决方案

这一章我们来聊聊最让人头疼的运行时问题——内存不够。

我刚开始接手全基因组分析项目时,最怕的就是半夜收到集群报警邮件。跑了一天的 HaplotypeCaller,啪一下崩了,留下一串 OOM 日志。那种感觉,嗯,相信你也体会过。

其实大部分 OOM 都可以提前预判。关键是你得知道 Java 虚拟机在喊“我没内存了”时,它到底想告诉你什么。

3.1 常见错误日志长什么样?

先看两个典型例子。你可以在日志最后几行找到它们。

例一:堆空间耗尽

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.util.Arrays.copyOfRange(Arrays.java:3669)
    at java.lang.String.<init>(String.java:207)
    at java.lang.StringBuilder.toString(StringBuilder.java:407)
    ...

例二:内存映射文件失败

Exception in thread "main" java.lang.OutOfMemoryError: Cannot create native thread
    at java.lang.Thread.start0(Native Method)
    at java.lang.Thread.start(Thread.java:717)
    ...

这两种错误本质上都是内存不够,但原因完全不同。我最初经常把第二种当成第一种去调参,结果浪费了不少时间。

3.2 为什么内存会爆?

简单说一下背景。GATK 底层是 Java 写的,Java 程序运行在 JVM 之上。JVM 有个“堆内存”(Heap)的概念,相当于给程序划了一块固定大小的空间。所有数据都在里边处理。

你的基因组数据有多大?人类全基因组 WGS 原始 BAM 文件动辄上百 GB。GATK 在操作时要加载参考基因组索引、读深度信息、重复标记结果、群体 VCF 片段……这些东西都往堆里塞。

举个例子:假设你的参考基因组用了 GRCh38——FASTA 索引文件 .fai 就要吃掉约 1.5 GB 内存。加上一个染色体的 interval 数据,已经占掉了 -Xmx 参数设值的一半。如果这时候你又开了多线程,每个线程还要附加一些本地内存开销。

堆满了,自然崩溃。

3.3 核心解决思路

我把常见的排查和修复流程画成一张图,方便你对照操作。

OOM 排查与解决流程 收到 OOM 错误 检查错误类型 堆空间不足 线程/映射失败 增加 -Xmx 值 示例:-Xmx64g → -Xmx100g 减少线程数 / 降低映射文件 示例:-Xmx + --max-threads 4 调整后重新运行 仍有 OOM?回退检查 检查参考文件 / 预处理步骤

这张图总结了核心动作。接下来我们展开讲每一步。

3.4 解决方案一:增加 JVM 堆内存

这是最直接的方案。GATK 大部分工具通过 -Xmx 参数控制最大堆内存。默认情况经常配得很保守,比如 -Xmx4g 或 -Xmx8g。跑 WGS 数据,这基本不够用。

我个人的经验:人类全基因组 WGS,如果做 GVCF 模式,24 个染色体会并行拉满整个集群。单线程我最少给到 64 GB。

具体怎么加?看下面的命令。

# 原始命令
gatk HaplotypeCaller \
    -R GRCh38.fa \
    -I sample.bam \
    -O sample.g.vcf.gz

# 加上堆内存设置
gatk --java-options "-Xmx100g" HaplotypeCaller \
    -R GRCh38.fa \
    -I sample.bam \
    -O sample.g.vcf.gz

注意:--java-options 要放在工具名称之前。位置错了,JVM 可能不认。

小技巧: 在提交集群作业时,建议把 -Xmx 设为你分配给任务的物理内存的 75%-85%。不要把节点所有内存都塞进 JVM——操作系统、其他进程、本地线程映射等也需要内存。我的习惯是将内存上限设为 实际可用内存 × 0.8。

3.5 解决方案二:调整内存映射相关参数

有时候 -Xmx 已经给到 120 GB,还是报 OOM。这时候往往不是堆空间不够,而是 JVM 启动时无法创建足够的内存映射。

GATK4 内部对参考索引和部分数据结构使用文件映射。如果你同时打开了太多输入文件(比如群体 VCF 联合分析),映射区域会抢占同一块地址空间。在 64 位系统里通常问题不大,但有些集群内核参数限制了最大映射。

这时考虑两种调整:

  • 降低 -Xmx 到 80%-90% 的物理内存,留更多空间给本地映射。
  • 减少并行线程数,多用 --max-threads 参数。

举个例子:

gatk --java-options "-Xmx80g" GenotypeGVCFs \
    -R GRCh38.fa \
    -V cohort.g.vcf.gz \
    -O output.vcf.gz \
    --max-threads 4

我之前在某个项目里遇到过:集群单个节点有 256 GB RAM,我一上来就把 -Xmx 设到 220 GB。结果跑着跑着就报“Cannot create native thread”。后来把 -Xmx 降到 200 GB,线程数从 8 降到 6,问题就解决了。

重要: 不要觉得内存越多越好。-Xmx 给太满,JVM 内部启动时会预留连续地址空间。系统碎片化严重时,即使可用内存超过 -Xmx,JVM 也拿不到这么大一块连续空间,直接报 OOM。留 20%-30% 给系统是稳妥做法。

3.6 解决方案三:检查输入文件与预处理步骤

还有一种隐秘的 OOM 原因——输入数据本身有问题。

比如 BAM 文件的标记重复信息不完整,或者参考索引的 .alt 文件包含过量片段。GATK 在加载时会额外开辟内存做解析。一旦遇到异常的大块数据,堆可能瞬间撑破。

排查步骤:

  1. 检查 BAM 文件的索引是否存在且更新。
  2. 验证参考基因组的 .dict 和 .fai 文件是否与 FASTA 版本对应。
  3. 用 samtools quickcheck 测试 BAM 是否损坏。
  4. 重新生成索引,确保不包含 corrupt region。
避坑指南: 我曾经花了两天定位一个 OOM 问题,最后发现是参考索引文件 .alt 没有正确构建——它里面有一个超大冗余片段。GATK 每次加载这个索引都尝试分配 50 GB 的映射空间,然后就挂了。如果你遇到“随机” OOM,先怀疑参考索引和输入 BAM 的完整性。

3.7 不同工具的内存“胃口”参考

不同的 GATK 工具对内存依赖不一样,我整理了一个参考表。这里的值基于人类 WGS 标准流程(30× 覆盖度)。

工具 建议堆内存 说明
HaplotypeCaller(GVCF) 48 GB - 100 GB 单个间隔较少时低值,全基因组大间隔时高值
GenotypeGVCFs 32 GB - 64 GB 联合基因分型,内存主要消耗在合并群体数据
Mutect2 16 GB - 32 GB 肿瘤体细胞突变,推荐同时降低读取线程数
BaseRecalibrator 8 GB - 16 GB 假设 BAM 已排序且索引正确
ApplyBQSR 8 GB - 12 GB 基本不吃内存,主要看 I/O 速度

实际数值取决于你的机器硬件配置和区间文件设计。我的建议是先跑一个小 interval 测试,观察峰值内存使用,再决定全基因组的内存分配。

3.8 额外补救:启用 G1GC 垃圾收集器

当你已经把 -Xmx 给够,但程序还是在运行中途逐渐变慢直到 OOM 时,问题可能出在垃圾回收策略上。Java 默认的 ParallelGC 在大堆情况下容易产生很长的停顿。

G1GC(Garbage First Garbage Collector)更适合大内存场景。它对堆大小变化更平滑,并能提前降低全 GC 的触发概率。

启用方式:

gatk --java-options "-Xmx100g -XX:+UseG1GC" HaplotypeCaller \
    -R GRCh38.fa \
    -I sample.bam \
    -O sample.g.vcf.gz

我个人更偏好 G1GC,尤其是在跑 HaplotypeCaller 的 20 号染色体这种大区间时,GC 停顿明显变短,跑完整个流程的时间也能节省 10%-15%。当然,如果你用的 Java 版本较老(社区版 8u40 以下),G1GC 可能有 bug。建议至少使用 Java 11+。

快速自测: 报 OOM 后立刻用 free -h 查看系统实际剩余内存,再用 ps -o rss,vsz,cmd | grep gatk 查看 GATK 进程的内存占用。如果系统内存还有剩余但进程报 OOM,多半是连续的堆地址空间不够,可以试试降低 -Xmx 或换 G1GC。

好了,关于 OOM 的应对就讲到这里。你想想看,其实大部分内存问题都能通过合理分配 -Xmx、适当降低阻塞线程数和检查数据完整性这三板斧解决。下次再遇到,别慌,按流程走一遍就好。