先看结论与判断条件
- AttachCurrentThread 只为当前原生线程取得 JNIEnv,不会自动建立应用 ClassLoader 上下文,因此线程附加成功与业务类可见是两件事。
- 应用线程可以把正确 ClassLoader 传给 Native 并保存全局引用,原生线程通过 loadClass 调用;稳定核心类也可在 JNI_OnLoad 中查找后缓存全局类引用。
- JNIEnv 只能在所属线程使用,JavaVM 可以跨线程保存;原生创建的线程退出前要按责任正确 detach。
- FindClass、CallObjectMethod 和 GetMethodID 后都要检查异常,失败路径必须清理局部引用并返回明确状态,不能带挂起异常继续调用 JNI。
- R8 对通过字符串、JNI 或反射访问的类和成员需要精确可达性规则,宽泛 keep 不能代替类加载器、签名和注册检查。
- 设备验收要覆盖 Java 线程与原生线程入口、进程重建、不同 ABI、混淆候选和加固候选,并区分类加载、链接和 API level 故障。
根因通常是 ClassLoader 上下文而不是线程未附加
原生代码创建 pthread 后,通常先通过 JavaVM 的 AttachCurrentThread 为当前线程取得 JNIEnv。这个步骤让线程可以调用 JNI,却不会凭空生成一个应用 Java 调用栈。FindClass 会根据当前 JNI 调用上下文选择类加载路径;在应用 Java 方法调用下进入 Native 时,它通常能够关联应用类加载器,而纯 Native 线程没有同样上下文,查找应用业务类就可能失败。
因此排查不能只打印“AttachCurrentThread 返回成功”。需要同时记录查找入口来自 Java 回调、JNI_OnLoad 还是原生线程,类名采用哪种形式,预期 ClassLoader 从哪里取得,FindClass 或 loadClass 后是否产生异常,以及结果引用属于局部还是全局。线程已经附加但加载器错误,会稳定得到 ClassNotFoundException 或空结果;盲目重复 Attach 不会改变加载器选择。
本文只回答原生线程为什么找不到 App 业务类,以及如何建立可诊断加载边界,不扩写成一般 JNI 教程或 Native 诊断构建指南。没有最终 APK、R8 配置和设备回执时,不能断言某次 ClassNotFound 已被修复。类加载失败、Native 符号缺失、ABI 不兼容和高版本 API 调用是不同故障,必须先分类再处置。
| 入口 | JNIEnv 来源 | 应用加载器可见性 | 建议 |
|---|---|---|---|
| Java 调用 Native | VM 传入当前线程 | 通常跟随调用类上下文 | 可直接查找并验证 |
| JNI_OnLoad | JavaVM 取得 | 有加载该库的特殊上下文 | 缓存稳定类与方法 |
| Native 创建线程 | AttachCurrentThread | 不自动继承应用调用栈 | 使用缓存加载器或类引用 |
| 线程池回调 | 视创建与入口而定 | 不能凭线程名推断 | 显式记录来源 |
| 跨线程复用 JNIEnv | 错误复用 | 未定义且不安全 | 每线程单独取得 |
| 未附加线程 | 没有有效 JNIEnv | 不能调用 JNI | 先附加并管理退出 |
JavaVM 可以缓存,JNIEnv 不能跨线程共享
JNIEnv 是线程局部接口,不能从 Java 主线程保存后交给 pthread 使用。Native 层可以在 JNI_OnLoad 缓存 JavaVM 指针,每个进入 JNI 的原生线程先调用 GetEnv 判断是否已经附加;未附加时再 AttachCurrentThread,并记录本次附加责任。线程退出前只对自己执行的附加调用 DetachCurrentThread,避免把由 VM 管理的 Java 线程错误分离。
线程生命周期要用 RAII 或统一包装器管理。异常返回、取消、pthread 退出和初始化失败都必须经过同一清理路径,不能只在正常结尾 detach。长生命周期线程可以保持附加,但要明确它何时退出;短任务如果反复创建线程,需要评估附加成本和局部引用表。本文不提供性能数字,具体线程模型应由目标设备基线和真实任务量决定。
JNIEnv 有效也不意味着此前异常已经清除。某次 FindClass 失败会留下挂起的 Java 异常,在异常未检查和处理前继续调用其他 JNI 方法,后续结果可能失去解释力。诊断包装器应在每个可能抛出异常的调用后立即检查,记录非敏感阶段码,将异常传播到受控 Java 边界或转换成明确错误状态,并清理本次创建的局部引用。
- JavaVM 在 JNI_OnLoad 保存且不跨线程保存 JNIEnv
- 每个原生线程用 GetEnv 判断当前附加状态
- 只分离由当前 Native 代码主动附加的线程
- 异常、取消和线程退出共享统一清理路径
- 每次类查找和方法调用后立即检查异常
- 失败状态包含入口与阶段但不输出业务秘密
在 Java 上下文安装正确 ClassLoader
通用方案是在已知应用 Java 上下文中取得业务锚点类的 ClassLoader,将它传入 Native,并通过 NewGlobalRef 保存。Native 同时缓存 ClassLoader.loadClass 的 method ID。原生线程附加后,不再对业务类直接调用 FindClass,而是用缓存加载器的 loadClass 查找点分隔二进制类名。安装过程必须在业务线程启动前完成,并在重复安装、进程重建和卸载时有清晰生命周期。
另一种方案是在 JNI_OnLoad 中查找数量很少且稳定的锚点类,随后把 jclass 转成全局引用并缓存所需 method ID。Android JNI 指南对 JNI_OnLoad 的 FindClass 有特殊类加载语境,这使它适合做集中注册和缓存。但它不适合枚举大量业务类,也不能绕过 R8 的名称变化。缓存范围越大,类卸载、动态特性和版本变更的管理成本越高。
ClassLoader 全局引用属于长期资源,必须在明确卸载或进程结束路径释放。若应用使用动态 feature、插件或多个加载器,一个全局默认加载器可能不覆盖全部类;记录中要写清加载器来源、锚点模块和适用类集合。不能因为一次 base 模块类加载成功,就推断按需模块或插件类也可见。加载器选择是架构事实,需要与模块交付和生命周期一起设计。
| 方案 | 适用范围 | 优势 | 主要限制 |
|---|---|---|---|
| Java 传入 ClassLoader | 多类与原生线程 | 来源明确且可诊断 | 需要安装时序和全局引用管理 |
| JNI_OnLoad 缓存 jclass | 少量稳定锚点 | 集中注册和快速复用 | 不适合动态模块与大规模缓存 |
| 每次 Native FindClass | 系统类或有 Java 上下文 | 代码简单 | 纯 Native 线程可能看不到业务类 |
| 缓存类名字符串 | 仅记录意图 | 不持有引用 | 仍需正确加载器和 R8 规则 |
| 传入 Class 对象 | 调用方已知目标类 | 避免二次名称查找 | 接口耦合和生命周期需管理 |
| 动态模块加载器 | feature 或插件类 | 匹配模块边界 | 交付、卸载和并发更复杂 |
引用、方法签名和异常要作为同一契约
FindClass 或 loadClass 返回的类对象在当前 JNI 调用中通常是局部引用,不能直接保存到另一个线程或下一次异步回调。需要长期使用时,用 NewGlobalRef 创建全局引用,并在生命周期结束时 DeleteGlobalRef。大量循环查找还要及时 DeleteLocalRef,防止局部引用表累积。弱全局引用适用于允许类被回收的场景,但使用前必须重新确认对象仍存活。
类找到后,GetMethodID 或 GetStaticMethodID 还依赖精确 JNI 签名。R8、Kotlin 生成签名、装箱类型、数组和内部类名称变化都可能让查找失败。每个方法 ID 应与对应 jclass 的全局引用、签名版本和候选 buildId 一起登记。调用前保证参数类型、线程和异常状态正确,调用后立即检查异常;不能把“类已找到”当成完整桥接通过。
失败路径要避免 ExceptionDescribe 在生产日志中泄露内部类名或用户数据。受控诊断构建可以输出经过脱敏的异常类别和入口阶段,生产版本则返回稳定错误码,并在 Java 或上层业务决定重试、降级或报告。若选择 ExceptionClear,必须先将错误转换为可追踪状态,不能静默清除后继续使用空引用。挂起异常、空 jclass 和错误 method ID 都应触发明确失败。
- 跨调用或跨线程缓存使用 GlobalRef 而非 LocalRef
- 全局引用有明确创建、替换和释放时点
- 循环中的局部引用及时删除
- 方法 ID 与 jclass、签名和 buildId 共同绑定
- GetMethodID 和 Call 方法后均检查异常
- 清除异常前转换成稳定且脱敏的诊断状态
R8 可达性与 ClassLoader 是两道不同门禁
R8 无法从普通静态调用图中自动理解所有 JNI 字符串、反射名称和间接入口。若 Native 通过类名或方法签名查找业务代码,需要精确 keep 规则保留必要名称与成员,或在构建时生成可追踪注册映射。宽泛 `-keep class **` 可能暂时掩盖规则遗漏,却会削弱优化并让边界失去所有者。正确做法是从每个 JNI 注册点和查找点生成最小规则,并用 release 候选测试。
keep 规则通过只说明 R8 没有移除或改写指定入口,不说明 Native 线程使用了正确 ClassLoader。反过来,正确加载器也无法找到被 R8 改名但 Native 仍使用旧字符串的类。诊断表应把“类是否保留”“最终名称是否匹配”“加载器是否可见”“签名是否匹配”“异常是否挂起”分成独立字段,避免一个失败被宽泛规则或重复查找暂时遮盖。
Kotlin 反射还依赖运行时可发现的类、成员和元数据。若同一路径同时使用 JNI 和 Kotlin reflection,需要分别确认 kotlin-reflect 或平台能力、元数据保留和名称规则。反射文档不能替某个序列化框架或生成代码作保证。不要把反射依赖、JNI 字符串入口和 VMP 保护范围混成一个 keep 包;每类间接入口都要有明确调用者与测试。
| 门禁 | 检查证据 | 失败表现 | 修复方向 |
|---|---|---|---|
| R8 可达性 | mapping、usage 和精确 keep | 类或成员被移除改名 | 修正规则与注册 |
| 加载器来源 | 锚点类和 loader 身份 | 业务类不可见 | 安装正确 ClassLoader |
| 线程附加 | GetEnv 与 attach 责任 | 没有有效 JNIEnv | 逐线程获取并清理 |
| JNI 名称 | 斜杠或点分隔规则 | 查找目标格式错误 | 按调用 API 使用正确名称 |
| 方法签名 | 最终描述符与 method ID | NoSuchMethod 或空 ID | 绑定精确签名 |
| 异常状态 | ExceptionCheck 和阶段码 | 后续 JNI 调用失真 | 立即处理并停止路径 |
不要把类加载失败误判成链接或 API 问题
NDK 常见兼容故障还包括 API level、缺失符号、STL、异常类型和错误依赖装载。这些问题可能在 System.loadLibrary、dlopen 或首次符号解析时出现,早于 FindClass,也可能在业务路径中表现为 UnsatisfiedLinkError。诊断应先确定目标 so 已按正确 ABI 加载、依赖符号可解析、JNI_OnLoad 返回成功,再进入类加载检查。重复调整 ClassLoader 不会修复缺失 Native 符号。
高于 minSdk 的 Native API 不能被无条件直接静态调用,必要时需要通过 dlopen 和 dlsym 做版本化处理。即使 API 在某个平台存在,动态链接 namespace 和设备实现仍可能影响可达性。这个边界与 App ClassLoader 不同:前者处理 Native 库与符号,后者处理 Java 类。日志中应使用不同阶段码,例如 library-load、symbol-resolve、thread-attach、class-load 和 method-lookup。
VMP 或 SO 保护可能改变加载顺序、库布局或异常边界,因此保护前后回归要保持相同 ABI、minSdk、依赖和类加载输入。出现问题时先用阶段码确认失败在哪一层,再比较最终 ELF 依赖、JNI 注册、R8 mapping 和 ClassLoader 来源。本文不宣称某种保护一定保持 ABI,实际结论必须来自同候选设备运行和故障回执。
- 目标 ABI 的 so 和依赖在类查找前已成功加载
- JNI_OnLoad 返回值与注册结果完整记录
- Native 符号解析和 Java 类加载使用不同阶段码
- 高版本 API 按 minSdk 和动态加载策略审查
- 保护前后保持相同 ABI、依赖和类加载输入
- ClassLoader 调整不会用于掩盖链接失败
用受控加载器包装器记录失败阶段
下面的 Java 示例展示加载器桥的应用侧实现。应用在受控 Java 上下文中用锚点类安装 ClassLoader,Native 在 JNI_OnLoad 缓存这个桥类的全局引用和 probe 方法 ID,原生线程附加后调用 probe,而不是再次 FindClass 业务类。示例接收诊断输入并返回稳定状态,不含真实业务类、令牌、地址或内部服务信息。
安装必须在原生工作线程创建前完成,source 字段只记录非敏感加载器来源。probe 使用 Class.forName 的非初始化模式检查可见性,区分 ClassNotFoundException 与 LinkageError,并拒绝无效类名。main 提供直接输入驱动的失败路径,便于公开示例独立验证;Android 生产代码不会依赖 main,而由受控 Native 桥调用静态方法。
生产实现还要在 Native 侧保存 JavaVM、逐线程取得 JNIEnv、管理桥类 GlobalRef、检查 CallStaticObjectMethod 异常并在主动附加的线程退出前 detach。若目标 jclass 或 method ID 需要缓存,也要绑定生命周期和候选版本。加载器桥只能修复上下文,不会修复错误的 R8 名称、JNI 签名、动态模块时序或 Native 链接问题。
import java.util.Objects;
import java.util.regex.Pattern;
public final class NativeClassLoaderProbe {
private static final Pattern BINARY_NAME = Pattern.compile(
"[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)+");
private static volatile ClassLoader appLoader;
private static volatile String loaderSource;
private NativeClassLoaderProbe() {}
public static synchronized void install(Class<?> anchor, String source) {
Objects.requireNonNull(anchor, "anchor");
if (source == null || source.isBlank()) {
throw new IllegalArgumentException("loader source is required");
}
ClassLoader loader = anchor.getClassLoader();
if (loader == null) {
throw new IllegalStateException("anchor has no application class loader");
}
if (appLoader != null && appLoader != loader) {
throw new IllegalStateException("a different class loader is already installed");
}
appLoader = loader;
loaderSource = source;
}
public static Result probe(String dottedName, String entry) {
if (dottedName == null || !BINARY_NAME.matcher(dottedName).matches()) {
throw new IllegalArgumentException("invalid binary class name");
}
if (entry == null || entry.isBlank()) {
throw new IllegalArgumentException("entry label is required");
}
ClassLoader loader = appLoader;
if (loader == null) {
throw new IllegalStateException("application class loader is not installed");
}
try {
Class<?> found = Class.forName(dottedName, false, loader);
return new Result(true, "ok", entry, loaderSource, found.getName());
} catch (ClassNotFoundException error) {
return new Result(false, "class-not-found", entry, loaderSource, dottedName);
} catch (LinkageError error) {
return new Result(false, "linkage-error", entry, loaderSource, dottedName);
}
}
public static void main(String[] args) {
if (args.length != 2) {
throw new IllegalArgumentException("usage: class-name entry-label");
}
String className = args[0];
if (!BINARY_NAME.matcher(args[0]).matches()) {
throw new IllegalArgumentException("class-name input is invalid");
}
install(NativeClassLoaderProbe.class, "diagnostic-entry");
Result result = probe(className, args[1]);
if (!result.found()) {
throw new IllegalStateException(result.status());
}
System.out.println(result.status() + ":" + result.loaderSource());
}
public record Result(boolean found, String status, String entry,
String loaderSource, String resolvedName) {}
}在混淆和目标 ABI 设备上完成闭环
类加载语义依赖真实 ART、线程、ClassLoader、R8 输出和 Native ABI,应在设备端 instrumented test 中验证。矩阵至少覆盖 Java 线程直接入口、原生 pthread、重复附加检测、进程重建、加载器未安装、类名错误、方法签名错误、挂起异常、混淆 release 和每个交付 ABI。动态 feature 或多进程存在时,再增加模块安装前后和目标进程。
每条回执要绑定最终 APK 摘要、mapping、keep 规则、so 摘要、ABI、系统版本、加载器锚点、入口阶段和返回状态。单一设备成功不能代表完整 API、ABI 和厂商矩阵;一次 ClassNotFound 消失也不能证明全链路正确,还要调用目标方法、验证异常路径并观察线程退出。保护前后对照每次只改变 VMP 或 SO 保护范围,避免同时修改 R8 规则后无法归因。
完整发布材料应包含 JNI 注册表、类与方法查找清单、加载器安装时序、全局引用生命周期、精确 keep 规则、链接依赖、设备矩阵和失败日志。需要评估实际 SO 时,可通过御盾中央平台提交脱敏的 JNI 入口、虚构化类名映射、加载器来源、最终候选摘要和目标 ABI,先定位 ClassLoader、R8 或链接层,再安排保护边界回归。
- Java 入口和纯 Native 线程入口分别验证
- 加载器缺失、类名错误和签名错误均有失败回执
- 混淆 release 使用对应 mapping 与精确 keep 规则
- 每个交付 ABI 和最低支持系统都有代表设备
- 进程重建与动态模块加载时序得到覆盖
- 保护前后 JNI 异常、引用和线程退出语义一致
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| JNI 注册、线程、引用、异常与类加载边界会直接影响 Native 桥接稳定性。 | Android JNI tips 说明 JNI 线程、引用、异常、FindClass 和注册实践。 | JNI 建议不能证明某个 SO 加固变换保持 ABI 或类加载语义。 |
| NDK 兼容故障常见于 API level、缺失符号、STL、异常类型和错误依赖装载。 | Android NDK common problems 汇总常见 Native 构建与运行问题。 | 故障清单不能替代目标 ABI 的实际启动、类加载和异常回归。 |
| 反射、JNI 和间接入口需要精确 keep 规则。 | R8 keep rules best practices 说明间接入口与最小保留规则。 | keep 规则只描述 R8 可达性,不定义 ClassLoader 上下文或 VMP 可保护范围。 |
| Kotlin 反射依赖运行时可发现的类、成员和元数据。 | Kotlin reflection 说明 Kotlin 运行时反射能力和依赖。 | 反射文档不覆盖具体序列化框架、生成代码或 JNI 注册的全部行为。 |
| 依赖真实 Android 运行时、组件和系统 API 的行为应通过设备端测试验证。 | Android instrumented tests 说明 instrumented test 适用于真实 Android 环境。 | 单一设备通过不能代表完整 API、ABI 和厂商矩阵。 |
| 高于 minSdk 的 Native API 不能被无条件直接静态调用,必要时需版本化动态加载。 | Android NDK stable APIs 说明 Native API level 和 dlopen、dlsym 使用边界。 | API 可用性不证明动态链接路径在所有 namespace 中可达,也不涉及 Java ClassLoader。 |
| 原生线程取得 JNIEnv 与取得应用 ClassLoader 上下文是两道独立门禁。 | 工程判断:线程附加只建立 JNI 调用能力,业务类可见性仍需缓存加载器、类引用或受控 Java 入口。 | 具体加载器选择仍受模块、进程、动态交付和候选构建影响。 |
| 类加载诊断应同时记录入口、加载器来源、最终名称、方法签名和异常状态。 | 工程判断:分层字段可以区分线程、R8、ClassLoader、签名和挂起异常问题。 | 诊断字段不能替代目标方法执行,也不应泄露真实业务类名和用户数据。 |
工程常见问题
AttachCurrentThread 成功后为什么 FindClass 仍失败?
附加只为当前线程取得 JNIEnv,不会自动继承应用 Java 调用栈和 ClassLoader,上下文不正确时业务类仍不可见。
可以把主线程的 JNIEnv 保存给原生线程吗?
不可以。JNIEnv 属于线程,应缓存 JavaVM,并由每个原生线程通过 GetEnv 或 AttachCurrentThread 取得自己的接口。
为什么推荐在 JNI_OnLoad 缓存少量类?
它有适合库加载的类加载语境,便于集中注册和建立全局引用,但仍需生命周期管理和精确 R8 规则。
加一条宽泛 keep 规则能解决所有 ClassNotFound 吗?
不能。它可能避免类被改名或移除,却不能修复错误 ClassLoader、线程复用、方法签名或挂起异常。
ClassLoader.loadClass 和 FindClass 的类名格式相同吗?
不同。loadClass 通常使用点分隔二进制名,FindClass 使用 JNI 斜杠形式;边界代码应固定一种 API 与格式。
准备 Native 线程类加载排查需要哪些材料?
准备最终 APK、JNI 注册和查找点、加载器锚点、R8 mapping 与 keep、so 和 ABI、异常阶段码、线程生命周期及设备回执。