Статьи

Миграция из javaagent в JVMTI: наш опыт

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

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

Javaagent

Первый вариант — использовать интерфейс java.lang.instrument . Этот подход загружает ваш код мониторинга в саму JVM с помощью параметра запуска -javaagent . Будучи опцией полностью Java, javaagents имеют тенденцию быть первым путем, если ваш опыт в разработке Java. Лучший способ проиллюстрировать, как вы можете извлечь выгоду из этого подхода, — на примере.

Давайте создадим действительно простой агент, который будет отвечать за мониторинг всех вызовов методов в вашем коде. И когда агент сталкивается с вызовом метода, он записывает вызов в стандартный поток вывода:

01
02
03
04
05
06
07
08
09
10
11
import org.objectweb.asm.*;
 
public class MethodVisitorNotifyOnMethodEntry extends MethodVisitor {
   public MethodVisitorNotifyOnMethodEntry(MethodVisitor mv) {
       super(Opcodes.ASM4, mv);
       mv.visitMethodInsn(Opcodes.INVOKESTATIC, Type.getInternalName(MethodVisitorNotifyOnMethodEntry.class), "callback", "()V");
   }
 
   public static void callback() {
        System.out.println("Method called!");    }
}

Вы можете использовать приведенный выше пример, упаковать его как javaagent (по сути, небольшой JAR-файл со специальным MANIFEST.MF) и запустить его с помощью метода premain () агента, подобного следующему:

1
java -javaagent:path-to/your-agent.jar com.yourcompany.YourClass

При запуске вы увидите группу «Метод называется!» сообщения в ваших лог-файлах. И в нашем случае больше ничего. Но концепция мощная, особенно в сочетании с инструментальными средствами для байт-кода, такими как ASM или cgLib, как в нашем примере выше.

Для простоты понимания примера мы пропустили некоторые детали. Но это относительно просто — при использовании пакета java.lang.instrument вы начинаете с написания собственного класса агента, реализующего открытый статический void premain (String agentArgs, Instrumentation inst) . Затем вам нужно зарегистрировать ваш ClassTransformer в inst.addTransformer . Поскольку вы, скорее всего, хотите избежать прямого манипулирования байт-кодом класса, вы должны использовать некоторую библиотеку манипулирования байт-кодом, такую ​​как ASM в нашем примере. При этом вам просто нужно реализовать еще пару интерфейсов — ClassVisitor (пропущен для краткости) и MethodVisitor.

JVMTI

Второй путь в конечном итоге приведет вас к JVMTI. JVM Tool Interface ( JVM TI ) — это стандартный нативный API, который позволяет нативным библиотекам захватывать события и управлять виртуальной машиной Java. Доступ к JVMTI обычно упакован в специальной библиотеке, называемой агентом.

Приведенный ниже пример демонстрирует ту же самую регистрацию обратного вызова, которую уже видели в разделе javaagent, но на этот раз она реализована как вызов JVMTI:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void JNICALL notifyOnMethodEntry(jvmtiEnv *jvmti_env, JNIEnv* jni_env, jthread thread, jmethodID method) {
    fputs("method was called!\n", stdout);
}
 
int prepareNotifyOnMethodEntry(jvmtiEnv *jvmti) {
    jvmtiError error;
    jvmtiCapabilities requestedCapabilities, potentialCapabilities;
    memset(&requestedCapabilities, 0, sizeof(requestedCapabilities));
 
    if((error = (*jvmti)->GetPotentialCapabilities(jvmti, &potentialCapabilities)) != JVMTI_ERROR_NONE) return 0;
 
    if(potentialCapabilities.can_generate_method_entry_events) {
       requestedCapabilities.can_generate_method_entry_events = 1;
    }
    else {
       //not possible on this JVM
       return 0;
    }
 
    if((error = (*jvmti)->AddCapabilities(jvmti, &requestedCapabilities)) != JVMTI_ERROR_NONE) return 0;
 
    jvmtiEventCallbacks callbacks;
    memset(&callbacks, 0, sizeof(callbacks));
    callbacks.MethodEntry = notifyOnMethodEntry;
 
    if((error = (*jvmti)->SetEventCallbacks(jvmti, &callbacks, sizeof(callbacks))) != JVMTI_ERROR_NONE) return 0;
    if((error = (*jvmti)->SetEventNotificationMode(jvmti, JVMTI_ENABLE,    JVMTI_EVENT_METHOD_ENTRY, (jthread)NULL)) != JVMTI_ERROR_NONE) return 0;
 
    return 1;
}

Есть несколько различий между подходами. Например, вы можете получить больше информации через JVMTI, чем агент. Но самое важное различие между ними связано с механикой нагружения. Хотя агенты инструментовки загружаются в кучу, они управляются одной и той же JVM. Принимая во внимание, что агенты JVMTI не регулируются правилами JVM и, следовательно, не подвержены влиянию внутренних компонентов JVM, таких как сборщик мусора или обработка ошибок времени выполнения. Что это значит, лучше всего объяснить на собственном опыте.

Миграция из -javaagent в JVMTI

Когда три года назад мы начали строить наш детектор утечек памяти, мы не обращали особого внимания на плюсы и минусы этих подходов. Не долго думая, мы реализовали решение как -javaagent .

На протяжении многих лет мы начали понимать последствия. Некоторые из них были не слишком приятными, поэтому в нашем последнем выпуске мы перенесли значительную часть нашей механики обнаружения утечек памяти в нативный код. Что заставило нас сделать такой вывод?

Прежде всего — когда вы находитесь в куче, вам нужно расположиться рядом с собственными структурами памяти приложения. Который, как узнал из мучительного опыта, может привести к проблемам в себе. Когда ваше приложение уже полностью заполнило кучу, последней вещью, в которой вы нуждаетесь, является детектор утечки памяти, который, похоже, только ускоряет появление OutOfMemoryError .

Но добавленное пространство кучи было меньшим из зла, преследующего нас. Настоящая проблема была связана с тем, что наши структуры данных были очищены с использованием того же сборщика мусора, который использовалось самим отслеживаемым приложением. Это приводило к более длительным и более частым паузам GC.

Хотя большинство приложений не возражали против нескольких дополнительных процентных пунктов, которые мы добавили к потреблению кучи, мы узнали, что от непредвиденного воздействия на паузы Full GC нам нужно было избавиться.
Татуировка уроборос (дракон ест свой хвост)

Хуже всего то, как работает Plumbr : он контролирует все создания и коллекции объектов. Когда вы контролируете что-то, вы должны следить. Отслеживание имеет тенденцию создавать объекты. Созданные объекты будут иметь право на GC. И теперь, когда вы наблюдаете за GC, вы только что создали замкнутый круг — чем больше мусора собирается, тем больше мониторов вы создаете, вызывая еще более частые запуски GC и т. Д.

При отслеживании объектов JVMTI уведомляет нас о смерти объектов. Однако JVMTI не позволяет использовать JNI во время этих обратных вызовов. Поэтому, если мы храним статистику отслеживаемых объектов в Java, невозможно сразу обновить статистику, когда нас уведомляют об изменениях. Вместо этого изменения должны быть кэшированы и применены, когда мы знаем, что JVM находится в правильном состоянии. Это создало ненужные сложности и задержки с обновлением фактической статистики.