MediaCodec 全链路深度剖析(三):Native 消息机制与 JNI 桥接
系列导读:第二篇结尾留下一个钩子——真正的状态机在 Native 层,Java 侧只是个薄壳。这一篇沿着
codec.start()、dequeueOutputBuffer()这种”看上去同步”的调用一路下钻,回答一个问题:Java 的同步调用,在 JNI 之下到底是怎么落到 Native、又是怎么”原地等回结果”的?
主角是 AOSP
foundation库里那套 ALooper / AHandler / AMessage,加上android_media_MediaCodec.cpp这一层 JNI 桥。本篇刻意只聚焦同步 API 这一条链——异步 Callback(setCallback / onOutputBufferAvailable)不进行介绍。
1. 为什么 Native 不复用 Java 的 Looper
翻开 MediaCodec.cpp,start() 长这样:
status_t MediaCodec::start() { |
三行代码,三个问题:
AMessage不是android.os.Message,那是什么?- “同步调用”为什么长得像异步?
- 系统已经有 Java Looper 了,为什么还要在 Native 再造一套?
第三个问题先回答:AOSP 的 foundation 库早于 ART 存在,很多系统组件(mediaserver、cameraserver)跑在根本没有 JVM 的进程里。Java 的 Handler/Looper 需要 ART 支撑,Native 用不了。所以 AOSP 在 frameworks/av/media/libstagefright/foundation/ 下维护了自己的一套,名字都加了 A 前缀:
| AOSP C++ | Java 对应 | 作用 |
|---|---|---|
ALooper |
Looper |
消息队列 + 循环线程 |
AHandler |
Handler |
接收回调的对象 |
AMessage |
Message |
消息本身,带 what 和 KV 参数 |
两套有一个关键差异:AMessage 不用 arg1 / arg2 / obj,用的是字典——setInt32("index", 3)/setInt64("timeUs", 100)/setObject("buffer", buffer)。好处是参数多了不用扩字段,坏处是取值要查表。
更重要的另一个差异:AMessage 原生支持同步等待,Java Handler 不支持。这是 PostAndAwaitResponse 能存在的基础。
2. CodecLooper:每个 codec 实例独享一条线程
每个 MediaCodec 实例在 Native 侧独享一个 Looper。初始化时会看到:
mCodecLooper = new ALooper; |
三个细节值得记:
| 参数/特性 | 含义 | 为什么这么设 |
|---|---|---|
canCallJava = false |
这条线程不期望调 Java | 真正要跨进 Java 的是 JMediaCodec 自带的另一条线程,两者职责分离 |
ANDROID_PRIORITY_AUDIO(-16) |
比普通前台线程高 | 音视频同步容不下 Looper 线程被挤占 |
| 一实例一 Looper | 不共享 | 同进程开 4 个解码器就有 4 个 CodecLooper,每个状态机一条线程 |
3. PostAndAwaitResponse:同步感从哪里来
start() 内部投了一条消息就返回了,外层却”同步”拿到了结果。机制是这样的:
// 调用方:投完消息就在 token 上睡觉 |
3.1 AReplyToken:一次调用的”信箱”
核心数据结构叫 AReplyToken,一个调用对应一个,只有三个字段:
| 字段 | 作用 |
|---|---|
mReply |
处理方填进来的回复,初始为空 |
mReplied |
是否已回复,调用方循环检查的断言 |
mLooper |
归属的 Looper,不参与同步 |
token 自己不带锁。整个进程里所有 token 共用一把全局锁和一个全局条件变量,都挂在 gLooperRoster 单例上。唤醒时用 broadcast,被唤醒的线程自己检查 mReplied 是不是轮到自己。
3.2 核心逻辑
// 调用方 |
// 处理方(在 Looper 线程 onMessageReceived 内) |
两个细节:
- broadcast 而不是 signal:一把条件变量看守所有 token,被唤醒的线程要自己检查
mReplied,不是自己就接着睡 - while 而不是 if:
Condition::wait从睡眠中返回,不代表”回复一定到了”——一方面 POSIX 允许虚假唤醒(没有任何signal/broadcast也可能自己醒),另一方面这里用的是broadcast,别人收到回复时我也会被一起叫醒。所以必须用while包住wait,醒来后重新检查mReplied,没轮到自己就继续睡
3.3 一次 codec.start() 的完整时序
sequenceDiagram
participant App as App 线程
participant JNI as JNI (JMediaCodec)
participant Q as Looper Queue
participant L as CodecLooper 线程
App->>JNI: native_start()
JNI->>JNI: new AMessage(kWhatStart)
JNI->>JNI: createReplyToken + setReplyToken
JNI->>Q: msg.post()
JNI->>JNI: wait on mRepliesCondition
Q->>L: 取出 msg
L->>L: onMessageReceived: 推状态机到 STARTED
L->>L: postReply: 写信箱 + broadcast
L-->>JNI: 唤醒
JNI->>JNI: 读 mReply 得到 status_t
JNI-->>App: start() 返回 OK
注意整条链路上没有任何一把锁保护 mState:所有入口(App 线程、底层回调线程、Looper 自己)都只 post 消息,状态机只在 CodecLooper 这一条线程上读写——这是后面状态机能”无锁”的根本原因,第四篇会展开。
4. JMediaCodec:JNI 桥的薄壳
Java 侧 codec.start() 走的不是直接下 Native,而是经过一层 JMediaCodec:
Java: codec.start() |
4.1 三层对象:谁是谁
讨论”codec.start() 在 JNI 之下发生了什么”之前,先把整条链路上到底有几个对象、各自归谁管画清楚——这张图后面整篇文章都会反复用到:
%%{init: {'flowchart': {'subGraphTitleMargin': {'top': 14, 'bottom': 14}, 'nodeSpacing': 50, 'rankSpacing': 70}}}%%
flowchart LR
subgraph J["Java 层(ART)"]
JM["MediaCodec.java
mNativeContext: long"]
end
subgraph B["JNI 桥
android_media_MediaCodec.cpp"]
JNI["JMediaCodec
: public RefBase
sp<MediaCodec> mCodec"]
end
subgraph N["Native 层(libstagefright)"]
MC["MediaCodec.cpp
真正的状态机
持有 CodecLooper"]
end
JM -. "mNativeContext
存 JMediaCodec*" .-> JNI
JNI == "sp<> 强引用" ==> MC
classDef java fill:#BBDEFB,stroke:#1565C0,stroke-width:2px,color:#0D47A1
classDef jni fill:#FFF59D,stroke:#F57F17,stroke-width:2px,color:#3E2723
classDef native fill:#FFCC80,stroke:#E65100,stroke-width:2px,color:#BF360C
class JM java
class JNI jni
class MC native
style J fill:#E3F2FD,stroke:#1976D2,stroke-width:1.5px,color:#0D47A1
style B fill:#FFF9C4,stroke:#F9A825,stroke-width:1.5px,color:#5D4037
style N fill:#FFE0B2,stroke:#EF6C00,stroke-width:1.5px,color:#BF360C
三个对象,一一对应不会变:
| 层 | 对象 | 归属 | 职责 |
|---|---|---|---|
| Java | MediaCodec.java |
ART / GC 管理 | 用户面对的 API;只是个壳 |
| JNI | JMediaCodec |
sp<> 引用计数管理 |
翻译 Java 调用 ↔ Native 调用 |
| Native | MediaCodec.cpp |
由 JMediaCodec::mCodec 强引用 |
真正的状态机、CodecLooper、PostAndAwaitResponse |
绑定关系靠 Java 侧的一个 private long mNativeContext 字段——里面存的就是 JMediaCodec* 指针的整数值(64-bit 系统下 8 字节)。native_setup 时通过 SetLongField 写入;之后每个 JNI 入口的第一步都是 getMediaCodec(env, thiz) 把这个 long 强转回 JMediaCodec* 用:
sp<JMediaCodec> codec = getMediaCodec(env, thiz); |
整个 android_media_MediaCodec.cpp 里 95% 的 JNI 入口都是这个模板:取 JMediaCodec → 调一次方法 → 把 status_t 翻成 Java 异常或返回值。本篇主线讲的”Java 同步调用怎么落到 Native”,这一层就是承接点,但本身没有任何业务逻辑。
4.2 唯一一把 sLock:与前文”无锁”形成对照
第 3 节强调过:Native 那一层的 mState 是无锁的,依靠 CodecLooper 单线程串行避免竞争。但翻开 JNI 文件,会看到一把全局静态锁:
static Mutex sLock; |
为什么 Native 都做到无锁了,JNI 这一薄层反而要加锁?因为这两层面对的调用者根本不一样:
| 层 | 调用方 | 是否需要锁 |
|---|---|---|
Native MediaCodec.cpp 的 mState |
只有 CodecLooper 一条线程能改 | 不需要 |
JNI mNativeContext 字段 |
任意 Java 线程都能直接调 JNI 入口 | 必须加锁 |
具体的并发场景就是 release vs 其他方法。假设没有 sLock,两条线程会这样交错:
sequenceDiagram
participant A as 线程 A(release)
participant F as mNativeContext
(Java 字段)
participant B as 线程 B(queueInputBuffer)
A->>F: 读出 old 指针
B->>F: 读出同一个 old 指针
A->>A: old->decStrong()
JMediaCodec 析构
A->>F: 写入 nullptr
Note over B: B 手里的指针已经悬空
B->>B: codec->queueInputBuffer(...)
use-after-free
问题点很清楚:A 还没把字段清空、对象还没析构之前,B 已经把指针读走了;等 A 析构完,B 手上那份指针就成了野指针。
sLock 把 getMediaCodec 和 setMediaCodec 串起来后,B 只可能看到两种结果:
- 要么 A 还没开始:B 拿到一个完整的
sp<JMediaCodec>,sp<>一旦构造就持有强引用,A 那边的decStrong至多让引用计数减 1,对象不会在 B 手里被析构; - 要么 A 已经做完:B 拿到
nullptr,提前返回错误。
两种结果都安全,不会出现”拿到一个正在死的对象” 这种中间态。
注意这把锁只保护 mNativeContext 这一个 8 字节字段——一进一出粒度极小,对吞吐几乎没影响。真正的状态机仍然在下层用消息驱动维持无锁。可以这样记:
JNI 这一层的 sLock,护的是”Java 对象 ↔ Native 对象的指针绑定”;
Native 那一层的无锁,靠的是”所有改动都走 Looper 串行”。
两层各管各的,组合起来就是 MediaCodec 那种”看上去多线程随便调,实际从不出竞争”的体验。
(JMediaCodec 还承担第二件事——把 Native 事件反向转回 Java Callback——那是异步 API 的路径,涉及 Global Ref、AttachCurrentThread、jmethodID 缓存等一整套 JNI 跨语言回调技巧,本篇不展开。)
5. 一次完整闭环:dequeueOutputBuffer
看一次 dequeueOutputBuffer 这个带 timeout 的同步 API 的完整来回,把前面所有零件串起来:
sequenceDiagram
participant App as App 线程
participant JNI as JMediaCodec
participant MC as MediaCodec.cpp
participant CL as CodecLooper
App->>JNI: dequeueOutputBuffer(timeout)
JNI->>MC: mCodec->dequeueOutputBuffer
MC->>CL: post kWhatDequeueOutputBuffer
MC->>MC: PostAndAwaitResponse 阻塞
Note over CL: 队列里没有可用 buffer 就先不回
Note right of CL: 底层数据准备好后
另一条消息入队
CL->>CL: 把 buffer 放进 mAvailPortBuffers
CL->>CL: 取出之前 pending 的 dequeue msg 处理
CL->>MC: postReply(携带 index)
MC-->>JNI: 条件变量唤醒
JNI-->>App: 返回 index
两条关键:
- 带 timeout 的同步等待:
PostAndAwaitResponse内部走条件变量的 timed wait——超时直接返回INFO_TRY_AGAIN_LATER,不等到天荒地老 - 消息按 FIFO 串行处理:dequeue msg 投早了没数据就先挂着,等数据到位的消息进来后被一并取出来回复。整条同步语义不依赖任何锁保护字段,全靠 CodecLooper 单线程串行 + token 唤醒
总结
本篇沿着一次”看上去同步”的 Java 调用下钻到 Native 侧,讲清了四件事:
- AOSP 为什么自造 A 系列原语(ALooper / AHandler / AMessage),以及和 Java Handler 的关键差异——原生支持同步等待
- CodecLooper 如何用”每实例一条线程串行处理”换来状态机的天然无锁
- PostAndAwaitResponse 怎样通过
AReplyToken+ 条件变量,把异步消息包装成 Java 看到的”同步调用” - JMediaCodec /
sLock为什么只锁mNativeContext一个字段,就能避免release与并发调用之间的 use-after-free
一句话收束:下层 Looper 串行做到无锁,上层 JNI 只锁一个字段防悬空——这就是 MediaCodec 同步 API 在 Native 侧的全部秘密。