SWT 堆外内存泄漏排查实录:一次 PNG 保存泄漏一张图,一行 g_free 治好

一、问题现象:一条来自 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_1buffervlibswt-pi3-gtk-4940r23.so): objdump -d 显示它调用了 2 次 gdk_pixbuf_save_to_bufferv@plt0 次 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 字节完全相同,一个补丁通吃两平台。

七、总结与启发

  1. 别被"分配点"骗了 :内存泄漏追踪器抓到的堆栈是"谁分配了内存",不是"谁漏了内存"。真正要追的是这块内存的 ownership 和释放路径。本例分配点在 libpng,泄漏点在 SWT Java 层。
  2. 三层证据链:API 文档(契约)→ 字节码/源码(行为)→ 反汇编(事实),任何一层单独都不够硬,三层交叉就是铁案。
  3. 修之前先量化:升级 SWT 到底有没有用,不是靠感觉,跑同一份复现、对比稳态斜率即可(3.134.0 与 3.115.100 斜率一致 = 没修)。
  4. 最小可复现 + 一键验证:一个 20 行的 snippet 就能让官方复现;通过脚本让修复可回归。
  5. 架构无关的修复是福气:纯 Java 层的补丁意味着不用碰原生构建链,aarch64/arm64 服务器也能直接受益。
相关推荐
飞虹地星海几秒前
从零到一跑通苍穹外卖:一个大二学生的暑假项目复盘
java
她的男孩3 分钟前
账号锁定配了"错 4 次锁 30 分钟",我连错 100 次一次没锁上:扒完 4091 行认证源码,找到 5 个坑
java·后端·架构
Methy7 分钟前
一次线上死锁排查:std::list::size () 居然是 O (n)?
c++·后端
程序猿乐锅12 分钟前
【黑马点评 | 第二篇】Redis 缓存更新策略与商铺缓存实现
java·网络·redis·spring·mybatis
livemetee14 分钟前
MySQL联合唯一索引(code,deleted)与逻辑删除问题
java·数据库
MacroZheng16 分钟前
Redis官方发布高颜值可视化工具,功能更是强的离谱!
java·redis·后端
wuminyu20 分钟前
纯轻量级锁体系下C2编译器锁粗化和消除机制剖析
java·linux·c语言·jvm·c++
ss27331 分钟前
AI全栈实战 | 1.4-02 Spring AOP:@Transactional 失效的 N 种场景,根因其实只有一个
java·spring·ai编程
回家路上绕了弯34 分钟前
为什么 AI 写代码时,总喜欢“防御性编程”?
后端
灯澜忆梦37 分钟前
【Redis中间件】#4 | 进阶数据类型语法
redis·后端