在Java项目中使用 com.itextpdf(无论是 iText 5 还是 iText 7)造成堆外内存(Off-Heap Memory)泄露,通常不是 Java 堆内存溢出(OOM: Java heap space),而是本机内存(Native Memory)被耗尽。
这种情况比较棘手,因为堆外内存不受 JVM 垃圾回收器(GC)的直接管理。以下是造成这种情况的几个主要核心原因及排查方向:
1. java.util.zip.Deflater / Inflater (最常见原因)
PDF 文件格式严重依赖压缩算法(主要是 FlateDecode)。iText 在生成 PDF 或读取 PDF 时,底层会大量调用 JDK 的 Deflater(压缩)和 Inflater(解压)类。
- 原理 :这些类是 Java 对底层 C 语言库
zlib的 JNI 封装。当创建一个Deflater对象时,它会在 JVM 堆中分配少量的内存,但会在**堆外(Native Memory)**分配缓冲区来处理压缩数据。 - 泄露原因 :
- 如果
Deflater对象没有显式调用end()方法,它所占用的堆外内存就不会立即释放。 - 虽然
Deflater实现了finalize()方法,会在 GC 回收 Java 对象时释放堆外内存,但如果 Java 堆内存充足,GC 迟迟不运行 ,那么堆中的Deflater对象一直存在,导致堆外的 Native 内存不断累积,最终导致进程被 OS 杀掉或抛出 OOM。
- 如果
- iText 的情况:iText 内部通常会处理好这些流的关闭,但在高并发或异常处理不当时(例如 try-catch 中未正确关闭 document/writer),可能导致这些底层对象未被及时释放。
2. NIO MappedByteBuffer (内存映射文件)
当 iText 处理大型 PDF 文件(读取或合并)时,为了提高性能,可能会使用 java.nio 包下的内存映射文件(Memory Mapped Files)。
- 原理 :
FileChannel.map()会将文件直接映射到堆外内存中。 - 泄露原因 :
- JDK 的
MappedByteBuffer释放机制非常"懒惰"。即使你关闭了FileChannel,映射的内存也不会立即释放,必须等待 ByteBuffer 对象被 GC 回收后,通过 Cleaner 机制才会释放堆外内存。 - 在 iText 中,如果你开启了部分读取(Partial Reading)或者使用了
RandomAccessFileOrArray(iText 5) /PdfReader(iText 7) 的某些构造函数处理大文件,就可能触发此机制。
- JDK 的
- 配置项 :在 iText 5 中,可以通过
GlobalSettings或构造函数参数控制是否使用内存映射。
3. PdfReader 未正确关闭
这是最直观的代码层面错误。
- 场景 :在合并 PDF 或提取页面时,创建了
PdfReader对象。 - 原因 :
PdfReader打开文件流时可能会占用底层文件句柄和相关的缓冲内存。如果循环处理大量文件而没有调用reader.close(),虽然 Java 对象还在,但底层资源(包含堆外缓冲)会泄露。
4. 静态资源缓存 (FontFactory / PdfFontFactory)
虽然这通常导致堆内存(Heap)溢出,但在某些特定配置下也会影响堆外。
- 场景:频繁加载字体文件(尤其是大的中文字体 .ttf/.otf)。
- 原因:iText 默认会缓存加载过的字体。如果你的应用动态加载字体文件(例如每次生成都从磁盘读取一个新的字体文件路径,而不是复用字体对象),iText 可能会把字体的元数据和字形数据一直缓存在静态 Map 中。
- 关联堆外:字体解析和渲染底层可能涉及 Java 2D 或其他图形库的 Native 调用,间接导致 Native 内存增长。
如何排查与解决
1. 确认是否为堆外泄露
使用 jstat -gc 观察 GC 频率。如果 Full GC 很少,但内存占用(RES/RSS)持续升高,且 Dump 出来的 Java Heap 很小,基本确认为堆外泄露。
建议使用 Java NMT (Native Memory Tracking) 进行诊断:
bash
-XX:NativeMemoryTracking=detail
然后使用 jcmd <pid> VM.native_memory summary 查看内存分布。如果 Internal 或 Other 部分很大,通常指向 zlib 或 DirectByteBuffer。
2. 代码层面优化 (Try-With-Resources)
确保所有的 iText 对象都在 finally 块或 try-with-resources 中关闭:
java
// iText 7 示例
try (PdfWriter writer = new PdfWriter(dest);
PdfDocument pdf = new PdfDocument(writer);
Document document = new Document(pdf)) {
// 业务逻辑
} // 自动调用 close()
注意 :对于 PdfReader,在使用完毕后必须显式关闭。
3. 禁用内存映射 (针对大文件问题)
如果你怀疑是 NIO 映射导致的,可以尝试强制 iText 不使用内存映射(牺牲一点性能换取稳定性)。
-
iText 5 :
java// 强制不使用内存映射 Document.compress = false; // 这是一个可能的开关,具体看版本 // 或者在读取时 PdfReader reader = new PdfReader(new RandomAccessFileOrArray(filename, false, true), null); -
iText 7 :
通常可以通过ReaderProperties设置或将文件读为InputStream传入,而不是传入 File/Path,从而避免内部自动优化为 MappedByteBuffer。
4. 调整 JVM 参数
如果是因为 GC 太懒惰导致堆外释放不及时,可以尝试以下参数:
- 限制堆外内存大小 :
-XX:MaxDirectMemorySize=xxxM(强迫 JVM 在堆外内存不足时触发 GC)。 - 调整 GC 策略:确保 Young GC 或 Mixed GC 能定期运行,从而回收掉持有堆外资源的 Java 对象(Deflater/DirectByteBuffer)。
5. 升级 iText 版本
老版本的 iText (特别是 iText 2.x 或早期的 5.x) 存在已知的内存泄露 Bug。确保使用的是 iText 5.5.13+ 或最新的 iText 7.x 版本。
总结
最可能的罪魁祸首是 java.util.zip 的压缩解压资源(Deflater/Inflater) 因为 GC 不及时而滞留在堆外,或者是 NIO MappedByteBuffer 导致的虚假泄露。优先检查代码中的 close() 逻辑,然后尝试限制 DirectMemorySize 来倒逼 GC 回收。
那么问题来了:究竟是谁在引用java.util.zip?是业务代码还是com.itextpdf自己?
答案是:绝大多数情况下,是在 com.itextpdf 组件内部引用的,而非你的业务代码直接调用。
虽然是你写的业务代码在操作 iText 对象(如 Document、PdfWriter),但底层的 java.util.zip.Deflater(压缩)和 Inflater(解压)是由 iText 自动调用的。
以下是详细的分析,帮助你理解为什么会这样:
1. 为什么 iText 内部必须引用它?
PDF 文件规范(ISO 32000)规定,PDF 内部的数据流(Streams)通常必须是压缩的,以减小文件体积。
- 默认算法 :PDF 最常用的压缩过滤器是
/FlateDecode。 - 对应实现 :这个算法完全对应 Java 标准库中的
java.util.zip包(基于 zlib)。
因此,当你执行以下"业务代码"时,iText 内部实际上在做如下操作:
- 场景 A:生成 PDF (Write)
- 你的代码 :
document.add(new Paragraph("Hello World")); - iText 内部 :将 "Hello World" 转换为 PDF 语法指令,然后调用
Deflater将这些指令进行压缩,最后写入PdfStream。
- 你的代码 :
- 场景 B:插入图片
- 你的代码 :
Image.getInstance("logo.png"); - iText 内部 :读取图片数据,可能会调用
Deflater对图片流进行重压缩(取决于图片格式和设置)。
- 你的代码 :
- 场景 C:读取/合并 PDF (Read)
- 你的代码 :
new PdfReader("input.pdf"); - iText 内部 :解析 PDF 结构,遇到压缩的内容流(Content Stream)时,调用
Inflater分配堆外内存进行解压,以便读取内容或复制页面。
- 你的代码 :
2. 只有一种情况是你业务代码引入的
除非你的业务逻辑 不仅仅是生成 PDF,还涉及将生成好的 PDF 文件打包下载。
- 例如 :你生成了 10 个 PDF,然后用
java.util.zip.ZipOutputStream把它们打成一个.zip压缩包返回给前端。 - 在这种情况下,
ZipOutputStream也会使用Deflater。如果这个流没关好,也会导致同样的堆外内存泄露。
但如果只是单纯的"导出 PDF"功能,99% 是 iText 内部产生的引用。
3. 为什么"iText 内部引用"会导致"由于你的业务代码"而泄露?
既然是 iText 内部引用的,为什么责任往往还在业务代码上?
这是一个责任链的问题:
- 底层 (JDK) :
Deflater分配了堆外内存。它依赖def.end()来释放,或者等待 GC 回收 Java 对象后触发finalize()。 - 中间层 (iText) :iText 封装了
Deflater。通常在PdfWriter.close()或Document.close()时,iText 会尝试去释放这些资源。 - 上层 (业务代码) :你必须负责调用 iText 对象的 close() 方法。
常见的泄露剧本:
-
未正确关闭资源 :
业务代码在
try块中生成 PDF,但是发生了异常(比如数据空指针)。如果没有在finally块中强制调用document.close()或writer.close(),iText 对象就"悬空"了。iText 没机会去调用内部Deflater.end(),堆外内存就泄露了。 -
高并发下的 GC 滞后 (最隐蔽的原因) :
即使你写了
close(),但如果系统并发很高:- iText 可能会频繁创建大量的
Deflater对象(Java 堆对象很小)。 - Java 堆内存(Heap)还没满,所以 GC 不会被触发。
- 但是,每个
Deflater背后都占用了几 KB 到几 MB 的 堆外内存 (Native Memory)。 - 结果:Java 堆很健康,但堆外内存被撑爆了,进程被 OS 杀死(OOM Killer)。
- iText 可能会频繁创建大量的
4. 如何验证这一点?
你可以通过 Arthas 或 jstack 验证调用栈。
如果发生了堆外内存问题,查看线程栈(Thread Dump),你会发现大量的调用栈停留在 Native 方法上,路径通常是:
YourBusinessCode -> iText -> java.util.zip.Deflater.deflateBytes (Native Method)。
总结
- 引用源头:iText 源码内部(为了满足 PDF 压缩规范)。
- 泄露根源 :通常是业务代码未在 finally 中关闭 Document/PdfWriter ,或者是JVM 的 GC 策略不够积极,导致 iText 内部持有的压缩对象没能及时释放堆外内存。