Всем привет! Меня зовут Роман Аймалетдинов и я разрабатываю клиентское приложение Ситимобил. Решил написать небольшую серию из трех статей по JNI, так как технология используется редко, но иногда она бывает очень полезной. Несмотря на то, что я разрабатываю классические приложения под Android, иногда хочется посмотреть технологии рядом со своей специализацией.
Содержание
Что такое JNI;
Шаги для запуска кода из JNI;
Hello World;
Run from JetBrains IDEA.
Что такое JNI?
JNI — это интерфейс, позволяющий из Java вызывать нативные функции. Например, метод С++, который что-нибудь делает. Допустим, мы пишем большую программу на простом и любимом Java или Kotlin, и нужно реализовать задачу коммивояжера для нашего клиента. Или мы пишем генетический алгоритм, который ищет что-то в большом объёме данных, и так уж вышло, что у нас есть замечательная реализация на С++. Особенно часто я слышу про JNI в gamedev- и в automotive-проектах. Однажды я работал в таком, будучи ещё интерном, и, точно не помню, но в общих чертах на С++ было много низкоуровневого кода по обработке потока данных, получаемого со спутника. JNI позволяет вручную управлять памятью. Можно написать фрагмент кода на C/С++, и при необходимости дёргать нативный метод и получать из него результат вычисления.
И ещё по поводу производительности: мы все знаем, что С/С++ побыстрее будут, и тогда получается, что мы сможем оптимизировать наши алгоритмы на Java, переписав их на C++. (спойлер: не надо, скорее всего, будет работать медленнее). Некоторые авторы в интернете говорят про увеличение производительности, поэтому в своей третьей статье я приведу тесты JNI/NDK и мы сможем сверить производительность.
Также для Android есть инструмент NDK, который всё про то же — запуск нативного кода и увеличение производительности отдельных алгоритмов.
Я вас запутал?
NDK — Native Development Kit (для Android).
JNI — Java Native Interface (общий интерфейс).
Шаги для запуска кода из JNI
Шаг 1. Создадим новый проект ex: JNI_aymaletdinov_roman.
Шаг 2. Создадим класс, у которого будут native-методы, и назовём его AwesomeLib.
public class AwesomeLib {
static {
System.loadLibrary("nativeLib");
}
}
System.loadLibrary("nativeLib"); — это статический метод, который загружает native-библиотеку из файловой системы в память и делает её экспортированные функции доступными для нашего Java-кода. То есть мы загружаем файл на С/С++, который будет выполнять сложные вычисления. Однако nativeLib мы ещё не создали, сделаем это позже.
Шаг 3. Следующим шагом нам необходимо объявить метод, помеченный ключевым словом native. Он не имеет реализации в Java, напишем её на C++.
public class AwesomeLib {
static {
System.loadLibrary("nativeLib");
}
public native void helloHabr(); // <--- native method
}
Шаг 4. Вызовем из Main нашу библиотеку, которая что-то делает в native-коде .
public class Main {
public static void main(String[] args) {
System.out.println("Hey! This is Java!");
AwesomeLib nativeLib = new AwesomeLib();
nativeLib.helloHabr();
}
}
Шаг 5. На Java мы всё сделали! Теперь начинается что-то непривычное: нужно обязательно установить MinGW-w64, иначе не на чем будет компилировать нативный код. При установке выберите x86-64 и пропишите bin переменные среды path.
Шаг 6. Затем необходимо написать реализацию нашего нативного метода на C++. В этом языке объявление и реализация обычно хранятся в файлах .h
и .cpp
соответственно. Открываем консоль; переходим в папку, в которой будут лежать native-файлы; генерируем заголовок для натива командой javac -h . absolutePath/MyClass.java.
После выполнения команды будет создано два файла: .h
и .class
.
Декомпилированный .class
мало интересен, а вот .h
— что-то не из нашего JVM-мира. На самом деле всё очевидно: наш метод сгенерировался таким, каким он будет в С++. При этом имя функции генерируется автоматически с использованием полного имени пакета, класса и метода. Кроме того, мы получаем два параметра, передаваемых нашей функции:
указатель на текущий JNIEnv;
Java-объект (AwesomeLib), к которому привязан метод.
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class nativelib_AwesomeLib */
#ifndef _Included_nativelib_AwesomeLib
#define _Included_nativelib_AwesomeLib
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: nativelib_AwesomeLib
* Method: sayHello
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_nativelib_AwesomeLib_helloHabr(JNIEnv *, jobject);
#ifdef __cplusplus
}
#endif
#endif
Шаг 7. Следующий шаг — создание .cpp
. То есть мы сами создаём файл с расширением .cpp
и копируем из .h
контракт метода. Я назвал файл тем же именем, что и Java-класс: AwesomeLib.cpp
.
#include "nativelib_AwesomeLib.h"
#include <iostream>
JNIEXPORT void JNICALL Java_nativelib_AwesomeLib_helloHabr(JNIEnv* env, jobject thisObject)
{
std::cout << "Hello Habr! This is C++ code!!" << std::endl;
}
Шаг 8. Затем наш нативный код надо скомпилировать. Когда мы выполним в консоли команду g++ -c -I"C:\Program Files\Java\jdk-12.0.1\include" -I"C:\Program Files\Java\jdk-12.0.1\include\win32" AwesomeLib.o.cpp -o AwesomeLib.o
, появится ещё один файл: .o
.
Смотрим на дерево проекта и видим новый файл:
Шаг 9. Осталось совсем немного. Теперь нужно сгенерировать .dll
с названием нативной библиотеки, которое мы указали в статике в Java. В консоли выполняем команду: g++ -shared -o nativeLib.dll AwesomeLib.o -Wl,--add-stdcall-alias
.
Смотрим, в IDE появился ещё один сгенерированный файл.
Шаг 10. Всё! Хочется запустить нашу программу с нативным кодом, но IDEA сейчас этого сделать не сможет. Она ничего не знает про С++, следовательно, для запуска воспользуемся консолью: java -cp . -Djava.library.path="[Абсолютный путь до папки, где лежит dll]" Main.java
Обратите внимание, что я перед запуском выполнил команду cd ..
для того, чтобы быть в той же папке, где лежит запускаемый мною файл Main
.
Как запустить проект из IDEA, а не через консоль?
Так как IDEA по умолчанию не знает ни о каких .dll
, она не сможет запустить наш проект по нажатию на треугольник “Run”. Чтобы запустить проект, нужно прописать VM options в меню “Edit configurations”.
У нас появится поле VM options, и мы добавляем Djava.library.path="[Абсолютный путь до папки где лежит .dll]”
.
Вот теперь точно всё! Можно запускать проект из IDEA и радоваться жизни.
Заключение
Краткий список шагов, чтобы было проще вспоминать тем, кто знает, читал и делал, но что-то подзабыл и нужна шпаргалка:
Установить MinGW-w64. При установке выбрать x86-64 и прописать bin переменные среды path.
Создать NativeLib.java с native-методами.
Перейти в консоли в папку (ex: src), в которую хотите сохранить сгенерированные файлы.
Выполнить
javac -h . AwesomeLib.java
.Выполнить
g++ -c -I"C:\\Program Files\\Java\\jdk-12.0.1\\include" -I"C:\\Program Files\\Java\\jdk-12.0.1\\include\\win32" AwesomeLib.cpp -o AwesomeLib.o
.Выполнить
g++ -shared -o nativeLib.dll AwesomeLib.o -Wl,--add-stdcall-alias
.Запустить программу:
java -cp . -Djava.library.path=”Путь до папки с .dll" Main
(или через студию).
Если вы внесли изменения в .cpp
, то заголовок пересоздавать не нужно, но если вы изменили контракт метода, то придётся начинать с этапа javac -h . ...
.
В следующей части я расскажу про более сложные методы в JNI, про передачу List<List>
и вызов Java из C++.
Комментарии (10)
Stronix
08.02.2022 19:43+1minGV x64
Это было смешно, однако оно называется MinGW-w64.
нужно обязательно установить
Ну, в Linux необязательно.
И зачем разделять шаги 8 и 9? (в данном примере)
Evleaps Автор
08.02.2022 20:15+1Спасибо за внимательность :) был не прав, исправлюсь.
По поводу шагов, не знаю что ответить, посчитал, что так прозрачнее последовательность действий.
eugeneyp
09.02.2022 16:19+2Надо отметить что следующий код будет выполнен только один раз
System.loadLibrary("nativeLib");
Вне зависимости сколько классов попытаются вызвать привязку нативных методов.
Как итог JDBC driver которые используют JNI будут работать только для первой загрузки classLoader. В качестве решения JDBC driver кладут в системные библиотеки IoC контейнера.
Эту проблемму позволяет избежать если привязывать нативные функции из С кода
jint RegisterNatives(JNIEnv *env, jclass clazz, const JNINativeMethod *methods, jint nMethods);
Также надо обратить внимание на управление пямятью, чтобы нативный код не стал обращяться к уже освобожденно или перемещенной переменной.
Но меня поразил большой бенефит когда идет вызов вложенных функций С++ -> Java -> C++ -> Java
То stacktrace в Exception показывает вызовы как Java так и С++ функций.
Bakuard
10.02.2022 10:49Автор, если у Вас появится время и желание, то очень хотелось бы увидеть статью со сравнением JNI и подходом предлагаемым JEP 419. Думаю эта тема будет интересна многим.
sandersru
10.02.2022 19:03https://github.com/zakgof/java-native-benchmark
вот тут например сравнение. При удобстве API, без необходимости писать лишние обертки на сях, примерно тот же результат.
Есть еще одна штука не покрытая. А именно GraalVM и org.graalvm.nativeimage.c.* которая в теории будет сильно быстрее. Если будет не лень, то померяю
Breathe_the_pressure
А нет ничего такого в Андроиде типа JNA, JNR как под JAVA? Я помню с JNI было очень неудобно работать.
Evleaps Автор
Привет! Да, я в начале статьи приложил ссылку на NDK.
В андроиде использовать native удобнее, все автоматизировано из коробки и нет необходимости выполнять какие либо команды в консоли. В третей статье, которая выйдет 15 февраля и будет про perfomance я приведу пример использования NDK и ссылку на репозиторий.
Breathe_the_pressure
Спасибо. А минимальная версия какая Андроида под него?
Evleaps Автор
Лично я в ходе изучения не видел никаких ограничений на минимальную версию API. Точно работает на версиях >= API 18. На более ранних версиях поддержка то же есть, но с некоторыми особенностями описанными на это странице developer.android.com
sandersru
А он вроде там точно такой же:
Supported Platforms
JNA will build on most linux-like platforms with a reasonable set of GNU tools and a JDK. See the native Makefile for native configurations that have been built and tested. If your platform is supported by libffi, then chances are you can build JNA for it.
Pre-built platform support may be found here.