JDK8环境下JNI开发实战:从原理到FFmpeg集成完整指南 1. 项目概述为什么今天还要折腾JNI如果你是一个Java开发者尤其是那些在音视频处理、高性能计算、硬件交互或者需要调用一些用C/C写成的古老但极其稳定的库的领域工作那么“Java Native Interface”这个名字你一定不陌生或者至少听说过。JNI这个从Java诞生之初就存在的技术允许Java代码与本地Native代码主要是C和C进行双向调用。听起来很酷对吧但在Spring Boot、微服务大行其道的今天很多人可能会觉得JNI是老古董是“屠龙之术”甚至有些教程和示例都停留在JDK 1.4的时代让人望而却步。但事实恰恰相反。JNI的生命力比很多人想象的要顽强得多。举个最简单的例子你项目里用到的某个核心算法库是十年前一位C大神写的性能极高且经过无数项目验证重写成Java不仅工程浩大性能还可能不达标。这时候JNI就是连接现代Java应用和这座“遗产金矿”的唯一桥梁。再比如你想在Java里直接操作某个特定的硬件设备或者调用操作系统底层API来实现一些Java标准库不提供的功能JNI几乎是必经之路。我最近就遇到一个需求一个Java服务需要依赖FFmpeg进行实时的视频转码切片。虽然有一些Java包装的库但在高并发、低延迟的苛刻场景下直接通过JNI调用FFmpeg的C API在性能和资源控制上有着无可比拟的优势。所以这个项目标题“Java Native Interface JDK8使用JNI 示例”的核心就是解决一个非常实际的问题在现代以JDK8为代表的Java开发环境中如何正确、高效且稳定地搭建起Java与C/C世界之间的桥梁并完成一个完整的、可复现的调用示例。这不仅仅是写几行代码那么简单它涉及到环境配置、编译工具链、内存管理、异常处理等一系列跨语言的复杂问题。本文将基于JDK8这个依然广泛使用的LTS版本带你从零开始手把手走通一个JNI示例的全过程并分享那些官方文档里不会写的“踩坑”经验。无论你是好奇想了解一下还是正被一个具体的本地库集成问题所困扰这篇文章都能给你提供一条清晰的路径。2. 环境准备与核心概念澄清在开始敲代码之前把环境理顺、概念搞清能避免后面绝大部分的莫名错误。JNI开发涉及到两套不同的生态系统Java和C/C因此对环境的整洁度要求比较高。2.1 JDK8的安装与关键工具确认首先确保你的系统上正确安装了JDK8。虽然标题提到了JDK8但原理对于更高版本的JDK也是通用的只是某些工具路径可能略有变化。JDK8提供了JNI开发最核心的两个命令行工具javacJava编译器用于编译你的Java源文件。javah在JDK8及更早版本中或javac -hJDK10推荐用于生成C/C头文件.h文件。这个头文件是连接Java和C的关键它声明了你需要实现的本地方法Native Method的C语言原型。注意从JDK10开始官方推荐使用javac -h 目录来替代老旧的javah工具。但考虑到我们目标环境是JDK8本文会同时介绍javah的方式这也是很多遗留项目和教程使用的方法了解它有助于你阅读旧的代码。实际上在JDK8中你也可以使用实验性的javac -h但为了通用性我们先按传统方式来。如何检查打开终端或Windows的CMD/PowerShell输入java -version javac -version确认输出显示的是1.8.x。如果只安装了JRE运行环境而没有JDK开发工具包javac命令将不可用你需要重新安装JDK8。2.2 C/C编译环境的搭建这是JNI开发中最容易卡住的一步。你的Java代码最终需要调用一个本地库在Windows上是.dll在Linux/Mac上是.so这个库需要你用C/C编译器编译出来。Windows推荐使用MinGW-w64或Cygwin。MinGW-w64更轻量生成的是原生Windows DLL。你可以下载 MSYS2 通过它的包管理器pacman来安装mingw-w64-x86_64-toolchain。安装后确保gcc或g命令可以被终端找到。Linux通常系统自带GCC。通过包管理器安装即可例如Ubuntu/Debian上sudo apt-get install build-essential。macOS需要安装Xcode Command Line Tools。在终端运行xcode-select --install即可。验证C编译器g --version # 或 clang --version (macOS通常默认是clang)2.3 理解JNI的核心交互模型不要把JNI想象成简单的函数调用。它是一套严格的协议。核心步骤抽象如下Java侧声明在一个Java类中使用native关键字声明一个方法但不提供方法体。这个方法就是“本地方法”。public native void sayHello(); public native int calculate(int a, int b);生成桥梁头文件使用javah工具根据上一步的Java类生成一个C语言的头文件.h。这个头文件里包含了对应本地方法的C函数原型函数名遵循特定的命名规则如Java_完整类名_方法名。C/C侧实现创建一个C或C源文件.c或.cpp#include上一步生成的头文件并按照原型实现具体的函数逻辑。这里是真正执行计算、调用其他C库如FFmpeg的地方。编译本地库将你的C/C源文件编译成动态链接库.dll/.so/.dylib。编译时必须包含JDK中的JNI头文件主要是jni.h这些头文件位于$JAVA_HOME/include和$JAVA_HOME/include/平台如win32,linux,darwin目录下。Java侧加载与调用在Java代码中使用System.loadLibrary(“你的库名”)或System.load(“库的绝对路径”)来加载编译好的动态库。之后就可以像调用普通Java方法一样调用之前声明的native方法了。关键点javah生成的头文件就像是Java和C之间的一份“合同”。Java运行时按照合同找到对应的C函数来执行。任何一边不遵守合同函数名不对、参数类型不匹配都会导致链接失败或运行时错误。3. 从零开始一个完整的JNI示例实操我们用一个经典的“Hello World”示例开始但会加入一些实用的元素比如传递参数和返回复杂结果。项目结构如下jni-demo/ ├── src/ │ └── com/ │ └── example/ │ └── jni/ │ └── HelloJNI.java ├── lib/ (存放生成的动态库) └── cpp/ (存放C源码)3.1 Java侧定义本地方法首先在src/com/example/jni/HelloJNI.java中编写我们的Java类。package com.example.jni; public class HelloJNI { // 加载本地库。注意参数是库名不包含平台特定的前缀lib和后缀.dll/.so static { // 方式1从java.library.path中加载库名是“hello” // System.loadLibrary(hello); // 方式2指定绝对路径加载更清晰避免路径问题 // System.load(/full/path/to/libhello.so); // 我们先注释掉等库编译好再决定用哪种方式 } // 声明本地方法 // 1. 一个简单的无参数无返回值 public native void sayHello(); // 2. 接收基本类型参数并返回基本类型 public native int add(int a, int b); // 3. 接收字符串参数并返回字符串 public native String echo(String input); // 4. 接收数组参数并修改其内容 public native void modifyArray(int[] array); public static void main(String[] args) { // 临时先不调用等库准备好 // HelloJNI hello new HelloJNI(); // hello.sayHello(); // System.out.println(1 2 hello.add(1, 2)); // System.out.println(Echo: hello.echo(Hello from Java)); // int[] arr {1, 2, 3}; // hello.modifyArray(arr); // System.out.println(Modified array: java.util.Arrays.toString(arr)); System.out.println(Java class loaded. Please compile the native library first.); } }3.2 生成JNI头文件这是连接Java和C的关键一步。我们需要先编译Java类然后使用javah生成头文件。编译Java类cd jni-demo javac -d . src/com/example/jni/HelloJNI.java这会在当前目录生成com/example/jni/HelloJNI.class。使用javah生成头文件JDK8方式# 注意javah 需要指定完整的类名并且要从类文件的根目录即包含com目录的上一级执行 # 我们当前在 jni-demo 目录它下面有 com 目录 javah -classpath . -o cpp/HelloJNI.h com.example.jni.HelloJNI-classpath .指定类路径为当前目录。-o cpp/HelloJNI.h指定输出头文件到cpp目录名为HelloJNI.h。com.example.jni.HelloJNI完整的类名。执行成功后查看cpp/HelloJNI.h你会看到一个类似下面的文件/* DO NOT EDIT THIS FILE - it is machine generated */ #include jni.h /* Header for class com_example_jni_HelloJNI */ #ifndef _Included_com_example_jni_HelloJNI #define _Included_com_example_jni_HelloJNI #ifdef __cplusplus extern C { #endif /* * Class: com_example_jni_HelloJNI * Method: sayHello * Signature: ()V */ JNIEXPORT void JNICALL Java_com_example_jni_HelloJNI_sayHello (JNIEnv *, jobject); /* * Class: com_example_jni_HelloJNI * Method: add * Signature: (II)I */ JNIEXPORT jint JNICALL Java_com_example_jni_HelloJNI_add (JNIEnv *, jobject, jint, jint); /* * Class: com_example_jni_HelloJNI * Method: echo * Signature: (Ljava/lang/String;)Ljava/lang/String; */ JNIEXPORT jstring JNICALL Java_com_example_jni_HelloJNI_echo (JNIEnv *, jobject, jstring); /* * Class: com_example_jni_HelloJNI * Method: modifyArray * Signature: ([I)V */ JNIEXPORT void JNICALL Java_com_example_jni_HelloJNI_modifyArray (JNIEnv *, jobject, jintArray); #ifdef __cplusplus } #endif #endif这个文件非常重要。它定义了四个C函数原型函数名非常长是Java_完整类名点替换为下划线_方法名的形式。JNIEXPORT和JNICALL是确保函数能被Java虚拟机正确调用的宏。参数中的JNIEnv*是指向JNI环境的指针是所有JNI函数的入口jobject是调用该本地方法的Java对象实例相当于Java里的this后面才是Java方法中定义的参数但类型被映射成了JNI类型如jint,jstring,jintArray。3.3 C侧实现本地方法现在我们在cpp目录下创建HelloJNI.cpp来实现这些函数。// HelloJNI.cpp #include HelloJNI.h #include iostream #include cstring // 实现 sayHello JNIEXPORT void JNICALL Java_com_example_jni_HelloJNI_sayHello(JNIEnv *env, jobject thisObj) { std::cout Hello World from C! std::endl; // 在JNI中推荐使用env-函数但为了简单演示这里直接用std::cout } // 实现 add JNIEXPORT jint JNICALL Java_com_example_jni_HelloJNI_add(JNIEnv *env, jobject thisObj, jint a, jint b) { return a b; // jint 可以直接当C的int用 } // 实现 echo JNIEXPORT jstring JNICALL Java_com_example_jni_HelloJNI_echo(JNIEnv *env, jobject thisObj, jstring input) { // 关键步骤将jstringJava字符串转换为C风格的字符串 const char *cstr env-GetStringUTFChars(input, NULL); if (cstr NULL) { return NULL; // 内存不足时可能返回NULL } // 这里可以对cstr进行操作例如拼接 std::string response C echoed: ; response cstr; // 必须释放从Java获取的字符串资源 env-ReleaseStringUTFChars(input, cstr); // 将C字符串转换回jstring并返回给Java return env-NewStringUTF(response.c_str()); } // 实现 modifyArray JNIEXPORT void JNICALL Java_com_example_jni_HelloJNI_modifyArray(JNIEnv *env, jobject thisObj, jintArray javaArray) { // 获取数组指针。第二个参数是isCopy表示返回的指针是副本还是直接指向Java数组内存。 jint *cArray env-GetIntArrayElements(javaArray, NULL); if (cArray NULL) { return; // 内存不足 } // 获取数组长度 jsize length env-GetArrayLength(javaArray); // 修改数组内容例如每个元素乘以2 for (jsize i 0; i length; i) { cArray[i] cArray[i] * 2; } // 关键如果GetIntArrayElements的第二个参数不是NULL且返回了JNI_TRUE说明拿到的是副本。 // 那么必须使用第三个参数mode来告诉JVM如何处理副本。 // 这里我们使用0表示将内容复制回Java数组并释放C数组。 // 更常见的做法是使用 JNI_COMMIT 和 JNI_ABORT但简单场景下用0相当于JNI_COMMIT | JNI_ABORT?或直接调用Release...即可。 // 实际上对于基本类型数组修改后通常需要调用Release...来提交更改。 env-ReleaseIntArrayElements(javaArray, cArray, 0); // 参数说明0 将内容复制回原数组并释放cArray缓冲区。 // JNI_COMMIT 复制回但不释放缓冲区后续可能还要用。 // JNI_ABORT 不复制回直接释放缓冲区丢弃修改。 }3.4 编译C代码为动态库这是平台相关的一步。你需要找到你的JDK安装路径JAVA_HOME。Linux/macOS 示例cd jni-demo/cpp # 假设JAVA_HOME/usr/lib/jvm/java-8-openjdk-amd64 g -I$JAVA_HOME/include -I$JAVA_HOME/include/linux -fPIC -shared -o ../lib/libhello.so HelloJNI.cpp # macOS 将 ‘linux‘ 替换为 ‘darwin‘输出 libhello.dylib # g -I$JAVA_HOME/include -I$JAVA_HOME/include/darwin -fPIC -shared -o ../lib/libhello.dylib HelloJNI.cppWindows (MinGW) 示例cd jni-demo\cpp # 假设JAVA_HOMEC:\Program Files\Java\jdk1.8.0_301 g -I%JAVA_HOME%\include -I%JAVA_HOME%\include\win32 -Wl,--add-stdcall-alias -shared -o ..\lib\hello.dll HelloJNI.cpp关键编译选项解释-I指定头文件搜索路径。必须包含$JAVA_HOME/include和对应的平台目录否则找不到jni.h。-fPICLinux/macOS生成位置无关代码这是共享库所必需的。-shared告诉编译器生成一个共享库动态链接库。-o指定输出文件。库的命名有约定Linux下通常以lib开头.soWindows下是.dllmacOS是.dylib。但System.loadLibrary(“hello”)查找时在Linux下会找libhello.so在Windows下找hello.dll在macOS下找libhello.dylib或hello.jnilib旧版。-Wl,--add-stdcall-aliasWindows处理函数名修饰确保导出的函数名与Java期望的匹配。3.5 运行Java程序现在回到Java代码我们修改HelloJNI.java中的静态块和main方法来加载库并测试。修改HelloJNI.java的静态块使用System.load指定绝对路径这样最不容易出错。static { // 根据你的平台和编译输出修改路径 // Linux/macOS // System.load(/path/to/jni-demo/lib/libhello.so); // Windows // System.load(C:\\path\\to\\jni-demo\\lib\\hello.dll); // 为了示例我们用一个相对路径的通用方法需要设置java.library.path // 但更推荐在运行时通过-Djava.library.path指定见下文。 }更实用的运行方式不修改代码通过命令行参数指定库路径。首先取消main方法中的注释。在终端中进入jni-demo目录执行# Linux/macOS java -Djava.library.path./lib -cp . com.example.jni.HelloJNI # Windows java -Djava.library.path.\lib -cp . com.example.jni.HelloJNI-Djava.library.path./lib告诉JVM去哪里寻找本地库。这样System.loadLibrary(“hello”)就能在./lib目录下找到libhello.so或hello.dll。-cp .指定类路径为当前目录。如果一切顺利你将看到输出Hello World from C! 1 2 3 Echo: C echoed: Hello from Java Modified array: [2, 4, 6]恭喜你已经完成了一个完整的、涉及多种数据类型的JNI调用流程。4. 深入解析JNI开发中的核心难点与最佳实践上面的“Hello World”流程看似顺畅但在真实项目中你会遇到各种棘手的问题。下面我结合自己的经验拆解几个最关键的部分。4.1 内存管理谁分配谁释放这是JNI编程中最容易导致内存泄漏或程序崩溃的地方。核心原则是对于通过JNI函数从Java端获取的资源如字符串、数组必须在使用完毕后通过对应的JNI函数释放。字符串使用GetStringUTFChars获取的C字符串指针必须用ReleaseStringUTFChars释放。即使获取失败返回NULL也无需释放。数组使用GetPrimitiveTypeArrayElements获取的数组指针必须用ReleasePrimitiveTypeArrayElements释放并根据第三个参数决定是否将修改写回Java数组。对象引用JNI中有局部引用Local Reference、全局引用Global Reference和弱全局引用Weak Global Reference。在本地方法中创建的Java对象如NewObject,NewStringUTF默认是局部引用它们会在本地方法返回后被JVM自动回收。但如果你需要长时间持有比如在C的全局变量中就必须将其升级为全局引用NewGlobalRef并且在不再需要时手动删除DeleteGlobalRef否则会导致内存泄漏。实操心得我习惯在获取资源后立即检查是否为NULL并在一个函数出口或使用C的RAII思想进行释放。对于复杂的、可能有多处返回的函数使用goto到一个统一的清理标签进行资源释放是C语言中一种清晰的错误处理模式。4.2 类型映射与数据转换Java类型和C/C的JNI类型并不直接等同需要转换。基本类型映射关系简单直接如jint-int,jlong-long long可以直接进行算术运算。引用类型这是重点。jstring不是char*jobjectArray也不是void**。必须使用JNIEnv提供的函数来操作。jstring-const char*GetStringUTFChars/ReleaseStringUTFChars。const char*-jstringNewStringUTF。访问Java对象的字段GetFieldID,GetIntField,SetObjectField等。调用Java对象的方法GetMethodID,CallVoidMethod,CallIntMethod等。一个常见场景在C中回调Java方法。假设你在C的某个事件循环中需要通知Java层。你需要在Java类中定义一个回调方法如onEvent(String msg)。在调用本地方法时将this对象或某个回调接口对象作为参数传递给C。在C侧保存这个对象的全局引用因为局部引用会失效。在需要回调时使用保存的JNIEnv注意JNIEnv是线程相关的不能跨线程使用和全局引用获取方法ID并调用。4.3 异常处理JNI函数调用可能会抛出Java异常例如GetStringUTFChars在内存不足时可能抛出OutOfMemoryError。但JNI函数本身不会因此中断C的执行流。C代码必须主动检查并处理异常。检查异常使用env-ExceptionCheck()或env-ExceptionOccurred()。处理异常可以选择清除异常env-ExceptionClear()并返回一个错误码。或者直接让异常传播回Java层。这时你的本地方法应该立即返回不再执行后续C代码JVM会在控制权交回Java时抛出这个异常。抛出异常也可以在C中主动抛出Java异常使用env-ThrowNew(exceptionClass, “message”)。踩坑记录最隐蔽的错误是调用了某个JNI函数后发生了异常但没有检查接着继续调用其他JNI函数。根据JNI规范在有未决异常Pending Exception的情况下调用大部分JNI函数结果是未定义的很可能导致JVM崩溃。所以在调用可能出错的JNI函数后养成检查异常的习惯。4.4 多线程与JNIEnvJNIEnv*指针是线程局部的Thread-Local。你不能将一个线程中获取的JNIEnv传递给另一个线程使用。如果需要在C创建的新线程中调用JNI函数必须通过JavaVM*接口来获取当前线程的JNIEnv。在JNI_OnLoad函数动态库加载时调用中保存JavaVM*指针。JavaVM* g_jvm NULL; JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { g_jvm vm; // ... 其他初始化 return JNI_VERSION_1_8; // 返回支持的JNI版本 }在新线程中通过g_jvm-AttachCurrentThread((void**)env, NULL)获取属于当前线程的JNIEnv。在线程结束前调用g_jvm-DetachCurrentThread()。5. 进阶整合在Java项目中依赖本地库以FFmpeg为例很多同学搜索JNI是因为项目需要集成像FFmpeg这样的C库。这里概述一下思路这比简单的“Hello World”复杂但流程是相通的。5.1 项目结构规划假设我们有一个Java Web服务需要调用FFmpeg进行视频处理。video-service/ ├── src/main/java/com/example/video/ │ ├── FFmpegWrapper.java (JNI接口层) │ └── VideoService.java (业务层) ├── src/main/resources/ ├── native/ │ ├── include/ (存放ffmpeg头文件) │ ├── lib/ (存放ffmpeg的.so/.dll文件) │ └── src/ │ ├── ffmpeg_jni.cpp (我们的JNI实现) │ └── CMakeLists.txt (或Makefile) └── pom.xml (或build.gradle)5.2 JNI层设计FFmpegWrapper.java中声明核心的本地方法例如转码public class FFmpegWrapper { static { // 先加载ffmpeg库如果它也是动态库 // System.loadLibrary(avcodec-58); // ... 再加载我们自己的JNI库 System.loadLibrary(video_jni); } public native int transcode(String inputPath, String outputPath, String options); // ... 其他方法如获取视频信息、截图等 }5.3 C侧实现与编译ffmpeg_jni.cpp中#include jni.h和 FFmpeg的头文件如extern “C” { #include libavcodec/avcodec.h }。实现Java_com_example_video_FFmpegWrapper_transcode函数。在这个函数内部使用标准的FFmpeg API进行转码逻辑。关键点你需要将FFmpeg的头文件和库文件路径告诉你的编译器。编译命令会变得复杂。# 示例编译命令 (Linux) g -I$JAVA_HOME/include -I$JAVA_HOME/include/linux \ -I./native/include \ # FFmpeg头文件路径 -L./native/lib \ # FFmpeg库文件路径 -lavcodec -lavformat -lavutil -lswscale \ # 链接FFmpeg库 -fPIC -shared -o ./native/libvideo_jni.so ffmpeg_jni.cpp使用构建工具如CMake来管理这种复杂的依赖和跨平台编译会更专业。5.4 打包与部署这是最大的挑战。你的Java应用现在不仅需要JAR包还需要附带特定平台Linux x64, Windows x64, macOS ARM64...的动态库。策略将编译好的JNI库如libvideo_jni.so和它依赖的所有其他本地库如FFmpeg的libavcodec.so等一起打包。目录结构通常放在JAR包外的某个固定目录或者打包进JAR的resources/native/os/arch/目录下在应用启动时解压到临时目录再加载。工具Maven可以使用maven-native-pluginGradle有gradle-cpp-plugin或直接使用Exec任务来调用CMake。更现代的做法是使用GraalVM Native Image进行提前编译将Java和所有本地代码打包成一个单独的可执行文件但这又是另一个话题了。6. 常见问题排查与调试技巧即使按照步骤来也难免出错。这里列一些常见错误和排查思路。问题1UnsatisfiedLinkError: no XXX in java.library.path原因System.loadLibrary()找不到指定的库。排查库名是否正确loadLibrary(“hello”)在Linux上找libhello.so。库文件是否真的存在检查编译输出路径。java.library.path是否包含库所在目录可以用System.getProperty(“java.library.path”)打印出来看看。运行时通过-Djava.library.path指定。库文件是否有执行权限Linux/macOS库文件是否依赖其他未找到的库使用ldd libhello.soLinux或otool -L libhello.dylibmacOS检查依赖。问题2UnsatisfiedLinkError: Native method not found原因Java虚拟机在加载的库中找不到与声明的native方法匹配的函数。排查函数签名不匹配这是最常见的原因。使用javap -s -p com.example.jni.HelloJNI查看Java方法的确切签名Signature与C函数名包括包名、类名、方法名和参数类型JNIEnv*, jobject, ...进行严格比对。一个下划线都不能错。C函数是否被正确导出Windows下需要extern “C”和__declspec(dllexport)或通过.def文件GCC/Clang下需要extern “C”来防止C名称修饰Name Mangling。是否重新编译了C库但没有重新启动Java进程旧的库可能还被缓存着问题3JVM崩溃Segmentation fault, SIGSEGV原因C代码访问了非法内存通常是JNI使用不当。排查是否访问了已经释放的jstring或数组指针是否在多线程中错误使用了JNIEnv*是否在发生Java异常后继续调用JNI函数使用调试器如GDB运行Java程序在崩溃时查看堆栈跟踪定位到C代码行。在编译C库时加入调试信息-g和更严格的警告-Wall -Wextra。问题4性能问题原因JNI调用本身有开销频繁地在Java和本地代码之间传递大量数据尤其是数组和字符串会严重影响性能。优化批处理尽量减少JNI调用的次数。比如不要在一个循环里每次调用一个JNI方法来处理一个数组元素而应该一次把整个数组传过去在C侧处理完再返回。使用直接缓冲区Direct Buffer对于大型的、需要频繁访问的数据如图像像素数据可以使用java.nio.ByteBuffer.allocateDirect()创建直接缓冲区然后通过GetDirectBufferAddress获取其内存地址在C中直接操作。这避免了复制开销。谨慎使用GetTypeArrayElements如果可能使用GetPrimitiveArrayCritical但它对JVM的GC有更严格的限制使用期间不能调用其他JNI函数。调试技巧在C中打印日志使用std::cout/printf或更专业的日志库。在Linux/macOS上输出会到控制台在Windows上可能需要附加到控制台或输出到文件。使用Java的-Xcheck:jni参数这个JVM选项会开启对JNI调用的额外检查能发现很多常见的误用如忘记释放局部引用虽然会降低性能但在调试阶段非常有用。单元测试为你的JNI方法编写Java单元测试JUnit确保每个功能点的正确性。

本月热点