В этом посте мы рассмотрим, как пишутся и работают отладчики Java / Scala. Собственные отладчики, такие как WinDbg для Windows или gdb для Linux / Unix, получают питание от хуков, предоставляемых им непосредственно ОС для мониторинга и управления состоянием внешнего процесса. JVM, выступая в качестве уровня абстракции поверх ОС, предоставляет собственную независимую архитектуру для отладки байт-кода.
Эта структура и ее API полностью открыты, документированы и расширяемы, что означает, что вы можете довольно легко написать свой собственный отладчик. Текущий дизайн фреймворка состоит из двух основных частей — протокола JDWP и уровня API JVMTI. У каждого есть свой набор преимуществ и вариантов использования, для которых он работает лучше всего.
Протокол JDWP
Протокол Java Debugger Wire Protocol используется для передачи запросов и получения событий (таких как изменения состояний потоков или исключений) между отладчиком и процессом отладчика с использованием двоичных сообщений, обычно по сети. Концепция этой архитектуры заключается в том, чтобы создать как можно большее разделение между ними. Это предназначено для того, чтобы уменьшить эффект Гейзенберга (физика Вернера, а не вашего дружелюбного Уолта по приготовлению пищи Мет), заставляющий отладчик изменять выполнение целевого кода во время его работы.
Удаление как можно большего количества логики отладчика из целевого процесса также помогает убедиться, что изменения в состоянии отлаженной виртуальной машины (такие как GC «Остановить мир» или OutOfMemoryErrors) не влияют на сам отладчик. Чтобы упростить задачу, JDK поставляется с JDI (интерфейс отладчика Java), который обеспечивает полную реализацию протокола на стороне отладчика, с возможностью подключения, отключения, мониторинга и управления состоянием целевой виртуальной машины.
Этот протокол тот же, что используется отладчиком Eclipse, например. Если вы посмотрите на аргументы командной строки, передаваемые вашему Java-процессу при его отладке в IDE, вы заметите дополнительные аргументы (-agentlib: jdwp = transport = dt_socket,…), переданные ему Eclipse для включения отладки JVM, а также установление порт, на который будут отправляться запросы и события.
API JVMTI
Второй ключевой компонент в современной архитектуре отладчика JVM — это набор собственных API-интерфейсов, охватывающих широкий спектр областей, связанных с работой JVM, известный как инструментальный интерфейс JVM (т.е. JVMTI). В отличие от JDWP, JVMTI разработан как набор API-интерфейсов C / C ++ вместе с механизмом для JVM для динамической загрузки предварительно скомпилированных библиотек (таких как .dll или .so), которые используют команды, предоставляемые API.
Этот подход отличается от JDWP тем, что он фактически выполняет отладчик внутри целевого процесса. Это увеличивает вероятность влияния отладчика на код приложения как с точки зрения производительности, так и стабильности. Однако ключевым преимуществом является возможность напрямую взаимодействовать с JVM практически в реальном времени.
Поскольку JVMTI предоставляет мощный набор низкоуровневых API-интерфейсов, я подумал, что было бы интересно немного углубиться и объяснить, как это работает, и какие классные вещи вы можете с этим сделать. Заголовки API доступны через jvmti.h, который поставляется с JDK.
Написание вашей библиотеки отладчика
Написание собственного отладчика требует создания собственной библиотеки ОС на C ++. Ваша «основная» функция в этом случае будет выглядеть так:
1
|
JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *) |
Функция будет вызываться JVM, когда ваш агент отладчика загружается JVM. Переданный вам всегда важный указатель JavaVM предоставит вам все необходимое для общения с JVM. В нем представлен класс jvmtiEnv, доступный через метод JavaVM :: GetEnv, который позволяет взаимодействовать со слоем JVMTI посредством концепции возможностей и событий.
Возможности JVMTI
Один из ключевых аспектов написания отладчика — очень внимательно относиться к влиянию кода отладчика на целевой процесс. Это особенно важно в случае нативных библиотек отладчика, где ваш код работает в непосредственной близости от приложения. Чтобы помочь вам получить более детальный контроль над тем, как ваш отладчик влияет на выполнение кода, в спецификации JVMTI вводится понятие возможностей .
При написании отладчика вы можете заранее сообщить JVM, какие наборы API-команд или событий вы собираетесь использовать (например, установить точки останова, приостановить потоки и т. Д.). Это позволяет JVM подготовиться к этому заранее и дает вам больше контроля над временем выполнения вашего отладчика. Этот подход также позволяет JVM от различных поставщиков программно сообщать вам, какие команды API в настоящее время поддерживаются из всей спецификации JVMTI.
Не все возможности созданы равными . Некоторые возможности имеют относительно небольшую нагрузку на производительность. Другие интересные, такие как can_generate_exception_events для получения обратных вызовов при возникновении исключения в коде или can_generate_monitor_events для получения обратных вызовов при получении блокировок, имеют более высокую стоимость. Причина в том, что они не позволяют JVM оптимизировать код во время JIT-компиляции в полной мере и могут вынудить JVM перейти в интерпретированный режим во время выполнения.
Другие возможности, такие как can_generate_field_modification_events, используемые для получения уведомления всякий раз, когда задается поле целевого объекта (т. Е. Задаются часы), обходятся еще дороже, замедляя выполнение кода на значительный процент. Несмотря на то, что JVM поддерживает одновременную загрузку нескольких собственных библиотек, некоторые возможности HotSpot, такие как can_suspend, используемые для приостановки и возобновления потоков, могут запрашиваться только одной библиотекой за раз.
Одна из самых сложных задач, с которыми мы столкнулись при создании производственного отладчика Takipi, заключалась в предоставлении аналогичных возможностей без дополнительных затрат (подробнее об этом в следующем посте).
Установка обратных вызовов . После получения набора возможностей следующим шагом будет настройка обратных вызовов, которые будут вызываться JVM, чтобы вы знали, когда что-то действительно произойдет. Каждый из этих обратных вызовов предоставит достаточно глубокую информацию о произошедшем событии. Например, для обратного вызова исключения эта информация будет включать местоположение байт-кода, в котором было сгенерировано исключение, поток, объект исключения и, если и где оно будет перехвачено.
1
2
3
4
|
void JNICALL ExceptionCallback(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, jmethodID method, jlocation location, jobject exception, jmethodID catch_method, jlocation catch_location) |
Важно отметить, что накладные расходы иногда делятся на две части. Первая часть приходит просто путем ее включения, поскольку это заставит JIT-компилятор компилировать вещи по-другому, просто чтобы создать потенциал для выполнения вызовов в вашем коде. Вторая часть возникает, когда вы фактически устанавливаете функцию обратного вызова, так как она заставляет JVM выбирать менее оптимизированные пути выполнения во время выполнения — те, через которые она может сделать вызов в ваш код вместе с дополнительными накладными расходами на анализ и передачу. Вы значимые данные.
Точки останова и часы . Ваш отладчик может предоставить знакомые возможности для проверки определенного состояния во время выполнения, например, SetBreakpoint, чтобы сигнализировать JVM о приостановке выполнения с определенной инструкцией байтового кода, или SetFieldModificationWatch, чтобы приостановить выполнение при каждом изменении поля. На этом этапе вы можете использовать другие дополнительные функции, такие как GetStackTrace и GetThreadInfo, чтобы узнать больше о вашей текущей позиции в коде и сообщить об этом.
Большинство функций JVMTI, подобных показанной ниже, ссылаются на классы и методы с использованием абстрактных дескрипторов, известных как jmethodID и jclass (это должно показаться знакомым, если вы когда-либо писали код Native Interface для Java). Предоставляются дополнительные функции, такие как GetMethodName и GetClassSignature , чтобы помочь вам получить фактические имена символов из константного пула класса. Затем вы можете использовать их для записи данных в файл в удобочитаемой форме или для визуализации в пользовательском интерфейсе, подобном тому, который мы видим в наших средах разработки каждый день.
Прикрепление вашего отладчика
После того, как вы написали свою библиотеку отладчика, следующий шаг — подключить ее к JVM. Есть несколько способов сделать это —
1. Подключение JDWP . Если вы пишете отладчик на основе JDWP, вам нужно добавить в отладчик аргумент запуска в виде — agentlib: jdwp = transport = dt_socket, suspend = y, address = localhost: <port>, чтобы включить его по сети. отладки. Эти аргументы детализируют форму связи между отладчиком и целью (в данном случае сокетами) и указывают, следует ли запускать отладчик в режиме ожидания.
2. Присоединение библиотеки JVMTI . JVM загружает библиотеки JVMTI с помощью аргумента командной строки agentpath, передаваемого процессу отладки и указывающего на местоположение вашей библиотеки на диске.
Альтернативный способ — добавить аргументы командной строки вашего агента в глобальную переменную среды JAVA_TOOL_OPTIONS, которая выбирается каждой новой JVM и значение которой автоматически добавляется в список существующих аргументов.
3. Удаленное подключение . Еще один способ присоединить ваш отладчик — использовать API удаленного подключения . Этот простой и мощный API-интерфейс позволяет подключать агенты к запущенным процессам JVM без запуска их с аргументами командной строки. Недостатком здесь является то, что у вас не будет доступа к некоторым возможностям, которые вы обычно хотели бы, например, can_generate_exception_events , так как они могут быть запрошены только при запуске виртуальной машины — к сожалению, некоторые из ваших возможностей отладчика.
Вы можете скачать производственный отладчик Takipi, чтобы увидеть некоторые из этих методов в действии здесь .