先看结论与判断条件

  • 把 dlclose 视为卸载流程的最后提交点,而不是资源回收入口;任何新任务、回调或指针借用都必须在 draining 前停止。
  • 卸载状态机至少区分 loaded、draining、quiesced 与 unloaded,并用 generation 拒绝旧任务在新实例加载后迟到回写。
  • Native 线程需要可观察的停止请求、退出回执和 join 责任;只设置布尔标志或等待固定时间不能证明线程已经离开 SO 代码。
  • JNI 方法、Java 回调、全局引用、函数指针、虚表和跨库对象属于不同租约,必须分别登记所有者与释放顺序。
  • 链接器 namespace、API level、ABI、依赖装载和异常运行库会影响动态加载路径,单一设备或静态检查不能代表完整兼容。
  • 诊断只记录状态、代次、活动计数和错误类别,不公开真实库名、符号、地址、线程标识、设备信息或可复现攻击步骤。

先判断业务是否真的需要动态卸载

很多移动应用只需要在进程生命周期内加载一次 Native 库,退出进程时由系统统一回收。若产品没有插件切换、可选算法模块、内存峰值控制或独立故障域等明确需求,主动卸载往往增加的不是收益,而是线程、回调、对象和依赖的组合风险。设计评审应先写出卸载触发条件、可接受停顿、失败策略和重新加载语义;仅以“暂时不用”作为理由,无法支撑复杂生命周期。

动态库句柄只是可见的一部分。库内可能启动工作线程、注册 JNI 方法、缓存 JavaVM 或全局引用、向宿主返回函数指针、创建 C++ 对象、持有文件描述符、安装定时器,或把回调交给其他模块。释放句柄并不能证明这些外部引用已经消失。工程上应把所有能在库代码外继续存活的对象列成租约清单,并明确谁创建、谁停止、谁等待、谁确认归零。

如果无法建立完整租约清单,更稳妥的策略是保持库加载到进程结束,或把需要频繁替换的工作放进独立进程。本文只讨论已确认需要动态卸载时的生命周期门禁,不声称某种架构对所有 App 都更快或更省内存。是否能够真正卸载、映射何时释放以及依赖如何处理,要由目标平台、具体加载方式和设备回执确认。

是否采用动态卸载的决策项
决策项需要的证据高风险表现建议动作
业务触发明确模块切换条件仅因暂时不用保持进程内常驻
资源收益目标设备测量只凭静态体积推断先建立基线
外部租约线程与回调清单无法列出所有者禁止卸载
失败策略超时后的安全状态强行继续 dlclose保持已加载并告警
重新加载新 generation 规则复用旧指针重建全部句柄
设备覆盖API 与 ABI 回执单一环境通过扩展运行矩阵

用状态机把停止接收放在 dlclose 之前

卸载入口首先把模块从 loaded 原子切换到 draining。这个状态变化需要早于任务取消,因为新的 Java 调用、Native API 调用、定时器和队列消费者必须立即被拒绝。调用方收到 module_draining 这类可区分错误后,选择等待、切换实现或结束业务,而不是继续取得函数指针。若关闭入口与任务扫描之间没有原子边界,扫描刚结束就可能进入一个新任务。

draining 阶段只允许收尾动作:发出停止请求、撤销队列、等待线程、注销回调、释放对象和归还指针租约。每个动作必须产生结构化回执,不能只写一条“开始卸载”日志。所有活动计数归零且依赖关系满足后,状态机进入 quiesced;只有 quiesced 可以调用句柄释放操作。若任何一项在限定的业务时间内不能归零,正确结果是保持 draining 或恢复为 loaded,而不是越过门禁。

unloaded 是不可逆的当前代次终态。重新加载必须生成新的 generation,创建新的入口表、回调注册和任务上下文。旧 generation 的异步结果即使在新库加载后到达,也只能被丢弃和记录,不能根据“模块又可用了”写入新实例。这个代次规则与线程停止规则相互补充:前者阻断迟到数据,后者避免处理器仍执行已经失效的代码路径。

Native 模块卸载状态机
状态允许动作禁止动作进入下一状态
loaded正常调用与新租约无归属调用原子关闭入口
draining取消、join、注销、释放创建任务与借出指针所有租约归零
quiesced最终一致性检查执行库内业务门禁全部通过
unloaded保留最小终态回执调用旧代码或对象仅新代次重载
failed记录阻塞原因强制跨越状态人工或受控恢复

Native 线程必须有停止协议和 join 责任

线程回收的关键不是发出 stop,而是知道线程何时不再执行库内代码。每个由模块创建或长期借用的线程应登记 generation、用途、停止通道、阻塞点、退出回执和 joinOwner。停止协议先拒绝新工作,再唤醒可能阻塞在条件变量、队列或文件读取上的线程,让其完成有限清理并返回。只更新一个原子布尔值,如果线程没有机会观察它,不能构成回收证据。

join 由明确的宿主线程负责,不能让待卸载线程等待自己,也不能在持有该线程退出所需的锁时阻塞等待。工程评审应画出锁与 join 顺序,尤其关注回调线程、线程池 worker 和析构路径之间的循环依赖。固定 sleep 只是在时间上碰运气:设备负载、调度、I/O 和取消语义都会改变退出时间。可靠门禁读取实际退出回执和线程注册表,而不是把等待时长当成成功。

线程可能通过 JNI 附着到运行时,这属于相邻但独立的资源边界。Android JNI tips 对线程、引用、异常和类加载给出平台建议;卸载流程需要确认模块创建的附着关系、线程本地资源和 Java 回调都已结束。本文不复制 JNI attach/detach 的完整专题,也不把通用文档写成目标 App 已通过。最终结论仍要来自具体线程路径和设备端运行记录。

Native 线程回收登记字段
字段用途不合格表现门禁
generation绑定模块实例线程跨代次复用拒绝旧任务回写
stopChannel可观察停止请求只有普通布尔值要求可唤醒通道
blockingPoint定位等待位置未知阻塞源不得进入 quiesced
exitReceipt证明执行函数已返回仅记录 stop sent等待实际终态
joinOwner指定等待责任无人 join 或自 join阻断卸载
heldResources释放锁与描述符退出时仍持有完成清理后归零

JNI 回调与 Java 引用要在代码失效前注销

Java 层可能保存 Native 方法入口、listener、Runnable、对象句柄或由 Native 创建的代理。卸载前,宿主先把公开入口切换为 draining,再取消 Java 侧调度,注销 listener 和 observer,等待正在执行的回调返回。Native 层随后清理其持有的全局引用和回调上下文。顺序不能倒置:如果先删除上下文,已经排队的 Java 回调可能拿到悬空状态;如果先释放代码,已注册入口可能仍指向失效区域。

动态注册的 JNI 方法需要纳入注册表,记录 Java 所有者、Native 模块 generation、活动调用数和注销状态。静态命名入口也要在业务层关闭可达性,不能因为没有显式注册表就忽略。注销动作只说明不再接受新调用,已经进入 Native 的调用仍要通过活动计数或作用域租约等待退出。异常路径同样必须归还计数,否则一次未清理的异常就会让状态机永远无法 quiesced。

JavaVM 指针、class 引用和 method ID 的使用边界容易被混在一起。公开内容只给出生命周期方法,不提供私有类名、真实方法名、原始地址或项目内部注册表。对于目标 App,需要分别核对全局引用、弱全局引用、线程局部引用、回调队列和类加载上下文。某个入口在静态扫描中存在或注销函数返回成功,都不能替代迟到回调与异常路径测试。

JNI 与 Java 回调的卸载顺序
对象停止新调用等待条件最终动作
Native API 门面切换 draining活动调用归零替换为不可用错误
Java listener从发布者注销排队回调清空释放宿主引用
动态 JNI 方法关闭业务入口Native 调用归零完成注销记录
全局引用停止创建使用者退出由正确运行时上下文释放
Runnable 或定时器取消调度执行中的任务返回删除任务对象
异常路径拒绝重试计数在 finally 归还保存有限错误类别

函数指针和跨库对象需要显式租约

通过 dlsym 或模块工厂返回的函数指针,离开加载器后常被缓存到全局表、虚表、lambda、回调或另一个 SO 中。卸载方看不到这些裸指针是否仍在使用,因此不能靠扫描内存决定安全。工程上应由宿主发放带 generation 的调用句柄,调用前增加活动租约,返回后归还;draining 后不再发放新租约,已有租约归零才允许进入 quiesced。

C++ 对象比函数指针更隐蔽。对象的成员函数、虚表、析构函数、类型信息和异常路径都可能依赖被卸载模块;即使对象数据位于宿主堆上,调用其行为仍可能进入库代码。跨边界对象应使用由宿主控制的 opaque handle 和显式 destroy,记录创建模块与 generation。对象必须在代码仍有效时销毁,不能把最后析构推迟到 dlclose 之后,也不能让共享指针循环掩盖存活数量。

函数指针比较、空指针赋值或删除单个工厂对象都不足以证明租约清空。验收需要枚举 API 表、回调表、插件注册、任务捕获、对象池和错误恢复缓存,确认每一类都能拒绝新借用并报告活动数量。若第三方库只暴露裸指针且没有撤销协议,宿主应保持依赖常驻,或把它放进可整体终止的进程,而不是强行制造不可证明的卸载。

代码与对象租约的所有权模型
租约类型所有者记录归还信号未归零后果
函数指针模块与 generation调用作用域结束阻断 quiesced
API 表表版本与模块句柄消费者释放表禁止 dlclose
C++ 对象opaque handle 注册表显式 destroy代码仍需常驻
回调闭包调度器与捕获对象注销且队列清空拒绝卸载
虚表对象创建模块与类型类别析构回执保持模块 loaded
错误恢复缓存request 与 generation取消或过期丢弃迟到恢复

依赖、namespace 与 API level 要独立验证

Android 动态链接器会在调用者所在的 namespace 约束下解析依赖、搜索路径和允许库。AOSP linker namespace 文档用于理解隔离与可达性,但 VNDK 场景不能直接替代普通 App 进程的设备运行验证。一个模块能 dlopen 成功,不代表其依赖都能按预期卸载或重新加载;另一个模块、运行库或全局句柄仍可能持有引用。卸载清单要记录直接依赖、共享运行库和加载所有者,而不是只记录顶层文件名。

Android NDK stable APIs 说明高于 minSdk 的 Native API 不能被无条件静态调用,必要时可通过 dlopen 和 dlsym 做版本化访问。这解决的是 API 可用性,不解决业务模块的完整卸载生命周期。装载器应把符号查找失败、API 不可用和模块 draining 区分为不同错误;回退实现也要有独立 generation,不能继续使用部分初始化的句柄或旧函数表。

Android NDK common problems 汇总 API level、缺失符号、STL、异常类型和依赖装载等常见兼容故障。卸载与重载测试要在最终 ABI、minSdk、目标 API 和打包依赖上执行,尤其检查共享 C++ 运行库、异常跨边界和初始化顺序。本文没有目标包或设备回执,因此不宣称任何候选 SO 已兼容,也不把一个模拟器或一台设备的结果推广到完整厂商矩阵。

动态装载环境的验证维度
维度需要记录失败表现结论边界
namespace调用者与允许依赖库在某路径不可达按目标进程验证
API levelminSdk 与动态 API符号不可用版本化回退
ABI最终打包架构依赖或调用约定不匹配逐 ABI 回执
C++ 运行库链接方式与所有者对象或异常跨边界不得只看加载成功
依赖图直接与共享依赖顶层卸载但依赖仍活跃分别记录句柄
重新加载新 generation 与初始化复用旧状态完整重建验证

诊断要证明最后一次访问发生在卸载之前

卸载问题常以低概率崩溃、非法跳转、析构异常或长时间卡住出现。诊断记录应围绕生命周期因果:何时关闭入口、活动任务多少、哪些线程收到停止、join 何时完成、回调与函数指针租约何时归零、何时进入 quiesced、句柄释放后是否还有旧 generation 事件。不要记录真实库路径、符号、地址、原始线程标识或用户数据;公开报告只保留脱敏阶段和错误类别。

Debug Android native code 文档说明 Native 调试和 tombstone 还原依赖正确符号、架构与构建产物。项目应把未剥离符号、发布候选身份、ABI 和崩溃回执绑定起来,避免用其他构建的符号解释当前故障。符号化可以帮助定位最后访问,但不能单独证明线程、回调和对象均已回收;还需与状态机事件、租约计数和同一版本的运行路径对齐。

Android instrumented tests 适合覆盖依赖真实运行时、组件和系统 API 的语义。卸载回归可用受控测试模块反复执行加载、工作、draining、取消、join、注销、quiesced、卸载与新代次重载,并故意安排迟到回调验证拒绝路径。具体次数、设备范围和通过标准由项目风险决定;单一设备通过不能代表完整 API、ABI 和厂商覆盖,静态审计或进程未崩溃也不等于门禁闭合。

  • 每个阶段记录 generation、状态和活动计数,不记录真实符号或地址
  • 符号文件与同一发布候选、ABI 和构建身份绑定
  • 线程退出、回调注销和指针租约分别产生可复核回执
  • 卸载后主动制造迟到事件并确认旧 generation 被拒绝
  • 进程死亡、异常路径与重新加载进入独立设备回归
  • 无目标设备证据时保持兼容性、性能和稳定性结论未证实

用事件清单门禁拒绝带活动租约的 unloaded

下面的 Python 示例读取脱敏 JSON 事件序列,验证 loaded、draining、quiesced、unloaded 的顺序。每个事件包含 generation、activeTasks、nativeThreads、javaCallbacks、pointerLeases、liveObjects 和 jniRegistrations;数值不得为负,代次不得变化。进入 quiesced 和 unloaded 时所有活动计数必须为零,unloaded 之后不允许再出现当前代次事件。

示例只检查事件声明的一致性,不能证明真实线程已经离开指令区、函数指针没有藏在第三方模块、链接器已经解除映射,也不能替代 tombstone、符号和设备测试。生产门禁还应把事件源绑定发布候选和运行实例,检查丢失事件、进程死亡和日志缓冲;测试使用公开安全夹具,不包含客户数据、真实库名、地址、凭据、内网信息或攻击链。

可同时参阅本站关于 linker namespace 与 Native worker 停止 join 契约的技术说明,分别处理依赖可达性和进程退出时的通用线程回收;当前页面只回答动态卸载前的跨资源顺序。准备具体 SO 的卸载评估时,可整理脱敏依赖图、线程表、JNI 注册表、指针租约、对象所有权和设备矩阵,再从御盾中央平台提交申请;页面框架会统一呈现本站内链和中央行动按钮。

  • loaded 到 unloaded 只能按定义顺序前进,禁止状态倒退
  • generation 在同一卸载序列中保持不变
  • draining 必须明确拒绝新工作和新租约
  • quiesced 与 unloaded 要求所有活动计数归零
  • unloaded 后任何当前代次事件均视为迟到访问
  • 结构通过后仍执行真实线程、JNI、指针、对象与设备回归
Native 动态卸载事件状态机校验器
from pathlib import Path
import json
import sys

if len(sys.argv) != 2:
    raise SystemExit("usage: unload_gate.py events.json")
path = Path(sys.argv[1])
if not path.is_file():
    raise SystemExit("event file is missing")
try:
    events = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
    raise SystemExit("event file is not valid JSON") from exc
if not isinstance(events, list) or not events:
    raise SystemExit("events must be a nonempty list")

order = {"loaded": 0, "draining": 1, "quiesced": 2, "unloaded": 3}
counters = (
    "activeTasks",
    "nativeThreads",
    "javaCallbacks",
    "pointerLeases",
    "liveObjects",
    "jniRegistrations",
)
generation = events[0].get("generation")
if not isinstance(generation, int) or generation < 1:
    raise SystemExit("generation must be a positive integer")
previous = -1
seen_unloaded = False
for number, event in enumerate(events, 1):
    if not isinstance(event, dict):
        raise SystemExit(f"event {number} must be an object")
    if event.get("generation") != generation:
        raise SystemExit(f"event {number} changes generation")
    state = event.get("state")
    if state not in order or order[state] < previous:
        raise SystemExit(f"event {number} has an invalid state transition")
    if seen_unloaded:
        raise SystemExit(f"event {number} occurs after unloaded")
    values = []
    for field in counters:
        value = event.get(field)
        if not isinstance(value, int) or value < 0:
            raise SystemExit(f"event {number} has invalid {field}")
        values.append(value)
    if state in {"quiesced", "unloaded"} and any(values):
        raise SystemExit(f"event {number} enters {state} with active leases")
    if state == "draining" and event.get("acceptsNewWork") is not False:
        raise SystemExit("draining must reject new work")
    if state == "unloaded":
        seen_unloaded = True
    previous = order[state]
if not seen_unloaded:
    raise SystemExit("the trace never reaches unloaded")
print(f"validated_events={len(events)} generation={generation}")

事实依据与适用边界

以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。

本文判断事实或工程依据适用限制
Android 动态链接器会按调用者所在 namespace 约束依赖解析、搜索路径和允许库。AOSP linker namespace 说明 namespace 对 DT_NEEDED、dlopen、搜索路径和可见库的解析规则。VNDK namespace 文档不能替代普通 App 进程、目标设备与最终打包依赖的运行验证。
高于 minSdk 的 Native API 不能被无条件静态调用,必要时可使用动态符号访问做版本化处理。Android NDK stable APIs 说明 Native API 可用性与 minSdk 的关系及 dlopen、dlsym 的适用方向。API 可用性检查不证明业务模块可以安全卸载,也不保证符号在所有 namespace 中可达。
Native 兼容故障需要关注 API level、缺失符号、STL、异常类型和依赖装载。Android NDK common problems 汇总这些常见失败类别及相应排查方向。通用故障清单不能替代目标 ABI、候选包、调用路径和设备矩阵的实际回归。
JNI 注册、线程、引用、异常和类加载边界会影响 Native 桥接稳定性。Android JNI tips 对线程附着、引用管理、异常处理、类加载和方法注册给出平台建议。JNI 建议不证明某个 SO 的线程与回调已经回收,也不证明加固变换保持 ABI。
Native 崩溃还原需要与目标架构和构建产物匹配的调试信息。Debug Android native code 说明 Native 调试、符号和 tombstone 还原所需的构建与架构条件。符号化帮助定位,不等于卸载状态机、租约归零或生产兼容性已经验证通过。
依赖真实 Android 运行时、组件和系统 API 的卸载语义应通过设备端测试验证。Android instrumented tests 说明设备端测试运行于 Android 设备或模拟环境并可访问运行时 API。单一设备或模拟环境通过不能代表完整 API、ABI、依赖版本和厂商矩阵。
dlclose 应位于停止接收、线程 join、回调注销和指针租约归零之后。工程判断:外部执行路径若仍可能进入模块代码,释放句柄会产生不可证明的迟到访问窗口。具体释放语义、依赖引用和映射行为由目标平台与加载方式决定,必须以项目回执确认。
重新加载需要新 generation,旧任务和回调不得写入新模块实例。工程判断:generation 将异步工作绑定到创建它的模块实例,可在重载后拒绝旧代次事件。代次门禁只能阻止应用接受迟到结果,不能证明服务端任务、线程或底层映射已经停止。

工程常见问题

调用 dlclose 返回后,是否就能认为所有 Native 代码已经不再执行?

不能直接这样推断。应先证明线程、活动调用、JNI 回调、函数指针和跨库对象均已归零,并用目标平台与设备回执确认释放语义。

线程已经收到 stop 标志,为什么还不能立即卸载 SO?

线程可能仍阻塞、尚未观察标志或正在执行库内清理。需要可唤醒的停止协议、实际退出回执和明确 joinOwner,而不是固定等待时间。

把所有函数指针设为 null 是否足以证明安全?

不足。指针可能被复制进回调、API 表、对象虚表、任务捕获或第三方模块;应通过带 generation 的租约注册表确认所有消费者已归还。

动态注册的 JNI 方法注销后,还需要等待活动调用吗?

需要。注销只阻止后续入口,已经进入 Native 的调用仍可能执行;应等待活动调用计数归零,并覆盖异常路径的计数归还。

一台测试设备反复加载卸载没有崩溃,是否可以发布?

不能仅凭这一点。还要覆盖最终 ABI、API、依赖、异常、进程死亡、迟到回调和重新加载,并把符号与同一候选构建绑定。

事件状态机校验通过是否代表 SO 已经安全卸载?

不代表。它只检查声明顺序和计数;仍需真实线程、JNI、函数指针、对象、链接器、tombstone 和设备端回执。

想用自己的 App 验证?

提交候选包、目标系统和关键业务路径,申请御盾 PoC 与兼容性评估。

继续阅读: SO 加固与 Native 兼容性检查清单