﻿# Closed\-world assumption в Java

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

![1357_cwa_in_java_ru/image1.png](https://import.viva64.com/docx/blog/1357_cwa_in_java_ru/image1.png)

## Проблема

Сейчас мы в PVS\-Studio разрабатываем новые [статические анализаторы](https://pvs-studio.ru/ru/blog/terms/0046/) для JavaScript/TypeScript и Go в дополнение к уже существующим\. Разработка первого выпала на Java команду\. Чтобы избежать необходимости дистрибьюции JRE и получить прирост производительности, мы решили собирать JavaScript/TypeScript анализатор в [native image](https://www.graalvm.org/latest/reference-manual/native-image/), т\. е\. сделать из Java\-приложения нативную программу\.

"Скачать производительность без смс и регистрации бесплатно" не получилось\. [GraalVM](https://www.graalvm.org/) сразу поставил нам свои условия: нужно было явно указать в конфигурации, какие классы участвуют в рефлексии, какие прокси будут созданы, какие ресурсы попадут в бинарник и какие функции мы зовём через [Foreign Function & Memory API](https://docs.oracle.com/en/java/javase/25/core/foreign-function-and-memory-api.html)\.

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

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

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

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

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

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

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


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

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


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

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

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

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

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


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

Благодаря этому возможны "вишенки" Java, например рефлексия, динамические прокси, `ServiceLoader`, DI\-фреймворки вроде [Spring](https://spring.io/), [Micronaut](https://micronaut.io/) или [Quarkus](https://quarkus.io/)\. 

В основе этой динамичности лежит архитектура, где виртуальная машина выступает центром "вселенной" 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](https://docs.oracle.com/en/java/javase/25/core/foreign-function-and-memory-api.html), который позволяет вызывать нативные методы непосредственно из Java\.

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

```cpp
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](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/invoke/MethodHandle.html) для функции [printf](https://en.cppreference.com/w/cpp/io/c/fprintf) \(2\); разрешаем имя функции во время выполнения \(3\); открываем [Arena](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/foreign/Arena.html), область управления off\-heap памятью \(4\); выделяем в ней блок, записываем туда строку в C\-формате \(5\) и вызываем функцию через [MethodHandle](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/invoke/MethodHandle.html) \(6\)\.


> MethodHandle — ссылка на исполняемый код\\\. Подробнее об этой технологии можно узнать \[здесь\]\(https://pvs\-studio\.ru/ru/blog/posts/java/1266/\)\\\.

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

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

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

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

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

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

## Итоги

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

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

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

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