Pull to refresh
117.37
Axiom JDK
на страже безопасности Java

Мониторинг «здесь и сейчас»: используем потоки событий JDK Flight Recorder

Level of difficultyMedium
Reading time10 min
Views1.8K


JDK Flight Recorder (JFR) — это диагностическая подсистема, встроенная в JVM. В основе JFR лежит очень простая идея, но вокруг нее выросла разнообразная экосистема решений, позволяющих решать широкий спектр задач.
В данной статье я хочу сфокусироваться на одном аспекте технологии JFR — потоковой обработке событий. Потоковая обработка появилась в JDK 14 в виде Flight Recorder Event Streaming API и позволяет прикладному коду обрабатывать события JFR с минимальной задержкой. Далее в статье я буду писать Streaming API для краткости.


Что такое событие JFR?


Чтобы объяснить возможности Streaming API, нужно немного рассказать о работе JFR.


Событие — это основное понятие в мире JFR. Есть источники событий (зонды) — часть из них встроена непосредственно в JVM и их более сотни, источником событий может выступать и прикладной код публикующий события через соответствующий API.
Событие имеет тип, который определяет его схему, и может включать разнообразные данные.


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


JFR отвечает за накопление и буферизацию событий. Те, кто пробовал реализовывать общие очереди в многопоточных приложениях, знают, что это таит в себе ряд подводных камней. JFR использует продвинутые трюки, в том числе локальные буфера потоков, чтобы минимизировать накладные расходы, связанные с накоплением событий. Также JFR обеспечивает политики ротации и вытеснения старых событий на диск и последующее их удаление.
Доступ к накопленным событиям возможен различными способами (API, jcmd, JMX). JFR имеет открытый бинарный формат записи событий, который поддерживается различными инструментами.


Зачем нужен Streaming API?


Изначально модель работы с JFR подразумевала ретроспективный анализ выгруженного массива событий.
Типичная сессия профилирования могла выглядеть так:


  • Старт сессий JFR;
  • Ожидание накопления событий;
  • Выгрузка массива событий JFR в файл;
  • Остановка сессии (события перестают публиковаться);
  • Работа с данными файла в Mission Control или другом инструменте.

При использовании непрерывного профилирования сессия стартует при запуске JVM и не останавливается. Но для анализа всё равно надо выгрузить данные в файл.


С развитием технологий мониторинга, однако, растёт потребность в получении диагностических событий в реальном времени. JVM предоставляет возможность мониторинга многих параметров системы через интерфейс JMX, но JFR события дают большую глубину и детализацию.


Появления Streaming API открыло возможности использования JFR для мониторинга в реальном времени.


Возможности Streaming API


Streaming API даёт возможность обрабатывать события JFR с минимальной задержкой без необходимости лишних манипуляций с файлами.
Есть два варианта подписки на события — пассивный и активный.
В пассивном варианте подписка делается без запуска сессии JFR, сбор событий в этом случае должен быть запущен другими методами.


Пример кода ниже позволяет начать слушать события типа "jdk.JavaMonitorEnter" (событие захвата монитора), публикуемые в локальном процессе, и печатать их в консоль.


try (EventStream stream = EventStream.openRepository()) {
   stream.onEvent("jdk.JavaMonitorEnter", System.out::println);
   stream.start();
}

Такую же подписку можно создать и передав в качестве параметра путь к дисковому буферу, используемому JFR.


Path path = Path.of("/tmp/myjfr/");
try (EventStream stream = EventStream.openRepository(path)) {
   stream.onEvent("jdk.JavaMonitorEnter", System.out::println);
   stream.start();
}

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


Активный режим позволяет управлять поведением зондов в рамках подписки. В активном режиме нет необходимости стартовать сессию JFR тем или иным способом, она привязана к подписке.


try (RecordingStream stream = new RecordingStream()) {
   stream.enable("jdk.JavaMonitorEnter").withStackTrace();
   stream.onEvent("jdk.JavaMonitorEnter", System.out::println);
   stream.start();
}

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


Возможно также использование RecordingStream и при удалённом подключении к JVM, но об этом ниже.


Конвертируем JFR события в метрики micrometer, используя Streaming API


Возможно у читателя возник вопрос — а зачем всё это нужно в моём Spring приложении? Попробую ответить на этот вопрос простым примером.


В JDK 21 появились виртуальные потоки (точнее JDK 21 — первая LTS версия, в которой они доступны). Один из нюансов виртуальных потоков (решенный в JDK 24) — это блокировка потока-носителя, если виртуальный поток владеет Java монитором (находится в synchronized блоке). Эта особенность может приводить к проблемам с исчерпанием пула потоков-носителей.
В JFR доступно событие, связанное с подобной блокировкой потока-носителя. А наше приложение уже имеет интеграцию с системой мониторинга, реализованную через инфраструктуру метрик в Spring.


Наша задача подписаться на JFR события и публиковать их в реестр метрик Spring.


Контролер отвечает за запуск подписки


@Component
class JfrEventLifecycle implements SmartLifecycle {
   private final AtomicBoolean running = new AtomicBoolean(false);
   private final JfrVirtualThreadPinnedEventHandler virtualThreadPinnedEventHandler;
   private RecordingStream recordingStream;
   JfrEventLifecycle(JfrVirtualThreadPinnedEventHandler virtualThreadPinnedEventHandler) {
       this.virtualThreadPinnedEventHandler = virtualThreadPinnedEventHandler;
   }
   @Override
   public void start() {
       if (!isRunning()) {
           recordingStream = new RecordingStream();
           recordingStream.enable("jdk.VirtualThreadPinned").withStackTrace();
           recordingStream.onEvent("jdk.VirtualThreadPinned", virtualThreadPinnedEventHandler::handle);
           // prevents memory leaks in long-running apps
           recordingStream.setMaxAge(Duration.ofSeconds(10));
           recordingStream.startAsync();
           running.set(true);
       }
   }
   @Override
   public void stop() {
       if (isRunning()) {
           recordingStream.close();
           running.set(false);
       }
   }
   @Override
   public boolean isRunning() {
       return running.get();
   }
}

Обработчик пропускает через себя события и обновляет метрику


@Component
class JfrVirtualThreadPinnedEventHandler {
   private static final int STACK_TRACE_MAX_DEPTH = 25;
   private final Logger log = LoggerFactory.getLogger(JfrVirtualThreadPinnedEventHandler.class);
   private final MeterRegistry meterRegistry;
   JfrVirtualThreadPinnedEventHandler(MeterRegistry meterRegistry) {
       this.meterRegistry = meterRegistry;
   }
   void handle(RecordedEvent event) {
       // marked as nullable in Javadoc
       var thread = event.getThread() != null ? event.getThread().getJavaName() : "<unknown>";
       var duration = event.getDuration();
       var startTime = LocalDateTime.ofInstant(event.getStartTime(), ZoneId.systemDefault());
       var stackTrace = formatStackTrace(event.getStackTrace(), STACK_TRACE_MAX_DEPTH);
       log.warn(
               "Thread '{}' pinned for: {}ms at {}, stacktrace: \n{}",
               thread,
               duration.toMillis(),
               startTime,
               stackTrace
       );
       var timer = meterRegistry.timer("jfr.thread.pinning");
       timer.record(duration);
   }
    private String formatStackTrace(RecordedStackTrace stackTrace, int maxDepth) {
       if (stackTrace == null) {
           return "\t<not available>";
       }
       String formatted = "\t" + stackTrace.getFrames().stream()
               .limit(maxDepth)
               .map(JfrVirtualThreadPinnedEventHandler::formatStackTraceFrame)
               .collect(Collectors.joining("\n\t"));
       if (maxDepth < stackTrace.getFrames().size()) {
           return formatted + "\n\t(...)"; // truncated
       }
       return formatted;
   }
   private static String formatStackTraceFrame(RecordedFrame frame) {
       return frame.getMethod().getType().getName() + "#" + frame.getMethod().getName() + ": " + frame.getLineNumber();
   }
}

Полный код проекта доступен по ссылке — https://github.com/mikemybytes/jfr-thread-pinning-spring-boot


Таким образом, нам удалось "подцепить" метрику и JFR и опубликовать её через реестр метрик Spring. Если у нас уже настроен стэк мониторинга, например, Prometheus + Grafana, то добавить чарт времени блокировки виртуальных потоков не составит труда.


Я думаю, что подобный сценарий будет наиболее популярным способом использования Streaming API в прикладном коде.
Разумеется, в идеальном мире нам было бы достаточно подключить какой-нибудь "jfr-starter" в проект и получить все интересные метрики автоматически. Но увы, такой модуль ещё никем не написан. Streaming API позволяет вам пойти и решить подобную проблему здесь и сейчас. И кто знает, может, читатель этой статьи и окажется автором того самого модуля "jfr-stater".


Как ещё можно использовать Streaming API?


Считать метрики на основе JFR событий — наиболее очевидное использование Streaming API. Но мне захотелось упомянуть ещё одно, не совсем очевидное, применение для данной технологии — автоматизация тестов на нефункциональные требования.


jfrunit — библиотека, позволяющая делать проверки с использованием событий JFR в JUnit тестах.


Кто-то может справедливо заметить, что JUnit не совсем предназначен для тестов на нефункциональные требования, и это правда …
Но иногда возникают ситуации, когда такие тесты нужны, и если можно использовать JUnit, то почему бы и нет.
Пример такой ситуации — тест на корректное освобождение ресурсов при отмене выполнения сложной асинхронной задачи. Ниже приведён пример теста на jfrunit.


@JfrEventTest
public class JfrUnitTest {

    public JfrEvents jfrEvents = new JfrEvents();

private JfrEventType eVirtualThreadStart = new JfrEventType("jdk.VirtualThreadStart") {};
private JfrEventType eVirtualThreadEnd = new JfrEventType("jdk.VirtualThreadEnd") {};

@EnableEvent("jdk.VirtualThreadStart")
@EnableEvent("jdk.VirtualThreadEnd")
@Test
public void test_no_thread_leak() throws InterruptedException {
    // задача создаёт несколько виртуальных потоков для работы
    CompletableFuture<Object> heavyTask = startHeavyTask();
    Thread.sleep(10);

    // отмена задачи должна завершать выполнение всех подзадач
    heavyTask.cancel(true);

    Assertions.assertThatThrownBy(() -> heavyTask.join())
    .isExactlyInstanceOf(CancellationException.class);

    jfrEvents.awaitEvents();

    var started = jfrEvents.filter(eVirtualThreadStart).count();
    var ended = jfrEvents.filter(eVirtualThreadEnd).count();

    // проверяем, что все созданные виртуальные потоки завершены
    Assertions.assertThat(ended)
        .as("VirtualThreadEnd events %d should be equal to VirtualThreadStart events %d", ended, started)
        .isEqualTo(started);
}

 …

Авторы проекта jfrunit в качестве примера проводят тест, в котором проверяется число SQL запросов при выполнении кода.


@SpringBootTest
@JfrEventTest
public class SpringJooqGradleApplicationTests {
   @Autowired
   public TestUserService testUserService;
   public JfrEvents jfrEvents = new JfrEvents();
   @Test
   public void createUser() {
       boolean success = testUserService.createUser(String.valueOf(ThreadLocalRandom.current().nextLong()),
           ThreadLocalRandom.current().nextInt());
       Assertions.assertThat(success).isTrue();
       jfrEvents.awaitEvents();
       Assertions.assertThat(jfrEvents.events().filter(this::isQueryEvent).count()).isEqualTo(1);
   }
}

Полный код проекта доступен по ссылке — https://github.com/moditect/jfrunit-examples/tree/main/spring-jooq-gradle


Это пример интересен тем, что использует другой проект JMC Agent для создания, c использованием инструментации, событий JFR связанных с работой JOOQ библиотеки.


Удалённый доступ к Streaming API


Доступ к Streaming API возможен не только в рамках одного JVM процесса.
Традиционно для удалённого доступа к диагностическим инструментам JVM используется протокол JMX. Он выключен по умолчанию и должен быть явным образом настроен для использования.
Пример кода ниже позволяет создать объект RecordingStream для подписки на события удалённой JVM доступной по JMX.


String host = "com.example";
int port = 7091;
String url = "service:jmx:rmi:///jndi/rmi://" + host + ":" + port + "/jmxrmi";
JMXServiceURL u = new JMXServiceURL(url);
JMXConnector c = JMXConnectorFactory.connect(u);
MBeanServerConnection connection = c.getMBeanServerConnection();
try (RemoteRecordingStream stream = new RemoteRecordingStream(connection)) {
   stream.enabled("jdk.JavaMonitorEnter").withStackTrace();
   stream.onEvent("jdk.JavaMonitorEnter", System.out::println),
   stream.start();
}

Под капотом RemoteRecordingStream использует poll стратегию, и задержка получения событий может составлять несколько секунд.


Другой способ удалённой подписки на события JFR использует совместный доступ к файловой системе.


Давайте рассмотрим данный сценарий на примере запуска агента мониторинга в качестве контейнера в Kubernetes в соответствии с паттерном "side car".


У нас есть POD, который содержит контейнер приложения. Мы хотим запустить агент мониторинга как дополнительный контейнер (это, например, важно, если мы не хотим вносить изменения в образ приложения). Для обмена данных, обоим контейнерам потребуется общий доступ к файловой системе, который можно обеспечить, создав дисковый том (volume) типа "dir" и примонтировав его к обоим контейнерам.



В приложении для запуска приложения используем дополнительные параметры командной строки


java \ 
-XX:StartFlightRecording \ -XX:FlightRecorderOptions:repository=/var/jfr/repo \
…

Параметры выше запускают JFR с настройками по умолчанию, дополнительно прописываем путь на диске для “репозитория” — директории, которую JFR будет использовать для буферизации событий.
В нашем случае путь /var/jfr/repo должен вести на дисковый том доступный обоим контейнерам, как было описано выше.
Теперь на агенте мы можем использовать следующий код для подписки на события.


Path path = Path.of("/var/jfr/repo/");
try (EventStream stream = EventStream.openRepository(path)) {
   stream.onEvent(System.out::println);
   stream.start();
}

Код выше — просто демонстрация подписки. В реальности код агента должен каким-то образом обрабатывать события и публиковать метрики в вашу инфраструктуру мониторинга.
Если сравнивать с JMX, подписка через файловую систему проще в настройке и менее уязвима с точки зрения безопасности. Влияние на процесс приложения также меньше в этом случае.


Заключение


Появление Streaming API для подписки на события JFR в JDK 14 существенно расширило возможности его использования для мониторинга в реальном времени.
Несмотря на то, что аудитория Streaming API — это прежде всего авторы мониторинговых агентов и инструментов диагностики, он без труда может быть использован в приложении. Иногда нужно решить частную проблему мониторинга “здесь и сейчас”, не дожидаясь, пока нужные вам метрики появятся в продуктах/фреймворках, которые вы используете. В этом случае воспользоваться Streaming API и реализовать метрику в своём коде — вполне оправданно.
Также jfrunit демонстрирует нам креативное использование для Streaming API, несвязанное с мониторингом.


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

Tags:
Hubs:
+17
Comments1

Articles

Information

Website
axiomjdk.ru
Registered
Employees
51–100 employees