FFmpeg 内存管理:一帧背后的引用计数

一帧图像不只是一块像素内存:在 FFmpeg 里,它背后可能挂着解码器、引用计数、buffer pool 和一串“谁还没松手”的生命周期。

很多人第一次写 FFmpeg 代码,都会被一个问题绊住:

“我拿到了一个 AVFrame,如果想保存一份,直接 memcpy 可以吗?”

答案是:可以,但经常不是最好的选择。

因为 FFmpeg 的很多对象不是“普通结构体 + 一块裸内存”。它更像一套引用计数系统:你看到的是 AVFrame,真正的大块数据可能在 AVBuffer 里;你以为拷贝了一帧,实际可能只是复制了几个指针;你以为释放了解码器,某个忘记释放的 frame 还可能把整个 buffer pool 顶住。

这篇文章就从“拷贝一帧”这个小问题出发,聊清楚 FFmpeg 的内存管理模型。

在这里插入图片描述

1. 想拷贝 AVFrame,先问:你要拷贝什么?

AVFrame 这个名字很容易误导人。它看起来像“一帧数据”,但更准确地说,它是一帧媒体数据的描述对象

一个视频 AVFrame 里通常有这些东西:

成员 / 概念 作用 容易误解的点
data[] 指向各个平面的数据地址,例如 Y、U、V 它通常只是指针,不代表内存归 AVFrame 结构体本身所有
linesize[] 每个平面的行跨度 不一定等于图像宽度,可能有对齐 padding
buf[] 保存数据 buffer 的引用 这是释放大块像素内存的关键引用
extended_buf 超过固定数组数量时的额外 buffer 引用 音频多声道等场景常见
width/height/format 描述图像尺寸和像素格式 只描述数据,不负责管理底层内存
pts/pkt_dts 时间戳信息 拷贝像素时不一定要保留,做同步时必须关注

所以“拷贝 AVFrame”至少有两种含义:

需求 典型做法 结果
只想多持有一份引用 av_frame_ref(dst, src) 快,通常不复制像素;底层 buffer 引用计数 +1
想要独立像素副本 av_frame_clone() 后必要时 av_frame_make_writable(),或新分配 frame 后拷贝数据 更安全,但可能产生真实内存拷贝
只想临时传递所有权 av_frame_move_ref(dst, src) 不复制数据,把引用从 src 转移到 dst
只想拷贝元信息 av_frame_copy_props(dst, src) 只复制时间戳、色彩信息等属性,不复制像素

这里最重要的区别是:

av_frame_ref() 不是深拷贝像素,它是在共享底层 buffer 的前提下增加引用。

这正是 FFmpeg 高效的地方:高清视频一帧动辄几 MB,如果每个模块都深拷贝一次,内存和 CPU 很快就会爆炸。FFmpeg 默认更倾向于“共享数据 + 引用计数”。

2. xxx_ref / xxx_unref:FFmpeg 的“借书登记表”

理解 FFmpeg 内存管理,先抓住一组命名习惯:

API 形态 含义 你可以怎么理解
xxx_alloc() 创建对象壳子或分配对象 从仓库拿一个新对象
xxx_free() 销毁对象,并通常把指针置空 彻底归还对象
xxx_ref() 增加一份引用 多登记一个借阅人
xxx_unref() 释放当前引用 当前借阅人还书
xxx_move_ref() 转移引用所有权 把借书卡从 A 换到 B,不新增借阅人
xxx_clone() 创建新对象并引用同一份底层数据 新建一个壳子,但底层书可能还是同一本

AVFrame 来说:

操作 会发生什么 注意点
av_frame_alloc() 只分配 AVFrame 结构体 不一定分配像素数据
av_frame_get_buffer() 给 frame 分配底层数据 buffer 数据通常挂在 buf[] 引用里
av_frame_ref(dst, src) dst 引用 src 的底层 buffer 两个 frame 共享数据;引用计数增加
av_frame_unref(frame) 释放 frame 当前持有的 buffer 引用 frame 壳子还在,可以复用
av_frame_free(&frame) 先 unref,再释放 frame 结构体,并置空指针 最常见的收尾方式

这里的设计非常像“借书登记表”:

角色 类比 真实对象
真正占空间的数据 像素 buffer / packet data
借书卡 一个引用 AVBufferRef
借阅登记表 引用计数 AVBuffer 内部 refcount
读者 持有引用的对象 AVFrameAVPacket、解码器内部结构
图书馆 管理 buffer 的地方 AVBufferPool 或普通 allocator

只有最后一个读者还书,书才真正能从系统里移走。

3. AVBuffer:真正管理大块内存的底座

在 FFmpeg 里,很多大块数据最终都会落到 AVBuffer / AVBufferRef 这套机制上。

简单说:

名称 作用 重点
AVBuffer 真正管理底层 data 和引用计数的对象 里面知道 data 怎么释放
AVBufferRef 指向 AVBuffer 的引用句柄 可以有多个 ref 指向同一块数据
av_buffer_ref() 复制一个引用 refcount +1
av_buffer_unref() 释放一个引用 refcount -1,归零时调用 free callback
av_buffer_alloc() 分配一块带引用计数的内存 常用于 frame/packet 底层数据
AVBufferPool buffer 池 用于复用频繁申请释放的大块内存

普通 AVBuffer 的释放逻辑很好理解:

  1. 分配一块 data;
  2. AVBuffer 包起来;
  3. 每多一个使用者,就多一个 AVBufferRef
  4. 每个使用者结束时 av_buffer_unref()
  5. refcount 归零时调用释放回调,真正释放 data。

AVBufferPool 又多了一层“复用”。

在这里插入图片描述

当解码器需要一帧 buffer 时,它可能不是每次都直接向系统 malloc,而是:

阶段 行为 结果
取 buffer av_buffer_pool_get(pool) 从池里拿一个可用 buffer;没有空闲才新分配
frame 使用中 AVFrame::buf[] 持有 AVBufferRef buffer 不能被回收
frame 释放 av_frame_unref/free() 引用释放,buffer 可能回到 pool
pool 销毁 pool 自身引用也释放,且没有外部 buffer ref 池里缓存的底层内存才真正 free

这就是很多 FFmpeg 内存问题难查的地方:

av_frame_free() 不一定等于“立刻把像素内存还给系统”,它可能只是把 buffer 还给池。真正释放要看 pool 是否销毁,以及所有引用是否都归零。

4. 为什么 FFmpeg 要这么设计?

因为视频处理太吃内存,也太吃拷贝成本。

一帧 1080p YUV420P 8bit 大概 3MB;如果是 10bit,接近 6MB。4K、10bit、多线程解码时,这个数字还会继续上升。

如果每个环节都做深拷贝,典型链路会一路膨胀:

环节 如果深拷贝 直接后果
解码输出 产出第一份像素数据 基础帧内存已经产生
滤镜处理 再拷贝一份给滤镜 CPU 多一次大块内存读写
缩放处理 再拷贝一份给缩放器 临时峰值继续抬高
渲染显示 再拷贝一份给渲染层 帧率和功耗都受影响
缓存复用 再拷贝一份给缓存 多路播放、批量缩略图时更容易爆内存

这条链路真正的问题可以概括成两类:

问题 后果 典型表现
CPU 拷贝成本高 帧率下降、耗电上升 高清视频、实时滤镜更明显
瞬时内存膨胀 多份大 frame 同时存在 多路播放、缩略图批处理时尤其明显

所以 FFmpeg 更偏向“共享引用,而不是层层复制”的模型:

设计选择 做法 收益
数据只放一份 像素数据放在底层 buffer 里 避免重复存储大块内存
对象通过 ref 共享 AVFrame / AVPacket 持有引用 模块间传递更轻量
用完主动 unref 谁增加引用,谁负责释放引用 生命周期可追踪
最后一个引用释放 refcount 归零后释放底层内存 保证没人使用时再回收

这套模型很强,但也有代价:你必须认真对待每一个 refunref

5. 常见对象的 ref / unref 心智模型

下面这张表可以作为写 FFmpeg 代码时的速查。

对象 常见创建 增加引用 / 复制引用 释放引用 销毁对象 典型坑
AVFrame av_frame_alloc() av_frame_ref() / av_frame_clone() av_frame_unref() av_frame_free() 只 free 了壳子思维,忘了 buf[] 才是大头
AVPacket av_packet_alloc() av_packet_ref() / av_packet_clone() av_packet_unref() av_packet_free() 循环读包时忘记 av_packet_unref()
AVBufferRef av_buffer_alloc() / av_buffer_ref() av_buffer_ref() av_buffer_unref() 通常没有单独 free,靠 unref 多一个 ref 就多一个释放责任
AVCodecContext avcodec_alloc_context3() 通常不 ref 不适用 avcodec_free_context() 释放 context 不代表外部 frame 引用也会消失
AVFormatContext avformat_open_input() 通常不 ref 不适用 avformat_close_input() demux 和 decode 是两套生命周期
SwsContext sws_getContext() 不适用 不适用 sws_freeContext() 尺寸/格式变化时旧 context 忘释放

一个很实用的习惯是:谁拿到 ownership,谁负责释放;谁调用了 ref,谁就必须配一个 unref

6. 忘记释放一个 AVFrame,为什么可能不只漏“一帧”?

现在回到最开头的问题:如果一个 AVFrame 忘记释放,会发生什么?

直觉上,很多人会以为只是漏了一个结构体:

sizeof(AVFrame) 几百字节?小问题。

真实情况完全不是这样。

AVFrame 自己只是壳。它可能通过 buf[] 挂着几 MB 的像素内存;如果这些 buffer 来自 AVBufferPool,漏掉一个 frame 引用,还可能让整个 pool 没法销毁。

在这里插入图片描述

后果可以分三层:

层级 泄漏内容 影响
第一层 AVFrame 结构体本身 很小,通常不是主因
第二层 AVFrame::buf[] 持有的像素 buffer 可能是几 MB 到几十 MB
第三层 AVBufferPool 因引用未归零无法销毁 可能拖住一批已归还 pool 的大块 buffer

这也是为什么某些问题看起来只是“漏了一帧”,最后却表现成几百 MB 甚至 GB 级 native 内存增长。

尤其在这些场景里,放大效应更明显:

场景 为什么更危险
批量缩略图 每个视频都要解一帧,次数多,容易线性累积
10bit / 4K 视频 单帧内存更大
多线程解码 每个线程可能持有 delayed frame / reference frame
H.264 / HEVC 解码器内部有参考帧队列和 DPB
异常 fallback 正常路径释放了,错误路径反而容易漏

所以 FFmpeg 内存排查里,一个非常重要的原则是:

不要只看“我最后有没有 free codec context”,还要看中间所有 frame / packet 的引用有没有归零。

avcodec_free_context() 能释放解码器自己持有的资源,但它不能替你释放已经丢到外面的 AVFrame。如果某个 AVFrame 指针被覆盖、丢失、或者异常路径漏掉,里面的 AVBufferRef 仍然可能让底层 buffer 活着。

7. 写 FFmpeg 代码的几个自检问题

最后给一组很实用的检查清单。每次写到 AVFrame / AVPacket 生命周期时,可以顺手过一遍。

自检问题 为什么要问
每个 av_frame_alloc() 是否都有对应 av_frame_free() frame 壳子和其中的 buffer 引用都要释放
每次循环复用 AVFrame* 前,旧值是否已经 unref/free? 防止旧指针被覆盖后永久失联
每个 av_frame_ref() 是否都有对应 av_frame_unref() 引用计数不归零,底层 buffer 就不能释放
错误路径、continuebreak 前是否清理了 frame? 内存泄漏最常出现在异常分支
成功码和失败码是否可能冲突? 成功被误判失败时,资源释放路径可能被绕过
AVPacket 循环读包后是否及时 av_packet_unref() packet 也常持有引用计数 buffer
释放 codec context 前,外部是否还持有输出 frame? context 释放不了外部 frame 的引用
是否把 shallow ref 当成 deep copy? 会导致意外共享或释放时机错误

如果只能记住一句话,我建议记这句:

在 FFmpeg 里,真正的大内存经常不在你手里的结构体里,而在它引用的 buffer 里;忘记释放一个引用,可能拖住一整片池化内存。

结尾

FFmpeg 的内存管理并不神秘,它只是把“谁拥有数据、谁还在使用数据、什么时候能释放数据”这件事做得非常细。

AVFrameAVPacketAVBufferRefAVBufferPool 看起来是一堆 API,背后其实是一套统一的思路:

核心问题 FFmpeg 的回答
大块数据要不要反复拷贝? 尽量不要,优先共享引用
多个对象共享数据怎么管理? 引用计数
频繁申请释放大块内存怎么办? buffer pool 复用
什么时候真正释放? 最后一个引用释放时
最容易出问题在哪里? 异常路径、覆盖旧指针、成功/失败语义混乱

写 FFmpeg,最怕的不是少写一个 free(),而是少想清楚一次“这个引用现在归谁”。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐