使用 Ant 构建 JNI 库
1. 构建 JNI 库的步骤
JNI 的核心概念是,使用
native
前缀声明的 Java 方法会绑定到本地库,这些本地库会由 Java 运行时动态加载。在 Windows 上,本地库是动态链接库(DLL),而在 Unix 上,共享库(.so)提供相同的功能,其他平台也有各自的等效形式。
编写新的 JNI 库,可按以下步骤进行:
1. 编写一个包含本地方法的 Java 类。
2. 将 Java 源文件编译为字节码。
3. 使用
javah
工具从编译后的类创建 C++ 头文件。
4. 将此头文件集成到一个 C++ 项目中,该项目的输出是一个动态或共享库。
5. 编写 C++ 代码以实现所需的实际功能。
6. 编译代码,并引入 JDK 中的 JNI 头文件。
7. 根据需要链接 JDK 中的库文件以解析外部引用。
下面是核心 JNI 构建过程的流程图:
graph LR
A[Java Stub Class] -->|Javac| B[Stub bytecodes]
B -->|Javah| C[C++ header]
D[C++ source] -->|CPP compile and link| E[native binary]
C --> E
F[JDK headers and libraries] --> E
执行本地代码还需要额外的步骤,本地库必须位于 Java 运行时的路径中。如果库不在路径中,运行时会抛出
java.lang.UnsatisfiedLinkError
错误。在团队开发 JNI 库时,另一个小问题是导入 JNI 头文件和库,由于这些文件都在 JDK 中,所以除非所有团队成员都将 JDK 安装在同一位置,否则不能使用硬编码引用这些文件,必须使用
JAVA_HOME
环境变量作为这些文件的基础位置,并考虑特定于操作系统的目录名称。
2. 编写 Java 存根
编写用于导出本地方法调用的 Java 代码是最简单的步骤,示例代码如下:
package org.example.antbook.cpu;
public class CpuInfo {
native long getCpuClock();
static {
System.loadLibrary("CpuInfo");
}
}
这个
CpuInfo
类目前导出了一个方法
getCpuClock
,并包含一个静态初始化器,用于按名称加载共享库。编译这个类与正常的构建过程没有区别,使用以下 Ant 任务:
<target name="compile" depends="init" >
<javac srcdir="src" includes="**/*.java"
destdir="${classes.dir}"/>
</target>
运行此任务后,会得到一个 Java 存根
.class
文件,可用于创建头文件。
3. 创建 C++ 头文件
C++ 头文件通过
<javah>
任务从类生成,该任务是 JDK 中
javah
工具的包装器。虽然这是一个可选任务,但除了 JDK 中的库外,它不需要其他外部库:
<target name="headers" depends="compile">
<javah destdir="${generated.dir}"
force="yes"
classpath="${classes.dir}">
<class name="org.example.antbook.cpu.CpuInfo"/>
</javah>
</target>
该任务需要一个包含存根类的类路径、类名和一个输出目录,会在该目录中生成一个名为
org_tasklibs_CpuInfo.h
的头文件。
javah
程序从包和类声明中派生文件名。
<javah>
任务不会对 Java 源文件进行依赖检查,因此不知道何时生成文件。
force="true"
属性会让任务始终创建头文件,即使目标文件已存在,这会导致效率低下,因为 C++ 编译器会始终重新构建库,即使没有任何更改。在
<javah>
任务支持依赖检查之前,需要将其排除在主构建之外,或者使用
<uptodate>
调用来使头文件生成目标依赖于目标文件是否过时。
4. 编写 C++ 类
JNI 开发过程的下一步是编写 C++ 代码。以下是 Windows 版本的示例代码,位于
org_example_antbook_cpu_CpuInfo.cpp
文件中:
#include "org_example_antbook_cpu_CpuInfo.h"
JNIEXPORT jlong JNICALL
Java_org_example_antbook_cpu_CpuInfo_getCpuClock
(JNIEnv *,
jobject) {
__int64 timestamp;
__int64 *pTimestamp=×tamp;
_asm rdtsc;
_asm mov ecx,pTimestamp
_asm mov [ecx],eax;
_asm mov [ecx+4],edx;
return timestamp;
}
rdtsc
指令将 CPU 时钟滴答计数存储到处理器的两个 32 位寄存器中,然后将结果的每一半移动到一个变量的不同部分,该变量成为
jlong
结果。在 Windows 上,这映射到非标准的
__int64
数据类型。为了创建函数原型,我们从头文件中复制函数声明,并在 C++ 文件源的开头添加对该文件的引用。这个头文件会引入 JDK 的
jni.h
头文件,该文件声明了所有 Java 数据类型和方法,因此 C++ 源文件不需要直接包含任何 JDK 文件,只需要包含提供对其他库(如操作系统 API)访问的头文件。
处理头文件时,如果使用 IDE 编译源文件,需要将 IDE 指向 JDK,这很脆弱且在不同系统上可能无法工作,更好的方法是将文件复制到源树中,可通过手动或使用 Ant 任务完成:
<target name="includes">
<copy todir="${build.dir}/imported/jni">
<fileset dir="${env.JAVA_HOME}/include" includes="**/*.h" />
</copy>
</target>
不过,使用
<cc>
任务时不需要这样做,而是使用其
<include>
元素指向 JDK 中的文件。同时,需要处理
JAVA_HOME/include
下特定于平台的子目录,如
include/win32
和
include/linux
。
5. 编译 C++ 源文件
现在可以声明
<cc>
任务来构建 C++ 文件,以下是依赖于
headers
目标的任务声明:
<target name="cc-windows" depends="headers" >
<cc debug="${build.debug}"
link="shared"
outfile="${dist.filename.nosuffix}"
objdir="${obj.dir}"
multithreaded="true"
exceptions="true" >
<compiler name="msvc" />
<fileset dir="src/cpp/windows"/>
<includepath location="${generated.dir}" />
<sysincludepath location="${env.JAVA_HOME}/include" />
<sysincludepath location="${env.JAVA_HOME}/include/win32" />
<linker name="msvc" >
<syslibset libs="kernel32,user32"/>
</linker>
</cc>
</target>
这个任务声明比较复杂,下面进行详细分析:
-
开始标签
:
<cc>
标签本身的所有属性都是跨平台的。例如,设置
debug
属性会告诉编译器生成调试信息,并让链接器在链接时包含这些信息。每个支持特定编译器或链接器的类都会为该工具生成适当的选项,用户只需选择选项即可。
link
属性告诉任务如何链接目标文件,以创建库文件或可执行文件,其可能的值如下表所示:
| Link type | Meaning |
| — | — |
| Shared | 共享库,.so 或 .dll |
| Application | 可执行文件;在 Windows 上以 .exe 结尾 |
| Static | 静态库,.a 或 .lib 扩展名;Unix 会添加
lib
前缀 |
| None | 不进行链接 |
outfile
属性不需要指定要生成的库或可执行文件的完整名称,只需指定基本名称,任务本身会确定最终名称。
objdir
参数指定中间目标文件的存放位置,
multithread
和
exceptions
选项为编译器和链接器指定更多选项。
-
配置编译器
:任务声明中的前五个嵌套元素是对编译器的指令:
<compiler name="msvc" />
<fileset dir="src/cpp/windows" includes="**/*.cpp"/>
<includepath location="${generated.dir}" />
<sysincludepath location="${env.JAVA_HOME}/include" />
<sysincludepath location="${env.JAVA_HOME}/include/win32" />
首先选择编译器,默认是 GNU gcc。
fileset
声明用于引入源文件,在复杂项目中,可能有通用源文件以及特定于平台和编译器的文件,可使用条件模式集来管理。编译器还需要知道外部目录中包含文件的位置,使用
<includepath>
和
<sysincludepath>
元素来描述。
<sysincludepath>
目录中列出的头文件不包含在依赖检查中,因为假定它们不会更改,而使用
<includepath>
指向生成的头文件是为了进行依赖检查。
-
配置链接器
:编译完成后进行链接,使用
linker
元素指定:
<linker name="msvc" >
<syslibset libs="kernel32,user32"/>
</linker>
这里不指定库文件的路径,
LINK
工具必须从
LIB
环境变量中提取该信息,需要指定要包含的库。可以在
<cc>
任务中或链接器声明本身中声明库集,当向
<cc>
任务添加多个条件链接器时,在不同链接器声明中声明库会成为首选选项。
-
运行编译器
:所有内容声明完成后,就可以运行编译器,以下是输出示例:
compile:
[javac] Compiling 1 source file to
C:\AntBook\Sections\Applying\cpp\build\classes
headers:
[javah] ClassArgument.name=
org.example.antbook.cpu.CpuInfo
cc-windows:
[cc] 1 total files to be compiled.
[cc] org_example_antbook_cpu_CpuInfo.cpp
[cc] Creating library CpuInfo.lib and object CpuInfo.exp
compile
目标将 Java 源文件编译为
.class
文件,
headers
目标使用该文件生成本地头文件,
cc-windows
目标则创建 DLL 文件。虽然输出消息可以改进,但检查输出目录会发现
CpuInfo.dll
已创建。
6. 部署和测试库
现在进入测试和本地部署阶段,可以像往常一样使用 JUnit 进行测试,以下是详细介绍:
-
设计测试
:
- 第一个测试应验证库是否加载以及方法是否返回。
- 第二个测试可以验证每次调用时时钟计数是否增加。如果了解预期的往返时间,还可以验证往返时间是否在合理范围内,但这可能会受到 Java 运行时行为变化的影响。
- 第三个测试可以重复调用目标,以确定 JIT 优化后调用本身的开销,这个数字可以从任何实际性能测量中减去,以获得准确的测量结果。
以下是测试代码示例:
package org.example.antbook.cpu;
import junit.framework.*;
public class CpuInfoTest extends TestCase {
private CpuInfo clock;
public CpuInfoTest(String name) {
super(name);
}
public void setUp() {
clock=new CpuInfo();
}
public void testClockCallReturns() {
long time1=clock.getCpuClock();
}
public void testClockCodeWorks() {
long time1=clock.getCpuClock();
long time2=clock.getCpuClock();
long diff=time2-time1;
System.out.println("Invocation time="+diff+" cycles");
assertTrue(diff>0);
}
public void testJitOptimization() {
int iterations=10000;
long diff=spin(iterations);
diff=spin(iterations);
assertTrue(diff>0);
int average=(int)(((float)diff)/iterations);
System.out.println("Total time=" + diff+" cycles");
System.out.println("Invocation time="+average+" cycles");
}
public long spin(int iterations) {
long time1=clock.getCpuClock();
long time2=0;
for(int i=0;i<iterations;i++) {
time2=clock.getCpuClock();
}
long diff=time2-time1;
return diff;
}
}
为了计算 JIT 热点优化后的平均往返时间,需要预热所有内容,通过在
spin
方法中实现迭代循环并调用两次,只记录第二次循环的迭代时间。
-
部署库
:在测试成功之前,需要将本地库部署到 Java 可以找到的位置,一个可靠的位置是 JDK 的
bin
子目录(至少在测试系统上是这样),但不能保证在其他系统上也能工作,因为这可能取决于 JRE 设置。以下是部署的 Ant 任务:
<property name ="deploy.dir"
location="${env.JAVA_HOME}/bin" />
<target name="deploy" depends="cc-windows">
<copy
file="${dist.dir}/${libname}"
todir="${deploy.dir}" />
<echo message="deployed to ${deploy.dir}" />
</target>
运行此目标会在文件发生更改时进行复制,但由于
<javah>
每次运行都会重新生成头文件,
<cc>
会解析源文件以确定头文件依赖关系,所以目前每次构建时文件都会被复制。如果目标 DLL 存在且正在使用(即有运行的 JVM 已加载它),部署目标将无法工作,复制操作会失败并导致构建中断。
-
测试目标
:可以使用
<junit>
调用测试,以下是配置示例:
<target name="test" depends="deploy">
<junit printsummary="withOutAndErr"
failureproperty="tests.failed"
fork="yes">
<classpath>
<pathelement location="${classes.dir}" />
<pathelement path="${dist.dir}" />
<pathelement path="${java.class.path}" />
</classpath>
<formatter type="plain" usefile="false"/>
<test name="org.example.antbook.cpu.CpuInfoTest" />
</junit>
<fail if="tests.failed">Tests failed</fail>
</target>
配置
<junit>
在任何失败后继续执行,使用条件
<fail>
在任何测试不成功时停止构建。如果测试成功,执行
ant test
的输出尾部会类似如下内容:
test:
[junit] Testsuite: org.example.antbook.cpu.CpuInfoTest
[junit] Tests run: 3, Failures: 0, Errors: 0,
Time elapsed: 0.371 sec
[junit] ------------- Standard Output ---------------
[junit] Invocation time=761 cycles
[junit] Total time=1514496 cycles
[junit] Invocation time=151 cycles
[junit] ------------- ---------------- ---------------
[junit]
[junit] Testcase: testClockCallReturns took 0.221 sec
[junit] Testcase: testClockCodeWorks took 0 sec
[junit] Testcase: testJitOptimization took 0.02 sec
BUILD SUCCESSFUL
这表明库正在加载(
testClockCallReturns
),并且重复调用会增加计数器(
testClockCodeWorks
),说明汇编代码正常工作。第一个测试耗时 0.221 秒,这是加载 Java 类和 DLL 的时间,即加载库的开销。第二个测试耗时 761 个周期,与 Win32 应用程序调用 Windows NT Ring Zero API 的 600 周期往返时间相当。优化后的往返时间降至 151 个周期,这是一个更可接受的数字,现在可以考虑使用这个库来计时应用程序中例程的执行时间。
7. 改进库查找
构建和测试过程中不太优雅的部分是需要将本地库放置在运行时的路径中。Java 1.2 增加了一个新属性
java.library.path
来指定本地库的搜索路径,以下是在 JUnit 测试中设置该属性的新目标:
<target name="test" depends="cc-windows">
<junit printsummary="withOutAndErr"
failureproperty="tests.failed"
fork="yes">
<sysproperty key="java.library.path"
value="${dist.dir}"/>
<classpath>
<pathelement location="${classes.dir}" />
<pathelement path="${dist.dir}" />
<pathelement path="${java.class.path}" />
</classpath>
<formatter type="plain" usefile="false"/>
<test name="org.example.antbook.cpu.CpuInfoTest" />
</junit>
<fail if="tests.failed">Tests failed</fail>
</target>
这里从依赖列表中移除了
deploy
目标,不再需要它,也不需要在清理目标中从 JRE 中移除库。由于不再需要担心
<cc>
生成的共享库的特定于操作系统的名称,所以支持其他操作系统也会更容易。当清理之前的构建并运行这个目标时,如果
fork
属性设置为
true
,一切都会按计划工作;如果设置为
false
,运行时将无法获取属性设置,库将无法加载,所有测试都会失败。因此,使用
<java>
运行包含本地库的 Java 程序时,一定要分叉 JVM。
8. 跨平台支持
既然知道 Ant 可以构建和测试本地库,那么如何支持 Unix 构建以及 Windows 库呢?我们希望使用 GNU 工具(gcc 和 ar)为 Linux/x86 重新构建代码。由于所有 Java 代码都是跨平台的,所以与 Java 源文件相关的所有构建和测试阶段都可以无需更改地工作,剩下需要处理的两个领域是 C++ 源文件和编译它的目标。
-
迁移 C++ 源文件
:虽然用于测量时钟的机器代码可以独立于操作系统工作,但在 C++ 源文件中描述该机器代码则取决于操作系统。在迁移到 Linux 时,计划完全重写函数,所以目前不需要考虑可移植性。在 Windows 上使用 Visual C++ 的内联汇编器编译代码,而在 Linux 上需要使用不同的方法。例如,在 Linux 上可以使用
rdtsc
指令的汇编语言包装器,或者使用其他方法来读取时间戳计数器。
-
调整编译目标
:需要为 Linux 平台创建新的编译目标,使用
gcc
作为编译器,
ar
作为静态库创建工具。以下是一个简单的示例:
<target name="cc-linux" depends="headers">
<cc debug="${build.debug}"
link="shared"
outfile="${dist.filename.nosuffix}"
objdir="${obj.dir}"
multithreaded="true"
exceptions="true" >
<compiler name="gcc" />
<fileset dir="src/cpp/linux"/>
<includepath location="${generated.dir}" />
<sysincludepath location="${env.JAVA_HOME}/include" />
<sysincludepath location="${env.JAVA_HOME}/include/linux" />
<linker name="gcc" >
<syslibset libs="m,pthread"/>
</linker>
</cc>
</target>
这个目标与 Windows 版本类似,但使用
gcc
作为编译器和链接器,并指定了适用于 Linux 的库。同时,需要在
src/cpp
目录下创建
linux
子目录,并将 Linux 特定的 C++ 源文件放在该目录中。
通过以上步骤,就可以使用 Ant 构建跨平台的 JNI 库,并进行测试和部署。在实际项目中,可能需要根据具体需求进行更多的调整和优化。
使用 Ant 构建 JNI 库
9. 管理跨平台构建的注意事项
在进行跨平台构建时,有一些额外的注意事项需要考虑,以确保构建过程的顺利进行。
-
文件路径和分隔符
:不同操作系统使用不同的文件路径分隔符,例如 Windows 使用反斜杠
\
,而 Unix/Linux 使用正斜杠
/
。在编写 Ant 脚本时,应尽量使用 Ant 的属性和函数来处理路径,避免硬编码分隔符。例如,可以使用
${file.separator}
来表示当前操作系统的文件分隔符。
-
编译器和链接器选项
:不同的编译器和链接器可能有不同的选项和行为。在编写跨平台的 Ant 脚本时,需要根据不同的平台选择合适的编译器和链接器,并设置相应的选项。例如,在 Windows 上使用 Visual C++ 编译器,而在 Linux 上使用 GCC 编译器,它们的编译和链接选项可能会有所不同。
-
环境变量
:不同的操作系统可能有不同的环境变量设置。在编写 Ant 脚本时,需要确保所有必要的环境变量都已正确设置。例如,在 Windows 上需要设置
PATH
、
INCLUDE
和
LIB
环境变量,而在 Linux 上需要设置
PATH
和
LD_LIBRARY_PATH
环境变量。
10. 优化构建过程
为了提高构建效率和减少不必要的构建时间,可以对构建过程进行一些优化。
-
增量构建
:Ant 支持增量构建,即只重新编译和链接那些发生了变化的文件。可以使用
<uptodate>
任务来检查文件是否有更新,从而避免不必要的编译和链接操作。例如,在
<javah>
任务中,可以添加
<uptodate>
任务来检查 Java 源文件是否有更新,只有在文件更新时才重新生成头文件。
<target name="headers" depends="compile">
<uptodate property="headers.up.to.date" targetfile="${generated.dir}/org_example_antbook_cpu_CpuInfo.h">
<srcfiles dir="src" includes="**/*.java" />
</uptodate>
<javah destdir="${generated.dir}"
force="${!headers.up.to.date}"
classpath="${classes.dir}">
<class name="org.example.antbook.cpu.CpuInfo"/>
</javah>
</target>
-
并行构建
:如果系统资源允许,可以使用 Ant 的并行构建功能来同时执行多个任务,从而加快构建速度。可以使用
<parallel>任务来并行执行多个目标。例如,可以并行执行 Java 编译和 C++ 编译任务:
<target name="parallel-build" depends="init">
<parallel>
<antcall target="compile" />
<antcall target="cc-windows" />
</parallel>
</target>
11. 错误处理和日志记录
在构建过程中,可能会出现各种错误,如编译错误、链接错误等。为了更好地调试和解决问题,需要进行有效的错误处理和日志记录。
-
错误处理
:可以使用 Ant 的
<fail>
任务来在出现错误时停止构建,并输出错误信息。例如,在
<junit>
任务中,如果测试失败,可以使用
<fail>
任务停止构建:
<target name="test" depends="deploy">
<junit printsummary="withOutAndErr"
failureproperty="tests.failed"
fork="yes">
<classpath>
<pathelement location="${classes.dir}" />
<pathelement path="${dist.dir}" />
<pathelement path="${java.class.path}" />
</classpath>
<formatter type="plain" usefile="false"/>
<test name="org.example.antbook.cpu.CpuInfoTest" />
</junit>
<fail if="tests.failed">Tests failed</fail>
</target>
-
日志记录
:Ant 提供了丰富的日志记录功能,可以使用
<echo>任务输出日志信息,也可以使用<logger>任务自定义日志记录器。例如,可以在关键步骤输出日志信息,方便调试和监控构建过程:
<target name="compile">
<echo message="Starting Java compilation..." />
<javac srcdir="src" includes="**/*.java"
destdir="${classes.dir}"/>
<echo message="Java compilation completed." />
</target>
12. 总结
通过本文的介绍,我们了解了如何使用 Ant 构建 JNI 库,包括构建步骤、编写 Java 存根、创建 C++ 头文件、编写 C++ 代码、编译源文件、部署和测试库等。同时,我们还探讨了如何进行跨平台构建、优化构建过程以及错误处理和日志记录等方面的内容。
以下是整个构建过程的流程图总结:
graph LR
A[编写 Java 存根] --> B[编译 Java 源文件]
B --> C[创建 C++ 头文件]
C --> D[编写 C++ 代码]
D --> E[编译 C++ 源文件]
E --> F[部署库]
F --> G[测试库]
H[迁移 C++ 源文件] --> I[调整编译目标]
I --> E
J[优化构建过程] --> B
J --> E
K[错误处理和日志记录] --> B
K --> E
K --> G
在实际项目中,需要根据具体需求和平台特点进行适当的调整和优化,以确保构建过程的高效性和稳定性。希望本文对您在使用 Ant 构建 JNI 库方面有所帮助。
超级会员免费看
8

被折叠的 条评论
为什么被折叠?



