Мы используем куки, чтобы пользоваться сайтом было удобно.
Хорошо
to the top

Вебинар: Тимлид: ожидания, реальность и внутренние вопросы - 15.04

>
>
>
Closed-world assumption в Java

Closed-world assumption в Java

26 Мар 2026
Автор:

При сборке Java приложения в нативный образ требуются настройки для работы рефлексии, прокси и других динамических механизмов Java. Зачем, если JVM справлялась с этим сама? Разбираем отличия между миром статической компиляции и динамической Java.

Проблема

Сейчас мы в PVS-Studio разрабатываем новые статические анализаторы для JavaScript/TypeScript и Go в дополнение к уже существующим. Разработка первого выпала на Java команду. Чтобы избежать необходимости дистрибьюции JRE и получить прирост производительности, мы решили собирать JavaScript/TypeScript анализатор в native image, т. е. сделать из Java-приложения нативную программу.

"Скачать производительность без смс и регистрации бесплатно" не получилось. GraalVM сразу поставил нам свои условия: нужно было явно указать в конфигурации, какие классы участвуют в рефлексии, какие прокси будут созданы, какие ресурсы попадут в бинарник и какие функции мы зовём через Foreign Function & Memory API.

Возник закономерный вопрос: "Почему виртуальная машина сама понимает всю динамику, а при нативной сборке приходится писать конфигурационные файлы?".

Интуитивный ответ "так устроен Graal" нас совершенно не удовлетворил. Он породил ощущение, что native image либо незрелая технология, либо компромисс с сомнительной ценностью. Конечно, это утверждение ложно, но, чтобы понять почему, нужно немного отойти от Java и вспомнить, что вообще значит "исполнять программу".

Ещё раз: что такое программа и как она исполняется

Когда мы пишем код на Java, Kotlin или другом объектно-ориентированном языке, мы оперируем классами, методами и пакетами. Но для процессора, который выполняет программу, всего этого не существует. Любой код в конечном итоге превращается в бинарные данные: наборы инструкций, констант и данных.

Чтобы передать управление на нужный участок или обратиться к данным, процессору нужен точный адрес. Есть несколько способов его получить:

  • фиксированный (жёстко зашитый в код);
  • относительный (вычисляемый относительно текущей позиции);
  • динамический (через таблицы, указатели, позднюю связку).

В языках со статической компиляцией вроде C, C++, Go или Rust задача разрешения адресов решается на этапе сборки. За это отвечают компилятор и линкер.

Линкер (компоновщик) — программа, которая собирает объектные файлы (результат работы компилятора) в один исполняемый файл или библиотеку. Она связывает вызовы функций с их определениями.

На этапе линковки известны все символы и точки вызова, поэтому линкер без труда собирает бинарник.

Символ — именованная сущность в коде (функция, переменная).

Динамическая Java

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

Реализовывать такой подход виртуальной машине помогают несколько фундаментальных свойств языка:

  • классы загружаются не целиком при запуске, а по мере необходимости через ClassLoader;
  • в байткоде вызовы методов представлены символическими ссылками, а не адресами;
  • реальный метод, который будет вызван, определяется только во время выполнения;
  • метаданные классов (поля, методы, аннотации) сохраняются и доступны рантайму.

Символическая ссылка — способ сослаться на член класса, используя его имя, а не прямой адрес в памяти. Например, "вызвать PrintWriter#println" вместо "перейти по адресу 0x954A".

Благодаря этому возможны "вишенки" Java, например рефлексия, динамические прокси, ServiceLoader, DI-фреймворки вроде Spring, Micronaut или Quarkus.

В основе этой динамичности лежит архитектура, где виртуальная машина выступает центром "вселенной" Java. Программа вращается вокруг JVM: спрашивает у неё адреса, просит выполнить методы и полностью доверяет ей процесс исполнения.

По сути, JVM выступает центральной осью исполнения:

  • все классы вращаются вокруг неё;
  • все вызовы проходят через неё;
  • все решения о том, что именно будет вызвано, принимаются ей в рантайме.

Java-приложение не содержит адресов. Адреса определяет JVM.

А теперь отправимся в удивительное приключение в мир мета-иронии и рассмотрим сравнение с осью подробнее.

JVM как ось в BMW X6

Представьте JVM не как виртуальную машину, а как ось в BMW X6 с восьмицилиндровым S63B44T4 объёмом 4.4 литра и мощностью 625 л. c.

Вся конструкция может выглядеть впечатляюще: двигатель выдаёт сотни лошадиных сил, электроника сложна, подвеска многорычажная. Но именно ось связывает вращение, нагрузку и движение в единое целое.

JVM выступает этой самой осью. Это тот самый конструктивный элемент, через который передаётся и согласуется вся динамика исполнения программы.

Если говорить конкретнее, то JVM:

  • управляет загрузкой классов;
  • разрешает символические ссылки;
  • хранит и интерпретирует метаданные;
  • определяет, какая реализация метода будет вызвана;
  • обслуживает рефлексию, прокси и динамическую подмену поведения.

Давайте ещё конкретнее. Java-программа говорит: "Я хочу вызвать метод с именем equals и сигнатурой (Object)Object", а JVM ей отвечает: "Окей, смотрим по какому адресу он лежит и вызываем".

Пока эта ось существует, проблем нет, но native image её убирает, и больше нечему согласовывать компоненты между собой.

Прыжок выше головы: нативная сборка Java

Нативная сборка пытается взять программу, рассчитанную на работу внутри сложной динамической среды, и превратить её в самодостаточный бинарник, работающий напрямую поверх ОС.

Это означает, что:

  • JVM как связующая ось исчезает;
  • решения, которые раньше принимались в рантайме, должны быть приняты заранее.

Линкеру нужен полный и замкнутый граф вызовов, и в этот момент возникает фундаментальное противоречие.

В Java:

  • классы могут быть загружены динамически;
  • методы могут вызываться через рефлексию;
  • прокси могут генерироваться на лету.

Для статического линкера это выглядит как чёрный ящик. Определить заранее, что именно будет вызвано, в общем случае невозможно. Что с этим делать?

Почему нельзя просто включить всё

Кажется, что решение очевидно: если нельзя понять, что будет использовано, включим всё подряд. Но на практике это не работает.

Во-первых, размер. Включить все классы, метаданные и инфраструктуру JVM означает увеличить размер бинарника в несколько раз. Теряется один из ключевых плюсов native image.

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

Но даже если бы мы были готовы пожертвовать размером и временем сборки, нас ждёт третья проблема: рефлексия всё равно не заработает автоматически. Важно не просто наличие классов, а сохранение конкретных метаданных: конструкторов, методов, сигнатур. Без знания, что именно будет запрошено через рефлексию, корректно сохранить всё невозможно.

Следовательно, стратегия "включить всё" не решает проблему ни теоретически, ни практически.

Конфигурация Graal Native Image

Native image исходит из предположения о замкнутости мира: все точки выполнения должны быть известны на этапе компиляции. Сборка строится вокруг анализа достижимости:

  • формируется граф вызовов;
  • всё, что не попало в граф, считается недостижимым и отбрасывается.

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

В такой ситуации мы оказались, когда возникла потребность читать значение из реестра Windows внутри анализатора. Подходящей библиотеки найдено не было, и мы решили обратиться к относительно новому Foreign Function & Memory API, который позволяет вызывать нативные методы непосредственно из Java.

Код для чтения реестра получится интересным, но слишком громоздким для примера, поэтому упростим его до обычного вывода "Hello, World" в консоль.

class Sandbox {
  void main() throws Throwable {
    Linker linker = Linker.nativeLinker(); // (1)
    MethodHandle printf = linker.downcallHandle( // (2)
        linker.defaultLookup().findOrThrow("printf"), // (3)
        FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS)
    );

    try (Arena arena = Arena.ofConfined()) { // (4)
      MemorySegment cString = arena.allocateFrom("Hello, world!\n"); // (5)
      printf.invoke(cString); // (6)
    }
  }
}

Здесь мы получаем линкер для нативных вызовов (1); создаём MethodHandle для функции printf (2); разрешаем имя функции во время выполнения (3); открываем Arena, область управления off-heap памятью (4); выделяем в ней блок, записываем туда строку в C-формате (5) и вызываем функцию через MethodHandle (6).

MethodHandle — ссылка на исполняемый код. Подробнее об этой технологии можно узнать здесь.

Для справки, аналогичный код на C:

int main() {
    printf("Hello, world!\n");
    return 0;
}

На JVM такой код работает корректно, но при запуске native image без соответствующей конфигурации возникает ошибка, стэктрейс которой показывает, что native image не может подготовить механизм вызова нативной функции: он не знает, как передать аргументы и принять результат, потому что описание вызова не было зафиксировано на этапе компиляции. Graal просит нас добавить информацию о точке вызова:

{
  "foreign": {
    "downcalls": [
      {
        "returnType": "jint", // (1)
        "parameterTypes": [ "void*" ] // (2)
      }
    ]
  }
}

Эта конфигурация описывает не конкретную функцию, а форму нативного вызова, которая может возникнуть в рантайме. Из неё Graal узнает размер возвращаемого значения, как его нужно принять и интерпретировать после возврата управления (1), а также размер аргумента, как он передаётся в нативный код и какие правила передачи должны быть соблюдены (2).

Так конфигурация заменяет решения, которые раньше JVM принимала во время выполнения.

Итоги

Какие выводы можно сделать?

JVM может понимать всё сама, потому что она и есть центр принятия решений во время выполнения. Native image лишён роскоши импровизации и вынужден принимать решения заранее.

Конфигурация в Graal Native Image не прихоть, не ограничение инструмента и не архитектурный дефект. Это прямое следствие динамики Java и попытки перенести её в мир статической компиляции.

Если JVM — это ось, вокруг которой вращается Java-приложение, то native image — снимок этой системы, зафиксированный в конкретном положении. И чтобы этот снимок был корректным, нужно явно через конфигурацию указать, что именно должно на него попасть.

Подписаться на рассылку
Хотите раз в месяц получать от нас подборку вышедших в этот период самых интересных статей и новостей? Подписывайтесь!
Популярные статьи по теме

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

Следующие комментарии next comments
close comment form