За последний год я переписал три проекта, где Kotlin общался с C++ через JNI. Начинал я, как и все, с простого Hello World, а закончил продакшен-пайплайном обработки изображений, который крутится на сервере под нагрузкой 500 RPS. В этой статье собрал всё, что знаю о JNI — от базовой настройки до реальных граблей, на которые наступал.
Зачем вообще JNI?
Kotlin отличный язык, но бывают задачи, где его производительности не хватает. В моём случае это была обработка изображений: ресайз, конвертация цветовых пространств, детекция лиц. На чистом Kotlin один запрос обрабатывался 120 мс, а на C++ — 15 мс. Разница в 8 раз. Если у вас похожие задачи — криптография, сигнальная обработка, тяжёлые вычисления — JNI может стать решением.
JNI (Java Native Interface) — это мост между JVM и нативным кодом. Да, Kotlin работает на JVM, поэтому весь API JNI доступен из Kotlin напрямую. Никаких магических библиотек — только стандартный jni.h и CMake.
Базовая настройка: Hello World
Начнём с минимального примера. Сначала Kotlin-сторона — объявляем нативную функцию:
class NativeLib {
companion object {
init {
System.loadLibrary("native_lib")
}
}
external fun stringFromJNI(): String
external fun processImage(data: ByteArray, width: Int, height: Int): ByteArray
}
Теперь C++ реализация. Заметьте — имя функции строго регламентировано: Java_пакет_класс_метод:
#include
#include
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_NativeLib_stringFromJNI(JNIEnv *env, jobject /* this */) {
std::string hello = "Привет из C++, Артём!";
return env->NewStringUTF(hello.c_str());
}
extern "C" JNIEXPORT jbyteArray JNICALL
Java_com_example_NativeLib_processImage(
JNIEnv *env, jobject,
jbyteArray data, jint width, jint height
) {
jsize len = env->GetArrayLength(data);
jbyte *pixels = env->GetByteArrayElements(data, nullptr);
// Обработка пикселей (инвертирование)
for (jsize i = 0; i < len; i++) {
pixels[i] = 255 - pixels[i];
}
jbyteArray result = env->NewByteArray(len);
env->SetByteArrayRegion(result, 0, len, pixels);
env->ReleaseByteArrayElements(data, pixels, 0);
return result;
}
CMakeLists.txt для сборки
Без CMake ничего не скомпилируется. Вот минимальный конфиг, который я использую во всех проектах:
cmake_minimum_required(VERSION 3.22.1)
project("native_lib" CXX)
find_library(log-lib log)
add_library(native_lib SHARED
native_lib.cpp
)
target_link_libraries(native_lib
${log-lib}
)
target_compile_features(native_lib PRIVATE cxx_std_17)
Передача строк и примитивов
Примитивы (int, long, double) передаются напрямую — никаких проблем. А вот со строками нужно быть аккуратным. Когда я первый раз передал строку из Kotlin в C++ и забыл освободить память через ReleaseStringUTFChars, сервер упал через 4 часа под нагрузкой с OutOfMemoryError.
Правило простое: каждый GetStringUTFChars должен иметь парный ReleaseStringUTFChars. Каждый NewStringUTF возвращает LocalRef, который JNI очистит автоматически, но если вы создаёте много объектов в цикле — лучше вызывать DeleteLocalRef вручную.
GlobalRef vs LocalRef
Это самая частая причина крашей, которую я видел у новичков. LocalRef живёт только внутри одного нативного вызова. Если вы хотите сохранить ссылку на Java-объект между вызовами — нужно создавать GlobalRef:
static jclass globalCallbackClass = nullptr;
static jmethodID globalCallbackMethod = nullptr;
extern "C" JNIEXPORT void JNICALL
Java_com_example_NativeLib_initCallback(
JNIEnv *env, jobject, jobject callback
) {
// Сохраняем класс и метод как глобальные ссылки
jclass localClass = env->GetObjectClass(callback);
globalCallbackClass = (jclass)env->NewGlobalRef(localClass);
env->DeleteLocalRef(localClass);
globalCallbackMethod = env->GetMethodID(
globalCallbackClass, "onResult", "(I)V"
);
}
extern "C" JNIEXPORT void JNICALL
Java_com_example_NativeLib_doWork(JNIEnv *env, jobject) {
// Глобальная ссылка живёт между вызовами
if (globalCallbackClass && globalCallbackMethod) {
env->CallVoidMethod(
env->NewObject(globalCallbackClass,
env->GetMethodID(globalCallbackClass, "", "()V")),
globalCallbackMethod, 42
);
}
}
Продакшен-пример: обработка изображений
В реальном проекте мы вызывали C++ библиотеку для обработки изображений из Kotlin-сервиса на Ktor. Пайплайн такой: Kotlin получает multipart-запрос с изображением, передаёт байты в C++, C++ ресайзит, накладывает водяной знак и возвращает готовые байты. Всю тяжёлую работу делает OpenCV в C++, а Kotlin занимается маршрутизацией и бизнес-логикой.
Главный урок: оборачивайте каждый JNI-вызов в try-catch на Kotlin-стороне и проверяйте исключения через env->ExceptionCheck() на C++-стороне. JNI не бросает C++ исключения — он просто устанавливает флаг pending exception, и если его не обработать, JVM упадёт при следующем JNI-вызове.
Типичные ошибки
За год я собрал свой личный топ граблей. Первая — утечки памяти. JNI-объекты, созданные в нативном коде, не собираются GC автоматически. Забыли DeleteGlobalRef? Память утекает. Вторая — краш JVM из-за nullptr. Если FindClass вернул nullptr, а вы продолжили работу — краш гарантирован. Третья — потокобезопасность. JNIEnv* привязан к потоку. Если вы вызываете JNI-функцию из другого потока, нужно сначала получить env через JavaVM->AttachCurrentThread.
JNI — не самый удобный инструмент, но когда нужен максимум производительности и у вас уже есть C++ код, альтернативы немного. В следующих статьях разберу Kotlin Native cinterop и передачу сложных структур. Удачи в нативных дебрях!
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье