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 核心解决思路
我把常见的排查和修复流程画成一张图,方便你对照操作。
这张图总结了核心动作。接下来我们展开讲每一步。
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 可能不认。
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,问题就解决了。
3.6 解决方案三:检查输入文件与预处理步骤
还有一种隐秘的 OOM 原因——输入数据本身有问题。
比如 BAM 文件的标记重复信息不完整,或者参考索引的 .alt 文件包含过量片段。GATK 在加载时会额外开辟内存做解析。一旦遇到异常的大块数据,堆可能瞬间撑破。
排查步骤:
- 检查 BAM 文件的索引是否存在且更新。
- 验证参考基因组的 .dict 和 .fai 文件是否与 FASTA 版本对应。
- 用
samtools quickcheck测试 BAM 是否损坏。 - 重新生成索引,确保不包含 corrupt region。
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+。
free -h 查看系统实际剩余内存,再用 ps -o rss,vsz,cmd | grep gatk 查看 GATK 进程的内存占用。如果系统内存还有剩余但进程报 OOM,多半是连续的堆地址空间不够,可以试试降低 -Xmx 或换 G1GC。
好了,关于 OOM 的应对就讲到这里。你想想看,其实大部分内存问题都能通过合理分配 -Xmx、适当降低阻塞线程数和检查数据完整性这三板斧解决。下次再遇到,别慌,按流程走一遍就好。