从JNI看FFI机制的典型设计

达芬奇密码2018-08-13 16:28

近几年来各种编程语言一直像雨后春笋一样不断出现,程序员们总是在不断寻找称心如意的编程利器,然而历史上却始终未出现一招鲜吃遍天的编程语言,一门编程语言总有其擅长的和不擅长的场合,不仅仅是由于编程语言的语法层面同时也和编程语言运行时的设计思路、平台的支持有着莫大的关系。于是在实际的编程实践中使用合适的编程语言干合适的事才是正确的选择。但当我们运用多种语言解决问题时却不得不面对一个困难的问题,即跨编程语言间的互相调用。


编程语言间的互相调用可以有几种方案:

  • 通过RPC调用来实现跨语言的调用,不同语言编写的程序运行在不同的进程中,通过网络、输入输出流、管道等设施。
  • 通过共享内存实现进程间通信
  • 通过FFI(Foreign Function Interface)机制,通过提供运行时兼容的设施(一般是lib及运行时支持)实现在同一进程内跨越语言的边界调用函数,例如Java的JNI就是这样一种设施


以上几种方案中,使用FFI是一种较为高效轻量级的方案,是真正意义上做到两种不同语言运行时混合协作的方案,当前大部分编程语言都提供了FFI机制使得可以跨越运行时边界调用C/C++编写的程序(以下称作native函数),JNI可能是大家最为熟悉的FFI机制,在Android,Java服务端编程中都有广泛的使用,但要用好JNI做到不踩坑则有必要理解FFI机制背后的原理。


FFI机制最重要的是解决不同运行时间内存管理的问题,以JNI为例,Java语言的内存管理采用垃圾收集机制,而C语言则是依赖于显示的申请释放内存,可以想象当Java调用C函数传递由Java运行时托管的对象时,由于对象指针被C运行时引用时Java运行时一端将无法获知对象被引用的情况,从而垃圾收集器将无法判断该对象是否被引用从而无法将其释放,那么JNI是怎样解决这一问题的呢。


首先JNI引入了local reference的概念,当调用native方法时jvm会在调用者线程堆栈中开辟一个frame来持有传入的参数对象的引用,java的垃圾收集(GC)是以一系列的root set为起点扫描对象是否被引用的,而这个frame便保障了GC能够发现传入native的参数处于被引用状态,从而不会被收集,当native函数返回时这些引用也自然被释放。另一方面native函数中也可创建对象的local reference也会保存在frame中,从而使得对象的声命周期被jvm托管,在native函数中创建的对象除非作为返回值返回获得新的引用,否则当从native返回后由于local reference被释放则对象的生命周期结束。然而由于jvm的垃圾收集不是实时发生,所以在native函数中大量创建local对象而依靠垃圾收集并不可取,应考虑显式调用DeleteLocalRef在native中完成释放。


local reference生命周期的特点使得local对象不能被native运行时持有,因此需要有更长生命周期的机制来实现需求,JNI中则是引入了生命周期完全可控的global reference,可以猜到global reference创建后应保存在jvm全局的root set中,确保对象既能在java中被访问又不会被GC回收,仅当在native中显式调用DeleteGlobalRef释放对象。


通过引入local/global reference能够解决跨运行时对象声明周期控制的问题,但问题到这里还没解决完。下面我们来看一个问题,以Java为例我们常用的垃圾收集器在垃圾收集时会搬动内存(copy收集、mark-compact收集均是如此),因此垃圾收集过程中需要修改指向内存块的指针,然而在使用JNI的情况下一旦进入了native的运行时垃圾收集器便无能为力了,因此我们在JNI中持有的reference一般是间接指向的,但某些情况下间接指向并不能满足需求,例如从Java运行时调用native函数时传入一个Array对象,API的signature如下:

    NativeType *Get<PrimitiveType>ArrayElements(JNIEnv *env, 
    ArrayType array, jboolean *isCopy);


可以看到API需要返回一个Native类型的Array给native访问,这时就不适合使用间接的引用了,为解决这一问题API带了一个isCopy参数在Oracle的jvm中该参数为非空时API的行为将是copy整个Array,为空时则为直接引用,但如果直接引用了内存则意味着jvm中垃圾收集时将无法搬动该内存块,从而将对垃圾收集性能产生影响,所以一般的选择都是选择copy行为。在选择copy的情况下如果想要对Array进行更新该怎么做呢,我们再来看一个API

    void Release<PrimitiveType>ArrayElements(JNIEnv *env, 
    ArrayType array, NativeType *elems, jint mode);


这里有一个mode参数,这个参数便是告诉运行时要如何将我们修改过的copy更新到原对象

mode        actions
0            copy back the content and free the elems buffer
JNI_COMMIT    copy back the content but do not free the elems buffer
JNI_ABORT    free the buffer without copying back the possible changes


可以看到mode为0或JNI_COMMIT时便能将变动通过copy的方式更新到原对象。当然这里一般需要程序保证从copy到release期间原对象不会被修改。

至此为止FFI设计最重要的环节--解决不同运行时间内存管理的问题基本解决了,JNI使用的这些方案也是大部分FFI采用的典型设计方案,这样当需要使用其他编程语言FFI机制时也就不再有什么神秘的黑魔法了。那是否还有非典型的FFI呢,大家不妨去了解一下Rust语言,看看没有运行时这一负担的情况下FFI会变得多么轻巧,而本文的讨论就到此结束了。

网易云新用户大礼包:https://www.163yun.com/gift

本文来自网易实践者社区,经作者陈谔授权发布。