Статьи

Миграция с Javaagent на JVMTI: наш опыт

Этот пост был написан Ago Allikmaa в блоге Plumbr.

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

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

Javaagent

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

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

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 () агента, подобного следующему:

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:

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, таких как GC или обработка ошибок во время выполнения. Что это значит, лучше всего объяснить на собственном опыте.

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

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

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

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

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

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

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

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