一、问题现象:一条来自 libpng 的泄漏堆栈
线上一个印章生成服务(SWT + GTK 做图,ImageLoader.save 存 PNG)被原生内存追踪抓到这样一条泄漏堆栈:
scss
g_try_realloc
libgdk_pixbuf-2.0 (png_set_mem_fn 重定向)
png_write_chunk_data / png_write_row / png_write_rows (libpng16)
gdk_pixbuf_save_to_callbackv
gdk_pixbuf_save_to_bufferv
Java_org_eclipse_swt_internal_gtk_GDK_gdk_1pixbuf_1save_1to_1bufferv
org.eclipse.swt.graphics.ImageLoader.save
CreateSeal.getImgFile
CreateSealThread.run → Display.readAndDispatch
进程 RSS 只涨不跌,最终 OOM 重启。SWT 版本 3.115.100。
第一反应是"libpng 泄漏了"------但注意,内存泄漏追踪器抓到的堆栈是**"谁分配了内存",不是 "谁漏了内存"。堆栈里的 png_write_chunk_data 只是编码器在 增长输出缓冲区**的那一次 realloc。真正的问题是:这块缓冲区被谁拿走了,又是谁没有还。
二、复现:把泄漏跑出来
写一个最小程序(噪声图让 PNG 体积最大、泄漏最明显),循环调用 ImageLoader.save:
java
ImageData data = ...; // 256x256 随机噪声 RGB
ImageLoader loader = new ImageLoader();
loader.data = new ImageData[]{ data };
for (int i = 0; i < 600; i++) {
loader.save(new ByteArrayOutputStream(), SWT.IMAGE_PNG);
}
在 Xvfb + GTK3 无头环境跑 600 次,每 50 次打一个点(VmRSS / Java 堆):
| 实验 | RSS 总增长 | 稳态泄漏/次 |
|---|---|---|
| SWT 3.115.100 | +174,940 KB | ~232 KB(PNG 体积 230,859 B) |
| SWT 3.134.0(2026 最新) | +169,904 KB | ~231 KB |

两条曲线都严格线性 ,而 Java 堆在 4~40 MB 间正常 GC 波动------增长全在堆外。顺带发现一个残酷事实:升级到 2026 年的最新版,泄漏速率一模一样,说明这从不是个"升级能解决"的问题。
三、根因:三层证据钉死 SWT
3.1 API 契约
gdk_pixbuf_save_to_bufferv() 官方文档写得明明白白:
buffer --- location to receive a pointer to the newly allocated buffer... The caller of the method takes ownership of the returned data, and is responsible for freeing it.
也就是说,返回的 gchar** buffer 必须由调用方 g_free()。
3.2 Java 字节码
反编译 ImageLoader.save(OutputStream, int)(javap -c)PNG 分支:
csharp
GDK.gdk_pixbuf_save_to_bufferv(pixbuf, buffer, len, type, null, null, null);
byte[] byteArray = new byte[(int) len[0]];
C.memmove(byteArray, buffer[0], byteArray.length); // 只拷贝
stream.write(byteArray);
...
OS.g_free(buffer_ptr); // 只释放了"像素缓冲",buffer[0] 从未释放
buffer[0](PNG 输出缓冲区)在任何出口都没有被释放。
3.3 原生反汇编
JNI 胶水 Java_...GDK_gdk_1pixbuf_1save_1to_1bufferv(libswt-pi3-gtk-4940r23.so): objdump -d 显示它调用了 2 次 gdk_pixbuf_save_to_bufferv@plt,0 次 g_free@plt ------只是把指针经 Get/ReleaseLongArrayElements 写回 Java long[]。原生侧也不释放。
结论:buffer[0] 在 Java 和原生两侧都没有释放路径,每次保存泄漏 ≈ 一张 PNG。而且这是引入时就存在的 bug(native ImageLoader 于 4.13 / Bug 545032 引入,首个 commit 就漏了这行)。
四、用 async-profiler 复现线上抓取
线上那条堆栈正是 async-profiler 的 nativemem 事件 + jfrconv 抓的。本地把同一套流程完整跑通:
bash
# 1) 启动"持续泄漏"的复现进程(ReproPngLeak 支持 total<=0 无限循环),拿到 java pid
xvfb-run -a java -cp ... ReproPngLeak 0 256 noise sync # 日志打印 pid=10499
# 2) attach 采集原生内存分配(malloc/realloc 全量追踪)
asprof start -e nativemem -f app.jfr 10499
sleep 25 # 让泄漏累积(256x256 噪声图 ~230KB/次,25s ≈ 300MB)
asprof stop 10499
# 3) 生成"未释放"内存的泄漏报告(火焰图 HTML)
jfrconv --total --nativemem --leak app.jfr app-leak.html
asprof stop 直接输出的 nativemem 摘要,Top1 与线上逐帧一致:
scss
--- 310731581 bytes (47.24%), 0 samples
[ 0] realloc_hook
[ 3] png_write_chunk_data
[ 7] png_write_row
[ 8] png_write_rows
[11] gdk_pixbuf_save_to_callbackv
[12] gdk_pixbuf_save_to_bufferv
[13] Java_org_eclipse_swt_internal_gtk_GDK_gdk_1pixbuf_1save_1to_1bufferv
[15] org.eclipse.swt.graphics.ImageLoader.save
占 47.24% / 310MB 的这块内存,就是 gdk_pixbuf_save_to_bufferv 返回、SWT 从未 g_free 的 PNG 输出缓冲区。第二栈(png_malloc_warn → deflateInit2_,zlib 压缩缓冲)指向同一调用链,但那是编码过程临时缓冲、png_write_end 会释放--------leak 只保留"分配后一直存活"的栈,所以 Top1 才是真凶。(打开 app-leak.html 可交互下钻火焰图,浏览器里截图即可。)
五、修复:一行补丁 + 运行时验证
java
// after: C.memmove(byteArray, buffer[0], byteArray.length);
OS.g_free(buffer[0]);
逐行复刻原生调用链做对照实验(唯一差异是这行 g_free):
| 模式 | 600 次后 RSS 增长 |
|---|---|
| buggy(同 ImageLoader.save) | 线性 +153,592 KB |
| fixed(+1 行 g_free) | 预热后完全走平(0 泄漏) |
且修复版 PNG 产物与官方逐字节一致------只释放内存,不改输出。
六、为什么不用重编原生库:aarch64 也能修
修复点在 Java 层,buffer[0] 是 jlong 指针、跨架构语义一致,所以:
- 原生
.so保持官方原版; - x86_64 与 aarch64 片段的
ImageLoader.class字节完全相同,一个补丁通吃两平台。
七、总结与启发
- 别被"分配点"骗了 :内存泄漏追踪器抓到的堆栈是"谁分配了内存",不是"谁漏了内存"。真正要追的是这块内存的 ownership 和释放路径。本例分配点在 libpng,泄漏点在 SWT Java 层。
- 三层证据链:API 文档(契约)→ 字节码/源码(行为)→ 反汇编(事实),任何一层单独都不够硬,三层交叉就是铁案。
- 修之前先量化:升级 SWT 到底有没有用,不是靠感觉,跑同一份复现、对比稳态斜率即可(3.134.0 与 3.115.100 斜率一致 = 没修)。
- 最小可复现 + 一键验证:一个 20 行的 snippet 就能让官方复现;通过脚本让修复可回归。
- 架构无关的修复是福气:纯 Java 层的补丁意味着不用碰原生构建链,aarch64/arm64 服务器也能直接受益。