1. 概述

本文将介绍如何使用 Java Native Access(简称 JNA)来调用本地动态库,而无需编写任何 JNI (Java Native Interface) 代码。

2. 为什么选择 JNA?

Java 及其他 JVM 语言在很大程度上实现了“一次编写,到处运行”的理念。✅ 然而,在某些场景下我们仍需要借助本地代码实现特定功能:

  • 重用以 C/C++ 或其他能生成本地代码的语言编写的遗留系统
  • 使用标准 Java 运行时中没有提供的系统级功能
  • 对应用中的某些部分进行性能优化(速度或内存)

早期这类需求通常只能通过 JNI 来解决。虽然有效,但 JNI 有明显缺点,让人避之不及 ❌:

  • 需要开发者手写 C/C++ 的“胶水代码”来桥接 Java 和本地代码
  • 每个目标平台都需要完整的编译和链接工具链
  • 数据在 JVM 与本地代码之间传递时繁琐且容易出错
  • 混合使用 Java 与本地库可能带来法律和支持问题

JNA 的出现就是为了解决这些复杂性 ✅。特别是对于动态库中的本地代码,无需编写任何 JNI 代码即可直接调用,大大简化了整个流程。

当然也有一些权衡:

  • 不能直接使用静态库
  • 相比手工编写的 JNI 代码略慢

但对于大多数应用而言,JNA 提供的简洁性和易用性远大于其性能劣势。除非有非常特殊的需求,否则 JNA 是目前 Java(或其他 JVM 语言)调用本地代码的最佳选择。

3. JNA 项目配置

要使用 JNA,首先需要在项目的 pom.xml 中添加依赖项:

<dependency>
    <groupId>net.java.dev.jna</groupId>
    <artifactId>jna-platform</artifactId>
    <version>5.6.0</version>
</dependency>

最新版本的 jna-platform 可从 Maven Central 获取。

4. 使用 JNA

使用 JNA 分两步走:

  1. 创建一个继承自 JNA 的 Library 接口的 Java 接口,用于描述调用本地代码时的方法签名和类型
  2. 将这个接口交给 JNA,它会返回该接口的具体实现类,从而可以调用本地方法

4.1. 调用 C 标准库方法

第一个例子我们来调用标准 C 库中的 cosh 函数,该函数计算双曲余弦值。在 C 程序中只需包含 <math.h> 即可使用:

#include <math.h>
#include <stdio.h>
int main(int argc, char** argv) {
    double v = cosh(0.0);
    printf("Result: %f\n", v);
}

创建对应的 Java 接口如下:

public interface CMath extends Library { 
    double cosh(double value);
}

然后使用 JNA 的 Native 类加载该接口并调用方法:

CMath lib = Native.load(Platform.isWindows()?"msvcrt":"c", CMath.class);
double result = lib.cosh(0);

⚠️ 重点在于 load() 方法的调用:它接受两个参数——动态库名称和 Java 接口,并返回该接口的一个具体实现对象,使我们可以调用其中的方法。

不同系统的动态库命名方式不同,比如 Linux 上是 libc.so,Windows 上则是 msvcrt.dll。所以我们借助 JNA 提供的 Platform 工具类判断当前操作系统,选择正确的库名。

注意:不需要手动加上 .so.dll 扩展名,也不需要加上 lib 前缀(Linux 平台共享库默认前缀)。

由于动态库在 Java 层的行为类似于 单例模式,一种常见做法是在接口中定义一个 INSTANCE 字段:

public interface CMath extends Library {
    CMath INSTANCE = Native.load(Platform.isWindows() ? "msvcrt" : "c", CMath.class);
    double cosh(double value);
}

4.2. 基本类型映射

上面的例子中方法参数和返回值都是基本类型,JNA 会自动处理这种映射关系,通常使用 Java 中自然对应的类型:

C 类型 Java 类型
char byte
short short
wchar_t char
int int
long com.sun.jna.NativeLong
long long long
float float
double double
char * String

⚠️ 有一个比较特殊的映射是 long 类型:因为在 C/C++ 中,long 在 32 位系统上是 4 字节,64 位系统上则是 8 字节。为了适配这种差异,JNA 提供了 NativeLong 类型,它会根据平台架构自动选择合适的数据宽度。

4.3. 结构体和联合体

另一个常见的场景是本地 API 接收指向 structunion 类型的指针。在 Java 接口中,对应参数或返回值应分别继承 StructureUnion 类。

例如以下 C 结构体:

struct foo_t {
    int field1;
    int field2;
    char *field3;
};

对应的 Java 类为:

@FieldOrder({"field1","field2","field3"})
public class FooType extends Structure {
    int field1;
    int field2;
    String field3;
};

⚠️ JNA 要求使用 @FieldOrder 注解来指定字段顺序,以便在调用本地方法前正确地序列化到内存缓冲区。

也可以重写 getFieldOrder() 方法达到同样效果。对于单一平台开发,前者已经足够;若需兼容多个平台(如对齐问题),则可使用后者增加填充字段。

Union 的处理类似,但有几个要点:

  • 不需要 @FieldOrder 注解或实现 getFieldOrder()
  • 调用本地方法前必须先调用 setType()

示例代码如下:

public class MyUnion extends Union {
    public String foo;
    public double bar;
};

使用方式:

MyUnion u = new MyUnion();
u.foo = "test";
u.setType(String.class);
lib.some_method(u);

如果字段类型相同,则需使用字段名:

u.foo = "test";
u.setType("foo");
lib.some_method(u);

4.4. 使用指针

JNA 提供了 Pointer 抽象类,用于处理未指定类型的指针(通常是 *void **)。该类提供了读写底层本地内存的方法,但也伴随着风险。

在使用前必须明确谁拥有这块内存,否则极易引发内存泄漏或非法访问等难以调试的问题。

假设我们知道怎么用,来看一个经典例子:使用 malloc()free() 分配和释放内存:

public interface StdC extends Library {
    StdC INSTANCE = // ... 实例创建省略
    Pointer malloc(long n);
    void free(Pointer p);
}

使用方式:

StdC lib = StdC.INSTANCE;
Pointer p = lib.malloc(1024);
p.setMemory(0l, 1024l, (byte) 0);
lib.free(p);

setMemory() 方法将缓冲区填充为指定字节(这里是 0)。⚠️ 注意 Pointer 实例并不知道它指向的是什么类型的数据,也不知道长度,因此很容易导致堆损坏。

后面我们会看到如何通过 JNA 的崩溃保护机制缓解此类错误。

4.5. 错误处理

早期的 C 标准库使用全局变量 errno 存储调用失败的原因。例如典型的 open() 调用:

int fd = open("some path", O_RDONLY);
if (fd < 0) {
    printf("Open failed: errno=%d\n", errno);
    exit(1);
}

但在现代多线程程序中这显然不行。幸运的是,C 预处理器让开发者依然能写出这样的代码。现在 errno 实际上是一个宏,会被展开为函数调用:

// Linux 示例
#define errno (*__errno_location ())

// Windows Visual Studio 示例
#define errno (*_errno())

这种方式在编译阶段有效,但在 JNA 中没有对应的宏机制。虽然我们可以显式声明展开后的函数并手动调用,但 JNA 提供了一个更好的替代方案:LastErrorException

任何在接口中声明抛出 LastErrorException 的方法,在调用本地方法后会自动检查错误。如果发生错误,JNA 会抛出该异常,并携带原始错误码。

让我们在之前的 StdC 接口中添加两个方法演示此功能:

public interface StdC extends Library {
    // ... 其他方法省略
    int open(String path, int flags) throws LastErrorException;
    int close(int fd) throws LastErrorException;
}

使用方式:

StdC lib = StdC.INSTANCE;
int fd = 0;
try {
    fd = lib.open("/some/path",0);
    // ... 使用 fd
}
catch (LastErrorException err) {
    // ... 错误处理逻辑
}
finally {
    if (fd > 0) {
       lib.close(fd);
    }
}

catch 块中可以通过 LastErrorException.getErrorCode() 获取原始 errno 值,用于后续错误处理。

4.6. 处理访问违规

⚠️ 正如前面提到的,JNA 并不会阻止我们滥用 API,特别是在涉及本地与 Java 之间传递内存缓冲区时。通常这类错误会导致访问违规并终止 JVM。

JNA 提供了一定程度上的保护机制,允许 Java 层捕获访问违规错误。有两种方式启用:

  • 设置系统属性 jna.protectedtrue
  • 调用 Native.setProtected(true)

启用保护模式后,JNA 会捕获原本会导致崩溃的访问违规错误,并抛出 java.lang.Error 异常。我们可以通过初始化一个无效地址的 Pointer 并尝试写入数据来验证这一点:

Native.setProtected(true);
Pointer p = new Pointer(0l);
try {
    p.setMemory(0, 100*1024, (byte) 0);
}
catch (Error err) {
    // ... 错误处理省略
}

⚠️ 官方文档明确指出:该特性仅适用于调试和开发阶段,生产环境不建议开启。

5. 总结

本文展示了如何使用 JNA 简单高效地调用本地代码,相比传统的 JNI 方案更加轻量、便捷。

一如既往,所有示例代码均可在 GitHub 查看。


原始标题:Using JNA to Access Native Dynamic Libraries