- 作者:老汪软件技巧
- 发表时间:2024-11-02 15:01
- 浏览量:
上一篇居然已经是一年前写的了,又写了一年代码,我遇到过更多 JNI 的问题,现在是时候完成这一篇了。
JNI 的基础:函数调用
最初接触 JNI 的时候我就有一个疑问,为什么在 Java 代码中只需要标记一下哪个方法是 native 的,其他代码都需要写在 native 层呢?既然是互相调用,为什么两边需要编写的代码并不对称呢?今天就来看看是怎么个事。
上一篇文章把 JNI 的使用分成了三部分:注册、调用和回调,这次我们来看一下这三部分具体是做了什么,以及原理上是如何实现的。
首先是注册,无论是动态注册还是静态注册,目的都是让一个 native 的函数和 Java 中标记为 native 的方法建立一对一的映射关系,从而实现 Java 调用 native 函数。这个注册的映射关系被记录在 JVM 上。JVM 的存在让 Java 拥有了跨平台的能力,也隔离了 native 环境,增加了 JNI 复杂度。
注册之后调用就很直观了,当 JVM 执行到 native 方法的时候,通过 JVM 上记录的 native 的函数指针来调用 native 函数。
调用函数是为了获得结果,无论同步返回还是异步返回,「结果」都是需要在 native 层来创建,并且在 JVM 内部使用的。对于基本类型来说,只需要规定 native 使用特定的类型,JVM 读到对应的内存数据时,在 JVM 内部创建一个等价的值即可,比较复杂的情况是在 native 层创建 Java 对象。Java 对象不可能在 JVM 外部存在,所以 JVM 需要提供一系列的函数,允许 native 层在 JVM 内部创建一个 Java 对象出来。
JVM 选择的实现方式是对外提供「接口指针」,每个线程有一个自己的接口指针,接口指针是指向指针的指针,指向了一个JNI 函数指针的数组,数组的 offset 是规定好的,native 代码按照规则就可以调用 JVM 内部的函数了。
【来自官方文档的图⬆️】
JVM 提供的内部函数是固定的,但是可以通过这些函数创建任何 Java 对象,调用这些对象的方法。JNI 的接口函数看起来有点像反射,某种程度上也可以按照反射的代码思路来思考,但二者并不相同。
这里可以解释一部分开篇提出的那个问题:Java 并不需要把想让 native 调用的代码特殊对待,比如去注册一下或者写一些样板代码,普普通通定义的一个 Java Class 就能通过 JNIEnv→FindClass 获取到。
简单总结一下,JNI 的目标是允许用 Java 开发的项目能使用 native 代码做一些事情,比如现有库的复用或者高性能的运算等。为了达成这个目标,JVM 需要提供两种能力:一是支持 native 的特定函数注册到 JVM,二是支持 native 代码操作 JVM 来获取和使用 Java 对象。
【我总结的图⬆️】
Native 代码对 JVM 的内存管理
函数调用解决了最基础的「通信」问题,要完整的实现 JVM 内外互相调用的功能,还有一件必须处理好的事情:JVM 内存管理。
Java 是有垃圾回收机制的编程语言,在调用外部代码的时候也要把垃圾回收机制考虑进去,具体来说就是两点:
不要提前回收了 native 需要使用的 Java 对象不要因为 native 函数调用引起 JVM 内存泄露
native 自身的内存管理并不会因为 JVM 的存在变复杂,不在讨论范围内。
JNI 中定义了两种引用类型,用于在 native 代码中持有 Java 对象,分别是本地引用(Local Reference)和全局引用(Global Reference)。两种引用的共同点是在 JVM 中都是强引用,并且支持手动删除引用;二者的区别则是生命周期,本地引用离开作用域就会自动释放,全局引用只能通过主动删除引用来释放。所有 JVM 传给 native 的参数对象和 native 代码直接创建的 Java 对象都是本地引用,必须通过 NewGlobalRef 明确将某个本地引用转成全局引用。
回顾一个小知识,被 native 代码引用的对象可以做为 GC Root。
从实际使用的方式来看,本地引用有点像智能指针,全局引用有点像裸指针。但本地引用也是支持主动释放的,比如在一个耗时很久的函数里,入参用完就可以手动调用一下 DeleteLocalRef,减小内存占用。全局引用则是除了手动创建和释放之外,还要避免多次释放,这在复杂的工程里是一件令人头疼的事情。
全局引用有一种特例,叫做 Weak Global References,需要通过 NewWeakGlobalRef 创建。弱全局引用类似于 JVM 中的弱引用,不会阻止 JVM 回收引用的对象,所以使用之前要先做空判断。通过弱全局引用,我们可以一定程度上简化 native 代码中对 JVM 内存的管理方式。由于 JVM 内部的 GC 时机无法判断,即使一个函数内部两次获取弱全局引用持有的对象也会出现中间被回收的情况,需要使用弱全局引用的时候推荐首次判断之后,创建一个本地引用来持有该对象。
JNI 的异常处理
纯粹的 Java 异常和 native 异常两边都可以独立解决,JNI 需要考虑的是 Java 和 native 互相调用的时候,提供尽可能多样化的异常处理方式。
JNI 允许 native 代码直接抛出任意 Java 的异常,也支持 native 代码自行处理调用 Java 代码时出现的异常,未处理的异常还会抛回 JVM。
JNI 的异常处理问题核心在 native 层。
Java 调用 native 时,native 代码自身有错误,可以选择返回自定义错误或者向 JVM 抛出异常。native 调用 Java 时,Java 抛出异常,这个异常会等到下一次 native 代码调用接口指针的非「异常处理函数」时才会抛出给 JVM。在抛出之前,native 层可以通过 ExceptionOccurred() 函数获取当前的异常信息,调用 ExceptionClear() 清除异常,把一个 Java 的异常在 native 层处理掉。
【感觉画了不如文字描述清晰的图⬆️】
异常处理函数是指在出现异常之后,即使再次调用也不会向 JVM 抛出异常的函数,数量比较少,我全部列在下面:
ExceptionOccurred()
ExceptionDescribe()
ExceptionClear()
ExceptionCheck()
ReleaseStringChars()
ReleaseStringUTFChars()
ReleaseStringCritical()
ReleaseArrayElements()
ReleasePrimitiveArrayCritical()
DeleteLocalRef()
DeleteGlobalRef()
DeleteWeakGlobalRef()
MonitorExit()
PushLocalFrame()
PopLocalFrame()
写不动了,中场休息
到此为止,我觉得我在写 JNI 代码的时候目光可以清澈起来了…直到遇到了 native 代码中也涉及到了多线程和协程。JNI 的线程问题我们下次细说。