Перейти к основному содержимому

Руководство по JNI (собственный интерфейс Java)

· 11 мин. чтения

Задача: Наибольшая подстрока без повторений

Для заданной строки s, найдите длину наибольшей подстроки без повторяющихся символов. Подстрока — это непрерывная непустая последовательность символов внутри строки...

ANDROMEDA 42

1. Введение

Как мы знаем, одной из основных сильных сторон Java является ее переносимость — это означает, что после того, как мы напишем и скомпилируем код, результатом этого процесса будет независимый от платформы байт-код.

Проще говоря, это может работать на любой машине или устройстве, поддерживающем виртуальную машину Java, и оно будет работать так гладко, как мы могли ожидать.

Однако иногда нам действительно нужно использовать код, скомпилированный для конкретной архитектуры .

Может быть несколько причин, по которым необходимо использовать собственный код:

  • Необходимость обращаться с некоторыми аппаратными средствами
  • Повышение производительности для очень требовательного процесса
  • Существующая библиотека, которую мы хотим использовать повторно вместо того, чтобы переписывать ее на Java.

Для этого JDK вводит мост между байт-кодом, работающим в нашей JVM, и собственным кодом (обычно написанным на C или C++).

Инструмент называется Java Native Interface. В этой статье мы увидим, как написать код с его помощью.

2. Как это работает

2.1. Нативные методы: JVM встречает скомпилированный код

Java предоставляет ключевое слово native , которое используется для указания того, что реализация метода будет обеспечиваться собственным кодом.

Обычно при создании собственной исполняемой программы мы можем использовать статические или общие библиотеки:

  • Статические библиотеки — все двоичные файлы библиотек будут включены как часть нашего исполняемого файла в процессе компоновки. Таким образом, нам больше не понадобятся библиотеки, но это увеличит размер нашего исполняемого файла.
  • Общие библиотеки — окончательный исполняемый файл содержит ссылки только на библиотеки, а не на сам код. Это требует, чтобы среда, в которой мы запускаем наш исполняемый файл, имела доступ ко всем файлам библиотек, используемых нашей программой.

Последнее имеет смысл для JNI, поскольку мы не можем смешивать байт-код и скомпилированный код в одном и том же двоичном файле.

Поэтому наша общая библиотека будет хранить собственный код отдельно в своем файле .so/.dll/.dylib (в зависимости от используемой операционной системы), а не быть частью наших классов.

Ключевое слово native превращает наш метод в своего рода абстрактный метод:

private native void aNativeMethod();

С основным отличием в том, что вместо того, чтобы быть реализованным другим классом Java, он будет реализован в отдельной нативной разделяемой библиотеке .

Будет создана таблица с указателями в памяти на реализацию всех наших нативных методов, чтобы их можно было вызывать из нашего Java-кода.

2.2. Необходимые компоненты

Вот краткое описание ключевых компонентов, которые нам необходимо принять во внимание. Мы объясним их далее в этой статье.

  • Код Java — наши классы. Они будут включать как минимум один нативный метод.
  • Нативный код — реальная логика наших нативных методов, обычно закодированная на C или C++.
  • Заголовочный файл JNI — этот заголовочный файл для C/C++ ( include/jni.h в каталоге JDK) включает все определения элементов JNI, которые мы можем использовать в наших собственных программах.
  • Компилятор C/C++ — мы можем выбирать между GCC, Clang, Visual Studio или любым другим, который нам нравится, если он может создать собственную общую библиотеку для нашей платформы.

2.3. Элементы JNI в коде (Java и C/C++)

Java-элементы:

  • «родное» ключевое слово — как мы уже говорили, любой метод, помеченный как родной, должен быть реализован в родной общей библиотеке.
  • System.loadLibrary(String libname) — статический метод, который загружает разделяемую библиотеку из файловой системы в память и делает ее экспортированные функции доступными для нашего Java-кода.

Элементы C/C++ (многие из них определены в jni.h )

  • JNIEXPORT - помечает функцию в общей библиотеке как экспортируемую, поэтому она будет включена в таблицу функций, и, таким образом, JNI сможет ее найти.
  • JNICALL — в сочетании с JNIEXPORT обеспечивает доступность наших методов для среды JNI.
  • JNIEnv — структура, содержащая методы, которые мы можем использовать в собственном коде для доступа к элементам Java.
  • JavaVM — структура, которая позволяет нам манипулировать работающей JVM (или даже запускать новую), добавляя к ней потоки, уничтожая ее и т. д.

3. Привет, мир JNI

Далее давайте посмотрим, как JNI работает на практике.

В этом руководстве мы будем использовать C++ в качестве родного языка и G++ в качестве компилятора и компоновщика.

Мы можем использовать любой другой компилятор по своему усмотрению, но вот как установить G++ в Ubuntu, Windows и MacOS:

  • Ubuntu Linux — запустите команду «sudo apt-get install build-essential» в терминале
  • Windows — установите MinGW
  • MacOS — запустите команду «g++» в терминале, и если ее еще нет, она ее установит.

3.1. Создание класса Java

Давайте начнем создавать нашу первую программу JNI с реализации классической «Hello World».

Для начала мы создадим следующий класс Java, который включает собственный метод, который будет выполнять работу:

package com.foreach.jni;

public class HelloWorldJNI {

static {
System.loadLibrary("native");
}

public static void main(String[] args) {
new HelloWorldJNI().sayHello();
}

// Declare a native method sayHello() that receives no arguments and returns void
private native void sayHello();
}

Как мы видим, мы загружаем разделяемую библиотеку в статическом блоке . Это гарантирует, что он будет готов, когда нам это нужно, и из любого места, где нам это нужно.

В качестве альтернативы, в этой тривиальной программе мы могли бы вместо этого загрузить библиотеку непосредственно перед вызовом нашего нативного метода, потому что мы больше нигде не используем нативную библиотеку.

3.2. Реализация метода на C++

Теперь нам нужно создать реализацию нашего нативного метода на C++.

В C++ определение и реализация обычно хранятся в файлах .h и .cpp соответственно.

Во- первых, чтобы создать определение метода, мы должны использовать флаг -h компилятора Java :

javac -h . HelloWorldJNI.java

Это создаст файл com_foreach_jni_HelloWorldJNI.h со всеми нативными методами, включенными в класс, переданный в качестве параметра, в данном случае только один:

JNIEXPORT void JNICALL Java_com_foreach_jni_HelloWorldJNI_sayHello
(JNIEnv *, jobject);

Как мы видим, имя функции генерируется автоматически с использованием полного имени пакета, класса и метода.

Кроме того, кое-что интересное, что мы можем заметить, это то, что мы получаем два параметра, передаваемых нашей функции; указатель на текущий JNIEnv; а также объект Java, к которому привязан метод, экземпляр нашего класса HelloWorldJNI .

Теперь нам нужно создать новый файл .cpp для реализации функции sayHello . Здесь мы будем выполнять действия, выводящие «Hello World» на консоль.

Мы назовем наш файл .cpp тем же именем, что и файл .h, содержащий заголовок, и добавим этот код для реализации нативной функции:

JNIEXPORT void JNICALL Java_com_foreach_jni_HelloWorldJNI_sayHello
(JNIEnv* env, jobject thisObject) {
std::cout << "Hello from C++ !!" << std::endl;
}

3.3. Компиляция и компоновка

На данный момент у нас есть все части, которые нам нужны, и связь между ними.

Нам нужно собрать нашу общую библиотеку из кода C++ и запустить ее!

Для этого мы должны использовать компилятор G++, не забывая включить заголовки JNI из нашей установки Java JDK .

Версия Убунту:

g++ -c -fPIC -I${JAVA_HOME}/include -I${JAVA_HOME}/include/linux com_foreach_jni_HelloWorldJNI.cpp -o com_foreach_jni_HelloWorldJNI.o

Версия Windows:

g++ -c -I%JAVA_HOME%\include -I%JAVA_HOME%\include\win32 com_foreach_jni_HelloWorldJNI.cpp -o com_foreach_jni_HelloWorldJNI.o

версия MacOS;

g++ -c -fPIC -I${JAVA_HOME}/include -I${JAVA_HOME}/include/darwin com_foreach_jni_HelloWorldJNI.cpp -o com_foreach_jni_HelloWorldJNI.o

Как только мы скомпилируем код для нашей платформы в файл com_foreach_jni_HelloWorldJNI.o , мы должны включить его в новую общую библиотеку. Как бы мы ни решили его назвать, это аргумент, передаваемый в метод System.loadLibrary .

Мы назвали наш «нативный», и мы будем загружать его при запуске нашего Java-кода.

Затем компоновщик G++ связывает объектные файлы C++ с нашей объединенной библиотекой.

Версия Убунту:

g++ -shared -fPIC -o libnative.so com_foreach_jni_HelloWorldJNI.o -lc

Версия Windows:

g++ -shared -o native.dll com_foreach_jni_HelloWorldJNI.o -Wl,--add-stdcall-alias

Версия MacOS:

g++ -dynamiclib -o libnative.dylib com_foreach_jni_HelloWorldJNI.o -lc

Вот и все!

Теперь мы можем запустить нашу программу из командной строки.

Однако нам нужно добавить полный путь к каталогу, содержащему только что сгенерированную нами библиотеку. Таким образом, Java будет знать, где искать наши нативные библиотеки:

java -cp . -Djava.library.path=/NATIVE_SHARED_LIB_FOLDER com.foreach.jni.HelloWorldJNI

Вывод консоли:

Hello from C++ !!

4. Использование расширенных функций JNI

Здороваться приятно, но не очень полезно. Обычно мы хотели бы обмениваться данными между кодом Java и C++ и управлять этими данными в нашей программе.

4.1. Добавление параметров к нашим нативным методам

Мы добавим некоторые параметры к нашим нативным методам. Давайте создадим новый класс с именем ExampleParametersJNI с двумя нативными методами, использующими параметры и возвращаемые значения разных типов:

private native long sumIntegers(int first, int second);

private native String sayHelloToMe(String name, boolean isFemale);

Затем повторите процедуру, чтобы создать новый файл .h с помощью «javac -h», как мы делали раньше.

Теперь создайте соответствующий файл .cpp с реализацией нового метода C++:

...
JNIEXPORT jlong JNICALL Java_com_foreach_jni_ExampleParametersJNI_sumIntegers
(JNIEnv* env, jobject thisObject, jint first, jint second) {
std::cout << "C++: The numbers received are : " << first << " and " << second << std::endl;
return (long)first + (long)second;
}
JNIEXPORT jstring JNICALL Java_com_foreach_jni_ExampleParametersJNI_sayHelloToMe
(JNIEnv* env, jobject thisObject, jstring name, jboolean isFemale) {
const char* nameCharPointer = env->GetStringUTFChars(name, NULL);
std::string title;
if(isFemale) {
title = "Ms. ";
}
else {
title = "Mr. ";
}

std::string fullName = title + nameCharPointer;
return env->NewStringUTF(fullName.c_str());
}
...

Мы использовали указатель *env типа JNIEnv для доступа к методам, предоставляемым экземпляром среды JNI.

В этом случае JNIEnv позволяет нам передавать строки Java в наш код на C++ и обратно, не беспокоясь о реализации.

Мы можем проверить эквивалентность типов Java и типов C JNI в официальной документации Oracle.

Чтобы протестировать наш код, мы должны повторить все этапы компиляции предыдущего примера HelloWorld .

4.2. Использование объектов и вызов методов Java из собственного кода

В этом последнем примере мы увидим, как мы можем манипулировать объектами Java в нашем родном коде C++.

Мы начнем создавать новый класс UserData , который будем использовать для хранения некоторой информации о пользователе:

package com.foreach.jni;

public class UserData {

public String name;
public double balance;

public String getUserInfo() {
return "[name]=" + name + ", [balance]=" + balance;
}
}

Затем мы создадим еще один класс Java с именем ExampleObjectsJNI с некоторыми собственными методами, с помощью которых мы будем управлять объектами типа UserData :

...
public native UserData createUser(String name, double balance);

public native String printUserData(UserData user);

Еще раз давайте создадим заголовок .h , а затем реализацию наших нативных методов на C++ в новом файле .cpp :

JNIEXPORT jobject JNICALL Java_com_foreach_jni_ExampleObjectsJNI_createUser
(JNIEnv *env, jobject thisObject, jstring name, jdouble balance) {

// Create the object of the class UserData
jclass userDataClass = env->FindClass("com/foreach/jni/UserData");
jobject newUserData = env->AllocObject(userDataClass);

// Get the UserData fields to be set
jfieldID nameField = env->GetFieldID(userDataClass , "name", "Ljava/lang/String;");
jfieldID balanceField = env->GetFieldID(userDataClass , "balance", "D");

env->SetObjectField(newUserData, nameField, name);
env->SetDoubleField(newUserData, balanceField, balance);

return newUserData;
}

JNIEXPORT jstring JNICALL Java_com_foreach_jni_ExampleObjectsJNI_printUserData
(JNIEnv *env, jobject thisObject, jobject userData) {

// Find the id of the Java method to be called
jclass userDataClass=env->GetObjectClass(userData);
jmethodID methodId=env->GetMethodID(userDataClass, "getUserInfo", "()Ljava/lang/String;");

jstring result = (jstring)env->CallObjectMethod(userData, methodId);
return result;
}

Опять же, мы используем указатель JNIEnv *env для доступа к необходимым классам, объектам, полям и методам из работающей JVM.

Обычно нам просто нужно предоставить полное имя класса для доступа к классу Java или правильное имя метода и сигнатуру для доступа к объектному методу.

Мы даже создаем экземпляр класса com.foreach.jni.UserData в нашем родном коде. Получив экземпляр, мы можем манипулировать всеми его свойствами и методами аналогично отражению в Java.

Мы можем проверить все другие методы JNIEnv в официальной документации Oracle .

4. Недостатки использования JNI

У моста JNI есть свои подводные камни.

Основным недостатком является зависимость от базовой платформы; мы, по сути, теряем функцию Java «написать один раз, запускать где угодно». Это означает, что нам придется создавать новую библиотеку для каждой новой комбинации платформы и архитектуры, которую мы хотим поддерживать. Представьте, какое влияние это могло бы оказать на процесс сборки, если бы мы поддерживали Windows, Linux, Android, MacOS…

JNI не только усложняет нашу программу. Это также добавляет дорогостоящий уровень связи между кодом, работающим в JVM, и нашим собственным кодом: нам нужно преобразовать данные, которыми обмениваются в обоих направлениях между Java и C++, в процессе маршалинга/демаршалинга.

Иногда нет даже прямого преобразования между типами, поэтому нам придется написать свой эквивалент.

5. Вывод

Компиляция кода для конкретной платформы (обычно) делает его быстрее, чем запуск байт-кода.

Это делает его полезным, когда нам нужно ускорить сложный процесс. Кроме того, когда у нас нет других альтернатив, например, когда нам нужно использовать библиотеку, которая управляет устройством.

Однако за это приходится платить, поскольку нам придется поддерживать дополнительный код для каждой отдельной платформы, которую мы поддерживаем.

Вот почему обычно рекомендуется использовать JNI только в тех случаях, когда нет альтернативы Java .

Как всегда, код этой статьи доступен на GitHub .