JNI与C混合开发

简介

如果读者是Java领域的开发人员,在研究Java底层逻辑的时候一定离不开源码分析。以研究CAS为例,其底层实现来自Unsafe类中的public final native boolean compareAndSetInt方法,这里使用native显然是因为Java层面已经满足不了来自开发者需要操作计算机底层的需求了。而这种使用native本地方法来间接调用计算机底层实现的过程被称为JNI技术(Java Native Interface,Java本地接口)。

通常这些JNI本地接口由C/C++语言来实现,在本期文章中,我将以“Windows环境下多核CPU的核心使用率检测”为主要功能来介绍我在为Spring开发CPU监控仪表盘功能的经历,帮助大家更好地理解JNI技术以及JNI技术的一些适用场景。

准备工作

为了确保拥有一个完整的开发环境来满足我们的需求,读者需要检查并安装缺失的组件或程序。这里以具备JDK17、IntellJ IDEA Ultimate 2023.1.2、CLion 2023.1.3为基础开发环境进行讲解。

MinGW-w64

虽然Cygwin、wsl2和MSVC都提供了不错的GCC环境,但是我依然推荐使用MinGW-w64来进行开发,这会减少一些后续开发中不必要的错误和适配性问题。

  1. 安装最新的11.0.0版本的mingw-w64,这里有两种安装形式可供选择:

    1. 使用exe安装包进行安装:

      1. 在mingw-w64页面底部下载MinGW-W64-install.exe

      2. 打开mingw-w64-installer.exe选择如下的环境其他操作均为默认
        mingw-w64安装

      3. 完成安装后将目录中的bin文件夹添加到环境变量中完成安装

    2. 压缩包直接解压,第一步中大部分用户会遇到The file has been downloaded incorrectly!问题导致安装失败,所以我更推荐第二种安装方法:

      1. 在mingw-w64页面底部下载x86_64-win32-seh

      2. 将压缩包解压到合适的位置后将目录中的bin文件夹添加到环境变量中完成安装

  2. 安装并配置环境变量完成后在cmd中使用命令gcc -v检查是否安装成功

  3. 在CLion中使用该安装完成的mingw-w64作为C/C++的编译器
    CLion配置

  4. 至此C/C++的编译环境准备完毕

Java项目环境

读者需要自行准备一个基于JDK17的测试项目,本章中的案例提供的具体的功能实现方法将适用于所有项目。当你的项目准备完毕后,在项目的任意包中编写一个CPU使用率获取类,后续都将以这个类来展开JNI:

public class CPUWatchDog {
	public CPUWatchDog() {}
	
	// native area
	public native double getCPULoad(int cpuIndex); // for single cpu core
	public native double[] getBatchCPULoad(); // for cpu core matrix
}

构建本地方法

若读者阅读过我的《探索JDK原理》系列文章或研究过JDK底层应该知道所有的native方法都是以形如JNIEXPORT 返回类型 JNICALL 函数名 (参数列表)的形式来定义的,这些宏被展开后就是一个完整的C/C++函数体。

构建C++工程

这些JNIEXPORT类型的函数通过JVM的本地方法栈被挂载到内存中以供Java应用在方法中调用它们,但是这些函数不都是需要开发者来建立的,Java提供了一套完备的工具来简化了它们的开发:

  1. 在JDK17中使用javac -h命令来为包含native方法的类生成C/C++头文件
    在这个项目中完整的命令为javac -h . src/.../CPUWatchDog.java这个命令有3个参数:

    1. -h .:指定生成的 JNI 头文件放在哪个目录下

    2. src/.../CPUWatchDog.java:被编译的Java文件的绝对路径或相对路径,若包含空格请使用转义符或引号包裹

    这里则是为src/.../CPUWatchDog.java.当前目录下生成头文件

  2. 编译完成后你会得到除了一个与CPUWatchDog.java同目录的CPUWatchDog.class文件还会得到一个文件名形如cn_____CPUWatchDog.h的头文件

  3. 现在可以基于这个头文件来编写它的cpp实现函数了,如果你已经将这个头文件移动到了一个独立文件夹中(如:native文件夹)你就可以通过CLion来打开这个文件夹作为一个工程来编写

  4. 在这个工程目录中创建一个任意文件名的cpp文件(成为源文件,如:CPUWatchDog.cpp)并将头文件中的两个函数拷贝到源文件中

    #include <jni.h>
    #include "cn_____CPUWatchDog.h" // 在同级目录下引入头文件
    
    // for single cpu core
    JNIEXPORT jdouble JNICALL Java_cn_____CPUWatchDog_getCPULoad
    (JNIEnv *env, jobject obj, jint cpuIndex)
    {
    }
    // for cpu matrix
    JNIEXPORT jdoubleArray JNICALL Java_cn_____CPUWatchDog_getBatchCPULoad  
    (JNIEnv *env, jobject obj)
    {
    }
    
  5. 这样一个半成品的C/C++工程就创建完成了,检查你的项目工程结构是否有其他问题(这里的CMakeLists.txt会在后面配置,这里仍旧是示例读者开发时仍需要以自己的为主)CLion项目结构

  6. 编写CMakeLists.txt将项目托管给CMake
    除了实现托管还有一个重要的作用就是配置最终构建文件的输出位置以及需要引入的头文件依赖,详细的内容应该如下:

    cmake_minimum_required(VERSION 3.25)
    project(native)
    
    set(CMAKE_CXX_STANDARD 11)  # C++版本
    
    include_directories(.)
    include_directories("C:/Program Files/Java/jdk-17.0.3.1/include")        # 引入jdk17的函数依赖
    include_directories("C:/Program Files/Java/jdk-17.0.3.1/include/win32")  # 引入jdk17的函数依赖
    
    add_library(CPUWatchDog SHARED        # 工程名称 构建方式
    			CPUWatchDog.cpp           # 源文件
    			cn_____CPUWatchDog.h)     # 头文件
    
    target_link_libraries(CPUWatchDog pdh pthread)  # 额外的动态链接库
    set_target_properties(CPUWatchDog PROPERTIES    # 编译输出配置
    					  RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/../resources/native"   # 输出目录
    					  LIBRARY_OUTPUT_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/../resources/native")  # 输出目录
    

    编写完成后保存并重载CMake工程

  7. 至此C++工程构建完毕,如果后续出现编译错误读者需要重点检查这里的几个步骤(文件名、函数名、include等)是否一致

函数实现

在这个案例中,我们需要实现的目标是“Windows环境下多核CPU的核心使用率检测”,在源文件中我们已经定义好了获取单个CPU核心使用率获取所有CPU核心使用率两个函数,现在我们来实现这两个函数的具体内容:

#include <jni.h>
#include <pdh.h>
#include <vector>
#include "cn_____CPUWatchDog.h"

extern "C"
{
#include <pthread.h>
}

/**
 * 获取本计算机的最大核心数
 */
int getNumberOfCores()
{
	SYSTEM_INFO sysInfo;
	GetSystemInfo(&sysInfo);
	return sysInfo.dwNumberOfProcessors;
}
  
/**
 * 封装获取单个CPU核心的使用率函数
 * @param cpuIndex CPU的唯一编号(默认从0开始到最大核心数-1)
 */
double getSingleCpuUsePercentage(int cpuIndex)
{
	PDH_HQUERY query;
	PDH_HCOUNTER counter;
	wchar_t counterPath[256];
	
	swprintf_s(counterPath, sizeof(counterPath), L"\\Processor(%d)\\%% Processor Time", cpuIndex);
	PdhOpenQuery(nullptr, 0, &query);
	PdhAddCounterW(query, counterPath, 0, &counter);
	PdhCollectQueryData(query);
	Sleep(1000);
	PdhCollectQueryData(query);
	PDH_FMT_COUNTERVALUE pdhValue;
	DWORD dwValue;
	PdhGetFormattedCounterValue(counter, PDH_FMT_DOUBLE, &dwValue, &pdhValue);
	PdhCloseQuery(query);
	return pdhValue.doubleValue;
}

/**
 * 获取单个CPU核心的使用率
 */
JNIEXPORT jdouble JNICALL Java_cn_____CPUWatchDog_getCPULoad
(JNIEnv *env, jobject obj, jint cpuIndex)
{
	return getSingleCpuUsePercentage(cpuIndex);
}

/**
 * 获取单个CPU核心的使用率但是需要返回一个指针来防止常量被销毁
 */
void *getSingleCpuUsePercentageWrapper(void *args)
{
	int core = *((int*)args);
	double load = getSingleCpuUsePercentage(core);
	return (void*)(new double(load));
}

/**
 * 获取所有CPU核心的使用率并以数组返回
 */
JNIEXPORT jdoubleArray JNICALL Java_cn_____CPUWatchDog_getBatchCPULoad  
(JNIEnv *env, jobject obj)
{
	int SIZE = getNumberOfCores();
	// 创建一个长度为SIZE的Java类型的double[]数组
	jdoubleArray arrayArray = env->NewDoubleArray(SIZE);
	jdouble *elements = env->GetDoubleArrayElements(arrayArray, 0);
	std::vector<pthread_t> threads(SIZE);
	std::vector<int> cores(SIZE);
	for (int i = 0; i < SIZE; i++)
	{
		cores[i] = i;
		pthread_create(&threads[i], nullptr, getSingleCpuUsePercentageWrapper, &cores[i]);
	}
	
	for (int i = 0; i < SIZE; i++)
	{
		double *result;
		pthread_join(threads[i], (void**)&result);
		elements[i] = *result;
		delete result;
	}
	
	env->ReleaseDoubleArrayElements(arrayArray, elements, 0);
	return arrayArray;
}

因为本文是主要介绍JNI的实现,具体的C++的实现逻辑就不再赘述感兴趣的读者可以翻阅资料或文献进一步研究。读者复制代码后需要修改JNIEXPORT的函数名来契合自己的项目

编译调用

编译

如果读者在准备工作的MinGW-w64阶段中CLion配置没有出现问题,那么可以直接使用编译按钮来进行编译。CLion构建

如果在控制台中输出的内容与如下差不多则说明编译完成:

C:\...\cmake.exe -DCMAKE_BUILD_TYPE=Debug -DCMAKE_MAKE_PROGRAM=C:/.../ninja.exe -G Ninja -S C:\...\native -B C:\...\cmake-build-debug
-- Configuring done
-- Generating done
-- Build files have been written to: C:/.../cmake-build-debug

[Finished]

来到在CMakeLists.txt的构建输出配置的目录中就可以找到编译成功的一个dll动态函数库(如:libCPUWatchDog.dll)这样一来就很明显能得出结论了,JNI实则是调用了基于JDK函数库编译出来的dllso文件来实现底层功能的。

调用

这里假设libCPUWatchDog.dll被放在了Java工程的src/main/resources/native文件夹中,回到CPUWatchDOg.java中来挂载这个函数库:

public class CPUWatchDog {
	static {
		URL resource = CPUWatchDog.class.getResource("/native/libCPUWatchDog.dll");
		if (resource == null)
			throw new Error("can't find native source from /native/libCPUWatchDog.dll");
		File file = new File(resource.getFile());
		System.load(file.getAbsolutePath());
	}
	// ...
}

至此一切工作就完成了,现在可以通过JUnit单元测试来调用这些方法验证我们的工程的可行性:

public class AppUnitTest {
	CPUWatchDog watchDog = new CPUWatchDog();
	@Test
	public void cpuUnitTest() {
		DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
		LocalDateTime currentTime = LocalDateTime.now();
		System.out.println("[" + currentTime.format(formatter) + " CPU-Monitor]" + Arrays.toString(watchDog.getBatchCPULoad()));
	}
}

运行后我们也得到了我们想要的输出结果:

运行结果

应用场景

JNI是一种能够让Java代码与其他语言写的代码互相调用的技术。这种技术让Java具有了调用硬件层级指令、使用底层库或者优化性能的能力。在一些特殊场景中JNI能发挥很好的作用但同样的它也会带来一些弊端和局限性:

  1. Java应用开发

    • 使用场景:当Java程序需要直接访问系统底层资源,或者调用某些已经由C/C++等语言实现的库时,JNI可以发挥重要作用。如:Java本身的图形界面库(Swing/AWT)在绘制3D图形或者复杂动画时性能较差,但如果调用OpenGL这种C语言的图形库就可以得到更好的性能

    • 局限性:编写JNI代码需要有C/C++的知识,并且要理解Java与C/C++间的内存管理差异。此外,用JNI编写的代码一般不具有平台移植性(需要考虑在所有操作系统中运行),这与Java "Write Once, Run Anywhere" 的理念相违背。

  2. Java Web应用开发

    • 使用场景:在Java Web应用中,JNI主要用于调用本地方法进行高性能计算,或者用于访问操作系统API,如:获取系统信息、操作文件系统等。

    • 局限性:由于Web应用通常在服务器上运行,所以如果依赖JNI,就可能导致服务器的移植性降低。此外,由于安全问题,Web应用中使用JNI可能会带来风险。

  3. 安卓应用开发

    • 使用场景:在安卓开发中,JNI常用于实现那些Java难以实现,或者在Java中效率较低的功能,如:图像处理、视频解码、音频处理等(这些文件通常会被编译成so文件)。

    • 局限性:在使用JNI时,开发者需要注意内存管理问题并防止出现内存泄漏。此外,JNI代码通常会使得应用的体积变大,这可能会对在移动设备上的运行产生影响。编写和维护JNI代码通常需要比较高的开发成本。

参考文献

[1] Liang, S. (1999). The JavaTM Native Interface: Programmer's Guide and Specification. Addison-Wesley Longman Publishing Co., Inc.
[2] Gordon, M. J., Thies, W., & Amarasinghe, S. (2012). Exploiting Coarse-Grained Task, Data, and Pipeline Parallelism in Stream Programs. ACM Sigplan Notices, 37(7), 151-162.
[3] Oracle. (2021). Java SE HotSpot at a Glance. Retrieved from https://www.oracle.com/java/technologies/javase/hotspot-glance.html.
[4] Zhao, J., Zhang, K., & Bai, G. (2013). Research on Optimization of JNI. International Journal of Database Theory and Application, 6(4), 1-10.