Вебинар: Тимлид: ожидания, реальность и внутренние вопросы - 15.04
При сборке 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 изначально проектировалась как живая и динамичная среда выполнения. Её модель принципиально иная: программа не собирается в готовый бинарник с точными адресами, а остаётся набором байт-кода, который интерпретируется и оптимизируется уже во время работы приложения.
Реализовывать такой подход виртуальной машине помогают несколько фундаментальных свойств языка:
ClassLoader;Символическая ссылка — способ сослаться на член класса, используя его имя, а не прямой адрес в памяти. Например, "вызвать PrintWriter#println" вместо "перейти по адресу 0x954A".
Благодаря этому возможны "вишенки" Java, например рефлексия, динамические прокси, ServiceLoader, DI-фреймворки вроде Spring, Micronaut или Quarkus.
В основе этой динамичности лежит архитектура, где виртуальная машина выступает центром "вселенной" Java. Программа вращается вокруг JVM: спрашивает у неё адреса, просит выполнить методы и полностью доверяет ей процесс исполнения.
По сути, JVM выступает центральной осью исполнения:
Java-приложение не содержит адресов. Адреса определяет JVM.
А теперь отправимся в удивительное приключение в мир мета-иронии и рассмотрим сравнение с осью подробнее.
Представьте JVM не как виртуальную машину, а как ось в BMW X6 с восьмицилиндровым S63B44T4 объёмом 4.4 литра и мощностью 625 л. c.
Вся конструкция может выглядеть впечатляюще: двигатель выдаёт сотни лошадиных сил, электроника сложна, подвеска многорычажная. Но именно ось связывает вращение, нагрузку и движение в единое целое.
JVM выступает этой самой осью. Это тот самый конструктивный элемент, через который передаётся и согласуется вся динамика исполнения программы.
Если говорить конкретнее, то JVM:
Давайте ещё конкретнее. Java-программа говорит: "Я хочу вызвать метод с именем equals и сигнатурой (Object)Object", а JVM ей отвечает: "Окей, смотрим по какому адресу он лежит и вызываем".
Пока эта ось существует, проблем нет, но native image её убирает, и больше нечему согласовывать компоненты между собой.
Нативная сборка пытается взять программу, рассчитанную на работу внутри сложной динамической среды, и превратить её в самодостаточный бинарник, работающий напрямую поверх ОС.
Это означает, что:
Линкеру нужен полный и замкнутый граф вызовов, и в этот момент возникает фундаментальное противоречие.
В Java:
Для статического линкера это выглядит как чёрный ящик. Определить заранее, что именно будет вызвано, в общем случае невозможно. Что с этим делать?
Кажется, что решение очевидно: если нельзя понять, что будет использовано, включим всё подряд. Но на практике это не работает.
Во-первых, размер. Включить все классы, метаданные и инфраструктуру JVM означает увеличить размер бинарника в несколько раз. Теряется один из ключевых плюсов 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