ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深入拆解Android Framework中的JNI:Java与C++的桥接机制

深入拆解Android Framework中的JNI:Java与C++的桥接机制 1. JNI到底是什么从Framework源码里的一行代码说起先说结论JNIJava Native Interface本质上是Java和C/C之间的一座桥一座定义好了规格、包装好了数据、隔离好了崩溃的桥。Android Framework里到处都是它的身影比如你调用SystemClock.currentThreadTimeMillis()底层实际上是C里systemTime()的封装你操作Bitmap对象底层是Skia图形库的像素操作你使用MessageQueue底层是epoll事件循环。Java层只是把请求递给Native层真正干活的是C/C。我第一次真正看明白JNI不是靠网上那些Hello World教程而是因为一个极其诡异的Bug某个线程偶尔卡死抓trace看不到任何Java调用栈只有一行nativePollOnce让我抓了三天头发。当时我才意识到如果不理解JNI在Framework里怎么串联起来遇到这种问题就只能靠试错。这篇内容我想从Android Framework视角把JNI这条线彻底拆开讲清楚Java层和Native层到底是怎么握手、怎么通信、又是怎么互相坑的。适合谁看如果你的目标是学习Android Framework开发、想做系统级调试、或者面试时需要讲清楚Binder/Handler底层机制这篇文章能帮你把“Java和C之间到底发生了什么”这条主线理清楚。Java基础扎实但没碰过Native的朋友也会很有收获。2. Java和Native为什么非要“桥接”不可2.1 Framework的分层思路Java层管逻辑Native层管效率Android的整个Framework是分层设计的应用层和Framework层大部分用Java写因为开发效率高、内存安全、适合复杂的业务逻辑再往下从libandroid_runtime、libbinder、libart开始基本就是C/C的地盘了因为涉及系统调用、内存布局、高性能计算、硬件交互。Java写不了也不适合写这些。打个比方Java层是公司的对外客服部负责接需求、理逻辑、包装结果Native层是技术施工队负责真正的工地作业。JNI就是两部之间的固定电话专线——号码是约定好的通话格式是约定好的通了话之后两边各干各的活。这套分层的好处很明显Framework开发人员能用Java快速迭代Native层又能保持高性能。坏处也明显——连接处坏了最难修。Java层看到的是UnsatisfiedLinkErrorNative层看到的是SIGSEGV段错误两边甚至不在同一份崩溃日志里。2.2 JNI在Framework里的典型形态看看android.os.SystemClock这个类它的currentThreadTimeMillis()是一个native方法在Framework里匹配的JNI实现是android.os.SystemClock_native系列函数。注册方式有两种静态注册Java声明public static native long currentThreadTimeMillis()Native侧写一个Java_android_os_SystemClock_currentThreadTimeMillis函数靠函数名硬性对应。这种方式简单直观但函数名过长、查找效率低、编译期没法校验签名。早期AOSP和部分SDK库还在用。动态注册在JNI_OnLoad里用RegisterNatives把Java层方法签名和Native层函数指针绑定起来。Framework内部大多数核心库都用这种方式因为注册表一旦建立运行时查找效率直接走索引而且可以灵活处理函数命名、重载、热替换。ART对动态注册的支持也更好调用开销低于静态查找。这里有个需要留意的细节不是所有Java层的native方法都能在同一个so库里找到对应实现。Framework几十个so之间是有依赖关系的比如libandroid_runtime.so依赖libutils.so、libbinder.so。加载顺序错了dlopen阶段就会崩溃。很多刚接触系统开发的人以为只要写了native方法就能自动找到实现其实还差一个“哪个库承载、什么时候注册”的环节。3. JNI的运行时机制从Java调用到Native返回的全过程3.1 Java层调用Native方法时JVM/ART在做什么从Java层发起一个native方法调用流程大概是这样的Java字节码里有一个invokestatic或invokevirtual指令目标方法带有ACC_NATIVE标志。ART虚拟机遇到了native方法不会像普通Java方法那样解释执行字节码而是查找这个native方法对应的JNI函数指针。找到一个ArtMethod结构体里的native_method_指针跳过去执行。进入JNI函数后需要处理Java传入的参数、获取JNIEnv、完成类型转换然后调用真正的C逻辑。返回时把C类型转回Java类型清理局部引用再回到Java层继续执行。这里面“查找JNI函数指针”是关键。对于静态注册ART会按方法名去已加载的so里符号查找对于动态注册直接查NativeMethod数组。动态注册快就快在省掉了字符串匹配。3.2 JNIEnv是什么它为什么每个线程都有一份JNIEnv是一个线程私有的JNI环境指针你可以理解为“这是Native代码访问Java世界的操作手册”。每个Java线程进入Native层时都会拿到自己线程专属的JNIEnv。你想调用Java层的方法、创建Java对象、获取Java类的字段都得通过这个指针。正因如此一个常见错误就是跨线程保存JNIEnv。子线程想调用之前主线程拿到的JNIEnv轻则拿到过期指针重则直接崩溃。正确做法是在Native侧开启新线程时先AttachCurrentThread拿到这个线程自己专属的JNIEnv用完再DetachCurrentThread。如果是已经由Java层调用Native接口才进入的线程那天然就有JNIEnv不需要再Attach。另外值得一提JNIEnv并不是普通的指针它是一个指向JNI函数表JNINativeInterface结构体的指针。调env-FindClass其实是在这个函数表里索引到具体函数再执行。这也是为什么JNI调用比直接C调用慢一层的原因之一——多了一次间接寻址。当然代价在大多数场景下可接受但如果你想极致优化某个高频调用路径就可以用JNI_OnLoad时缓存jclass、jmethodID来减少重复查找开销。3.3 数据类型转换Java的String到C的字符串不是那么简单这是初学者最容易翻车的地方之一。Java里传入Native的String在C层拿到的不是简单的const char*而是一个jstring类型。你需要调用GetStringUTFChars或GetStringChars显式转换const char* utfStr env-GetStringUTFChars(jstr, nullptr); // 使用utfStr env-ReleaseStringUTFChars(jstr, utfStr);这里有几个容易踩的坑必须配对Release忘记释放会导致内存泄漏。虽然GC最终会回收Java层对象但本地内存这块因为JNI pin住之后不会立刻释放。GetStringUTFChars可能返回副本JVM/ART为了安全可能拷贝一份内容而非直接给你原始数据指针。对比C层数据修改的效果时要特别注意。中文字符用Modified UTF-8JNI层的UTF-8是改良版遇到\u0000会编码成双字节。如果直接在C里按普通UTF-8解析长度可能多算或少算。如果字符串读取很频繁可以用GetStringCritical减少拷贝但有严格限制critical区域内不能调用任何JNI函数否则可能死锁或崩溃。强需求下建议直接用GetStringUTFChars性能损失换来的是可靠性。3.4 引用类型与生命周期局部引用、全局引用、弱全局引用JNI里有三种引用很多人用了很久也就只认识局部引用。局部引用LocalReference在Native方法返回时由VM自动释放。生命周期短但因为JNI LocalReference Table大小有限Android上通常是512项或更多如果你在循环里创建了大量局部引用而不删会导致表溢出崩溃信息是ReferenceTable overflow。全局引用GlobalReference需要手动NewGlobalRef创建显式DeleteGlobalRef释放。适用于缓存Java对象跨多次JNI调用复用。弱全局引用WeakGlobalReference用NewWeakGlobalRef创建不会阻止GC回收底层Java对象。适合缓存类对象而非实例因为类对象通常不会频繁卸载。一个实用经验在Framework层写JNI时最好在JNI_OnLoad阶段把需要反复调用的jclass和jmethodID全部Cache起来使用全局引用保存。这样避免了每次调用前FindClass/GetMethodID的开销也规避了局部引用溢出问题。但缓存有个前提这个类在运行期不会被不同的ClassLoader反复加载否则你拿到的全局引用可能指向旧Class对象导致内存泄漏甚至调用错版本的方法。4. Java与Native的互相调用不只是Java调C这么简单4.1 Native回调JavaCallVoidMethod与回调线程处理JNI不只有Java调用Native的流向还有反方向Native层在某个C回调里需要调用Java层的方法。常见场景包括传感器数据上报、Socket数据到达、Binder回调、异步任务结果返回。Native侧操作大概是jclass clazz env-FindClass(com/example/MyCallback); jmethodID method env-GetMethodID(clazz, onResult, (I)V); jobject obj env-NewObject(clazz, method, result); // 或者拿到预先缓存的Java实例直接调用实例方法 env-CallVoidMethod(javaObj, callbackMethod, arg1, arg2);这里最大的坑是线程。如果你的Native回调跑在一个C创建的线程上不是由Java层JNI调用进入的线程那此刻是没有JNIEnv的。你得先JavaVM-AttachCurrentThread(env, nullptr)拿到这个线程自己的JNIEnv再调用Java方法调用完之后是否DetachCurrentThread取决于这个线程后续是否还会继续回调Java代码——如果线程是一次性的用完就退出建议Detach如果线程是常驻循环可以Attach一次留在那但要保证不再使用旧JNIEnv引用是每次循环都重新拿这个线程的JNIEnv还是复用看你的结构设计。踩过坑的人都知道在线程退出时如果不Detach某些Android版本的ART会在线程退出时不耐烦地打印一堆警告甚至崩溃。4.2 异常处理机制Native层怎么处理Java层抛出的异常Java层抛异常会打断方法栈但JNI函数里C代码不会因为Java异常自动中断。也就是说你在Native代码里调用了CallVoidMethodJava侧此方法抛了异常JNI函数的返回值是不确定的但C代码还继续往下跑。你需要显式用ExceptionCheck或ExceptionOccurred去检查然后决定是抛回去、吞掉、还是打印日志。env-CallVoidMethod(javaObj, methodId, arg); if (env-ExceptionCheck()) { // 处理或清除异常 env-ExceptionDescribe(); // 打印日志 env-ExceptionClear(); // 清除避免继续传播 }如果不清除异常下一次JNI调用时异常状态还在会让调用栈变得非常混乱。所以我的习惯是在Native层所有跨边界调用后都加一行ExceptionCheck检查。虽然代码丑但排查崩溃时真的救过我好几次。4.3 崩溃与安全为什么说JNI是“踩钢丝”Native崩溃不像Java异常有异常处理机制兜底。C段错误直接让整个进程崩溃没有任何回旋余地。Android的tombstone日志记录了崩溃时的寄存器状态、堆栈信息、内存映射平时看起来没感觉真遇到问题它就是唯一线索。安全性方面还有一点值得注意JNI是Java世界的后门。一旦Native代码出纰漏可以绕过Java层的访问控制直接操作内存。系统级App的JNI代码尤其要小心别把敏感的Native接口暴露成Java层随意可达的通路否则等于给攻击者开了扇方便之门。在Framework开发中所有Native接口都应该做参数合法性校验——长度、边界、空指针、类型转换前的检查任何一项缺失都可能成为漏洞。5. Framework核心模块的JNI实践分析5.1 Binder驱动与JNI系统服务的通信底座Android系统进程间通信的底座是Binder它本质上是内核态的一个驱动协议。Java层有BinderProxy、Binder这样的类Native层有BBinder、BpBinder这两者之间就是靠JNI维护映射关系。以ServiceManager为例Java层的ServiceManager.addService()最终会走到BinderInternal的native方法进入Native层调用spIServiceManager的C接口。在整个链路里JNI就像口岸海关Java侧的Binder对象要过一道关转成Native侧的spBBinder或BpBinder引用才能参与真正的内核通信内核消息回来时再反向转成Java对象供上层使用。这里面有一个比较绕的知识点Java层的Binder对象内部持有一个mObject字段long类型这个长整型实际上是一个指针指向Native层的BBinder或BpBinder。看Framework源码时如果你见到一大串十六进制数字的mObject别疑惑那就是JNI层的对象映射底牌。AIDL生成的Proxy类各种native方法的本质就是在维护这条映射链路的稳定。5.2 Handler与Looperepoll如何走进Java世界Looper.loop()是所有Android应用消息循环的核心。Java层看到的是Message msg queue.next();但MessageQueue.next()调用了nativePollOnce(timeout)真正做的就是进入C层通过Looper::pollOnce调用epoll_wait让当前线程睡到有事件进来或者超时才醒。JNI在这里的角色是把Java的long timeout参数转成C的int64_t把Native层返回的事件个数转成Java的int。这个设计巧妙在Java层不需要知道epoll是什么也不需要在每个线程里维护文件描述符。JNI把底层的复杂性完全隔离了Java层只拿结果。而如果你在这里出现超时时间不准的问题可以先怀疑JNI长整型转换溢出——毫秒转纳秒时要小心1970年这个基准点Java传进来的超时是相对时间Native计算要用systemTime(SYSTEM_TIME_MONOTONIC)对齐。5.3 Bitmap与Surface像素和渲染的跨界之旅Bitmap是另一个典型的JNI重度使用者。Java层Bitmap对象在Native层对应着SkBitmap同一块像素内存被两层同时引用。BitmapFactory.decodeFile()从文件解码出像素数据Native层操作完再将结果封装成Java对象返回。如果你对Bitmap的recycle()和GC之间的关系搞不清楚很容易出现“我在Java层拿到一个Bitmap但是Native层的像素内存已经被回收了”的崩溃这个崩溃的栈还特别难看。Surface和GraphicBuffer链路更复杂Java层Surface.lockCanvas()会通过JNI调用Native层的Surface::lock()拿到一个ANativeWindow_Buffer把图形缓冲区的地址直接映射到Java可见的Canvas绘制命令中。整条链路涉及GraphicBuffer分配、同步锁、内存映射JNI只是为了打通Surface对象和安全访问缓冲区之间的通道。这也解释了为什么做系统级图形开发的人必须同时懂Java、C和图形内存管理——光靠Java层看SurfaceView是看不出所以然的。6. 实操经验CLion配置JNI开发环境的完整过程6.1 NDK与工具链的选择网上关于CLion配置JNI的教程不少但真正能跑通的并不多。我按照自己的实践整理了一套基本都是踩过坑之后沉淀下来的最终方案。首先你需要三个基础组件Android NDK通过Android Studio的SDK Manager安装或者直接下载NDK压缩包解压。建议使用NDK r21以上版本避免老版本编译器对C标准支持不全的问题。CLion用一个新一点的版本2023.1以后对CMake支持很稳定。CLion本身不内置Android工具链我们需要手动指给CMake。CMakeCLion自带的CMake可能和NDK里的工具链版本冲突。建议把CMake版本固定和NDK推荐的版本一致可以看NDK目录下build/cmake/android.toolchain.cmake注释里的推荐版本。我的建议是不要在CLion里直接新建“Android项目”而是建立一个空的C项目再用CMake指定Android工具链。这样代码提示和编译调试都更自由避免Android Studio那一套Gradle配置的干扰。6.2 CMakeLists.txt里的关键配置一份能用的CMakeLists.txt大概长这样cmake_minimum_required(VERSION 3.22.1) project(native-jni-demo) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 指定Android平台和架构 set(ANDROID_PLATFORM android-21) set(ANDROID_ABI arm64-v8a) # 引入NDK工具链 set(CMAKE_TOOLCHAIN_FILE $ENV{ANDROID_NDK_HOME}/build/cmake/android.toolchain.cmake) add_library(native-jni SHARED native_lib.cpp utils.cpp ) target_include_directories(native-jni PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ) # 如果依赖系统库比如liblog加target_link_libraries target_link_libraries(native-jni android log )几个要点ANDROID_ABI如果只想在模拟器上跑用x86_64真机调试用arm64-v8a。两边都支持就用ANDROID_ABI设为多个值但编译时间会明显变长。ANDROID_PLATFORM不是越高越好有些老系统库在低版本平台才能找到。Flexible的话建议android-21起步。一定要先把ANDROID_NDK_HOME这个环境变量配好否则CMAKE_TOOLCHAIN_FILE解析不到NDK路径CLion会报“No CMAKE_CXX_COMPILER could be found”这个错大多数人第一次都见过。6.3 CLion里如何加载so并调试CLion不像Android Studio那样有AGP帮他部署so到设备上你需要自己来用CMake构建出libnative-jni.so。手动adb push到/data/local/tmp目录或者直接push到应用的nativeLibrary目录。如果只想验证JNI逻辑不需要跑Android App可以写一个小的main.cpp在main函数里加载libnative-jni.so并手动调用JNI函数——这样调试最方便。但要记得初始化JavaVM否则JNIEnv是拿不到的。如果是Android App场景建议在Android Studio里跑App、在CLion里用Attach to Process附加调试Native代码。CLion对lldb的集成做得很成熟断点、变量查看、调用栈都能正常用。我自己把这种方式跑通之后JNI开发效率肉眼可见地提升——不用每次改完C代码都要重新编APK、重新安装、重新起App。CLion独立编译so、独立断点调试比在Android Studio里等Gradlesync快太多了。6.4 JNI环境搭建的坑点清单用CLion配JNI环境常见的问题我列在下面方便大家自查现象可能原因解决方案CMake报找不到编译器NDK路径没配好或toolchain文件版本不匹配检查ANDROID_NDK_HOME确认NDK版本对应toolchain文件存在JNI函数不触发so没有打包进APK或System.loadLibrary包名不一致检查.so文件名、loadLibrary的参数、是否用了useLibrary崩溃在FindClass返回nullClassLoader上下文不对用env-FindClass前确保当前线程的ClassLoader是目标类所在ClassLoader或者缓存jclass局部引用溢出循环里大量创建局部引用没删除及时DeleteLocalRef或把高频需求改成全局引用缓存中文乱码JNI字符串编码问题统一用UTF-8注意Modified UTF-8带来的长度偏差7. 常见问题与排查技巧实录7.1 启动白屏、UnsatisfiedLinkError、so加载失败怎么定位很多人在开发React Native或者自己写Native库时遇到过启动白屏或直接崩在UnsatisfiedLinkError。这类问题的排查路径可以这样走看logcat里有没有dlopen failed字样后面跟着的是找不到library还是找不到符号cannot locate symbol。如果是“找不到library”检查so是不是真的被打进APK的lib/abi/目录里用unzip -l app.apk确认。如果是“找不到符号”大概率是链接库时少link了依赖的库或者依赖库版本和编译时不一致。如果应用加载成功但没走JNI方法重点检查Java层native方法声明和C注册函数签名是否一致尤其返回值类型和参数个数。签名不匹配通常不会给你明显的错而是运行时调用一个错误的函数指针很隐蔽。我之前遇到过一种很典型的坑某个so在armeabi-v7a设备上正常一到arm64-v8a设备就UnsatisfiedLinkError。后来发现是只编了32位so没有64位版本。Android 5.0以后64位设备上如果没有匹配ABI的so系统不会自动用32位的顶替。7.2 内存地址对齐Java对象引用到底能不能当指针用有些野路子教程会让Java层把对象的hashCode或者某个long当指针传给Native然后在Native层强行转成对象指针。这个做法极度危险一定不要学。理由很简单ART的GC会移动对象Java对象的堆地址不是固定的。你这次拿到的地址下一次GC之后可能就失效了。正确做法是用JNI的GetObjectField/SetObjectField或者通过GlobalReference来管理对象引用。Java层和Native层之间的“指针传递”应该走jlong来存Native对象的指针也就是Framework里常见的mNativePtr模式反向再把jlong转回C指针这才是安全且通用的方案。mNativePtr这个模式在Framework里用得非常广泛Bitmap的mNativePtr存的是native层对象指针Paint的mNativePtr存的是SkPaint指针。Java层完全不碰Native对象内部字段只保存一个不透明的长整型句柄Native层需要时再还原成真正的指针。这样做既绕开了GC对象移动问题也让Java层无法直接篡改Native层数据结构。7.3 JNI的性能开销到底有多大从调用开销到方法内联严格说JNI调用比普通Java方法调用慢。慢在哪里呢跨语言边界的参数和返回值需要转换JNI类型映射。函数指针间接寻址多了一层。ART对JNI方法做不了很多Java层的优化比如方法内联。局部引用表的维护本身就有开销。但这个慢有多慢要看具体调用场景。一次JNI调用大概在几十纳秒到几百纳秒级别。如果你的业务逻辑是每次调用需要做大量Native计算如图像处理、协议解析那JNI开销几乎可以忽略。最怕的是那种“在循环里频繁小额调用JNI”的模式比如一次循环几十万次每次都调一个只做简单加减的native方法那性能就会相当难看。优化方向主要有三个减少JNI调用次数把循环体挪到Native层去。数据先拷贝一次在Native层批量处理完再返回结果。避免每次都查方法ID、字段ID在JNI_OnLoad或第一次使用时缓存起来。合理使用GetPrimitiveArrayCritical处理数组。但注意critical区的限制不能在区内调用其他JNI函数。7.4 经典问题速查表问题原因调试建议native方法没反应静态注册名称不匹配、so没加载、注册表没写好看logcat用nm -D查so符号确认Java声明和C函数名格式Java层拿到Native返回值是脏数据类型不匹配或未初始化确认JNI类型映射尤其jboolean和bool的差异全局引用泄漏忘记DeleteGlobalRef在代码里使用RAII管理jobject或者用WeakGlobalReference做缓存Native回调Java崩溃线程没有AttachjmethodID已失效回调入口先AttachCurrentThread缓存jmethodID数组访问崩溃数组索引越界或GetArrayLength用错对象先用GetArrayLength取长度确认数组元素类型再访问ART 14以上报abort非SDK接口限制或NativeMemory问题使用ASAN构建抓native crash检查tombstone8. 几条JNI学习的实用思路先读源码再写代码。知乎上常有人问“怎么学Android Framework”我的回答都一样从JNI切入是最快的路径。因为Framework层所有核心模块的Java/Native边界都是JNI弄懂JNI就相当于拿到了读整个Framework源码的密码。推荐阅读顺序先看android.os.SystemClock的native实现最简单再看MessageQueue的nativePollOnce涉及epoll和线程等待然后看Binder的native实现涉及对象映射和引用管理最后啃Bitmap和Surface的JNI衔接涉及内存管理和图形缓冲。多利用Android源码在线搜索。AOSP的frameworks/base/core/jni/目录下躺着几乎所有核心JNI实现文件名通常是android_*.cpp。读完这个目录你对JNI的理解就不是“会写两个demo”而是“能看懂系统级JNI架构”。用CLion搭建一个独立调试环境。说实话在Android Studio里调试JNI每次都要重新build APK确实效率太低。CLion直接编译so、直接attach调试开发体验完全不一样。花半天配置环境很值。我个人在实际操作中还有一个体会写JNI代码时永远先写崩溃日志和边界检查再写业务逻辑。JNI不像Java那样有异常机制兜底一个空指针就能让整个App连同一起的系统进程当场去世。每次拿到Native指针后的第一行代码永远应该是判空每次从Java拿参数后的第一行代码永远应该是校验长度和类型。这种习惯没有华丽的地方但真的能让你少掉很多头发。最后再分享一个小技巧如果碰到“Java层调Native方法时参数明明是对的Native拿到的却是乱码”这样的诡异问题。别急着怀疑编译器或ART先检查是不是自己的JNI签名写错了——参数类型和返回类型任何一个对不上ART都会在注册期或调用期做出各种让人困惑的表现。用javap -s查看Java侧方法的签名和C侧注册签名逐一对比这个办法解决过我至少四次类似的烦恼。
返回列表