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 分两步走:
- 创建一个继承自 JNA 的 Library 接口的 Java 接口,用于描述调用本地代码时的方法签名和类型
- 将这个接口交给 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 接收指向 struct 或 union 类型的指针。在 Java 接口中,对应参数或返回值应分别继承 Structure 或 Union 类。
例如以下 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.protected 为 true
- 调用 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 查看。