杰瑞科技汇

Linux Java 如何调用 C 代码?

核心概念:Java Native Interface (JNI)

JNI 是 Java 平台的一部分,它定义了一套规范,允许 Java 代码和其他语言(主要是 C/C++)进行交互,它就像一座桥梁,让 Java 可以调用 C/C++ 的函数,反之亦然。

Linux Java 如何调用 C 代码?-图1
(图片来源网络,侵删)

调用流程图:

[Java Code]  <--->  (JVM - Java Virtual Machine)  <--->  [JNI Header Files]  <--->  [C/C++ Code]
     ^                                                                  ^
     |                                                                  |
  (调用 `native` 方法)                                                (实现 `native` 方法)

完整步骤与实例

我们将创建一个简单的例子:

  1. Java 侧: 定义一个 native 方法(一个没有实现体的方法)。
  2. 编译 Java 代码: 生成 .class 文件。
  3. 生成 C 头文件: 使用 javah (旧版) 或 javac -h (新版) 工具,根据 .class 文件生成 C 语言头文件 (.h),这个头文件包含了 Java native 方法的 C 函数声明。
  4. 编写 C 代码: 实现头文件中声明的 C 函数。
  5. 编译 C 代码: 将 C 代码编译成共享库(.so 文件),在 Linux 下,共享库就是 JNI 的“胶水”。
  6. 运行 Java 程序: 告诉 JVM 在哪里找到我们编译好的 .so 文件,并执行 Java 代码。

详细步骤

第 1 步:准备 Java 代码

创建一个名为 HelloJNI.java 的文件,注意 native 关键字,它表示这个方法的实现在其他地方(这里是 C 语言)。

HelloJNI.java

Linux Java 如何调用 C 代码?-图2
(图片来源网络,侵删)
public class HelloJNI {
    // 声明一个 native 方法
    public native void sayHello();
    // static 代码块在类被加载时执行,通常用于加载本地库
    static {
        // System.loadLibrary 会加载名为 "hellojni" 的共享库 (libhellojni.so)
        // 它会按照标准路径查找,如 LD_LIBRARY_PATH
        System.loadLibrary("hellojni");
    }
    public static void main(String[] args) {
        new HelloJNI().sayHello();
    }
}

第 2 步:编译 Java 代码

打开终端,进入 HelloJNI.java 所在的目录,执行编译命令:

# javac 是 Java 编译器
javac HelloJNI.java

执行后,会生成一个 HelloJNI.class 文件。

第 3 步:生成 C 头文件

这是连接 Java 和 C 的关键一步,我们需要根据 HelloJNI.class 文件生成一个 C 语言头文件。

重要提示: 从 JDK 10 开始,javah 命令已被废弃,推荐使用 javac -h 选项。

# javac -h . HelloJNI.class
  • -h .: 指定头文件生成的目录, 表示当前目录。
  • HelloJNI.class: 要处理的 Java 类文件。

执行后,会在当前目录生成一个名为 HelloJNI.h 的头文件。不要手动修改这个文件!

HelloJNI.h (内容示例)

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

关键点解读:

  • JNIEXPORT, JNICALL: JNI 定义的宏,用于标识函数的导出和调用规范。
  • Java_HelloJNI_sayHello: 这是 C 函数的命名规则,非常严格:Java_ + 包名(如果有)_ + 类名 + _ + 方法名
  • (JNIEnv *, jobject): 这是函数的参数。
    • JNIEnv *: 指向 JNI 环境的指针,通过它可以在 C 代码中调用 Java 的功能。
    • jobject: 指向调用该方法的 Java 对象的引用(如果是 static 方法,则是 jclass,指向类对象)。
  • void: 返回值类型,对应 Java 中的 void
  • ()V: 这是方法的签名,对应 Java 中的 (无参数) 和 V (void 返回值),签名规则很重要,稍后会提到。

第 4 步:编写 C 代码

创建一个 HelloJNI.c 文件,实现 HelloJNI.h 中声明的函数。

HelloJNI.c

#include <stdio.h>
#include "HelloJNI.h" // 包含生成的头文件
// 实现 Java_HelloJNI_sayHello 函数
JNIEXPORT void JNICALL Java_HelloJNI_sayHello(JNIEnv *env, jobject obj) {
    printf("Hello from C! This is printed by the C code.\n");
    // (可选) 通过 JNIEnv 调用 Java 方法
    // 调用 System.out.println
    // jclass clazz = (*env)->FindClass(env, "java/lang/System");
    // jfieldID outID = (*env)->GetStaticFieldID(env, clazz, "out", "Ljava/io/PrintStream;");
    // jobject outObj = (*env)->GetStaticObjectField(env, clazz, outID);
    // jclass printStreamClass = (*env)->FindClass(env, "java/io/PrintStream");
    // jmethodID printlnID = (*env)->GetMethodID(env, printStreamClass, "println", "(Ljava/lang/String;)V");
    // jstring message = (*env)->NewStringUTF(env, "Hello from C via Java call!");
    // (*env)->CallVoidMethod(env, outObj, printlnID, message);
}
  • 我们先实现最简单的功能:直接在 C 代码中打印一句话。
  • JNIEnv *env 的使用方式在 C 和 C++ 中略有不同,上面的代码是 C 语言的写法,如果是 C++,JNIEnv 是一个类的实例,可以直接调用方法,如 env->FindClass(...)

第 5 步:编译 C 代码为共享库 (.so 文件)

这是在 Linux 下最关键的一步,我们需要使用 GCC (GNU Compiler Collection) 将 C 源文件编译成动态链接库(Shared Object, .so)。

# gcc -fPIC -shared -I ${JAVA_HOME}/include -I ${JAVA_HOME}/include/linux -o libhellojni.so HelloJNI.c

命令分解:

  • gcc: GNU C 编译器。
  • -fPIC: (Position-Independent Code) 生成位置无关代码,这是创建共享库的必要选项。
  • -shared: 生成共享库(.so 文件)而不是可执行文件。
  • -I <path>: 指定头文件搜索路径,我们需要包含 JNI 的头文件。
    • ${JAVA_HOME}/include: 包含核心 JNI 头文件,如 jni.h
    • ${JAVA_HOME}/include/linux: 包含 Linux 平台特定的 JNI 头文件。
    • 注意: 请确保 ${JAVA_HOME} 环境变量已正确设置为你 JDK 的安装路径。
  • -o libhellojni.so: 指定输出的共享库名称。Java 的 System.loadLibrary("hellojni") 会查找 lib<name>.so 格式的文件,所以我们命名输出为 libhellojni.so
  • HelloJNI.c: 我们的 C 源文件。

执行成功后,你会得到一个 libhellojni.so 文件。

第 6 步:运行 Java 程序

Java 代码、C 代码和它们之间的“桥梁”(.so 文件)都准备好了,让我们运行它。

System.loadLibrary("hellojni") 默认会在系统库路径(如 /lib, /usr/lib)中查找 libhellojni.so,但通常我们把它放在当前目录,所以需要通过 LD_LIBRARY_PATH 环境变量来告诉去哪里找。

# 设置 LD_LIBRARY_PATH 为当前目录 (.)
export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH
# 运行 Java 程序
# java -cp . HelloJNI

或者,用一行命令完成:

# java -Djava.library.path=. -cp . HelloJNI
  • -Djava.library.path=.: 这个 JVM 参数可以指定加载本地库的路径,它会覆盖 LD_LIBRARY_PATH,这里我们指定为当前目录 。

预期输出:

Hello from C! This is printed by the C code.

恭喜!你已经成功地在 Java 中调用了 C 代码!


进阶主题与注意事项

数据类型映射

Java 和 C 的数据类型不能直接混用,需要通过 JNI 进行转换。

Java 类型 C 类型 描述
byte jbyte 8-bit 有符号整数
short jshort 16-bit 有符号整数
int jint 32-bit 有符号整数
long jlong 64-bit 有符号整数
float jfloat 32-bit 浮点数
double jdouble 64-bit 浮点数
char jchar 16-bit Unicode 字符
boolean jboolean JNI_TRUE (1) 或 JNI_FALSE (0)
Object jobject 任何 Java 对象
Class jclass Java 类对象
String jstring Java 字符串
Object[] jobjectArray 任意对象的数组
byte[] jbyteArray 字节数组
... ... ...

方法签名

当 Java 方法的参数或返回值不是基本类型时,需要使用签名来描述。

  • ()V: 无参数,返回 void
  • (I)I: 一个 int 参数,返回 int
  • (Ljava/lang/String;)V: 一个 String 参数,返回 void
  • ([I)[I: 一个 int 数组参数,返回 int 数组

可以使用 javap 工具来查看类的签名:

javap -s -p HelloJNI.class

更好的替代方案

虽然 JNI 功能强大,但它也有一些缺点:

  • 复杂: 手动管理内存、类型转换,容易出错。
  • 破坏 Java 的跨平台性: 为每个平台(Windows, Linux, macOS)都需要编译不同的本地库。
  • 性能开销: Java 和 C 之间的数据传递有性能损耗。

在考虑使用 JNI 之前,请评估是否有更好的替代方案:

  • JNA (Java Native Access):

    • 优点: 比 JNI 简单得多!它通过一个简单的接口描述文件(或注解)让你直接在 Java 中调用 C 函数,无需编写任何 C 代码或处理复杂的类型转换,JNA 在后台帮你处理了所有 JNI 的繁琐工作。
    • 缺点: 可能有轻微的性能开销(通常可以忽略不计)。
    • 推荐: 对于大多数应用,JNA 是比 JNI 更好的选择
  • GraalVM Native Image:

    • 优点: 这是一个革命性的技术,它可以将 Java 应用程序(包括其依赖项)编译成一个独立的、本地的可执行文件,启动速度极快,内存占用极低,你可以将 C/C++ 代码(通过 JNIForeign Function & Memory API)直接编译进这个原生镜像中。
    • 缺点: 需要改变构建流程,不支持所有 Java 特性(如动态类加载)。
方法 优点 缺点 适用场景
JNI - 功能最强大,直接与 JVM 交互
- 性能最高(数据传递后)
- 非常复杂,繁琐
- 需要编写和维护 C/C++ 代码
- 破坏跨平台性
- 极致性能要求的模块
- 需要深度 JVM 内部交互
- 复杂的 C/C++ 库集成
JNA - 非常简单,只需 Java 代码
- 自动处理类型转换和内存管理
- 跨平台
- 可能有轻微性能开销
- 对复杂 C 结构体支持有限
- 快速集成现有 C/C++ 库
- 大多数常见的本地代码调用场景
GraalVM - 极致的启动速度和低内存
- 真正的原生体验
- 改变构建和部署模式
- 不支持所有 Java 特性
- 微服务、Serverless、命令行工具
- 需要最小化资源占用的应用

对于新项目,除非你有非常特殊的需求(需要编写新的、高度优化的本地算法并直接操作 JVM 内部数据),否则强烈建议优先考虑 JNA,如果你只是想学习底层原理或者维护一个古老的 JNI 项目,那么掌握 JNI 仍然是必要的。

分享:
扫描分享到社交APP
上一篇
下一篇