浅谈JNI

在尝试用Java实现一个调节系统音量的小工具时,我感到深深的无力。只得求助于更贴近底层的C语言,用JNI来实现。以此为契机,简单总结一下JNI。

什么是JNI

JNI(Java Native Interface)是Java提供的一套编程接口,用于实现Java代码与本地代码的交互。它允许Java程序调用本地代码,也允许本地代码回调Java代码。

为什么要用JNI

  • 由于Java无法直接访问硬件、操作系统、数据库等底层资源,因此需要借助c/c++来实现。
  • 对于一些已有的用其它编程语言实现的库,程序员可以直接使用,无需重新实现。
  • 使用底层的库,如计算、图形、渲染,往往可以获得更好的性能。

如何应用JNI

因为网上有很多相关的资料,就不再赘述了。简言之:

  1. 在Java类中使用native关键字声明一个方法。
  2. 使用 javac -h 命令生成头文件。
  3. 创建一个c/c++文件,引入头文件,并实现对应的方法。
  4. 使用本地编译器(如gcc)编译c/c++文件,生成动态链接库。
  5. 在Java代码中引入动态链接库,并调用对应的方法。

按照以上顺序,就能实现一个简单的JNI程序。

JNI的深层理解

我们知道无论什么语言,最终都需要编译成机器码,存入到内存中,再由CPU读取运行。所以在CPU层面上看,JNI调用的本质就是一段机器码(由Java JIT生成)调用了另一段机器码(由c编译器生成),只是一个简单的函数跳转。

此处可以看出,JNI的调用并不是像微服务架构那样,将Java和本地代码分别开多个进程,进程间通信(IPC)。而是在Java进程的堆区中额外划分出C本地堆(C Native Heap),在使用malloc()、new 或者JNI函数(如 GetStringUTFChars)时,所申请的内存就来自此处,通过函数指针进行的高效内存调用。这使得JNI相比于微服务架构,天然的有更低的资源开销和更快的性能。

对于一段由c/c++/Rust等语言编写的本地代码,在被Java调用前,就需要由对应的编译器(如gcc,clang,rustc)直接编译为针对特定操作系统和CPU架构的机器码,并打包在动态链接库中,这也就是上文中第四步做的事。

当Java代码执行到 native 方法时,会发生以下几步:

  1. JVM已经通过JIT编译器,将调用这个 native 方法的Java代码本身编译成了机器码。

  2. 这段机器码的执行逻辑是:“准备参数,然后调用 Java_Class_Method 这个函数”。

  3. JVM在之前加载动态库时,已经将 Java_Class_Method 这个符号解析为C代码编译后所在的内存地址。

  4. CPU的执行流于是直接跳转到动态库中那块预先编译好的C机器码的地址,开始执行。

  5. C代码执行完毕后,再通过返回指令跳转回JIT编译好的Java机器码中继续执行。

按照这个逻辑理解,是否只要最终能编译成机器码,就可以交互呢?答案是否定的,至少对于Java来说。

从JNI的应用角度看,在第五步引入动态链接库后,还有两大关键问题需要解决:

  1. 如何以一种稳定、普适、高效的方式,在二进制层面与各种本地代码库进行连接和基本通信的问题。
  2. 如何安全地交换数据(协调GC)、保持类型安全、处理异常等具体业务问题。

我们一个一个来看。

问题一

在解决这个问题前,先回到问题本身,我们可能会好奇于,都编译成机器码了还需要额外的规范来协调吗?难道机器码还有区别吗?

当然,机器码本身没有不同,但调用函数时如何准备参数、如何传递、如何返回,不同的约定会导致完全不同的机器码排列组合。如果不遵守同一套约定,双方就无法正确对话。

举个例子来理解,假设我们的程序由两部分组成:主程序 main.exe 和库 library.dll,并有一个简单的函数add。

1
int result = add(10, 20);
  • main.exe 由 编译器A 编译,它遵循的ABI规则是:“参数从右向左压栈,调用者清理栈”。

  • library.dll 中的 add 函数由 编译器B 编译,它期望的ABI规则是:“参数从左向右压栈,被调用者清理栈”。

现在,main.exe 调用 library.dll 中的 add 函数:

  1. main.exe 按照自己的规则,生成机器码:先压入 20,再压入 10,然后调用 add。

  2. add 函数开始执行,它按照自己的规则从栈上读取参数。它以为第一个参数在最左边,于是读到了 10(但实际上应该是 20),第二个参数读到了 20(但实际上应该是 10)。虽然结果是 30,但过程是错的。

  3. 函数返回后,add 函数按照自己的规则,清理了栈上的两个参数。

  4. 执行权回到 main.exe。main.exe 也按照自己的规则,又一次尝试清理栈上的两个参数。

  5. 此时,栈指针已经被彻底破坏,完全错位。程序几乎必然会在后续的操作中崩溃。

由此看出,尽管双方生成的机器码本身都是合法、正确的,但因为遵循了不同的“调用约定”,导致程序错误。

JNI最终采用C ABI(C语言应用程序二进制接口)作为双方的约定,这不仅仅是因为它的稳定和兼容性,更是因为JVM本身就是由C/C++编写的应用程序。可以说C ABI是JVM的“母语”,用它来与外界沟通是效率最高、最可靠的方式。

问题二

由于JVM机制的特殊性,与本地代码存在着两大根本的冲突:

  1. 为了优化性能(减少内存碎片),GC(垃圾回收)会定期移动内存中的对象。这就导致C/C++无法获得一个可靠的对象地址,一旦GC移动对象,C指针立即变成悬空指针,导致程序崩溃。
  2. Java的强类型和沙箱机制,不会允许你访问一个对象的私有字段。而C/C++可以访问任意内存,没有内置的安全检查,这就导致数据泄露和安全漏洞。

这就需要JNI的特定规范来约束本地代码,让他们以一种JVM能够理解和安全管理的方式运行。具体来讲,一方面JNI禁止直接操作指针,而是引入了 “JNI引用”(jobject, jstring等) 的概念,使JVM能在背后协调本地代码对对象的访问:要么临时“钉住”对象不让GC移动(Pin),要么先复制一份数据(Copy);另一方面,JNI强制所有访问都必须通过 JNIEnv* 提供的函数(如 GetObjectField, GetIntArrayElements),在执行操作前进行必要的安全检查(如检查数组索引是否越界)。

此处的Pin和Copy策略的选择完全取决于JVM,虽然不同的策略会有所差异(如对应的内存地址不同),但作为Java开发者,无法也不应该假设JVM会采用哪种策略,而是必须按照规范编写代码(即总是成对调用Get/Release),以保证在任何情况下都是正确的。

最终,C ABI提供了基础的、跨平台的函数调用机制,而JNI特定规范则在此基础上定义了专属于JVM的、安全的内存对象交互语义。两者结合,共同构成了Java与本地代码交互的完整解决方案。

JNI的内存管理

到上文为止,Java与本地代码的交互机制已经比较完整了。但是还有一些细节没有涉及,如明明在同一片内存中,为什么垃圾回收机制不会影响到C/C++呢?

当然你可能会觉得为什么不让垃圾回收机制统一管理本地代码呢,这样不是更方便吗?这不是技术上无法实现,而是有其巧思在此,主要是为了兼顾性能、灵活性和语言特性而必须付出的代价。此处不深究。

这个问题可以从内存的角度来看,虽然Java代码和本地代码都运行在整个Java进程的内存上,但其内部还有划分。在Java进程的堆区中会额外划分出C本地堆(C Native Heap),在使用malloc()、new 或者JNI函数(如 GetStringUTFChars)申请的内存就来自此处,而JVM的垃圾回收器(GC)完全不关心这块区域。申请了就必须释放,否则就会泄漏。而两者的的栈帧则是共用同一个进程栈,这也是为什么C函数中的局部参数会自动回收。

JNA和JNR

JNA (Java Native Access),是一个开源库,它基于JNI,但提供了一个纯Java的接口来访问本地库,不需要写任何C/C++代码。

示例:调用 C 标准库函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import com.sun.jna.Library;
import com.sun.jna.Native;
import com.sun.jna.Platform;
import com.sun.jna.Pointer;
import com.sun.jna.Structure;
import com.sun.jna.ptr.IntByReference;

// 1. 定义接口映射C标准库
public interface CLibrary extends Library {
// 2. 加载C标准库(在不同平台上名称不同)
CLibrary INSTANCE = Native.load(Platform.isWindows() ? "msvcrt" : "c", CLibrary.class);

// 3. 声明要映射的C函数
// printf函数
int printf(String format, Object... args);

// strlen函数
long strlen(String str);

// time函数
long time(Pointer pointer);

// 映射带有指针参数的函数(如获取环境变量)
String getenv(String name);

// 映射需要结构体的函数
int gettimeofday(TimeVal tv, Pointer tz);
}

// 定义与C结构体对应的Java类
@Structure.FieldOrder({"tv_sec", "tv_usec"})
public class TimeVal extends Structure {
public static class ByReference extends TimeVal implements Structure.ByReference {}

public long tv_sec; // 秒
public long tv_usec; // 微秒

@Override
public String toString() {
return tv_sec + "秒 " + tv_usec + "微秒";
}
}

public class JnaExample {
public static void main(String[] args) {
// 调用printf
CLibrary.INSTANCE.printf("Hello, World from JNA!\\n");

// 调用strlen
String testStr = "Hello JNA";
long length = CLibrary.INSTANCE.strlen(testStr);
System.out.println("字符串 '" + testStr + "' 的长度是: " + length);

// 调用time
long currentTime = CLibrary.INSTANCE.time(null);
System.out.println("当前时间戳: " + currentTime);

// 调用getenv
String path = CLibrary.INSTANCE.getenv("PATH");
System.out.println("PATH环境变量: " + (path != null ? path : "未找到"));

// 调用gettimeofday(需要结构体)
TimeVal timeVal = new TimeVal();
CLibrary.INSTANCE.gettimeofday(timeVal, null);
System.out.println("当前时间: " + timeVal);

// 演示处理指针参数(模拟C中的int*)
IntByReference ref = new IntByReference(42);
System.out.println("IntByReference值: " + ref.getValue());
}
}

高级特性:调用自定义库

假设我们有一个自定义的 C 库 mylib,其中包含以下函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// mylib.h
#ifndef MYLIB_H
#define MYLIB_H

typedef struct {
int x;
int y;
} Point;

int add(int a, int b);
double multiply(double a, double b);
void modify_point(Point* p);

#endif

对应的 JNA 调用代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import com.sun.jna.Library;
import com.sun.jna.Native;
import com.sun.jna.Structure;

// 定义Point结构体
@Structure.FieldOrder({"x", "y"})
class Point extends Structure {
public int x;
public int y;

public Point() {}

public Point(int x, int y) {
this.x = x;
this.y = y;
}
}

// 定义自定义库接口
interface MyLib extends Library {
MyLib INSTANCE = Native.load("mylib", MyLib.class);

int add(int a, int b);
double multiply(double a, double b);
void modify_point(Point p); // JNA会自动处理指针
}

public class CustomLibExample {
public static void main(String[] args) {
// 调用add函数
int sum = MyLib.INSTANCE.add(5, 3);
System.out.println("5 + 3 = " + sum);

// 调用multiply函数
double product = MyLib.INSTANCE.multiply(2.5, 4.0);
System.out.println("2.5 * 4.0 = " + product);

// 调用modify_point函数(传递结构体)
Point p = new Point(10, 20);
System.out.println("修改前: (" + p.x + ", " + p.y + ")");

MyLib.INSTANCE.modify_point(p);
System.out.println("修改后: (" + p.x + ", " + p.y + ")");
}
}

JNR (Java Native Runtime),由JRuby团队开发,旨在解决JNA的性能问题。它同样允许纯Java代码调用本地库。JNR的设计比JNA更底层、更智能。它的核心是 libffi(一个广泛使用的外部函数接口库)。

示例:调用 C 标准库函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import jnr.ffi.LibraryLoader;
import jnr.ffi.Pointer;
import jnr.ffi.annotations.Delegate;
import jnr.ffi.annotations.In;
import jnr.ffi.annotations.Out;
import jnr.ffi.annotations.Transient;
import jnr.ffi.types.size_t;

import java.nio.ByteBuffer;

public class JnrExample {
// 1. 定义接口映射C标准库
public interface LibC {
// 2. 加载C标准库
static LibC load() {
return LibraryLoader.create(LibC.class).load("c");
}

// 3. 声明要映射的C函数
int printf(String format, Object... args);

@size_t long strlen(String str);

long time(Pointer pointer);

String getenv(String name);

// 使用更精确的类型映射
int gettimeofday(TimeVal tv, Pointer tz);
}

// 定义TimeVal结构体
public static class TimeVal extends jnr.ffi.Struct {
public final Signed64 tv_sec = new Signed64();
public final Signed64 tv_usec = new Signed64();

public TimeVal(jnr.ffi.Runtime runtime) {
super(runtime);
}
}

public static void main(String[] args) {
LibC libc = LibC.load();

// 调用printf
libc.printf("Hello, World from JNR!\\n");

// 调用strlen
String testStr = "Hello JNR";
long length = libc.strlen(testStr);
System.out.println("字符串 '" + testStr + "' 的长度是: " + length);

// 调用time
long currentTime = libc.time(null);
System.out.println("当前时间戳: " + currentTime);

// 调用getenv
String path = libc.getenv("PATH");
System.out.println("PATH环境变量: " + (path != null ? path : "未找到"));

// 调用gettimeofday
TimeVal tv = new TimeVal(jnr.ffi.Runtime.getSystemRuntime());
libc.gettimeofday(tv, null);
System.out.println("当前时间: " + tv.tv_sec.get() + "秒 " + tv.tv_usec.get() + "微秒");
}
}

高级特性:调用自定义库

使用 JNR 调用前面提到的自定义库 mylib:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import jnr.ffi.LibraryLoader;
import jnr.ffi.Struct;

public class JnrCustomLibExample {
// 定义自定义库接口
public interface MyLib {
static MyLib load() {
return LibraryLoader.create(MyLib.class).load("mylib");
}

int add(int a, int b);
double multiply(double a, double b);
void modify_point(Point p);
}

// 定义Point结构体
public static class Point extends Struct {
public final Signed32 x = new Signed32();
public final Signed32 y = new Signed32();

public Point(jnr.ffi.Runtime runtime) {
super(runtime);
}
}

public static void main(String[] args) {
MyLib mylib = MyLib.load();

// 调用add函数
int sum = mylib.add(5, 3);
System.out.println("5 + 3 = " + sum);

// 调用multiply函数
double product = mylib.multiply(2.5, 4.0);
System.out.println("2.5 * 4.0 = " + product);

// 调用modify_point函数
Point p = new Point(jnr.ffi.Runtime.getSystemRuntime());
p.x.set(10);
p.y.set(20);
System.out.println("修改前: (" + p.x.get() + ", " + p.y.get() + ")");

mylib.modify_point(p);
System.out.println("修改后: (" + p.x.get() + ", " + p.y.get() + ")");
}
}

总的来说,两者都是 JNI 的优秀替代方案,可以大大简化 Java 与本地代码的交互过程。

选择哪一个取决于具体需求:

  • 如果需要快速实现功能并且对性能要求不是极高,选择 JNA
  • 如果需要更好的性能并且愿意学习更复杂的 API,选择 JNR

从、堆栈说开去——浅谈内存管理

起因于昨日的面试,问Java中int和Interger的区别,没答上来。故总结一下数据结构中堆、栈的相关知识。

堆、栈的词源

堆(heap)让人联想到土堆、草堆,有“杂乱无章、自由拜访”的意味,而在英语中本意为“杂乱拜访的物体”。栈(stack)在古代指存放货物的仓库(如“货栈”),货物通常按顺序堆叠存放,有“层层堆叠、顺序存取”的特点。内存管理中堆、栈的也是同样。

内存

要想理清堆、栈在内存管理中的区别,就首先应弄清此处讨论的内存是什么。内存在广义上包括随机存储器(RAM),只读存储器(ROM),以及高速缓存(CACHE)。

  • 只读存储器(ROM),一般用于存放计算机的基本程序和数据(如bios),往往直接焊死在主板上,且工作过程中只能读出,不能更改。
  • 高速缓存(CACHE)位于CPU与主存(RAM)之间,是一个读写速度比内存更快的存储器,用于解决CPU速率和主存访问速率差距过大的问题。

而在狭义上的内存往往指随机存储器(RAM),也就是用户可以自由插拔的内存条中的存储空间。它是CPU可以直接、快速访问数据的地方,一般的程序指令想要被执行就必须先被加载到RAM中。

但由于RAM的容量有限,且无隔离性(所有程序共享同一物理地址空间,程序 A 可随意读写程序 B 的内存),所以实际上程序并不会直接访问RAM的地址,而是访问虚拟地址,通过内存管理单元 (MMU) 动态翻译成RAM中的实际地址,并利用硬盘(或 SSD)上的一部分空间(称为页面文件或交换空间)来扩展可用的“内存”资源。为了区分虚拟地址空间和实际地址空间,就把RAM的实际地址空间称为物理内存,程序访问的虚拟地址空间称为虚拟内存。

所以虚拟内存并不是一个物理硬件,只是在程序的眼里,它在操作一个连续、独立且大小可能超过物理内存总容量的私有地址空间。对于程序员来说,也就不需要手动管理物理地址,而是只专注虚拟内存的管理。

所以在这里的内存管理实际指的是虚拟内存的管理。

内存管理

以32位CPU为例,最大寻址$2^{32}$,那么虚拟地址空间的地址范围就应该是0x00000000~0xFFFFFFFF,也就是提供4GB的虚拟内存。

原生程序(如 C/C++)

在程序的视角看,内存被划分为几个逻辑区域,从低地址(0x00000000)开始,至高地址结束(0xFFFFFFFF)结束,依次为代码段(Text Segment)、数据段(Data Segment)、BSS 段(Block Started by Symbol)、堆(Heap)、栈(Stack)。

  • 代码段:只读,存放编译后的机器指令。
  • 数据段:读写,已初始化的全局变量和静态变量。
  • BSS段:读写,未初始化的全局变量和静态变量。且会隐式初始化为0。
  • 堆:读写,从低地址向高地址,存放动态分配的内存(malloc/new 申请),需手动分配/释放(free/delete)。
  • 栈:读写,从高地址向低地址,存放函数调用栈帧(局部变量、参数、返回地址),上下文数据(寄存器备份、异常处理链)。

在一段c/c++代码运行前,会先编译成机器码存入代码段,并将全局变量、静态变量等存入数据段或BSS段中。CPU会读取代码段中的机器码,基于指令和内存地址,动态的读取或修改其它段的数据。

一方面函数的调用强调严格的顺序和高效的逻辑关系,另一方面又在某些时候需要动态的内存空间和大内存需求(如处理运行时才能确定大小的数据),就干脆分成两部分,也就是栈和堆。所以栈和堆在本质上都是存储空间,只是设计目标不同,导致实现方式不同,最终承担的职责也不同。

具体来说,例如:

1
int a = 1;

变量a和其值1都被分配在栈上,当函数或代码块结束时,a会自动从栈中销毁。

容易导致内存泄露的往往是下面一种情况:

1
2
3
char* p = new char[100];
//在堆上开辟了100个char长度的空间,同时将p压入了栈中
//指针变量p的指向堆中保存了100个char的首地址

当函数或代码块结束时,p会自动从栈中销毁,但堆中的100个char不会被销毁。如果不手动释放,就会导致内存泄漏。

JVM

众所周知,Java 程序通过 JVM(Java 虚拟机) 运行,其内存结构其内存结构与原生程序(如 C/C++)也有显著区别。但归根结底是要通过操作系统的虚拟内存系统映射到物理内存才能使用,只是需要经过 JVM 的抽象层(如 GC、字节码解释器)封装。

示例:
当在java中创建对象时

1
Object obj = new Object(); // 在 JVM 堆中分配内存

JVM 向操作系统申请虚拟内存页 → 操作系统将其映射到物理内存 → CPU 通过 MMU(内存管理单元)访问实际物理地址。

JVM中的内存结构主要分为线程私有区域(Thread-Local),和线程共享区域(Shared),栈和堆就分别存放在两个区域中(当然线程私有区域中又分为方法区、本地方法栈、程序计数器、堆栈、栈帧、对象引用、对象数据等等,栈和堆又可以划分成多个区域,但由于我自己还没搞懂,就先按下不表)。

  • 栈(JVM Stack):存储方法参数和局部变量(基本类型 + 对象引用),存放计算过程的中间结果(如 i++ 的临时值),方法执行完毕后返回的位置。
  • 堆(JVM Heap):存放对象实例,对象实例的属性值,对象实例的引用。

所以在大体上来说,JVM中的栈和堆,与原生程序中的栈和堆是很相似的。

那么回到int和Interger上来。Java中有两种数据类型:基本数据类型,有boolean、byte、int、char、long、short、double、float;引用数据类型 ,分为数组、类、接口。Java为每个基本数据类型都提供了对应的包装类,并在Java 5引入了自动装箱和自动拆箱,使其可以方便的相互转化。

Java中int往往存于栈中,而Integer往往存于堆中。但如果认为这是通过数据类型来判断的,就倒果为因了。从线程私有区域、线程共享区域的名字上就可以看出,实际上是通过上下文来区分的。

例如

1
2
3
void method(){
int a = 1;
}

a作为一个方法的局部变量存在,存放在栈中。

1
2
3
class demo {
int a = 1;
}

在这里a作为一个类的成员变量存在,属于全局变量,存放在堆中。

只不过,由于类中常用Integer,方法中常用int,所以有这种错觉。归根结底是根据线程私有还是线程共享,或者说全局变量还是局部变量来区分。

至于这种代码习惯的产生有很多原因,例如Integer默认为null,而且可以用List<Integer>,而int只占用4字节,计算更高效。

由于Integer实际是一个类,所以在创建Integer实例的时候,实际获得的是地址。所以两个通过new生成的Integer变量永远是不相等的。

1
2
3
Integer a = new Integer(1);
Integer b = new Integer(1);
System.out.println(a == b);//false

但当int和Integer类型比较时,由于自动拆箱的机制存在,会自动把Integer转换成int,然后进行比较。

1
2
3
Integer a = new Integer(1);
int b = 1;
System.out.println(a==b);//true

当然此处都是使用==进行比较,但Java中还有一个相似的方法:equals。==对于基本数据类型,比较的是值;对于引用数据类型,比较的是的内存地址,即是否指向同一个变量。equals是Object类的一个方法,默认实现与==相同。但许多类(如string,Integer)都重写了equals方法,用于比较对象的内容。如果需要比较自定义类的内容,就必须重写equals方法。

由于JVM的垃圾回收机制,可以自动释放堆上不再使用的对象,所以程序员往往不需要主动释放对象。

我的第一个博客!

对于是否要开设个人博客,起初是很纠结的。一方面,有个专属的空间确实很酷;
但另一方面,我又不是个能写的人,很怀疑自己能不能一直坚持下去。但与其踌躇不定,不如行动起来,这篇博客就如此诞生了。若说vue不会写,直接套用模板还不会么?
相信这会是一个好头(没坚持下去,这篇博客当然也看不到了,哈哈)

2025-07-23 21:03:36
部署到服务器上,也太麻烦了???