Анализ причин выхода из процесса JVM

JVM Linux

Недавно мы тестировали миграцию платформы APM на ES APM, и некоторые студенты сообщили об интересном явлении: проект пакета jar, развернутый в докере, в новой версии APM завершается после запуска процесса и бесконечно перезапускается. в к8с.

В этой статье написано об идее расследования, которое в основном включает следующее содержание.

  • Когда завершается процесс JVM
  • поток демона, поток не демона
  • С точки зрения исходного кода процесс выхода из JVM

Нижний уровень APM заключается в использовании пакета jar javaagent для перезаписи байт-кода, так почему же между ними такая большая разница? Сначала я считал само собой разумеющимся, что новая версия кода ES APM была ядовитой, что делало невозможным обслуживание. Позже я запустил его один раз в локальной среде без докеров и обнаружил, что проблем нет, и временно исключил проблему новой версии APM. Посмотрим, как пишется код.

@SpringBootApplication(scanBasePackages = ["com.masaike.**"])
open class MyRpcServerApplication

fun main(args: Array<String>) {
    runApplication<MyRpcServerApplication>(*args)
    logger.info("#### ClassRpcServerApplication start success")
    System.`in`.read()
}

В предыдущей статье "Небольшая проблема с /dev/null, почти поедающим обувь вживую" мы анализировали точку stdin в контейнере./dev/null./dev/nullявляется специальным файлом устройства, и все полученные данные отбрасываются. кто-то положил/dev/nullМетафора «черной дыры» из/dev/nullЧтение данных немедленно вернет EOF,System.in.read()Вызов завершится напрямую. Ссылка на эту статью здесь:Tickets.WeChat.QQ.com/Yes/LY AJ WC B-Oh…

Таким образом, после выполнения основной функции основной поток завершается, а старый и новый APM совпадают. Следующий шаг — выяснить общую проблему: когда завершается процесс JVM.

Когда завершается процесс JVM

По этому поводу написано в разделе "12.8. Выход из программы" Спецификации языка Java, а ссылка здесь:docs.Oracle.com/JavaColor/spec…, я вставил содержимое ниже.

A program terminates all its activity and exits when one of two things happens:

  • All the threads that are not daemon threads terminate.
  • Some thread invokes the exit method of class Runtime or class System, and the exit operation is not forbidden by the security manager.

В переводе есть только две ситуации, которые вызывают выход JVM:

  • Все процессы, не являющиеся демонами, завершаются
  • Поток с именем System.exit() или Runtime.exit() для явного выхода из процесса.

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

Далее, давайте проверим эту идею.Метод заключается в использовании jstack.Чтобы предотвратить выход JVM, подключенной к новой версии APM, вручную добавьте длительный сон. Перепакуйте, скомпилируйте и запустите образ, используйте дамп jstack для извлечения стека потоков, вы можете прочитать его напрямую, или используйте для анализа инструмент XSheepdog для анализа потоков компании «PerfMa».

Видно, что в новой версии APM только один основной поток, заблокированный в спящем режиме, является потоком, не являющимся демоном.Если этот поток также завершается, все потоки, не являющиеся демоном, завершились. Главное здесь не выходило или это было вызвано добавлением сна позже.

Затем сравните старую версию APM и результаты анализа XSheepdog.

Видно, что в старой версии APM есть 5 потоков, не являющихся демонами, из которых 4 потока, не являющихся демонами, являются резидентными потоками внутри старой версии APM.

На данный момент причина более ясна, в среде докеровSystem.in.read()Вызов не блокируется, он немедленно завершается, и основной поток завершается. В старой версии весь процесс JVM не завершался из-за запущенного резидентного потока обработки APM, не являющегося демоном. В новой версии из-за отсутствия резидентных потоков, отличных от демона, после выхода из основного потока не существует потоков, отличных от демона, и завершается вся JVM.

Анализ исходного кода

В следующем анализе исходного кода в качестве примера используется следующий код Java:

public class MyMain {
    public static void main(String[] args) {
        System.out.println("in main");
    }
}

Далее приступим к отладке исходного кода.После запуска JVM войдет в метод JavaMain файла java.c.

int JNICALL JavaMain(void * _args) {
    // ...
    /* Initialize the virtual machine */
    InitializeJVM();

    // 获取 public static void main(String[] args) 方法
    mainID = (*env)->GetStaticMethodID(env, mainClass, "main",
                                       "([Ljava/lang/String;)V");
    // 调用 main 方法
    (*env)->CallStaticVoidMethod(env, mainClass, mainID, mainArgs);
    // main 方法结束以后接下来调用下面的代码
    LEAVE();
}

Внутри метода JavaMain нужно инициализировать JVM, а затем использовать JNI для вызова класса входаpublic static void main(String[] args)метод, если основной метод завершается, будет вызван следующий метод LEAVE.

#define LEAVE() \
    do { \
        if ((*vm)->DetachCurrentThread(vm) != JNI_OK) { \
            JLI_ReportErrorMessage(JVM_ERROR2); \
            ret = 1; \
        } \
        if (JNI_TRUE) { \
            (*vm)->DestroyJavaVM(vm); \
            return ret; \
        } \
    } while (JNI_FALSE)

Метод LEAVE вызывает DestroyJavaVM(vm);, чтобы вызвать выход JVM, что, конечно, условно. Исходный код destroy_vm показан ниже.

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

Вы также можете немного изменить код, создать новый резидентный поток t, не являющийся демоном, и опрашивать наличие файла /tmp/test.txt каждые 3 секунды. Основной поток завершается сразу после запуска JVM.

public class MyMain {
    public static void main(String[] args) {
        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                File file = new File("/tmp/test.txt");
                while(true) {
                    if (file.exists()) {
                        break;
                    }
                    System.out.println("not exists");
                    try {
                        TimeUnit.SECONDS.sleep(3);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }

            }
        });
        t.setDaemon(false);
        t.start();
        System.out.println("in main");
    }
}

В этом примере, когда основная функция завершает работу, она входит в метод destroy_vm В этом методе цикл while будет ждать, пока сам станет последним потоком, не являющимся демоном. Если количество потоков, не являющихся демонами, больше 1, он будет заблокирован и будет ждать, а JVM не завершит работу, как показано ниже.

Также стоит отметить, что концепция потока демона Java разработана сама по себе, и в собственных потоках Linux нет соответствующей функции.

резюме

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

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