Загрузка
Все статьи

Kotlin + C++: реальный кейс — видеопроцессинг на сервере через JNI

Как мы построили сервер видеотранскодинга на Kotlin REST API + C++ FFmpeg через JNI. Прокачка JNI_OnLoad, передача фреймов через DirectByteBuffer, обработка ошибок и бенчмарки.

Нам понадобился сервер для транскодинга видео на лету. Требования: 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 без копирования.

Поделиться статьёй

Комментарии

0
0 / 2000

Пока нет комментариев

Будьте первым, кто оставит комментарий к этой статье

Читайте также

CMake: модульная архитектура для большого C++ проекта — без ad-hoc include путей

Как мы реорганизовали CMake-структуру проекта на 15+ модулей, избавились от хаотичных include_directories и перешли на современный CMake с target-based зависимостями.

20 июня 2026 10 мин

C++: потоки и потокобезопасность — мьютексы, atomics и deadlock-ы на практике

Разбираем многопоточность в C++ на реальных примерах из продакшена: мьютексы, lock_guard, unique_lock, std::atomic, deadlock-ы и как их избежать. Сравнение с корутинами Kotlin.

18 июня 2026 11 мин

eBPF: профилирование C++ и Kotlin сервисов в продакшене без оверхеда

Как мы внедрили eBPF для профилирования высоконагруженных C++ и Kotlin сервисов в продакшене без остановки и перекомпиляции. Реальные скрипты bpftrace, поиск утечек памяти и трейсинг JVM-системных вызовов.

17 июня 2026 12 мин