JNI

作者: 程序员札记 | 来源:发表于2022-05-30 22:45 被阅读0次

    中讲到了ClassLoader类加载的4个本地方法,那么为啥要使用本地方法?本地方法跟普通的Java方法有啥区别?本地方法又称JNI(Java Native Interface)调用,即通过java方法调用使用本地代码如C/C++实现的方法,通过本地方法Java可以获取底层操作系统提供的能力,如文件读写,网络I/O,利用GPU计算等,可以借助其他语言实现Java本身并不直接支持的特性,如利用C/C++的开源库来实现Java的协程,可以借助底层语言如汇编实现对性能要求较高的功能,除此之外还可以通过本地方法与JVM本身,任一Java对象交互,如ClassLoader中通过启动类加载器加载核心类库,JVM的监控等。本地方法在以数据加工处理的后端应用中比较少见,但是在需要经常跟底层硬件交互的Android中经常使用。

    原理

    Java本地方法(Native Method)通过JNI(Java Native Interface)提供的一系列API调用其他语言的函数实现的相对底层的功能。

    为什么需要JNI

    当需要实现的功能依赖操作系统底层的特性,单纯依靠Java无法独立完成,需要借助其他语言;

    • 保证Java在跨平台的同时实现对底层的控制;

    • 在Java中直接调用其他语言已经实现的现成功能;

    • 使用更加底层的语言实现程序对时间敏感性和性能的要求。

    使用JNI的问题

    • 当在某个操作系统下使用了JNI标准,将本地代码编译生成了动态链接库后,如果要将这个程序移植到其他操作系统,需要在新的平台重新编译代码生成动态链接库,根本原因是** JNI依赖动态链接库,但是动态链接库并不跨平台 **;
    • 对其他语言的不正确使用可能会造成程序出现错误,例如使用C语言进行内存操作时未及时回收内存可能引起的内存泄漏;
    • 对其他语言的依赖过高,提高了Java和其他语言的耦合性,也提高了对项目代码的维护成本。

    JNI调用过程

    image.png

    Java通过JNI调用C/C++编译链接后的动态链接库来实现相对底层的功能。

    但是C/C++或其他语言编译链接生成动态链接库又是依赖具体操作系统平台的,因此每种平台都有自己的一套动态链接库。而每个平台都有自己的JVM,不同平台下的JVM,会去加载某个固定类型的动态链接库文件,使得依赖于操作系统的功能可以被正常的调用,JVM才能正常跨平台,这一过程可以参考下面的图来进行理解:

    image.png

    实现

    环境

    • Mac OS 10.15.7;
    • JDK8

    Java native本地方法定义

    package com.wkw.study.jni;
    
    public class JNITest {
    
        static{
            //从 java.library.path 路径上加载动态链接库
            System.loadLibrary("MyNativeDll");
        }
    
        //定义native方法
        public static native void callCPPMethod();
    
        public static void main(String[] args) {
            //输出 java.library.path 具体路径
            System.out.println("DLL path: " + System.getProperty("java.library.path"));
            //调用动态链接库中的具体方法
            callCPPMethod();
        }
    
    }
    

    代码主要完成的工作:

    • 在静态代码块中,调用loadLibrary方法加载本地java.library.path系统变量定义下的动态链接库,参数为不包含扩展名的动态链接库库文件名。在windows平台下会加载dll文件,在Linux平台下会加载so文件,在Mac Os下会加载libXxx.jnilib文件,在本例中,会加载libMyNativeDll.jnilib动态链接库库文件。
    • 声明了一个native方法,native关键字负责通知JVM这里调用方法的是本地方法,该方法在外部用其他语言被定义
    • 在main方法中,打印加载目录的路径,并调用本地方法。

    生成native方法头文件

    JDK命令生成头文件:

    javac -h ./ JNITest.java #-h指定生成头文件的目录

    该命名会生成两个文件:首先生成JNITest.class,其次在指定目录下生成C/C++头文件com_wkw_study_jni_JNITest.h

    头文件com_wkw_study_jni_JNITest.h定义了一个方法Java_com_wkw_study_jni_JNITest_callCPPMethod,这个方法对应定义的Java native方法public static native void callCPPMethod()
    JNI在C/C++语言中定义的规则:Java_包名_类名_方法名,头文件命名规则:包名_类名.h

    /* DO NOT EDIT THIS FILE - it is machine generated */
    #include <jni.h>
    /* Header for class com_wkw_study_jni_JNITest */
    
    #ifndef _Included_com_wkw_study_jni_JNITest
    #define _Included_com_wkw_study_jni_JNITest
    #ifdef __cplusplus
    extern "C" {
    #endif
    /*
     * Class:     com_wkw_study_jni_JNITest
     * Method:    callCPPMethod
     * Signature: ()V
     */
    JNIEXPORT void JNICALL Java_com_wkw_study_jni_JNITest_callCPPMethod
      (JNIEnv *, jclass);
    
    #ifdef __cplusplus
    }
    #endif
    #endif
    

    可以看到头文件中只有函数声明,没有具体实现:
    extern "C"表示这部分代码使用C语言规则来进行编译和连接的;
    JNIEXPORT和JNICALL是头文件jni.h中定义的两个宏,使用JNIEXPORT支持在外部程序代码中调用该动态库中的方法,使用JNICALL定义函数调用时参数的入栈出栈约定;
    函数名称由Java前缀+包名+类名+方法名组成,在该方法中有两个参数,通过第一个参数 JNIEnv *的对象可以调用jni.h中封装好的大量函数 ,第二个参数代表着native方法的调用者,当Java代码中定义的native方法是静态方法时这里的参数是jclass,非静态方法的参数是jobject。

    其中jni.h及其包含的其他头文件可以在JAVA_HOME\include目录下找到,如下图:


    image.png

    JNIEXPORT和JNICALL的宏定义位于jni_md.h中,该头文件通过jni.h引入的,如下:

    image.png

    attribute是GCC编译器使用的,这个宏定义是向编译器表示该方法的可见性的,这里不做详细讨论。

    动态链接库

    动态链接库是运行期按需加载链接的共享函数库,在Linux上典型的如实现C标准函数库的glibc对应的动态链接库文件为lib目录下的libc.so,在windows上就是dll后缀的文件,dll是Dynamic Link Library的缩写,通常位于C:\Windows\System32目录下。JVM依赖的动态链接库文件通常位于JAVA_HOME\jre\bin目录下,windows上的如下图:

    image.png

    应用程序可以从动态链接库中根据方法名查找对应方法的实现,对C/C++而言就是获取对应方法的指针,然后通过指针调用方法,如ClassLoader类的load_zip_library方法的实现,代码如下:

    void ClassLoader::load_zip_library() {
      assert(ZipOpen == NULL, "should not load zip library twice");
      //首先确保java依赖的本地基础库文件已经加载
      os::native_java_library();
      char path[JVM_MAXPATHLEN];
      char ebuf[1024];
      void* handle = NULL;
      //获取动态链接库的地址,并加载下面的zip库
      if (os::dll_build_name(path, sizeof(path), Arguments::get_dll_dir(), "zip")) {
        handle = os::dll_load(path, ebuf, sizeof ebuf);
      }
      //如果加载失败抛出异常
      if (handle == NULL) {
        vm_exit_during_initialization("Unable to load ZIP library", path);
      }
      //从动态链接库中查找对应方法的实现,ZipOpen等是方法指针的别名
      ZipOpen      = CAST_TO_FN_PTR(ZipOpen_t, os::dll_lookup(handle, "ZIP_Open"));
      ZipClose     = CAST_TO_FN_PTR(ZipClose_t, os::dll_lookup(handle, "ZIP_Close"));
      FindEntry    = CAST_TO_FN_PTR(FindEntry_t, os::dll_lookup(handle, "ZIP_FindEntry"));
      ReadEntry    = CAST_TO_FN_PTR(ReadEntry_t, os::dll_lookup(handle, "ZIP_ReadEntry"));
      ReadMappedEntry = CAST_TO_FN_PTR(ReadMappedEntry_t, os::dll_lookup(handle, "ZIP_ReadMappedEntry"));
      GetNextEntry = CAST_TO_FN_PTR(GetNextEntry_t, os::dll_lookup(handle, "ZIP_GetNextEntry"));
      Crc32        = CAST_TO_FN_PTR(Crc32_t, os::dll_lookup(handle, "ZIP_CRC32"));
     
      //如果查找方法实现失败
      if (ZipOpen == NULL || FindEntry == NULL || ReadEntry == NULL ||
          GetNextEntry == NULL || Crc32 == NULL) {
        vm_exit_during_initialization("Corrupted ZIP library", path);
      }
     
      //从libjava.dll查找CanonicalizeEntry,不过从1.3开始不再使用,所以未检查是否加载成功
      void *javalib_handle = os::native_java_library();
      CanonicalizeEntry = CAST_TO_FN_PTR(canonicalize_fn_t, os::dll_lookup(javalib_handle, "Canonicalize"));
    }
    

    ZipOpen等实际都是方法指针的别名,其定义如下:

    image.png

    zip库可在JAVA_HOME\jre\bin目录下找到,如下:

    image.png

    创建具体CPP文件

    #include "com_wkw_study_jni_JNITest.h"
    #include <stdio.h>
     
    JNIEXPORT void JNICALL Java_com_wkw_study_jni_JNITest_callCPPMethod (JNIEnv *, jclass) {
        printf("**CPP Method**\nprint from cpp");
    }
    

    引用头文件并实现其中的函数,也就是native方法将要实际执行的逻辑,CPP文件名随意,本例中为callCPPMethod.cpp

    1. 将CPP文件编译为动态链接库
    gcc -I"./" -I"/Library/Java/JavaVirtualMachines/jdk1.8.0_241.jdk/Contents/Home/include" -I"/Library/Java/JavaVirtualMachines/jdk1.8.0_241.jdk/Contents/Home/include/darwin" -shared -o libMyNativeDll.jnilib callCPPMethod.cpp
    

    这里使用gcc编译器进行编译:

    • -I指定头文件的路径,本例中需要3个头文件jni.hjni_md.hcom_wkw_study_jni_JNITest.h,所以引入了3次:

    com_wkw_study_jni_JNITest.h在当前目录下;

    jni.h/Library/Java/JavaVirtualMachines/jdk1.8.0_241.jdk/Contents/Home/include目录下;
    jni_md.h在/Library/Java/JavaVirtualMachines/jdk1.8.0_241.jdk/Contents/Home/include/darwin目录下;

    • -shared指定生成动态链接库,如果不使用这个标志那么外部程序将无法连接;
    • -o指定目标的名称,这里将生成的动态链接库命名为libMyNativeDll.jnilib;
    • callCPPMethod.cpp为被编译的C/C++源程序文件名。

    gcc命令具体过程:

    image.png
    1. 测试
      当前已经完成自定义一个Java native方法的所有流程,来看下生成的所有文件:


      image.png

    接下来测试验证跑下JNITest.main方法,异常:

    Exception in thread "main" java.lang.UnsatisfiedLinkError: no MyNativeDll in java.library.path
        at java.lang.ClassLoader.loadLibrary(ClassLoader.java:1860)
        at java.lang.Runtime.loadLibrary0(Runtime.java:870)
        at java.lang.System.loadLibrary(System.java:1122)
        at com.wkw.study.jni.JNITest.<clinit>(JNITest.java:6)
    

    找不到动态链接库MyNativeDll。这是因为System.loadLibrary("MyNativeDll")是从系统变量java.library.path指定的路径中找libMyNativeDll.jnilib动态链接库文件并进行加载,因此需要将libMyNativeDll.jnilib放到java.library.path指定的路径下,有两种方法:

    • 修改JVM参数,直接将java.library.path的路径指定为当前libMyNativeDll.jnilib库文件所在的路径:
      -Djava.library.path=/Users/developmac/workspace/develop/ideaWorkspace/comprehensive-learning/web-study/src/main/java/com/wkw/study/jni/

    -将libMyNativeDll.jnilib库文件拷贝到默认的加载目录下,具体的路径可以通过System.getProperty("java.library.path")获取,该方法可能会获得多个目录,放在任意一个目录下即可。
    再次启动,运行符合预期:

    DLL path: /Users/developmac/workspace/develop/ideaWorkspace/comprehensive-learning/web-study/src/main/java/com/wkw/study/jni/
    **CPP Method**
    print from cpp
    

    总结

    JNI调用过程:

    image.png

    从代理模式角度来看:

    image.png
    • 代理角色:包含native方法的JNI类

    • 实现角色:C/C++或其他语言实现的动态链接库

    • 客户端:调用native方法的java类程序

    • 接口(抽象角色):在JNI中接口这一角色的存在感相对薄弱,因为JNI是跨语言的,所以说无法严格的定义一个接口并让它同时应用于Java和其他语言。但是通过生成的.h头文件,在一定程度上实现了从接口规范上统一了Java中native方法和其他语言中的函数。

    所以上图中让客户端的调用过程跳过了接口,直接指向了代理角色,再由代理角色调用实现角色完成功能的调用。

    总的来说,JNI起到了一个代理或中介的作用,与常见代理不同的是这里只做方法的调用,而不实现逻辑上的增强。

    拓展

    JNIEnv RegisterNatives

    上述通过javah生成的头文件中C函数名是固定的,必须符合特定的规范,那么有没有办法自定义C的函数名,并程序控制Java方法与本地方法实现的绑定?答案是JNIEnv对象的RegisterNatives方法。该方法的定义在jdk/src/share/javavm/export/jni.h中,如下图:

    image.png

    其中methods是JNINativeMethod数组指针, JNINativeMethod的定义在同一个jni.中,如下图:


    image.png

    其中name表示java方法名字符串的指针,signature表示方法描述符字符串的指针,fnPtr是该java方法对应的本地方法实现的函数指针。那么问题来了,怎样获取方法描述符字符串了?答案是javap -s命令,如下图:

    image.png

    descriptor就是对应方法的方法描述符字符串了。

    与RegisterNatives方法相对的是UnregisterNatives,用于卸载某个类的本地方法,从而允许该类的本地方法重新链接到一个新的动态链接库,其方法定义如下:
    ![


    image.png

    具体来说RegisterNatives有两种用法,一种是JDK的标准用法,在Java类中定义一个本地registerNatives方法,用javah生成对应的本地方法头文件,在registerNatives方法中完成其他本地方法的注册,典型的如java.lang.Object类,其定义的registerNatives方法如下:

    image.png](https://img.haomeiwen.com/i26273155/8f4901afb8d68598.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

    registerNatives的本地方法实现在jdk/src/share/native/java/lang/Object.c中,如下图:


    image.png

    第二种不通过javah生成头文件, 利用JVM加载库文件会回调库文件中的JNI_OnLoad方法来实现本地方法的注册,JNI_OnLoad方法的定义也在jni.h中,如下图:

    image.png

    HelloWorld.cpp的实现如下:

    #include "jni.h"
    #include <stdio.h>
     
    JNIEXPORT void JNICALL say_hello_world
      (JNIEnv * env, jclass arg, jstring instring);
     
    JNIEXPORT jint JNICALL
    JNI_OnLoad(JavaVM *vm, void *reserved){
            JNIEnv* env;
            //判断当前JDK判断是否大于1.6
            if (vm->GetEnv((void **)&env, JNI_VERSION_1_6) != JNI_OK) {
                 return -1;
            }
            JNINativeMethod nm[1];
            nm[0].name = "say";
            nm[0].signature = "(Ljava/lang/String;)V";
            nm[0].fnPtr = (void*)say_hello_world;
            //查找特定类
            jclass cls = env->FindClass("jni/HelloWorld");
            if (cls == NULL){
                return JNI_ERR;
            }
            //注册本地方法
            if(env->RegisterNatives(cls, nm, 1)<0){
                return JNI_ERR;
            }
            //返回当前库文件要求的最低JDK版本
            return JNI_VERSION_1_6;
    }
     
    JNIEXPORT void JNICALL say_hello_world
      (JNIEnv * env, jclass arg, jstring instring)
     {
       //将java的String对象指向的字符串拷贝一份
       const jbyte *str =
            (const jbyte *)env->GetStringUTFChars(instring, JNI_FALSE);
       //打印字符串
        printf("Java_jni_HelloWorld_say->%s\n",str);
        //释放str指向的字符串内存
        env->ReleaseStringUTFChars(instring, (const char *)str);
        return;
     }
    

    将上述文件重新编译成库文件,重新执行,结果如下:


    image.png

    JDK库文件

    位置

    linux上JDK依赖的库文件在JAVA_HOME/jre/lib/amd64下,其中java这个库文件就是JDK中所有本地方法对应的库文件,如下图:


    image.png

    可以使用objdump 查看so文件导出的方法,如通过objdump -tT libjava.so|grep "java_lang_ClassLoader"可查看libjava.so中ClassLoader类的本地方法,如下图:

    image.png

    源代码

    JDK的本地方法的实现都在OpenJDK jdk/src/share/native下,按照类名来存放,如下图:


    image.png

    本地方法的字节码

    将上述示例先用javac -g重新编译一遍,再执行javap -v 可查看具体方法的字节码,如下图:

    image.png

    本地方法say(String content)没有任何字节码,通过flags中的ACC_NATIVE标记该方法是本地方法。调用say方法时没有任何特殊,还是通过invokestatic指令调用,在链接该方法时会根据ACC_NATIVE标记完成对应的本地方法实现的查找和绑定。

    相关文章

      网友评论

          本文标题:JNI

          本文链接:https://www.haomeiwen.com/subject/qblaprtx.html