记一次 PDF 转 Word 服务频繁崩溃的排查与解决:Java 堆内存并不是全部
摘要
一个基于 Spring Boot + Aspose.PDF 的 PDF 转 Word 在线工具,部署在 2C2G 云服务器上,频繁出现服务进程被系统杀死的问题。初步怀疑是 Java 堆内存不足,但调大 -Xmx 后反而崩溃更快。经过逐步排查,最终定位到根因:Aspose.PDF 底层使用 JNI 调用 C++ 引擎,其内存消耗在 Java 堆之外的原生内存(Native Memory)区域,而这块区域的大小不受 -Xmx 限制。
本人在线小工具(www.onltool.site) 欢迎大家进来体验批评和指正,有兴趣的小伙伴进来看看。
问题背景
技术栈:
- Spring Boot 2.5 + MyBatis-Plus
- Aspose.PDF 23.1(system scope,jar 包71MB)
- Apache POI 5.2.5(Word、Excel、PPT 处理)
- 服务器配置:2 核 2G 内存
症状:
- 上传一个不到 2MB 的 PDF 文件进行转换,服务端 Java 进程直接被操作系统杀死
- 没有任何 Java OOM 异常日志(OutOfMemoryError),也没有业务异常
- 重启后再次转换,依旧崩溃
原启动参数:
nohupjava-Xms512m-Xmx1024m-jarocean-system-1.0.0.jar>/dev/null2>&1&排查过程(碰过的壁)
尝试 1:怀疑 Aspose 内部优化方法开销太大
当前 pdfToWord()方法中,Aspose 加载 PDF 后调用了 pdfDocument.optimizeResources()。查文档发现这个方法做的是:合并重复资源、移除未使用对象、优化字体和图片——全部针对 PDF 内部结构,对输出 Word 文档毫无影响。 操作:移除 optimizeResources()调用。 结果:无效,仍然崩溃。尝试 2:怀疑转换后内存未及时释放
Aspose.PDF 底层是 C++ 引擎,通过 JNI 调用。Document.close()只释放 Java 层的包装对象,C++ 层的原生内存需要等待 GC 触发 finalizer 才回收。于是主动加了 System.gc()来提前回收。 操作:在 Document 的 try-with-resources 块结束后调用 System.gc()。 结果:崩溃更频繁了。事后分析:System.gc()在全量 GC 时会触发 Aspose C++ 对象的 finalizer,如果 finalizer 有并发 bug 或原生内存状态不一致,直接导致 JVM 进程 SIGSEGV。这是一个危险的"优化"。尝试 3:分片转换 + 并行处理
把大 PDF 按页拆成5页一组的小 PDF,每片单独转 DOCX,用4线程并行处理,最后合并成一个 Word 文档。理论上每片的内存占用只有全量加载的1/N。 操作: - PdfFileInfo 轻量获取页数 - PdfFileEditor.extract(start, end)流式拆分 - ExecutorService.newFixedThreadPool(4)并行转 - mergeDocx()用 ZIP 级 XML拼接合并 结果:转换后的 Word 文档质量严重下降——跨页表格被切断、样式在各片之间不一致、页眉页脚丢失。分片破坏了 PDF 文档的结构完整性,无法接受。 操作:回退分片逻辑。尝试 4:反复调整 JVM 堆大小
逻辑推断:服务器总共 2G,-Xmx 占多了留给操作系统的就少了,堆小了 Java 层面又不够。-Xmx结果 1024m 崩溃 768m 崩溃 512m 崩溃 反复调了几轮,全都崩溃。到这一步开始怀疑:问题根本不在 Java 堆里。根因分析
运行dmesg|tail-10查看系统日志: Out of memory: Killed process18473(java)total-vm:1856432kB, anon-rss:823456kB, file-rss:0kB, shmem-rss:0kBOS 的 OOM Killer 直接杀了 Java 进程。计算一下内存分布: 2G 服务器总内存 ├── 操作系统预留 ~300M ├── JVM 堆(-Xmx1024m)1024M ├── Metaspace(类元数据) ~200M ├── JIT CodeCache ~128M ├── 线程栈(约50线程) ~100M ├── 直接内存 / MappedByteBuffer ~50M ├────────────────────────────────── │ 以上合计 ~1800M ├────────────────────────────────── │ 剩余给 Aspose native ~200M ← 远远不够 └──────────────────────────────────根本原因:Aspose.PDF 底层是 C++ 引擎,通过 JNI 调用。它需要的内存是原生内存(Native Memory / Off-Heap),不在 -Xmx 管控范围内。PDF 文件在磁盘上只有 2MB,但 Aspose C++ 引擎加载时需要:
- 解码嵌入字体(TrueType/OpenType)
- 渲染矢量图形
- 展开图片像素
- 构建页面对象树
这些操作全在原生物理内存中完成。一个看似很小的 PDF,内部可能嵌入了完整的中文字体包(10MB+),或者包含高分辨率图片,在内存中展开后轻松占用 300-500MB 原生内存。
调大 -Xmx 为什么会更糟:Java 堆越大,留给原生内存的剩余空间就越小。-Xmx1024m 时原生内存只剩 ~200M,Aspose 稍微多用一点就爆。-Xmx512m 时原生内存有 ~700M,理论上会好一些,但 2G 总量实在太紧,稍有波动还是扛不住。
最终方案
既然换不了服务器,就从几个方向同时防守:
限制文件大小(20MB)
application.yml:
spring: servlet: multipart: max-file-size: 20MB max-request-size: 20MB file-size-threshold: 3MB# 超过 3MB 溢写到磁盘,不占堆内存server: tomcat: max-http-form-post-size: 20MB后端校验:
if(file.getSize()>20*1024*1024){returnApiResult.error("文件大小不能超过 20MB");}前端预拦截:
handleFile(f){if(f.size>20*1024*1024){this.$message.error('最大20M')return}}并发控制(同一时刻只允许一个 PDF 转换)
private final Semaphore pdfConvertSemaphore=new Semaphore(1);@PostMapping("/pdf-to-word")public ApiResult<String>pdfToWord(@RequestParam("file")MultipartFilefile){boolean acquired=false;try{acquired=pdfConvertSemaphore.tryAcquire(3, TimeUnit.SECONDS);if(!acquired){returnApiResult.error("当前转换任务繁忙,请稍后重试");}returnconvert(file, f ->fileConvertService.pdfToWord(f),"converted.docx");}finally{if(acquired)pdfConvertSemaphore.release();}}JVM 参数调优
nohupjava\-Xms128m\# 起步 128M,不预占太多-Xmx768m\# 最大 768M,给原生内存留空间-XX:MaxMetaspaceSize=256m\# 限制类元数据上限-XX:ReservedCodeCacheSize=128m\# 限制 JIT 编译缓存-XX:+UseG1GC\# G1 回收器,大堆停顿低-Dfile.encoding=UTF-8\-jarocean-system-1.0.0.jar>/dev/null2>&1&2G 内存分配(最终版) ├── 操作系统 ~300M ├── JVM 堆(-Xmx768m)768M ├── Metaspace(上限) 256M ├── CodeCache(上限) 128M ├── 线程栈 ~100M ├───────────────────────────── │ 以上合计 ~1550M ├───────────────────────────── │ 剩余给 Aspose native ~450M ← 够小 PDF 使用 └─────────────────────────────总结
| 误区 | 正确认知 |
|---|---|
内存不够就调大-Xmx | -Xmx只管 Java 堆;Aspose 这类 JNI 框架吃的是堆外原生内存,调大-Xmx反而挤占原生内存空间 |
| 小 PDF 文件不会耗多少内存 | PDF 内部可能嵌入完整字体包、高分辨率图片,内存展开后可达文件大小的50-100 倍 |
System.gc()能解决内存问题 | 在 JNI 场景下System.gc()可能触发原生层 finalizer 的并发 bug,导致进程直接崩溃 |
| 分片转换降低内存 | 分片破坏了 PDF 文档结构完整性,跨页元素丢失,质量不可接受 |
| Java 进程被杀会有 OOM 日志 | 原生内存耗尽时,OS OOM Killer 直接SIGKILL进程,Java 根本没机会写日志 |
核心经验:排查 Java 服务崩溃,先看 dmesg。如果看到 Out of memory: Killed process,说明是 OS 层面的 OOM,不是 JVM 层面的 OutOfMemoryError。这种情况调 -Xmx 没用,要检查 JNI 原生内存消耗或者直接升服务器配置。2G 服务器跑 Aspose.PDF 就是走钢丝,没有银弹。
