Почему Java-разработчикам может потребоваться отладка кода C / C ++?

Я работал с несколькими java-проектами, в которых использовались собственные библиотеки, созданные другой командой из той же организации. Обычно мы вызывали код C ++ из java.

Проблема с кодом C ++, вызываемым из Java, заключается в том, что он обычно не отображается из java. Мы видим только интерфейс верхнего уровня с JNI / JNA, но не знаем, что происходит под капотом.

В результате мы не можем получить много информации от java-отладчика и профилировщика, которые мы используем ежедневно.

В этом посте я опишу отладчик GDB, который может работать с собственным кодом. В качестве примера мы создадим библиотеку C ++ для Linux (.so-файл), вызовем из Java и отладим ее.

Поскольку низкоуровневая часть JDK также написана на C ++, мы также рассмотрим, как отлаживать собственный код JDK.

GDB

GDB или GNU Debugger - это отладчик командной строки, который поставляется с большинством дистрибутивов Linux и поддерживает множество процессоров. GDB поддерживает как удаленный, так и локальный режим.

Важно отметить, что на данный момент GDB не поддерживает отладку кода Java ( на https://www.gnu.org/software/gdb/ вы не найдете Java в списке поддерживаемых языков ). Если вы хотите отлаживать Java-код из командной строки, вы можете попробовать JDB. Он похож на GDB, но имеет меньшую функциональность.

Сказав, что GDB не позволяет вам отлаживать java-код, он может отлично отлаживать нативную часть (написанную не на java) приложения Java. Если для вас важно отлаживать и Java, и собственный код «в одной среде IDE», вы можете попробовать использовать Netbeans или Eclipse. Netbeans использует отладчик по умолчанию для кода Java и GDB для собственного кода. Однако для пользователей IDE переключение между Java и собственным кодом не будет видно.

Конечно, отладка из командной строки может сначала показаться странной для людей, знакомых с IDE. Но у него есть некоторые преимущества. Одна из них - возможность работать на удаленном хосте. Хотя удаленная отладка встроена в java, отладка приложения, работающего в другой части земного шара, с использованием удаленных отладчиков IDE может быть очень медленной. С другой стороны, GDB работает на целевом хосте. Это особенно полезно, когда вам нужно оценить некоторый код во время отладки.

Подготовить собственный код

Весь код доступен в моем репозитории на github.

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

Часто самый простой способ включить отладочную информацию - это добавить ее в полученную библиотеку. Давайте напишем простое приложение на Java и библиотеку C ++.

Определяем интерфейс нативных методов и загружаем библиотеку. Код не запускается, потому что библиотеки сейчас не существует. Скоро построим.

Имена методов говорят сами за себя: nativePrint - выводит константную строку в стандартный вывод, nativeSleep - засыпает на мс миллисекунды, nativeAllocate - выделяет память для массива размером n, nativeCrash - вызывает сбой приложения (мы смоделируем сбой в конце статьи, чтобы проверить, какая информация доступна для исследования) .

Давайте сгенерируем файл заголовка (интерфейс) C ++ для методов, определенных в JNIDemoJava.java.

# generate c++ header file and put to cpp folder
/usr/lib/jvm/jdk-11.0.1/bin/javac java/jnidemo/JNIDemoJava.java -h cpp/

После того, как мы получили определение методов C ++, мы можем их реализовать:

Наша реализация на C ++ готова, и мы можем создать общую библиотеку:

запустите «./scripts/buildLib.sh»

Один из важных параметров - -g. Он сообщает компилятору GCC о необходимости включения отладочной информации в библиотеку. Это увеличивает размер библиотеки и позволяет нам видеть исходный код во время отладки.

Отладка GDB

Попробуем отладить наш код. Вы можете запустить gdb и java-приложение вместе или прикрепить gdb к работающему приложению. Давайте проверим второй вариант, так как запуск gdb вместе с java-приложением может потребовать модификации сценариев запуска (модификация довольно проста).

Примечание. У меня возникла проблема с настройками ptrace по умолчанию в Ubuntu, описанная здесь. Я исправил это следующей командой echo 0 ›/ proc / sys / kernel / yama / ptrace_scope.

Приступим к отладке:

# start java app
# find application PID
jps
# start gdb with application PID
gdb -p 1234
# gdb will pause our application
# add a breakpoint to our code (all java packages has Java prefix)
(gdb) break Java_jnidemo_JNIDemoJava_nativeAllocate
    Breakpoint 1 at 0x7f5e77dfe944: file src/cpp/JNIDemo.c, line 35.
# resume application and wait when it will stop at breakpoint
(gdb) cont
    Continuing.
    [Switching to Thread 0x7f5ee5ef5700 (LWP 4052)]
Thread 2 "java" hit Breakpoint 1, Java_jnidemo_JNIDemoJava_nativeAllocate (
        env=0x7f5edc013340, obj=0x7f5ee5ef4980, objNumber=100)
        at src/cpp/JNIDemo.c:35
    35     return internalNativeAllocate(env, objNumber);
# step into our "internalNativeAllocate" function using "s" command
(gdb) s
internalNativeAllocate (env=0x7f5edc013340, objNumber=100)
    at src/cpp/JNIDemo.c:20
20     jclass classDouble = (*env)->FindClass(env, "java/lang/Double");
# after stop at breakpoint we can do different operations (check    # stacktrace, variables, registers, threads, etc.). Let's print     # value of objNumber parameter
(gdb) print objNumber 
$6 = 100
# we can try to debug jdk code. In this case we don't have debug     # symbols and won't get a lot of information (but info about        # threads, registers, stacktrace is still available
(gdb) set step-mode on
(gdb) set step-mode onQuit
(gdb) s
24         jmethodID midDoubleInit = (*env)->GetMethodID(env, classDouble, "<init>", "(D)V");
(gdb) s
0x00007f5ee44fc320 in jni_GetMethodID ()
   from /usr/lib/jvm/jdk-11.0.1/lib/server/libjvm.so
#exit
quit

Я не буду описывать здесь все команды gdb, так как список довольно большой.

Отладка JDK

Мы только что получили некоторый опыт отладки нативных библиотек. Наш следующий шаг - отладка встроенной части JDK (большая часть jdk написана на C ++).

В качестве примера мы будем отлаживать собственный метод writeBytes (…), который вызывается как часть известного вызова System.out.println (…):

#FileOutputStream.java
...
private native void writeBytes(byte b[], int off, int len, boolean append)
    throws IOException;
...

Во-первых, нам нужно загрузить и скомпилировать JDK с дополнительными параметрами, чтобы сохранить символы отладки.

#clone jdk from mercurial
hg clone http://hg.openjdk.java.net/jdk/jdk 
#configure and make build
bash ./configure --with-target-bits=64 --with-debug-level=slowdebug --disable-warnings-as-errors --with-native-debug-symbols=internal
make clean
make all

Давайте запустим и отлаживаем наше приложение с помощью gdb, используя jdk из папки build / linux-x86_64-server-slowdebug. Теперь мы можем увидеть источник метода writeBytes (Java_java_io_FileOutputStream_writeBytes). Если мы отлаживаем наше приложение с помощью обычного jdk, мы не увидим исходный код. Это также позволяет нам видеть имена переменных.

(gdb) list Java_java_io_FileOutputStream_writeBytes
64     writeSingle(env, this, byte, append, fos_fd);
65 }
66 
67 JNIEXPORT void JNICALL
68 Java_java_io_FileOutputStream_writeBytes(JNIEnv *env,
69     jobject this, jbyteArray bytes, jint off, jint len, jboolean append) {
70     writeBytes(env, this, bytes, off, len, append, fos_fd);
71 }
72 
(gdb)

Автоматический запуск GDB при сбое приложения

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

Однако в некоторых производственных системах дампы ядра могут быть отключены. Одна из причин - их размер. Допустим, ваше приложение использует 80 ГБ ОЗУ (куча Java + объекты, выделенные машинным кодом), тогда при каждом сбое будет создаваться файл дампа ядра на 80 ГБ. Если у вас есть система / скрипт, который каждые 5 минут проверяет, живо ли ваше приложение, и перезапускает его, если оно мертво, вы можете довольно быстро исчерпать дисковое пространство.

В этом случае полезно автоматически вызывать GDB при сбое приложения. Будет намного проще понять, какой код вызвал проблему, проверить состояние переменных и не нужен файл дампа ядра. Для этого нам нужно просто добавить в наше приложение параметр -XX: OnError = ”gdb -% p '.

Чтобы продемонстрировать этот сценарий, я создал в нашей библиотеке метод nativeCrash (он пытается получить доступ к памяти, которая не была выделена должным образом).

Давайте запустим это приложение с параметром -XX: OnError = ”gdb -% p”. Сразу после запуска приложение вылетит и мы получим gdb в терминале:

# A fatal error has been detected by the Java Runtime Environment:
#
#  SIGSEGV (0xb) at pc=0x00007f7348cba806, pid=10055, tid=10057
#
# JRE version: OpenJDK Runtime Environment (10.0.2+13) (build 10.0.2+13-Ubuntu-1ubuntu0.18.04.4)
# Java VM: OpenJDK 64-Bit Server VM (10.0.2+13-Ubuntu-1ubuntu0.18.04.4, mixed mode, tiered, compressed oops, g1 gc, linux-amd64)
# Problematic frame:
# C  [libJNIDemo.so+0x806]  Java_jnidemo_JNIDemoJava_nativeCrash+0x1c
#
...
(gdb)

Из приведенного выше кода мы видим, что проблема связана с методом Java_jnidemo_JNIDemoJava_nativeCrash, и он вызывается из потока с идентификатором 10057.

Чтобы проверить источник, в котором произошел сбой, и значения, присвоенные его переменной, мы можем сделать следующее: найти поток gdb-id, переключиться на этот поток, переключиться на фрейм, где мы ожидаем проблемы. Вот как мы можем это сделать:

# find a thread that caused an error
(gdb) info threads
  Id   Target Id         Frame 
* 1    Thread 0x7f73ad6f2380 (LWP 10055) "java" 0x00007f73ac8aed2d in __GI___pthread_timedjoin_ex (threadid=140134807701248, thread_return=0x7ffc2cdb9338, 
    abstime=0x0, block=<optimized out>) at pthread_join_common.c:89
  2    Thread 0x7f73ad6f0700 (LWP 10057) "java" 0x00007f73acfca6c2 in __GI___waitpid (pid=10098, stat_loc=0x7f73ad6eefcc, options=0)
    at ../sysdeps/unix/sysv/linux/waitpid.c:30
  3    Thread 0x7f73a960e700 (LWP 10058) "GC Thread#0" 0x00007f73ac8b66d6 in futex_abstimed_wait_cancelable (private=0, abstime=0x0, expected=0, 
    futex_word=0x7f73a40295a8)
    at ../sysdeps/unix/sysv/linux/futex-internal.h:205
...
# switch to this thread
(gdb) thread 2
...
#6  0x00007f73ac0ee228 in signalHandler(int, siginfo_t*, void*) ()
   from /usr/lib/jvm/java-11-openjdk-amd64/lib/server/libjvm.so
#7  <signal handler called>
#8  0x00007f7348cba806 in Java_jnidemo_JNIDemoJava_nativeCrash (
    env=0x7f73a4012a00, obj=0x7f73ad6ef980) at src/cpp/JNIDemo.c:11
...
# switch to the frame with our code
(gdb) frame 8
#8  0x00007f7348cba806 in Java_jnidemo_JNIDemoJava_nativeCrash (
    env=0x7f73a4012a00, obj=0x7f73ad6ef980) at src/cpp/JNIDemo.c:11
11       printf( "%c\n", s[0] );

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

P.S. Замечание о segfaults

Когда вы начнете отладку большого приложения, вы можете заметить, что выполнение gdb внезапно останавливается. Причина в том, что gdb автоматически останавливает приложение при возникновении segfault. Это имеет смысл для некоторых приложений, но не для приложений Java. JDK использует различные инструменты, которые могут вызывать ошибки сегментации (спекулятивная загрузка памяти, исключение NullPointerException и т. Д.). JDK обрабатывает SIGSEGV внутренне, но gdb об этом не знает. Вот почему нам нужно заставить GDB игнорировать их.

(gdb) handle SIGSEGV nostop noprint pass