JNI编程初体验

213 阅读8分钟

什么是JNI

Java原生接口(Java Native Interface,简称JNI)是一套强大的编程框架,它允许运行在Java虚拟机(JVM)中的Java代码与用其他编程语言(如C、C++和汇编)编写的原生代码进行交互。JNI是Java平台的重要组成部分,为开发者在需要突破纯Java环境限制时提供了必要的桥梁。

JNI 的核心价值与应用场景

在深入技术细节之前,理解为何需要JNI至关重要。其核心价值在于赋予Java程序更广泛的能力,主要体现在以下几个方面:

  • 性能提升: 对于计算密集型任务,如图像处理、音视频编解码、复杂的数学运算等,使用C/C++等原生代码可以获得比纯Java实现更高的执行效率。
  • 复用现有代码库: 无需用Java重写已经存在的、久经考验的C/C++库,通过JNI可以直接在Java应用中调用这些原生库,节省开发时间和成本。
  • 访问特定于平台的功能: Java以其“一次编写,到处运行”的跨平台性著称,但也因此牺牲了对底层操作系统和硬件特定功能的直接访问能力。JNI允许Java程序调用原生API,从而实现与特定平台的深度集成。
  • 与底层硬件交互: 在开发需要直接控制硬件设备的应用程序时,例如读卡器、打印机等,通常需要通过原生代码来实现,JNI则负责连接Java应用层与这些原生驱动。

JNI 工作流程概览

JNI的交互是双向的,既可以从Java调用原生方法,也可以从原生代码中回调Java方法。一个典型的从Java调用原生代码的开发流程如下:

  1. Java层:声明原生方法
    在Java类中使用native关键字声明一个或多个方法,这些方法没有方法体。同时,通过System.loadLibrary("库名")静态代码块来加载即将实现这些原生方法的动态链接库(在Windows上是.dll文件,在Linux/macOS上是.so文件)。
  2. 编译Java代码并生成头文件
    使用javac命令编译包含原生方法的Java类。然后,利用JDK提供的javah工具(或在较新版本的JDK中使用javac -h)根据编译后的.class文件生成一个C/C++头文件(.h文件)。这个头文件定义了与Java中native方法相对应的C/C++函数原型。
  3. 原生层:实现原生方法
    创建一个C或C++源文件,包含上一步生成的头文件,并根据其定义的函数原型编写具体的实现。这些原生函数会接收一个JNIEnv*指针和一个指向调用该原生方法的Java对象的jobject(或jclass对于静态方法)作为参数。
  4. 编译原生代码生成动态链接库
    使用C/C++编译器(如GCC、Clang或Visual C++)将原生代码编译成一个动态链接库。编译时需要链接JNI相关的头文件和库,这些通常位于JDK的安装目录中。
  5. 运行Java程序
    执行Java程序。当调用到native方法时,JVM会通过之前加载的动态链接库,找到并执行对应的原生函数。

动手实现一个JNI

环境配置(VScode为例)

  • 添加JDK的include路径到VS Code配置

  1. 在VS Code中打开 c_cpp_properties.json 文件:

    • 按 Ctrl+Shift+P 打开命令面板
    • 输入 "C/C++: Edit Configurations (UI)" 或直接编辑 .vscode/c_cpp_properties.json
{
  "configurations": [
    {
      "name": "Win32",
      "includePath": [
        "${workspaceFolder}/**",
        "C:/Program Files/Java/jdk-21/include",  // 修改为你的JDK路径
        "C:/Program Files/Java/jdk-21/include/win32"  // Windows平台需要这个
       ],
       //如果你配置了JAVA_HOME,上面代码可以替换成
        "includePath": [
            "${workspaceFolder}/**",
            "${env:JAVA_HOME}/include",
            "${env:JAVA_HOME}/include/win32"  // Linux用linux, macOS用darwin
        ]
      "defines": [],
      "compilerPath": "C:/mingw64/bin/g++.exe"
    }
  ],
  "version": 4
}

如果不生效

image.png 可以试试这样

开始实操

  1. 准备一个空的工作文件夹,创建一个java文件
public class MyJNIDemo {
    static {
        // Load native library
        System.loadLibrary("myjnidemo");
    }

    // Native method declaration
    public native String sayHello(String name, int age);

    public static void main(String[] args) {
        MyJNIDemo myJNIDemo = new MyJNIDemo();
        String result = myJNIDemo.sayHello("World", 25);
        System.out.println(result);
    }
}

运行命令,编译 Java 代码并生成头文件 javac -h . MyJNIDemo.java会得到两个同名文件MyJNIDemo.class``MyJNIDemo.h (注意注释不能出现中文,否则编译不会通过)

  1. 准备C++工具文件(文件命名和Load native library这里设置的名字保持一致)
#include<jni.h>
#include<string>
#include"MyJNIDemo.h"

JNIEXPORT jstring JNICALL Java_MyJNIDemo_sayHello(JNIEnv *env, jobject obj, jstring name, jint age){
    
    const char *nameStr = env->GetStringUTFChars(name, nullptr);
    std::string cppName(nameStr);
    env->ReleaseStringUTFChars(name, nameStr); 

    std::string greeting = "Hello, " + cppName + "! You are " + std::to_string(age) + " years old.";

    return env->NewStringUTF(greeting.c_str());
}

同样的不要出现中文注释 运行命令g++ -I"%JAVA_HOME%\\include" -I"%JAVA_HOME%\\include\\win32" -shared -o myjnidemo.dll myjnidemo.cpp 将CPP文件编译成dll文件。(Linux/mac是.so)

3.最后运行java MyJNIDemo

image.png

补充

这行代码是JNI中原生(C/C++)代码的核心部分,它是一个遵循JNI规范的函数实现,用于响应来自Java代码的native方法调用。

C++

JNIEXPORT jstring JNICALL Java_MyJNIDemo_sayHello(JNIEnv *env, jobject obj, jstring name, jint age);

我们把它拆解成几个关键部分来理解:


1. JNIEXPORTJNICALL

这两者是定义在 jni.h 头文件中的宏。它们处理的是编译器和操作系统的底层细节,以确保Java虚拟机(JVM)能够正确地找到并调用这个函数。

  • JNIEXPORT: 这个宏负责函数可见性。它将该函数标记为从动态链接库(在Windows上是 .dll,在Linux/macOS上是 .so)中“导出”的函数。这样,当JVM加载这个库时,它就能在库中找到这个函数。在不同的平台和编译器上,它会被展开为不同的关键字,例如在Windows上是 __declspec(dllexport)
  • JNICALL: 这个宏负责调用约定(Calling Convention)。调用约定定义了函数参数如何传递(例如通过栈还是寄存器)以及调用结束后谁负责清理栈。确保原生函数和JVM使用相同的调用约定是至关重要的,否则会导致程序崩溃。在 x86 架构的 Windows 系统上,这通常对应于 _stdcall

简单记法:你几乎总需要把这两个宏按顺序写在函数声明的开头,它们是JNI函数的“标准前缀”。


2. 返回类型:jstring

这是函数的返回类型。jstring 是JNI定义的数据类型,它在原生代码中代表了Java世界的 java.lang.String 对象。

  • 关键点jstring 不是 C/C++ 中的 char*std::string。它是一个指向JVM内部Java对象的不透明引用。你不能直接对它进行字符串操作。

  • 如何使用:你必须通过 JNIEnv 指针提供的专用函数来处理它。

    • 要从 jstring 获取C风格的字符串,使用 env->GetStringUTFChars(...)
    • 要在C++代码中创建一个新的 jstring 以返回给Java,使用 env->NewStringUTF(...)

3. 函数名:Java_MyJNIDemo_sayHello

这是JNI中最重要的命名约定,JVM就是通过这个精确的名字来找到与Java native方法对应的C/C++函数。这个命名规则被称为“静态注册”。

它的构成规则如下:

  • Java_ : 固定前缀。

  • MyJNIDemo: Java类的完整限定名(Fully Qualified Class Name),其中包名中的点 . 被替换为下划线 _

    • 如果你的类是 MyJNIDemo 且没有包,就是 MyJNIDemo
    • 如果你的类在包 com.example 中,即 package com.example; class MyJNIDemo {...},那么这部分就是 com_example_MyJNIDemo
  • _ : 分隔符。

  • sayHello: Java代码中声明的 native 方法的名称。

    • 如果Java方法名中本身包含下划线 _,则会被转义为 _1

所以,这个函数名 Java_MyJNIDemo_sayHello 明确地告诉我们,它对应的是 MyJNIDemo 类中的一个名为 sayHellonative 方法。


4. 函数参数列表

参数列表总是以两个固定的参数开始,后面跟着与Java native方法相对应的参数。

固定参数
  1. JNIEnv *env: 这是JNI环境的指针,是最重要的一个参数

    • 它是一个指向线程本地(thread-local)结构的指针,包含了几乎所有JNI函数。
    • 你可以把它看作是原生代码与JVM进行交互的“桥梁”或“总接口”。所有对Java对象的操作,如创建对象、调用Java方法、访问字段、处理字符串和数组等,都必须通过 env 指针来完成。
    • 注意JNIEnv 指针是线程独有的,不能在多个线程之间共享。
  2. jobject obj: 指向调用这个native方法的Java对象实例的引用。

    • 它相当于Java中的 this 关键字。
    • 如果你需要在这个原生方法中访问调用它的那个Java对象的非静态字段,或者回调它的非静态方法,你就会用到这个 obj
    • 特例:如果Java中的native方法被声明为 static,那么这里的第二个参数会变成 jclass clazz,它代表的是类本身(一个 java.lang.Class 对象),而不是一个实例。
映射参数

这两个固定参数之后,是与Java native方法声明的参数一一对应的部分。

  • jstring name: 对应Java方法中的 String name 参数。
  • jint age: 对应Java方法中的 int age 参数。

JNI为Java的每一种基本类型和引用类型都定义了相应的原生类型(如jint, jfloat, jboolean, jobjectArray等)。

总结

将以上所有部分组合起来,这行声明完整地描述了一个JNI函数的契约: