Практическое руководство по интеграции JNI + полный пример

Введение

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

  • В приложении на Java может понадобиться обойтись без помощи сборщика мусора (GC) и вручную управлять памятью в тех областях программы, где всё зависит от высокой производительности.

  • Можно попробовать портировать на Rust алгоритм, требующий высокой производительности. Также это поможет, если нужно скрыть реализацию.

  • С другой стороны, в приложении, написанном на Rust, может потребоваться просто предоставить определённую возможность из Rust экосистеме Java, просто упаковав эту фичу в виде JAR.

В этом посте будет подробно рассказано, как организовать и интегрировать Rust и Java в рамках одного и того же проекта. Здесь мы делаем акцент на практике, разберём конкретные примеры кода и приведём пошаговые объяснения. Дочитав пост, вы узнаете, как написать межъязыковое приложение, в котором бесшовно взаимодействуют Rust и Java.

Контекст: понятие о JNI и об управлении памятью в Java

Нативный интерфейс Java (JNI) служит мостом, соединяющим Java и нативный код, написанный на C/C++ или Rust. Притом, что в синтаксическом отношении JNI относительно прост, известно, насколько он нетривиален на практике. Всё дело в заложенных в нём неявных правилах управления памятью и потоками.

Сегменты памяти в среде выполнения Java

  • Куча Java: Здесь располагаются объекты Java. Кучей автоматически управляет сборщик мусора.

  • Нативная память: это память, выделяемая нативным кодом (например, на Rust). Сборщик мусора ею непосредственно не управляет, поэтому при работе с ней требуется специально не допускать возникновения утечек.

  • Прочие: различные сегменты, например, кэши для кода и метаданные для скомпилированных классов. Понимать эти границы принципиально важно, чтобы писать высокопроизводительный межъязыковой код, безопасно работающий с памятью.

Практическая интеграция: проект rust-java-demo 

Разберём реальный пример. В нашем открытом репозитории rust-java-demo продемонстрировано, как бесшовно интегрировать код Rust в приложения, написанные на Java.

Упаковываем платформо-специфичные библиотеки Rust в отдельный архив JAR

Байт-код Java не зависит от конкретной платформы, а двоичные файлы Rust — зависят. Если внедрить динамическую библиотеку Rust в архив JAR, возникает зависимость от платформы. Притом, что можно собрать отдельный файл JAR для каждой архитектуры, это осложнит распространение и развёртывание программы.

Есть лучший выход: упаковать платформо-специфичные библиотеки Rust в разные каталоги в пределах одного архива JAR, а затем динамически загружать нужную библиотеку во время выполнения.

Распаковав мультиплатформенный архив JAR (jar xf rust-java-demo-2c59460-multi-platform.jar), найдёте в нём такую структуру каталогов:

При помощи простой утилиты мы, ориентируясь на платформу хоста, загружаем нужную библиотеку:

static {
    JarJniLoader.loadLib(
        RustJavaDemo.class,
        "/io/greptime/demo/rust/libs",
        "demo"
    );
}

При таком подходе можно гибко работать с платформами, не мешая разработчику и не жертвуя удобством эксплуатации.

Унификация логов в пределах Rust и Java

Отладка межъязыковых проектов может быстро превратиться в кошмар, если не позаботиться об унификации логирования. Решая эту проблему, мы как через воронку пропустили все логи — на Rust и на Java — через один и тот же бэкенд SLF4J.

На стороне Java определим простую обёртку Logger:

public class Logger {
    private final org.slf4j.Logger inner;
    public Logger(org.slf4j.Logger inner) {
        this.inner = inner;
    }
    public void error(String msg) { inner.error(msg); }
    public void info(String msg) { inner.info(msg); }
    public void debug(String msg) { inner.debug(msg); }
    // ...
}

После этого Rust будет вызывать данный логгер через интерфейс JNI. Вот упрощённая реализация на Rust:

impl log::Log for Logger {
    fn log(&self, record: &log::Record) {
        let env = ...; // получаем среду JNI 
        let java_logger = find_java_side_logger();
        let logger_method = java_logger.methods.find_method(record.level());
        unsafe {
            env.call_method_unchecked(
                java_logger,
                logger_method,
                ReturnType::Primitive(Primitive::Void),
                &[JValue::from(format_msg(record)).as_jni()]
            );
        }
    }
}

Затем регистрируем его как глобальный логгер:

log::set_logger(&LOGGER).expect("Failed to set global logger");

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

Вызов асинхронных функций Rust из Java

Одна из выдающихся черт Rust — его мощная асинхронная среда выполнения. К сожалению, методы JNI нельзя объявлять async, поэтому вызывать асинхронный код Rust из Java не так просто:

#[no_mangle]
pub extern "system" fn Java_io_greptime_demo_RustJavaDemo_hello(...) {
    // ❌ Это не будет компилироваться
    foo().await;
}
async fn foo() { ... }

Но block_on() блокирует актуальный поток, даже, если это поток Java. Вместо этого воспользуемся более идиоматичным подходом: скомбинируем асинхронное порождение задач с CompletableFuture на стороне Java, обеспечив таким образом неблокирующую интеграцию.

На стороне Java:

public class AsyncRegistry {
    private static final AtomicLong FUTURE_ID = new AtomicLong();
    private static final Map<Long, CompletableFuture<?>> FUTURE_REGISTRY = new ConcurrentHashMap<>();
}
public CompletableFuture<Integer> add_one(int x) {
    long futureId = native_add_one(x); // Call Rust
    return AsyncRegistry.take(futureId); // Get CompletableFuture
}

Этот паттерн, используемый в Apache OpenDAL , позволяет Java-разработчикам решать, когда блокировать код и блокировать ли вообще. Благодаря этому интеграция получается более гибкой.

Отображение ошибок Rust на исключения Java

Чтобы унифицировать обработку исключений в обоих языках, преобразуем Result::Err Rust в RuntimeException Java :

fn throw_runtime_exception(env: &mut JNIEnv, msg: String) {
    let msg = if let Some(ex) = env.exception_occurred() {
        env.exception_clear();
        let exception_info = ...; // Извлекаем класс исключения + сообщение
        format!("{}. Java exception occurred: {}", msg, exception_info)
    } else {
        msg
    };
    env.throw_new("java/lang/RuntimeException", &msg);
}

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

Заключение

В этой статье мы исследовали ключевые аспекты взаимодействия между Rust и Java:

  • Упаковку платформо-специфичных нативных библиотек в единый файл JAR.

  • Унификацию логов между Rust и Java.

  • Наладку взаимодействия  между асинхронными функциями Rust и CompletableFuture из Java.

  • Отображение ошибок Rust на исключения Java.

Более подробные объяснения и полнофункциональный пример выложены в нашем открытом репозитории.

Комментарии (2)


  1. Gabenskiy
    01.12.2025 13:32

    что-то введение не заметил, а где такая комбинация может понадобиться?


    1. ph_piter Автор
      01.12.2025 13:32

      В первую очередь о Rust под Android, о чем в блоге Google была статья в середине ноября https://security.googleblog.com/2025/11/rust-in-android-move-fast-fix-things.html и существует вот такой курс https://google.github.io/comprehensive-rust/android.html