Нам понадобился сервер для транскодинга видео на лету. Требования: REST API на Kotlin (Ktor), транскодинг через FFmpeg, поддержка до 100 параллельных потоков, latency не больше 2 секунд на минуту видео. Чистый Kotlin с ProcessBuilder и внешним ffmpeg-клиентом работал, но имел огромный оверхед на спавн процессов. Решение — обернуть FFmpeg в нативную библиотеку и вызывать через JNI.
Архитектура решения
Общая схема такая: Kotlin Ktor принимает HTTP-запрос с видео-файлом, сохраняет во временный файл и вызывает нативный метод через JNI. C++ библиотека открывает файл через FFmpeg API, декодирует, обрабатывает (ресайз, изменение битрейта, изменение контейнера) и кодирует в целевой формат. Результат записывается в выходной файл, путь к которому возвращается в Kotlin.
Почему именно JNI, а не отдельный процесс? Потому что спавн процесса ffmpeg на каждый запрос занимал 100–300 мс только на запуск. При 100 RPS это 10–30 секунд суммарного оверхеда. Через JNI — вызов функции занимает микросекунды.
C++ сторона: FFmpeg обёртка
Начнём с C++ кода. Это упрощённая версия нашей обёртки:
// native_video_processor.cpp
#include
#include
#include
#include
#include
#include
#include
struct TranscodeContext {
AVFormatContext* input_ctx = nullptr;
AVFormatContext* output_ctx = nullptr;
AVCodecContext* decoder_ctx = nullptr;
AVCodecContext* encoder_ctx = nullptr;
SwsContext* sws_ctx = nullptr;
int stream_index = -1;
};
static const char* TRANSCODE_CTX_CLASS =
"com/video/TranscodeContext";
// Кэшируем JNI-референсы при загрузке библиотеки
static jclass g_transcode_ctx_class = nullptr;
static jfieldID g_ctx_ptr_field = nullptr;
extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
JNIEnv* env = nullptr;
if (vm->GetEnv(reinterpret_cast(&env),
JNI_VERSION_1_8) != JNI_OK) {
return JNI_ERR;
}
// Кэшируем класс и field ID
g_transcode_ctx_class = reinterpret_cast(
env->NewGlobalRef(env->FindClass(TRANSCODE_CTX_CLASS)));
g_ctx_ptr_field = env->GetFieldID(
g_transcode_ctx_class, "nativePtr", "J");
return JNI_VERSION_1_8;
}
static TranscodeContext* getCtx(JNIEnv* env, jobject obj) {
jlong ptr = env->GetLongField(obj, g_ctx_ptr_field);
return reinterpret_cast(ptr);
}
extern "C" JNIEXPORT jlong JNICALL
Java_com_video_NativeVideoProcessor_nativeInit(
JNIEnv* env, jobject thiz, jstring inputPath,
jstring outputPath, jint targetWidth, jint targetHeight,
jlong targetBitrate) {
const char* input = env->GetStringUTFChars(inputPath, nullptr);
const char* output = env->GetStringUTFChars(outputPath, nullptr);
TranscodeContext* ctx = new TranscodeContext();
int ret = avformat_open_input(
&ctx->input_ctx, input, nullptr, nullptr);
if (ret < 0) {
env->ReleaseStringUTFChars(inputPath, input);
env->ReleaseStringUTFChars(outputPath, output);
delete ctx;
jclass exClass = env->FindClass("java/io/IOException");
env->ThrowNew(exClass, "Failed to open input file");
return 0;
}
ret = avformat_find_stream_info(ctx->input_ctx, nullptr);
if (ret < 0) {
env->ReleaseStringUTFChars(inputPath, input);
env->ReleaseStringUTFChars(outputPath, output);
avformat_close_input(&ctx->input_ctx);
delete ctx;
jclass exClass = env->FindClass("java/io/IOException");
env->ThrowNew(exClass, "Failed to find stream info");
return 0;
}
// Ищем видео-стрим
for (unsigned i = 0; i < ctx->input_ctx->nb_streams; i++) {
if (ctx->input_ctx->streams[i]->codecpar->codec_type
== AVMEDIA_TYPE_VIDEO) {
ctx->stream_index = i;
break;
}
}
env->ReleaseStringUTFChars(inputPath, input);
env->ReleaseStringUTFChars(outputPath, output);
return reinterpret_cast(ctx);
} JNI_OnLoad: почему это важно
JNI_OnLoad вызывается один раз при загрузке нативной библиотеки через System.loadLibrary(). Здесь мы кэшируем JNI-референсы: классы, field ID, method ID. Без этого на каждый нативный вызов будет происходить поиск по имени строки — это медленно и создаёт GC-pressure из-за локальных референсов. Также NewGlobalRef предотвращает выгрузку класса GC, пока библиотека загружена.
Kotlin сторона: REST API и вызов JNI
// NativeVideoProcessor.kt
package com.video
import java.nio.ByteBuffer
class NativeVideoProcessor {
init {
System.loadLibrary("native_video_processor")
}
private external fun nativeInit(
inputPath: String,
outputPath: String,
targetWidth: Int,
targetHeight: Int,
targetBitrate: Long
): Long
private external fun nativeProcessFrame(
ctxPtr: Long,
inputBuffer: ByteBuffer,
outputBuffer: ByteBuffer,
inputSize: Int
)
private external fun nativeCleanup(ctxPtr: Long)
private external fun nativeGetProgress(ctxPtr: Long): Double
fun transcode(
inputPath: String,
outputPath: String,
width: Int = 1280,
height: Int = 720,
bitrate: Long = 2_000_000L
): TranscodeResult {
val ctxPtr = nativeInit(
inputPath, outputPath, width, height, bitrate)
if (ctxPtr == 0L) {
throw IllegalStateException(
"Failed to initialize transcoder")
}
try {
val inputBuffer = ByteBuffer.allocateDirect(
4 * 1024 * 1024) // 4MB
val outputBuffer = ByteBuffer.allocateDirect(
4 * 1024 * 1024)
val inputFile = java.io.RandomAccessFile(
inputPath, "r")
val channel = inputFile.channel
var totalRead = 0L
while (true) {
inputBuffer.clear()
val bytesRead = channel.read(inputBuffer)
if (bytesRead <= 0) break
inputBuffer.flip()
nativeProcessFrame(
ctxPtr, inputBuffer,
outputBuffer, bytesRead)
totalRead += bytesRead
}
channel.close()
inputFile.close()
val progress = nativeGetProgress(ctxPtr)
return TranscodeResult(outputPath, progress, totalRead)
} finally {
nativeCleanup(ctxPtr)
}
}
}
data class TranscodeResult(
val outputPath: String,
val progress: Double,
val bytesProcessed: Long
)Передача данных через DirectByteBuffer
Ключевая оптимизация — ByteBuffer.allocateDirect(). Это выделяет память вне Java-кучи (native heap), и C++ может получить прямой указатель через GetDirectBufferAddress(). Никакого копирования между JNI-границей! Для видео-фреймов это критично — один HD-фрейм в RGB занимает 6 МБ, и копирование на каждый кадр убило бы всю производительность.
Обработка ошибок C++ → Kotlin
Один из самых болезненных аспектов JNI — обработка ошибок. У нас есть два паттерна. Первый — env->ThrowNew() из C++ кода. Это создаёт Java-исключение, которое будет брошено после возврата из нативного метода. Важно: после ThrowNew нужно СРАЗУ выйти из функции — не продолжать выполнение C++ кода. Второй — возврат кода ошибки (int) и обработка на стороне Kotlin. Мы используем первый для критических ошибок (не удалось открыть файл, нет видео-стрима) и второй для некритичных (предупреждения о качестве).
Потокобезопасность JNI
JNIEnv* валиден только в потоке, в котором он был получен. Если вы вызываете JNI-методы из нескольких Kotlin-потоков (а мы так делаем — Ktor обрабатывает запросы в coroutine-ах на разных потоках), каждый поток должен получить свой JNIEnv* через JavaVM->AttachCurrentThread(). Поэтому все JNI-вызовы мы делаем через Kotlin external-методы, и JVM сама управляет привязкой к потокам.
Бенчмарк: Kotlin ProcessBuilder vs JNI+FFmpeg
Результаты на 100 транскодингов 1080p→720p h264:
| Метрика | ProcessBuilder + ffmpeg | JNI + FFmpeg |
|---------------------------|------------------------|--------------|
| Среднее время (сек) | 8.4 | 5.1 |
| P99 latency (сек) | 12.3 | 6.8 |
| Пиковая память (МБ) | 3200 | 1800 |
| CPU utilisation (%) | 95 | 78 |
| Overhead на запуск (мс) | 180 | 0.3 |
| Параллельных потоков max | 25 | 100 |Разница — в 1.5–2 раза по скорости и в 4 раза по количеству параллельных потоков. JNI-подход использует меньше памяти, потому что нет необходимости запускать отдельный процесс с собственным адресным пространством. Оверхед на вызов JNI-функции — доли микросекунды, по сравнению с сотнями миллисекунд на спавн процесса.
Итог: JNI — не самая приятная технология для работы, но когда нужна производительность, которая не достижима в чистой JVM — это рабочий инструмент. Главное правило: минимизировать количество переходов через JNI-границу и передавать данные через DirectByteBuffer без копирования.
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье