先看结论与判断条件
- 把 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 的异步结果即使在新库加载后到达,也只能被丢弃和记录,不能根据“模块又可用了”写入新实例。这个代次规则与线程停止规则相互补充:前者阻断迟到数据,后者避免处理器仍执行已经失效的代码路径。
| 状态 | 允许动作 | 禁止动作 | 进入下一状态 |
|---|---|---|---|
| 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 已通过。最终结论仍要来自具体线程路径和设备端运行记录。
| 字段 | 用途 | 不合格表现 | 门禁 |
|---|---|---|---|
| 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,需要分别核对全局引用、弱全局引用、线程局部引用、回调队列和类加载上下文。某个入口在静态扫描中存在或注销函数返回成功,都不能替代迟到回调与异常路径测试。
| 对象 | 停止新调用 | 等待条件 | 最终动作 |
|---|---|---|---|
| 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 level | minSdk 与动态 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、指针、对象与设备回归
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 和设备端回执。