Как стать автором
Обновить
41.74

Java становится надежнее: «Целостность по умолчанию» в действии

Уровень сложностиПростой
Время на прочтение9 мин
Количество просмотров661
Автор оригинала: Ana-Maria Mihalceanu

Команда Spring АйО перевела статью про ужесточение контроля за динамической загрузкой агентов, ограничения доступа к опасным методам работы с памятью и JNI в новых версиях JDK.


Java продолжает развиваться, делая упор на стабильность и устойчивость. Новые версии JDK отражают этот тренд: ограничивается динамическая загрузка агентов, прекращается поддержка опасных методов доступа к памяти, усиливается контроль над использованием JNI. В JDK 22 появилось Foreign Function and Memory (FFM) API, которое упрощает вызов внешнего кода и обеспечивает безопасный доступ к памяти без управления со стороны JVM. Эти изменения поддерживают концепцию "целостности по умолчанию" и формируют более предсказуемую и надёжную экосистему Java. В этой статье мы подробно разберём, как эти обновления укрепляют платформу и делают е безопаснее.

Целостность в платформе Java

В программном обеспечении термин “целостность” (integrity) означает гарантию того, что конструкции, из которых мы создаем программы, являются завершенными и надежными. Это означает, что спецификации Java Platform полностью покрывают предметную область, а ее реализации строго соответствуют этим спецификациям. Такая фундаментальная целостность позволяет вам создавать логику своего приложения с уверенностью в том, что вы можете положиться на конструкции платформы Java.

В программировании "целостность" (integrity) — это гарантия, что конструкции, используемые для создания программ, завершенные и надежные. В Java это означает, что спецификации платформы полностью охватывают предметную область, а их реализации строго соответствуют этим спецификациям. Такая заложенная в основу платформы целостность позволяет разработчикам уверенно создавать приложения, опираясь на проверенные механизмы платформы.

JEP "Целостность по умолчанию" (Integrity by Default) акцентирует внимание на защите кода и данных от нежелательного вмешательства. Хотя инкапсуляция — ключевой инструмент для обеспечения целостности, платформа Java всё ещё содержит небезопасные API, которые могут эту целостность подорвать. Это способно негативно повлиять на корректность, простоту в поддержке, безопасность, производительность и масштабируемость приложений или библиотек:

  • Instrumentation API позволяет агентам модифицировать байткод любого метода в любом классе.

  • Метод AccessibleObject::setAccessible(boolean) позволяет игнорировать границы инкапсуляции при рефлексии. Он предназначен для поддержки сериализации и десериализации объектов, но открывает доступ к вызову приватных методов, чтению и изменению приватных полей, а также записи в final поля. Это делает возможным вмешательство в структуру любого класса, нарушая принципы инкапсуляции.

  • Класс sun.misc.Unsafe включает методы, которые могут получать доступ к private методам и полям и записывать в final поля, игнорируя границы инкапсуляции.

  • Java Native Interface (JNI) позволяет нативному коду взаимодействовать с объектами Java. Однако он также предоставляет доступ к private методам и полям, а также возможность изменять final поля, обходя ограничения инкапсуляции.

Чтобы предотвратить некорректное использование небезопасных API, JEP предполагает ограничение прямого доступа к ним, чтобы библиотеки, фреймворки и инструменты не могли обращаться к ним по умолчанию. Кроме того, текущие JEP должны постепенно решить проблемы, связанные с использованием таких API, предлагая альтернативы для миграции и позволяя обходить вводимые по умолчанию ограничения, если это действительно необходимо.

Постепенное ограничение доступа к небезопасным API и альтернативы

Организуйтесь, чтобы запретить динамическую загрузку агентов

В качестве развития темы целостности JDK 21, JEP 451 сфокусировался на рисках, связанных с динамической загрузкой агентов в работающую JVM. Агенты, появившиеся в JDK 5, позволяют оснащать классы инструментами, такими как профайлеры, для мониторинга приложений. Например, для отладки удаленного приложения вы, возможно, используете опцию -agentlib:jdwp, позволяющую включить встроенный в JVM агент при запуске. Внутри Java использует Attach API, который с соответствующими привилегиями на уровне ОС позволяет инструментам подключаться к работающей JVM.

Некоторые библиотеки используют Attach API, чтобы незаметно подключаться к JVM, загружать агенты динамически и получить практически неограниченные возможности в плане изменения кода на лету. Однако такие действия несут риски для целостности приложения. Начиная с JDK 21, JVM предупреждает о динамической загрузке агентов, чтобы информировать о возможных угрозах и подготовить к будущим релизам, где такие действия могут быть запрещены по умолчанию. Это изменение не затрагивает большинство инструментов, которые не требуют динамической загрузки агентов.

💡Для соблюдения best practices библиотекам рекомендуется загружать агенты при запуске приложения с использованием опций -javaagent или -agentlib. Такой подход обеспечивает баланс между удобством обслуживания и сохранением целостности вашего приложения.

Принимайте меры против использования методов с небезопасным способом доступа к памяти

В течение долгого времени класс sun.misc.Unsafe предоставлял разработчикам доступ к операциям низкого уровня, в особенности к таким задачам, как:

  • Выполнение операций непосредственно с памятью, чтобы достичь лучшей производительности 

  • Управление off-heap памятью без ограничения на ByteBuffer

  • Выполнение атомарных операций типа compare-and-swap.

Однако, использование этих методов сопряжено с рисками:

  • Они могут вызывать непредсказуемое поведение, сбои приложения или снижение производительности, обходя оптимизации JVM.

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

  • Их небезопасная природа усложняет поддержку и снижает уровень безопасности.

Начиная с Java 23, JDK постепенно выводит из оборота методы доступа к памяти внутри sun.misc.Unsafe, по причине рисков и ограничений, связанных с небезопасными операциями. Вместо них предлагаются безопасные альтернативы: VarHandle API (введен в JDK 9) и Foreign Function and Memory API (введен в JDK 22).

Например, вы могли использовать Unsafe для атомарных обновлений при работе с on-heap памятью. Чтобы избежать рисков, рекомендуется заменить Unsafe на VarHandle, который позволяет достичь той же цели более безопасным способом:

// Migration example from Unsafe
private static final Unsafe UNSAFE = ...;
private static final long OFFSET;

static {
    try {
        OFFSET = UNSAFE.objectFieldOffset(Point.class.getDeclaredField("x"));
    } catch (Exception ex) {
        throw new AssertionError(ex);
    }
}

private int x;

public boolean update(int newValue) {
    return UNSAFE.compareAndSwapInt(this, OFFSET, x, newValue);
}

/// Use VarHandle to achieve the same
private static final VarHandle HANDLE = MethodHandles.lookup().findVarHandle(Point.class, "x", int.class);

public boolean update(int newValue) {
    return HANDLE.compareAndSet(this, x, newValue);
}

Аналогично, если вы использовали Unsafe для работы с off-heap памятью, рекомендуется перенести этот код на конструкции FFM API, такие как MemorySegment, и управлять жизненным циклом памяти с помощью Arena:

// Using Unsafe for off-heap memory
long address = UNSAFE.allocateMemory(1024);
UNSAFE.putInt(address, 42);
int value = UNSAFE.getInt(address);
UNSAFE.freeMemory(address);

// Using MemorySegment from FFM API to achieve the same
try (Arena arena = Arena.ofShared()) {
    long byteSize = ValueLayout.JAVA_INT.byteSize();
    MemorySegment segment = arena.allocate(byteSize);
    segment.set(ValueLayout.JAVA_INT, 0, 42);
    int value = segment.get(ValueLayout.JAVA_INT, 0);
}

Эти стандартные API предлагают вам безопасную, производительную альтернативу для большинства use cases, гарантируя совместимость с современными и будущими версиями Java.

💡 Вот несколько инструментов, которые помогут выявить зависимости от Unsafe:

  • Обращайте внимание на предупреждения компилятора javac.

  • Используйте JDK Flight Recorder (JFR) — событие jdk.DeprecatedInvocation фиксирует вызовы терминально устаревших методов.

  • Начиная с JDK 23, запускайте приложение с новой опцией командной строки --sun-misc-unsafe-memory-access={allow|warn|debug|deny}, чтобы отслеживать влияние депрекации и удаления этих методов на ваши зависимости.

В будущих релизах JDK методы доступа к памяти в sun.misc.Unsafe будут постепенно выводиться из использования. План выглядит так:

  1. JDK 23: методы помечены как deprecated, с предупреждениями во время компиляции и выполнения (по умолчанию --sun-misc-unsafe-memory-access=allow).

  2. JDK 24: согласно JEP 498, предупреждения в рантайме включены по умолчанию (по умолчанию --sun-misc-unsafe-memory-access=warn).

  3. JDK 26: неподдерживаемые операции будут выбрасывать исключения (по умолчанию --sun-misc-unsafe-memory-access=deny).

  4. После JDK 26: методы будут полностью удалены, а опция --sun-misc-unsafe-memory-access игнорироваться.

При миграции с методов доступа к памяти из sun.misc.Unsafe избегайте использования неподдерживаемых внутренних возможностей JDK. Это снизит риск сбоев приложения при изменениях в платформе.

Готовьтесь к ограничениям на использование JNI

С момента появления в JDK 1.1 Java Native Interface (JNI) упрощает взаимодействие между Java и нативным кодом. Однако, несмотря на его несомненную полезность, использование JNI может подорвать целостность приложения:

  • Вызов нативного кода может привести к непредсказуемому поведению, включая сбои JVM. Обмен данными между нативным кодом и Java часто осуществляется через прямые байтовые буферы — области памяти, неподконтрольные сборщику мусора JVM. Использование буфера, связанного с невалидной областью памяти, неизбежно приведёт к ошибкам и нестабильности.

  • Нативный код может получать доступ к полям через JNI, а также вызывать методы, обходя проверки доступа, выполняемые JVM.

  • Нативный код может некорректно использовать функции JNI, такие как GetPrimitiveArrayCritical, что приводит к нежелательному поведению сборщика мусора, проявляющемуся на всем протяжении работы программы.

JNI нельзя отключить, поэтому невозможно полностью исключить вызовы нативного кода с использованием опасных функций JNI. JVM загружает нативные библиотеки через методы load и loadLibrary класса java.lang.Runtime. Аналогичные методы в классе java.lang.System просто перенаправляют вызовы к Runtime. Загрузка нативной библиотеки сопряжена с риском, так как библиотека может выполнять нативный код через функции инициализации или метод JNI_OnLoad, вызываемый Java runtime. Из-за этих рисков в JDK 24 введены ограничения на использование методов load и loadLibrary.

В отличие от JNI, большинство функций Foreign Function and Memory (FFM) API безопасны по умолчанию. Многие сценарии, ранее реализованные через JNI и нативный код, теперь можно перенести на FFM API, который не нарушает целостность платформы Java.

При загрузке и связывании нативных библиотек через FFM API код Java может указать параметры, несовместимые с типами внешней функции. Вызов такого метода downcall может привести к сбоям виртуальной машины или непредсказуемому поведению. Однако небезопасные методы FFM API менее рискованны, чем функции JNI: например, они не позволяют изменять значения final полей в Java-объектах. По умолчанию использование небезопасных методов в FFM API разрешено, но при этом в рантайме отображается предупреждение:

WARNING: A restricted method in java.lang.foreign.Linker has been called
WARNING: java.lang.foreign.Linker::downcallHandle has been called by ReadFileWithFopen in an unnamed module
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled

Указанный фрагмент предупреждает, что код Java использует небезопасный и ограниченный метод FFM API. Учитывая риск непредсказуемого поведения и сбоев JVM, разработчикам следует осторожно разрешать нативный доступ для кода Java на этапе запуска. Такое разрешение подтверждает необходимость загрузки и связывания нативных библиотек, снимая наложенные ограничения.

💡Если библиотека использует JNI или FFM API, её документация должна информировать пользователей (например, разработчиков приложений) о необходимости явного разрешения нативного доступа. При разработке или деплойменте ответственность за выдачу такого разрешения ложится на вас. Например, если ваше приложение использует библиотеку, требующую опции --enable-native-access=ALL-UNNAMED в рантайме, помните, что эта опция снимает ограничения на нативный доступ через JNI и FFM API для всех классов внутри class path.

Опция --enable-native-access=ALL-UNNAMED имеет широкий охват, поэтому для минимизации рисков и улучшения целостности рекомендуется переместить JAR-файлы, использующие JNI или FFM API, в модульный путь. Это позволит включать нативный доступ только для этих JAR-файлов, а не для всего class path. Если вы переместите JAR-файл из class path в модульный путь без модуляризации, Java runtime будет рассматривать его как автоматический модуль и назначит ему имя на основании имени файла.

Если нативный доступ для модуля не объявлен, любые попытки выполнить ограниченные операции в этом модуле считаются нелегальными. Реакция Java runtime на такие операции регулируется опцией --illegal-native-access и может быть настроена следующим образом:

  • --illegal-native-access=allow — разрешает выполнение операций без предупреждений или исключений.

  • --illegal-native-access=warn — позволяет выполнение операций, но выдает предупреждение при первом фиксировании нелегального доступа в модуле. Только одно предупреждение выдается на модуль. В JDK 24 этот режим установлен по умолчанию.

  • --illegal-native-access=deny — выбрасывает исключение IllegalCallerException для каждой операции с нелегальным нативным доступом.

До JDK 24 попытки вызвать ограниченные методы FFM из модулей без разрешённого нативного доступа (через опцию --enable-native-access) приводили к выбросу IllegalCallerException. В JDK 24 поведение было смягчено для большей согласованности с традициями JNI: теперь нелегальный нативный доступ в FFM API вызывает предупреждения вместо исключений. Чтобы вернуть прежнее поведение, можно использовать следующую комбинацию опций:

java --enable-native-access=Module1,... --illegal-native-access=deny ...

Чтобы подготовить ваш код к предстоящим изменениям, рекомендуется запускать существующий код с опцией --enable-native-access=deny. Это поможет выявить участки кода, которые требуют нативного доступа.

Заключение

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

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм - Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

Теги:
Хабы:
+6
Комментарии0

Публикации

Информация

Сайт
t.me
Дата регистрации
Численность
11–30 человек