JNI(二):Java 里的一个 long,为什么能找到 C++ 对象?

JNI(二):Java 里的一个 long,为什么能找到 C++ 对象?

上一篇沿着 nativePollOnce() 解决了第一个断点:

text 复制代码
Java native 声明
        ↓ 动态注册
C++ 函数地址

但 C++ 入口里紧接着出现了第二个断点:

cpp 复制代码
NativeMessageQueue* nativeMessageQueue =
        reinterpret_cast<NativeMessageQueue*>(ptr);

Java 传来的是一个 long,C++ 为什么能把它变成 NativeMessageQueue*?

如果只回答"mPtr 保存了指针",仍然不够。读者还会继续困惑:

  • Java MessageQueue 和 C++ NativeMessageQueue 是不是同一个对象的两种写法?
  • JNI 会不会自动为 Java 对象创建一个 C++ 对象?
  • jobject 是不是 Java 对象的内存地址?
  • Java 对象被 GC 后,Native 对象会不会一起释放?
  • JNIEnv* 和 jobject 为什么不能随便跨线程、跨调用保存?

本文沿着四个方法建立完整的对象模型:

text 复制代码
nativeInit      创建关系
nativePollOnce  使用关系
nativeWake      使用关系
nativeDestroy   解除关系

**范围说明:**本文主线基于 Android 16 LegacyMessageQueue。CombinedMessageQueue 可按平台 flag 和进程条件启用 Concurrent 实现,其队列结构和跨线程 mPtr 保护不属于本文范围;具体判定放在文末版本附注。

一、第一层对象模型:Java MessageQueue 与 NativeMessageQueue 是两个对象

Java 与 C++ 各有自己的对象:

text 复制代码
Managed Heap                         Native Memory / C++ Heap

Java MessageQueue                    NativeMessageQueue
┌──────────────────┐                 ┌────────────────────┐
│ Java 消息链表     │                 │ sp<Looper> mLooper │
│ mBlocked         │                 │ pollOnce()         │
│ mPtr ────────────┼──── 地址值 ────▶│ wake()             │
└──────────────────┘                 └────────────────────┘
       │                                       │
       │ ART / GC 管理                         │ C++ 引用计数管理
       ▼                                       ▼

需要先记住三句话:

Java MessageQueue 与 NativeMessageQueue 是两个独立对象。
JNI 提供跨边界调用和引用规则,但不会自动创造两者的一一对应关系。
mPtr 是 Android Framework 主动设计的关联字段,不是 JNI 自动生成的能力。

这与 Binder、文件描述符等场景的"Java 持有一个数字,再用它找到 Native 资源"在设计思路上相似,但数字的具体含义不同:这里确实是进程内 Native 对象地址转换而来的值;Binder handle 和 fd 则由各自子系统解释,不能混为同一种指针。

二、nativeInit() 怎样创建 Native 对象?

Java 构造 MessageQueue 时:

java 复制代码
MessageQueue(boolean quitAllowed) {
    mQuitAllowed = quitAllowed;
    mPtr = nativeInit();
}

源码位置:MessageQueue 构造函数。

nativeInit() 的 C++ 实现是:

cpp 复制代码
static jlong android_os_MessageQueue_nativeInit(
        JNIEnv* env, jclass clazz) {
    NativeMessageQueue* nativeMessageQueue = new NativeMessageQueue();
    if (!nativeMessageQueue) {
        jniThrowRuntimeException(env, "Unable to allocate native queue");
        return 0;
    }

    nativeMessageQueue->incStrong(env);
    return reinterpret_cast<jlong>(nativeMessageQueue);
}

源码位置:android_os_MessageQueue_nativeInit()。

按执行顺序翻译:

text 复制代码
1. new NativeMessageQueue()
   在进程的 Native 内存中创建 C++ 对象。

2. incStrong(env)
   手动增加一份与 Java mPtr 生命周期配对的强引用,
   使 NativeMessageQueue 在后续使用期间保持存活。

3. reinterpret_cast<jlong>(nativeMessageQueue)
   把指针值编码进 Java long 可承载的 jlong。

4. Java mPtr = nativeInit()
   Java 对象保存这个值,供后续 native 调用带回 C++。

mPtr 不是 NativeMessageQueue 本体,也不是 Java 对象地址。它只是 Java 侧保存的一枚 Native 句柄;在这个具体实现里,句柄值来自 NativeMessageQueue*。

三、第二层对象模型:NativeMessageQueue 内部还关联独立的 Native Looper

构造函数继续建立当前线程与 Native Looper 的关系:

cpp 复制代码
NativeMessageQueue::NativeMessageQueue()
        : mPollEnv(NULL), mPollObj(NULL), mExceptionObj(NULL) {
    mLooper = Looper::getForThread();
    if (mLooper == NULL) {
        mLooper = new Looper(false);
        Looper::setForThread(mLooper);
    }
}

源码位置:NativeMessageQueue::NativeMessageQueue()。

这段代码也能纠正"JNI 会切到 Native 线程"的误解:

text 复制代码
Looper::getForThread()

取的是当前线程关联的 Native Looper;如果还没有,就创建一个并通过线程局部状态与当前线程关联。不是另起一个线程执行消息循环。

于是对象关系进一步变成:

text 复制代码
当前 Linux Looper 线程
        │
        ├─ Java Looper
        │      └─ Java MessageQueue
        │              └─ long mPtr
        │
        └─ Native Looper
               ▲
               └─ NativeMessageQueue 持有 sp<Looper>

Java MessageQueue 负责 Java 消息链表;Native Looper 负责 fd / eventfd 等待与唤醒,也可以调度 Native MessageEnvelope。NativeMessageQueue 是二者之间的适配层;第三篇会继续展开 Native Looper 怎样合并 Java Message 与 Native Message 的截止时间。

四、使用时为什么能把 jlong 转回指针?

等待路径:

cpp 复制代码
static void android_os_MessageQueue_nativePollOnce(
        JNIEnv* env, jobject obj, jlong ptr, jint timeoutMillis) {
    NativeMessageQueue* nativeMessageQueue =
            reinterpret_cast<NativeMessageQueue*>(ptr);
    nativeMessageQueue->pollOnce(env, obj, timeoutMillis);
}

唤醒路径:

cpp 复制代码
static void android_os_MessageQueue_nativeWake(
        JNIEnv* env, jclass clazz, jlong ptr) {
    NativeMessageQueue* nativeMessageQueue =
            reinterpret_cast<NativeMessageQueue*>(ptr);
    nativeMessageQueue->wake();
}

源码位置:nativePollOnce() 与 nativeWake()。

之所以能转回,不是 C++ 能从任意数字推导出对象,而是因为这个值最初就是同一进程中那个指针编码成的 jlong,并且对象在使用期间仍然存活。

这里隐含了三个必须同时成立的前提:

text 复制代码
ptr 来自正确的 nativeInit()
NativeMessageQueue 尚未销毁
调用发生在同一进程地址空间

只要破坏其中一个,reinterpret_cast 仍然可能"转换成功",但解引用时会进入未定义行为,常见结果就是 native crash。

因此,不要把这种做法理解为"所有 Java long 都可以安全变成 C++ 指针"。这是由 Framework 封装、生命周期受控的内部协议。

五、销毁时为什么是 decStrong(),不是简单 delete?

Java 侧释放路径:

java 复制代码
private void dispose() {
    if (mPtr != 0) {
        nativeDestroy(mPtr);
        mPtr = 0;
    }
}

C++ 侧:

cpp 复制代码
static void android_os_MessageQueue_nativeDestroy(
        JNIEnv* env, jclass clazz, jlong ptr) {
    NativeMessageQueue* nativeMessageQueue =
            reinterpret_cast<NativeMessageQueue*>(ptr);
    nativeMessageQueue->decStrong(env);
}

源码位置:MessageQueue.dispose()、nativeDestroy()。

对于这里使用默认 strong lifetime 的 NativeMessageQueue,incStrong() 和 decStrong() 形成强引用计数上的增加---释放配对,最后一份强引用释放会触发对象销毁;RefBase 另有扩展 weak lifetime,不能把这句话外推成所有模式的绝对规则。传入的 env 不参与强引用计数值的计算,也不是 Java 对象的所有权凭证;启用 DEBUG_REFS 后,它还会作为引用跟踪 id,用于关联和诊断引用增减记录。

这里还要把 Native 侧的两个对象拆开:mPtr 直接关联的是 NativeMessageQueue,不是 Native Looper 本体。MessageQueue 基类通过成员 sp<Looper> mLooper 持有一份 Looper 强引用。NativeMessageQueue 的析构函数体本身为空;对象析构过程中,MessageQueue 基类成员 sp<Looper> mLooper 会自动析构,从而释放该对象持有的这一份 Looper 强引用。它不会直接强制销毁 Looper。与此同时,Looper::setForThread() 还会把同一个 Looper 保存进当前线程的 thread_local static sp<Looper> gThreadLocalLooper。

因此,NativeMessageQueue 被销毁,不代表 Native Looper 一定同时销毁。只要线程局部的 gThreadLocalLooper 或其他 sp<Looper> 仍持有强引用,Looper 就可以继续存活;Looper 持有的 mWakeEventFd 句柄要等自身最终析构时才随 unique_fd 成员关闭,而句柄引用的 eventfd 对象与计数器由 Linux 内核管理。源码位置:MessageQueue::mLooper、NativeMessageQueue 析构、gThreadLocalLooper。

这条链实际上包含三类需要分别判断的生命周期:

text 复制代码
Java MessageQueue      → ART / GC 与 Looper 引用关系
NativeMessageQueue     → mPtr 配对的 RefBase 强引用
Native Looper          → mLooper、gThreadLocalLooper 等 sp<Looper> 强引用

这比"Java GC 自动删除 C++ 对象"准确得多:

text 复制代码
Java GC
直接管理 Java MessageQueue 是否可达

C++ RefBase
直接管理 NativeMessageQueue 的强引用计数

dispose / nativeDestroy
协调两边生命周期

Java GC 不知道任意 new NativeMessageQueue() 分配了多少 Native 内存,也不知道何时可以安全释放。Framework 必须提供一条明确的销毁路径。

把 mPtr 设回 0 也不是装饰,但这一步本身不释放 Native 内存。真正释放配对强引用的是前一行 nativeDestroy(mPtr) → decStrong(env);随后赋值只是把 Java 句柄标记为失效:

text 复制代码
这个 Java 对象已经不再持有可用的 NativeMessageQueue。

还要注意两行之间的执行窗口:decStrong(env) 如果释放了最后一份强引用,NativeMessageQueue 可以在 nativeDestroy() 返回前就完成析构;此时 Java 字段尚未执行 mPtr = 0,仍暂时保留旧地址值。因此,mPtr != 0 不等于 Native 对象此刻必然有效,清零也不是锁、内存屏障或防止并发 use-after-free 的机制。真正的并发安全来自 Legacy 的 MessageQueue 锁与生命周期协议,或 Concurrent 的在途 mPtr 引用保护。

在正常的 Framework 调用路径中,这两套协议会保护实际使用过程;这里强调的只是脱离协议时,单独检查地址值是否非零不能作为对象有效性的并发证明,而不是说正常调用中的 mPtr 天然不可靠。

后续代码可以据此拒绝继续使用旧句柄,降低重复释放或 use-after-free 的风险。

到这里,mPtr 的创建、转换、使用与销毁主链已经闭环。至于 dispose() 为什么会执行,将在"六、dispose() 到底由谁触发?"继续说明。

进阶补充:Native Looper 比队列活得更久,fd callback 为什么不会 UAF?

这一节不影响理解 mPtr 主链,主要用于解释 fd callback 的生命周期安全;第一次阅读可以先跳到下一节。

如果 Native Looper 仍由 TLS 中的 gThreadLocalLooper 持有,而 NativeMessageQueue 已经析构,Looper 里原先注册的 fd callback 会不会回调悬空对象?AOSP 没有让 Looper 直接强持有原始队列 callback,而是插入了一层 WeakLooperCallback:

text 复制代码
Native Looper
    │ 强持有
    ▼
WeakLooperCallback 代理
    │ wp<LooperCallback> 弱引用
    ▼
NativeMessageQueue

注册 fd 时创建的是代理对象:

cpp 复制代码
mLooper->addFd(
        fd,
        Looper::POLL_CALLBACK,
        looperEvents,
        sp<WeakLooperCallback>::make(this),
        reinterpret_cast<void*>(events));

事件发生后,代理先尝试把弱引用提升成临时强引用:

cpp 复制代码
int WeakLooperCallback::handleEvent(int fd, int events, void* data) {
    sp<LooperCallback> callback = mCallback.promote();
    if (callback != nullptr) {
        return callback->handleEvent(fd, events, data);
    }
    return 0;
}

这形成了完整的安全分支:

text 复制代码
wp.promote()
├─ 成功:临时取得 sp,保证 handleEvent() 执行期间对象存活
└─ 失败:NativeMessageQueue 已销毁,返回 0
                                      ↓
                 Looper 移除对应 fd 的 callback 注册

因此,Native Looper 可以比 NativeMessageQueue 活得更久,却不会仅凭旧 fd 注册解引用已经销毁的队列。源码位置:WeakLooperCallback 定义与实现、setFileDescriptorEvents()、Looper 处理 callback 返回值。

六、dispose() 到底由谁触发?

在本文核对的 Android 16 LegacyMessageQueue 中,dispose() 有两个直接调用入口:

text 复制代码
正常退出路径:MessageQueue.next() 发现 mQuitting == true
GC 兜底路径:MessageQueue.finalize()

1. 正常退出:quit() 不直接调用 dispose()

Looper.quit() 与 quitSafely() 都只是把退出请求转交给 MessageQueue.quit(safe)。核心动作只有设置退出标记,并叫醒可能正在等待的 Looper:

java 复制代码
void quit(boolean safe) {
    synchronized (this) {
        if (mQuitting) return;
        mQuitting = true;
        if (safe) {
            removeAllFutureMessagesLocked();
        } else {
            removeAllMessagesLocked();
        }
        nativeWake(mPtr);
    }
}

注意,此时还没有执行 nativeDestroy()。真正清理发生在 Looper 线程再次进入 next() 并走到退出分支时:

java 复制代码
if (mQuitting) {
    dispose();
    return null;
}

next() 返回 null 后,Looper.loop() 会结束。但 quit(false) 与 quitSafely() 到达该分支的时机不同:

text 复制代码
quit(false)
→ 删除全部待处理消息
→ nativeWake
→ next() 发现 mQuitting
→ dispose()

quitSafely()
→ 删除未来消息,保留已经到期的消息
→ nativeWake
→ next() 继续返回并 dispatch 已到期消息
→ 没有可继续处理的消息后
→ dispose()

源码位置:MessageQueue.quit() 与 next() 的退出分支、Looper.quit()/quitSafely()。

因此,主动退出路径相对明确,但清理不是在调用退出方法的那一行同步完成。quit(false) 要等 Looper 线程重新推进到 next();quitSafely() 还可能继续 dispatch 多条已经到期的消息,直到没有可继续返回的消息才执行 dispose()。

2. GC 兜底:finalize() 会调用 dispose(),但时间不确定

同一份源码还保留了:

java 复制代码
@Override
protected void finalize() throws Throwable {
    try {
        dispose();
    } finally {
        super.finalize();
    }
}

这说明 Java MessageQueue 最终进入终结处理时,也会尝试释放 mPtr。但它只能看成兜底:对象何时变得不可达、何时执行 finalizer 都没有确定时限;只要 Looper、线程局部状态或其他对象仍然引用这条队列,GC 路径就不会发生。

这里是在解释这份 AOSP 源码的兜底行为,不是在推荐一般 JNI 代码依赖 finalize() 管理 Native 资源。

所以不能写成"Java 对象不可达后,Native 对象立即释放"。准确说法是:

在 Looper 线程能够继续正常推进消息循环的前提下,主动 quit/quitSafely 会使它进入 dispose(),但 quitSafely() 会先处理已经到期的消息;finalizer 提供非确定时间的兜底。nativeDestroy() 定义释放动作,但其执行时机取决于 Java 侧何时真正触发 dispose()。

3. MessageQueue 生命周期为什么跟 Looper 和线程绑在一起?

生命周期归属只需记住三点:

text 复制代码
可退出 Looper:通常由 quit / quitSafely 驱动 dispose。
主线程 Looper:禁止 quit,通常随所在进程一直存在。
finalize:只提供触发时间不确定的兜底,不是推荐的资源管理方案。

"队列暂时没有消息"只会让线程等待,不会自动退出。第二篇的生命周期闭环因此由对象创建、Looper 主动退出和 GC 兜底三条事实共同组成。

七、补充模型:不要把 jobject、JNIEnv* 当成 mPtr

到这里,mPtr 的创建、使用、主动退出和 GC 兜底已经闭环。下面不再扩展另一条主线,只做一次反向对照:jobject 和 JNIEnv* 虽然也表现为指针形态,却不能按 mPtr 的规则保存。

jobject 不是 Java 对象裸地址

nativePollOnce() 收到的 jobject obj 表示当前 Java MessageQueue,但它是 ART 可识别的 JNI 引用,不是可以永久保存的 Java Heap 裸地址。传给 native 方法的对象参数默认是 local reference:

text 复制代码
local reference 只在当前线程、当前 native 调用期间有效。
跨越这次调用长期持有 Java 对象,需要 NewGlobalRef / DeleteGlobalRef。

所以方向要分开:Java 用 mPtr 保存 Native 句柄;Native 若要长期保存 Java 对象,必须使用 ART 能追踪的引用机制。

为什么 mPollObj 暂存 jobject 却没有变成 GlobalRef?

NativeMessageQueue::pollOnce() 会临时保存传入的 env 和 pollObj:

cpp 复制代码
void NativeMessageQueue::pollOnce(
        JNIEnv* env, jobject pollObj, int timeoutMillis) {
    mPollEnv = env;
    mPollObj = pollObj;

    mLooper->pollOnce(timeoutMillis);

    mPollObj = NULL;
    mPollEnv = NULL;
}

保存它们的真实用途,是在其他 fd 就绪时从 Native callback 回到 Java MessageQueue.dispatchEvents()。下面只保留事件转换后的 Java 回调点:

cpp 复制代码
int NativeMessageQueue::handleEvent(int fd, int looperEvents, void* data) {
    int events = /* 把 looperEvents 转成 Java 侧事件位 */;
    int newWatchedEvents = mPollEnv->CallIntMethod(
            mPollObj,
            gMessageQueueClassInfo.dispatchEvents,
            fd,
            events);
    if (!newWatchedEvents) return 0;
    // 若监听事件变化则更新 fd 注册。
    return 1;
}

完整实现见 NativeMessageQueue::handleEvent()。fd callback 仍发生在同一次 nativePollOnce() 内、同一个 Looper 线程上,并在返回 Java 前清空 mPollObj/mPollEnv,因此没有跨越 local reference 和当前 JNIEnv* 的有效窗口。若改成"返回以后由另一个线程回调",才需要 GlobalRef、回调线程自己的 JNIEnv* 以及新的释放规则。

JNIEnv* 为什么不能跨线程保存?

JNIEnv* 属于当前已附着线程,不能把线程 A 的值交给线程 B。只需记住这个对照:

text 复制代码
Java 线程调用 nativePollOnce
→ 已经附着,直接收到本线程 JNIEnv*,不需要再次 attach。

Native 自建线程要调用 Java
→ 通过 JavaVM AttachCurrentThread,取得该线程自己的 JNIEnv*。

完整引用表、Native 线程回调和 detach 规则属于 JNI 生命周期专题,不再向下展开。

八、把四种"指针形态"彻底分开

JNI 代码里经常同时出现几种看起来像指针的东西,最容易互相替换着理解。

形式 在本文中的含义 能否长期作为有效句柄/引用使用 谁理解它
Java long mPtr / JNI jlong Native 指针编码成的句柄值 数值可以保存;只有对应 Native 对象存活且并发协议成立时才是有效句柄 Framework 自己的协议
NativeMessageQueue* C++ 对象指针 只有持有有效所有权,或处于受保护的借用期内,才能安全解引用 C++
jobject ART 可识别的 Java 对象引用 local 引用仅在创建线程和当前 native 调用窗口内有效;长期需 GlobalRef ART / JNI
JNIEnv* 当前线程的 JNI 接口与上下文 只能由对应的已附着线程使用 ART / 当前线程

它们在源码中都表现为底层整数、指针或引用句柄,但解释者和生命周期完全不同。

最危险的 JNI bug,往往不是语法错误,而是把一种值按另一种值的规则保存和使用。

为什么必须区分?混淆这些值的规则,通常会导致 Native 泄漏、use-after-free、重复释放,以及跨线程错误使用 JNI 引用或 JNIEnv*。

把这个模型迁移到其他 Java/Native 双对象时,只需检查六个问题:

text 复制代码
谁创建?
谁持有?
谁销毁?
销毁后怎样标记失效?
是否可能并发使用?
Native 是否长期持有 Java 对象?

九、本文闭环:mPtr 到底是什么?

现在可以准确回答:

Java MessageQueue 与 C++ NativeMessageQueue 是同一进程中的两个独立对象。nativeInit() 创建 Native 对象,并用 incStrong(env) 为 Java mPtr 关联关系建立一份手动强引用;nativePollOnce()、nativeWake() 在对象存活期间把句柄值传回 C++。在 Looper 线程能够继续正常推进的前提下,主动 quit/quitSafely 会使其进入 next() 的退出分支;finalizer 则只是时间不确定的 GC 兜底。dispose() → nativeDestroy() → decStrong() 释放的是这份配对强引用,mPtr = 0 只把 Java 句柄标记为失效。强引用释放后,NativeMessageQueue 是否立即析构仍取决于是否存在其他强引用;它即使析构,也不能证明 Native Looper 同时销毁。Looper 中遗留的 fd 注册通过 WeakLooperCallback + wp.promote() 安全失效,不会直接回调悬空队列。三类对象的关系与生命周期由 Framework 显式协调,而不是 JNI 或 GC 自动完成。

至此,我们解释了"方法怎样连上"和"对象怎样找到"。第三篇把两者放回 Handler 的真实现场:

text 复制代码
为什么 nativePollOnce 能让线程等待?
为什么 nativeWake 能把它叫醒?
JNI 在这条链上负责哪一段?
为什么再往后的 Binder 不是 JNI 的另一种写法?

源码与延伸阅读

版本附注:Android 16 CombinedMessageQueue 的启用范围

这里的开关是 Framework 平台 flag useConcurrentMessageQueueInApps,不是 App 开发者可自行配置的普通应用选项。该 flag 开启时,非 Robolectric 进程可进入 Concurrent 实现;否则源码继续按进程名与 core UID 判断,并排除部分测试进程。

Legacy 模式并不禁止其他线程调用 nativeWake() 等 Native 方法:enqueueMessage() 本来就可以由发送线程执行,并在同一个 MessageQueue 锁内判断 needWake、使用 mPtr。Legacy 的跨线程路径依赖这把锁和原有生命周期约束。Concurrent 模式放宽了队列操作对同一把锁的依赖,因此 Looper 线程可以直接使用 mPtr,其他线程则必须在使用 Native 对象前后调用 incrementMptrRefs() / decrementMptrRefs(),保证并发 teardown 期间对象仍然存活。源码中的 mPtr 字段注释 直接列出了这两套协议;启用判断见 CombinedMessageQueue.computeUseConcurrent()。

相关推荐
Dovis(誓平步青云)10 分钟前
家里设备越来越多,如何用一张空间地图控制灯光和温度![
android·java·前端·javascript·人工智能·电脑
晚风叙码2 小时前
MySQL 数据类型详解:从数值到字符串,一篇讲透
android·mysql·adb
传奇开心果编程2 小时前
【Compose Multiplatform 跨端开发学与练】第3课 布局与组件
android·windows·学习·ui·ios·kotlin·composer
传奇开心果编程5 小时前
【Compose Multiplatform 跨端开发学与练】第8课 资源管理与主题
android·windows·学习·ios·kotlin·web·composer
传奇开心果编程7 小时前
【Compose Multiplatform 跨端开发学与练】第9课 测试与调试
android·学习·macos·ios·kotlin·web·composer
传奇开心果编程7 小时前
【Compose Multiplatform 跨端开发学与练】第4课 导航与路由
android·windows·学习·ui·ios·kotlin·composer
传奇开心果编程7 小时前
【Compose Multiplatform 跨端开发学与练】第6课 状态管理与架构
android·学习·ui·ios·架构·kotlin·composer
事圆则缓7 小时前
Android AOSP 定制常见概念:源码目录、系统镜像与刷机流程
android
传奇开心果编程7 小时前
【Compose Multiplatform 跨端开发学与练】第2课 Compose 基础语法
android·windows·学习·ui·ios·kotlin·composer
supabc1238 小时前
Celium:连接 Windows、Mac、Linux 与 Android,让远程访问和设备管理更简单
android·linux·windows·macos·远程访问·网络管理·celium