调试信息功能

目录 #

简介 #

要构建包含调试信息的本机可执行文件,请在编译应用程序时为 javac 提供 -g 命令行选项,然后传递给 native-image 构建器

javac -g Hello.java
native-image -g Hello

这将启用源级调试,调试器(GDB)然后将机器指令与 Java 文件中的特定源行相关联。生成的镜像将包含 GNU 调试器 (GDB) 能够理解的格式的调试记录。此外,您可以将 -O0 传递给构建器,它指定不应执行任何编译器优化。禁用所有优化不是必需的,但通常它可以使调试体验更好。

调试信息不仅对调试器有用。它还可以被 Linux 性能分析工具 perfvalgrind 用于将执行统计信息(例如 CPU 利用率或缓存未命中)与特定的命名 Java 方法相关联,甚至将它们链接到原始 Java 源文件中的 Java 代码行。

默认情况下,调试信息仅包含参数和局部变量某些值的详细信息。这意味着调试器会报告许多参数和局部变量为未定义。如果您将 -O0 传递给构建器,则将包含完整的调试信息。如果您希望在使用更高级别的优化(-O1 或默认的 -O2)时包含更多参数和局部变量信息,您需要向 native-image 命令传递额外的命令行标志

native-image -g -H:+SourceLevelDebug Hello

使用标志 -g 启用调试信息不会影响生成本机镜像的编译方式,也不会影响其执行速度或运行时使用的内存量。但是,它会显着增加磁盘上生成的镜像的大小。通过传递标志 -H:+SourceLevelDebug 来启用完整的参数和局部变量信息会导致程序以略微不同的方式进行编译,对于某些应用程序来说,这可能会减慢执行速度。

基本的 perf report 命令显示一个直方图,显示每个 Java 方法的百分比执行时间,它只需要将标志 -g-H:+SourceLevelDebug 传递给 native-image 命令。但是,更复杂的 perf 使用(例如,perf annotate)和 valgrind 的使用需要调试信息与识别已编译 Java 方法的链接符号相补充。默认情况下,Java 方法符号会从生成的本机镜像中省略,但可以通过向 native-image 命令传递一个额外的标志来保留它们

native-image -g -H:+SourceLevelDebug -H:-DeleteLocalSymbols Hello

使用此标志将导致生成的镜像文件的大小略微增加。

注意:本机镜像调试目前在 Linux 上运行,并提供对 macOS 的初始支持。此功能处于实验阶段。

注意:Linux 上的 perfvalgrind 的调试信息支持是一项实验性功能。

源文件缓存 #

-g 选项还启用对任何 JDK 运行时类、GraalVM 类和应用程序类的源进行缓存,这些类可以在生成本机可执行文件时找到。默认情况下,缓存是在生成的二进制文件旁边的名为 sources 的子目录中创建的。如果使用选项 -H:Path=... 指定了本机可执行文件的目标目录,则缓存也会重新定位到该目标目录下。使用命令行选项提供通向 sources 的替代路径,并为调试器配置源文件搜索路径根目录。缓存中的文件位于与本机可执行文件的调试记录中包含的文件路径信息匹配的目录层次结构中。源缓存应该包含调试生成二进制文件所需的所有文件,仅此而已。此本地缓存提供了一种便捷的方式,在调试本机可执行文件时,仅将必要的源提供给调试器或 IDE。

该实现试图对查找源文件进行智能处理。它使用当前的 JAVA_HOME 来查找 JDK src.zip,以便在搜索 JDK 运行时源时找到它。它还使用类路径中的条目来建议 GraalVM 源文件和应用程序源文件的位置(有关用于识别源位置的方案的详细信息,请参见下文)。但是,源布局确实有所不同,可能无法找到所有源。因此,用户可以使用选项 DebugInfoSourceSearchPath 在命令行上明确指定源文件的位置

javac --source-path apps/greeter/src \
    -d apps/greeter/classes org/my/greeter/*Greeter.java
javac -cp apps/greeter/classes \
    --source-path apps/hello/src \
    -d apps/hello/classes org/my/hello/Hello.java
native-image -g \
    -H:DebugInfoSourceSearchPath=apps/hello/src \
    -H:DebugInfoSourceSearchPath=apps/greeter/src \
    -cp apps/hello/classes:apps/greeter/classes org.my.hello.Hello

DebugInfoSourceSearchPath 选项可以重复多次,以通知所有目标源位置。传递给此选项的值可以是绝对路径或相对路径。它可以标识目录、源 JAR 文件或源 ZIP 文件。也可以使用逗号分隔符一次指定多个源根目录

native-image -g \
    -H:DebugInfoSourceSearchPath=apps/hello/target/hello-sources.jar,apps/greeter/target/greeter-sources.jar \
    -cp apps/target/hello.jar:apps/target/greeter.jar \
    org.my.Hello

默认情况下,应用程序、GraalVM 和 JDK 源的缓存是在名为 sources 的目录中创建的。DebugInfoSourceCacheRoot 选项可用于指定替代路径,该路径可以是绝对路径或相对路径。在后一种情况下,该路径相对于通过选项 -H:Path 指定的生成的执行文件目标目录(默认为当前工作目录)进行解释。例如,以下 previous 命令的变体使用当前进程 id 指定一个绝对的临时目录路径

SOURCE_CACHE_ROOT=/tmp/$$/sources
native-image -g \
    -H:DebugInfoSourceCacheRoot=$SOURCE_CACHE_ROOT \
    -H:DebugInfoSourceSearchPath=apps/hello/target/hello-sources.jar,apps/greeter/target/greeter-sources.jar \
    -cp apps/target/hello.jar:apps/target/greeter.jar \
    org.my.Hello

生成的缓存目录将类似于 /tmp/1272696/sources

如果源缓存路径包含一个尚不存在的目录,则它将在填充缓存期间创建。

请注意,在上述所有示例中,DebugInfoSourceSearchPath 选项实际上是多余的。在第一种情况下,apps/hello/classes/apps/greeter/classes/ 的类路径条目将用于推导出默认搜索根目录 apps/hello/src/apps/greeter/src/。在第二种情况下,apps/target/hello.jarapps/target/greeter.jar 的类路径条目将用于推导出默认搜索根目录 apps/target/hello-sources.jarapps/target/greeter-sources.jar

支持的功能 #

当前支持的功能包括

  • 通过文件和行或方法名称配置的断点
  • 逐行单步执行,包括进入和跳过函数调用
  • 堆栈回溯(不包括详细说明内联代码的帧)
  • 打印原始值
  • 结构化(逐字段)打印 Java 对象
  • 以不同级别的一般性强制转换/打印对象
  • 通过路径表达式访问对象网络
  • 按名称引用方法和静态字段数据
  • 按名称引用绑定到参数和局部变量的值
  • 按名称引用类常量

请注意,在已编译的方法中单步执行包括内联代码的文件和行号信息,包括内联的 GraalVM 方法。因此,即使您仍在同一个已编译方法中,GDB 也可能会切换文件。

从 GDB 调试 Java 的特殊注意事项 #

GDB 目前不支持 Java 调试。因此,调试功能是通过生成调试信息来实现的,该调试信息将 Java 程序建模为等效的 C++ 程序。Java 类、数组和接口引用实际上是指向包含相关字段/数组数据的记录的指针。在相应的 C++ 模型中,Java 名称用于标记底层 C++(类/结构)布局类型,Java 引用显示为指针。

例如,在 DWARF 调试信息模型中,java.lang.String 标识一个 C++ 类。此类布局类型声明了预期的字段,例如类型为 inthash 和类型为 byte[]value,以及方法,例如 String(byte[])charAt(int) 等。但是,在 Java 中显示为 String(String) 的复制构造函数在 gdb 中显示为 String(java.lang.String *)

C++ 布局类使用 C++ 公共继承从类(布局)类型 java.lang.Object 继承字段和方法。后者又从一个名为 _objhdr 的特殊结构类继承标准 oop(普通对象指针)头字段,该类包含最多两个字段(取决于 VM 配置)。第一个字段称为 hub,其类型为 java.lang.Class *,也就是说,它是指向对象类的指针。第二个字段(可选)称为 idHash,其类型为 int。它存储对象的标识哈希码。

ptype 命令可用于打印特定类型的详细信息。请注意,Java 类型名称必须用引号括起来,以便转义嵌入的 . 字符。

(gdb) ptype 'java.lang.String'
type = class java.lang.String : public java.lang.Object {
  private:
    byte [] *value;
    int hash;
    byte coder;

  public:
    void String(byte [] *);
    void String(char [] *);
    void String(byte [] *, java.lang.String *);
    . . .
    char charAt(int);
    . . .
    java.lang.String * concat(java.lang.String *);
    . . .
}

ptype 命令还可用于识别 Java 数据值的静态类型。当前示例会话针对简单的 hello world 程序。Main 方法 Hello.main 传递一个参数 args,其 Java 类型为 String[]。如果调试器在进入 main 时停止,我们可以使用 ptype 打印 args 的类型。

(gdb) ptype args
type = class java.lang.String[] : public java.lang.Object {
  public:
    int len;
    java.lang.String *data[0];
} *

这里有一些细节值得强调。首先,调试器将 Java 数组引用视为指针类型,就像它对待每个 Java 对象引用一样。

其次,该指针指向一个结构,实际上是一个 C++ 类,该类使用一个整数长度字段和一个数据字段来模拟 Java 数组的布局,该数据字段的类型是嵌入到模拟数组对象的内存块中的 C++ 数组。

数据字段的元素是指向基本类型的引用,在本例中是指向 java.lang.String 的指针。数据数组的标称长度为 0。但是,为 String[] 对象分配的内存块实际上包含足够的空间来保存由字段 len 的值确定的指针数量。

最后,请注意 C++ 类 java.lang.String[] 继承自 C++ 类 java.lang.Object。因此,数组仍然是对象。特别是,正如我们将在打印对象内容时看到的那样,这意味着每个数组也包含所有 Java 对象共有的对象头字段。

print 命令可用于将对象引用显示为内存地址。

(gdb) print args
$1 = (java.lang.String[] *) 0x7ffff7c01130

它还可以用于逐个字段打印对象的内容。这是通过使用 * 运算符取消对指针的引用来实现的。

(gdb) print *args
$2 = {
  <java.lang.Object> = {
    <_objhdr> = {
      hub = 0xaa90f0,
      idHash = 0
    }, <No data fields>}, 
  members of java.lang.String[]:
  len = 1,
  data = 0x7ffff7c01140
}

数组对象包含从类 _objhdr(通过父类 Object)继承的嵌入式字段。_objhdr 是添加到调试信息中的合成类型,用于对所有对象开头存在的字段进行建模。它们包括 hub(对对象的类的引用)和 hashId(唯一的数字哈希码)。

显然,调试器知道局部变量 args 的类型(java.lang.String[])和内存位置(0x7ffff7c010b8)。它还了解嵌入到引用对象中的字段的布局。这意味着可以使用 C++ 的 .-> 运算符在调试器命令中遍历底层对象数据结构。

(gdb) print args->data[0]
$3 = (java.lang.String *) 0x7ffff7c01160
(gdb) print *args->data[0]
$4 = {
   <java.lang.Object> = {
     <_objhdr> = {
      hub = 0xaa3350
     }, <No data fields>},
   members of java.lang.String:
   value = 0x7ffff7c01180,
   hash = 0,
   coder = 0 '\000'
 }
(gdb) print *args->data[0]->value
$5 = {
  <java.lang.Object> = {
    <_objhdr> = {
      hub = 0xaa3068,
      idHash = 0
    }, <No data fields>}, 
  members of byte []:
  len = 6,
  data = 0x7ffff7c01190 "Andrew"
}

回到对象头中的 hub 字段,之前曾提到过它实际上是对对象的类的引用。这实际上是 Java 类型 java.lang.Class 的一个实例。请注意,该字段使用指向底层 C++ 类(布局)类型的指针进行类型化。

(gdb) print args->hub
$6 = (java.lang.Class *) 0xaa90f0

从 Object 类开始的所有类都继承自一个通用的自动生成的标头类型 _objhdr。正是这种标头类型包含 hub 字段。

(gdb) ptype _objhdr
type = struct _objhdr {
    java.lang.Class *hub;
    int idHash;
}

(gdb) ptype 'java.lang.Object'
type = class java.lang.Object : public _objhdr {
  public:
    void Object(void);
    . . .

所有对象都有一个指向类的通用标头这一事实使得可以执行简单的测试来判断一个地址是否是对象引用,如果是,则判断对象的类是什么。给定有效的对象引用,始终可以打印从 hub 的 name 字段引用的 String 的内容。

请注意,因此,这使得调试器观察到的每个对象都可以向下转换为其动态类型。也就是说,即使调试器只看到了(例如)java.nio.file.Path 的静态类型,我们也可以轻松地向下转换为动态类型,该类型可能是子类型(例如 jdk.nio.zipfs.ZipPath),从而可以检查我们无法从静态类型单独观察到的字段。首先,将值转换为对象引用。然后使用路径表达式通过 hub 字段和 hub 的 name 字段取消对位于 name String 中的 byte[] 值数组的引用。

(gdb) print/x ((_objhdr *)$rdi)
$7 = (_objhdr *) 0x7ffff7c01130
(gdb) print *$7->hub->name->value
$8 = {
  <java.lang.Object> = {
    <_objhdr> = {
      hub = 0xaa3068,
      idHash = 178613527
    }, <No data fields>}, 
   members of byte []:
   len = 19,
  data = 0x8779c8 "[Ljava.lang.String;"
 }

寄存器 rdi 中的值显然是对 String 数组的引用。事实上,这并非巧合。示例会话在放置在 Hello.main 入口处的断点处停止,此时 String[] 参数 args 的值将位于寄存器 rdi 中。回过头来看,我们可以看到 rdi 中的值与命令 print args 打印的值相同。

一个更简单的命令允许只打印 hub 对象的名称,如下所示:

(gdb) x/s $7->hub->name->value->data
798:	"[Ljava.lang.String;"

实际上,定义一个 gdb 命令 hubname_raw 来对任意原始内存地址执行此操作非常有用。

define hubname_raw
  x/s (('java.lang.Object' *)($arg0))->hub->name->value->data
end

(gdb) hubname_raw $rdi
0x8779c8:	"[Ljava.lang.String;"

尝试为无效引用打印 hub 名称将失败,并打印错误消息。

(gdb) p/x $rdx
$5 = 0x2
(gdb) hubname $rdx
Cannot access memory at address 0x2

如果 gdb 已经知道引用的 Java 类型,则可以使用更简单的 hubname 命令版本进行打印,而无需进行强制转换。例如,上面检索到的 String 数组 $1 具有已知类型。

(gdb) ptype $1
type = class java.lang.String[] : public java.lang.Object {
    int len;
    java.lang.String *data[0];
} *

define hubname
  x/s (($arg0))->hub->name->value->data
end

(gdb) hubname $1
0x8779c8:	"[Ljava.lang.String;"

本机映像堆包含每个包含在映像中的 Java 类型的唯一 hub 对象(java.lang.Class 的一个实例)。可以使用标准 Java 类字面量语法引用这些类常量。

(gdb) print 'Hello.class'
$6 = {
  <java.lang.Object> = {
    <_objhdr> = {
      hub = 0xaabd00,
      idHash = 1589947226
    }, <No data fields>}, 
  members of java.lang.Class:
  typeCheckStart = 13,
  name = 0xbd57f0,
  ...

不幸的是,有必要引用类常量字面量以避免 gdb 将嵌入的 . 字符解释为字段访问。

请注意,类常量字面量的类型是 java.lang.Class 而不是 java.lang.Class *

Java 实例类、接口、数组类和数组(包括基本类型数组)都存在类常量。

(gdb)  print 'java.util.List.class'.name
$7 = (java.lang.String *) 0xb1f698
(gdb) print 'java.lang.String[].class'.name->value->data
$8 = 0x8e6d78 "[Ljava.lang.String;"
(gdb) print 'long.class'.name->value->data
$9 = 0xc87b78 "long"
(gdb) x/s  'byte[].class'.name->value->data
0x925a00:	"[B"
(gdb) 

接口布局被建模为 C++ 联合类型。联合的成员包括实现接口的所有 Java 类的 C++ 布局类型。

(gdb) ptype 'java.lang.CharSequence'
type = union java.lang.CharSequence {
    java.nio.CharBuffer _java.nio.CharBuffer;
    java.lang.AbstractStringBuilder _java.lang.AbstractStringBuilder;
    java.lang.String _java.lang.String;
    java.lang.StringBuilder _java.lang.StringBuilder;
    java.lang.StringBuffer _java.lang.StringBuffer;
}

给定一个类型化为接口的引用,可以通过查看相关的联合元素来解析为相关的类类型。

如果我们取 args 数组中的第一个 String,我们可以要求 gdb 将其强制转换为接口 CharSequence

(gdb) print args->data[0]
$10 = (java.lang.String *) 0x7ffff7c01160
(gdb) print ('java.lang.CharSequence' *)$10
$11 = (java.lang.CharSequence *) 0x7ffff7c01160

hubname 命令不适用于这种联合类型,因为它只适用于包含 hub 字段的联合元素的对象。

(gdb) hubname $11
There is no member named hub.

但是,由于所有元素都包含相同的标头,因此可以将它们中的任何一个传递给 hubname 以识别实际类型。这允许选择正确的联合元素。

(gdb) hubname $11->'_java.nio.CharBuffer'
0x95cc58:	"java.lang.String`\302\236"
(gdb) print $11->'_java.lang.String'
$12 = {
  <java.lang.Object> = {
    <_objhdr> = {
      hub = 0xaa3350,
      idHash = 0
    }, <No data fields>},
  members of java.lang.String:
  hash = 0,
  value = 0x7ffff7c01180,
  coder = 0 '\000'
}

请注意,hub 打印的类名包含一些尾随字符。这是因为存储 Java String 文本的数据数组不能保证以零结尾。

调试器不仅理解局部变量和参数变量的名称和类型。它还了解方法名称和静态字段名称。

以下命令在类 Hello 的主入口点设置断点。请注意,由于 GDB 认为这是一个 C++ 方法,因此它使用 :: 分隔符来分隔方法名称和类名称。

(gdb) info func ::main
All functions matching regular expression "::main":

File Hello.java:
	void Hello::main(java.lang.String[] *);
(gdb) x/4i Hello::main
=> 0x4065a0 <Hello::main(java.lang.String[] *)>:	sub    $0x8,%rsp
   0x4065a4 <Hello::main(java.lang.String[] *)+4>:	cmp    0x8(%r15),%rsp
   0x4065a8 <Hello::main(java.lang.String[] *)+8>:	jbe    0x4065fd <Hello::main(java.lang.String[] *)+93>
   0x4065ae <Hello::main(java.lang.String[] *)+14>:	callq  0x406050 <Hello$Greeter::greeter(java.lang.String[] *)>
(gdb) b Hello::main
Breakpoint 1 at 0x4065a0: file Hello.java, line 43.

一个包含对象数据的静态字段示例是由类 BigInteger 中的静态字段 powerCache 提供的。

(gdb) ptype 'java.math.BigInteger'
type = class _java.math.BigInteger : public _java.lang.Number {
  public:
    int [] mag;
    int signum;
  private:
    int bitLengthPlusOne;
    int lowestSetBitPlusTwo;
    int firstNonzeroIntNumPlusTwo;
    static java.math.BigInteger[][] powerCache;
    . . .
  public:
    void BigInteger(byte [] *);
    void BigInteger(java.lang.String *, int);
    . . .
}
(gdb) info var powerCache
All variables matching regular expression "powerCache":

File java/math/BigInteger.java:
	java.math.BigInteger[][] *java.math.BigInteger::powerCache;

静态变量名称可用于引用存储在该字段中的值。还要注意,地址运算符可用于标识字段在堆中的位置(地址)。

(gdb) p 'java.math.BigInteger'::powerCache
$13 = (java.math.BigInteger[][] *) 0xced5f8
(gdb) p &'java.math.BigInteger'::powerCache
$14 = (java.math.BigInteger[][] **) 0xced3f0

调试器通过静态字段的符号名称取消引用以访问存储在该字段中的基本值或对象。

(gdb) p *'java.math.BigInteger'::powerCache
$15 = {
  <java.lang.Object> = {
    <_objhdr> = {
    hub = 0xb8dc70,
    idHash = 1669655018
    }, <No data fields>},
  members of _java.math.BigInteger[][]:
  len = 37,
  data = 0xced608
}
(gdb) p 'java.math.BigInteger'::powerCache->data[0]@4
$16 = {0x0, 0x0, 0xed5780, 0xed5768}
(gdb) p *'java.math.BigInteger'::powerCache->data[2]
$17 = {
  <java.lang.Object> = {
    <_objhdr> = {
    hub = 0xabea50,
    idHash = 289329064
    }, <No data fields>},
  members of java.math.BigInteger[]:
  len = 1,
  data = 0xed5790
}
(gdb) p *'java.math.BigInteger'::powerCache->data[2]->data[0]
$18 = {
  <java.lang.Number> = {
    <java.lang.Object> = {
      <_objhdr> = {
        hub = 0xabed80
      }, <No data fields>}, <No data fields>},
  members of java.math.BigInteger:
  mag = 0xcbc648,
  signum = 1,
  bitLengthPlusOne = 0,
  lowestSetBitPlusTwo = 0,
  firstNonzeroIntNumPlusTwo = 0
}

识别源代码位置 #

实现的目标之一是简化调试器的配置,以便它能够在程序执行期间停止时识别相关的源文件。native-image 工具试图通过在适当结构化的文件缓存中累积相关源来实现此目标。

native-image 工具使用不同的策略来定位 JDK 运行时类、GraalVM 类和应用程序源类以包含在本地源缓存中。它根据类的包名识别要使用的策略。因此,例如,以 java.*jdk.* 开头的包是 JDK 类;以 org.graal.*com.oracle.svm.* 开头的包是 GraalVM 类;任何其他包都被视为应用程序类。

JDK 运行时类的源代码是从用于运行本机映像生成过程的 JDK 版本中找到的 src.zip 中检索的。检索到的文件缓存在 sources 子目录下,使用模块名称(对于 JDK11)和相关类的包名来定义源代码所在目录层次结构。

例如,在 Linux 上,class java.util.HashMap 的源代码将缓存在文件 sources/java.base/java/util/HashMap.java 中。此类及其方法的调试信息记录将使用相对目录路径 java.base/java/util 和文件名 HashMap.java 来识别此源文件。在 Windows 上,除了使用 \ 而不是 / 作为文件分隔符外,情况将相同。

GraalVM 类的源代码是从 ZIP 文件或从类路径条目派生的源代码目录中检索的。检索到的文件缓存在 sources 子目录下,使用相关类的包名来定义源代码所在目录层次结构(例如,类 com.oracle.svm.core.VM 的源代码文件缓存在 sources/com/oracle/svm/core/VM.java 中)。

缓存的 GraalVM 源代码的查找方案根据每个类路径条目中找到的内容而有所不同。给定一个类似于 /path/to/foo.jar 的 JAR 文件条目,相应的文件 /path/to/foo.src.zip 被视为可以从中提取源代码文件的候选 ZIP 文件系统。当条目指定类似于 /path/to/bar 的目录时,则目录 /path/to/bar/src/path/to/bar/src_gen 被视为候选。当 ZIP 文件或源代码目录不存在或不包含至少一个与预期 GraalVM 包层次结构之一匹配的子目录层次结构时,将跳过候选。

应用程序类的源代码是从类路径中的条目派生的源 JAR 文件或源代码目录中检索的。检索到的文件缓存在 sources 子目录下,使用相关类的包名来定义源代码所在目录层次结构(例如,类 org.my.foo.Foo 的源代码文件缓存在 sources/org/my/foo/Foo.java 中)。

缓存的应用程序源代码的查找方案根据每个类路径条目中找到的内容而有所不同。给定一个类似于 /path/to/foo.jar 的 JAR 文件条目,相应的 JAR 文件 /path/to/foo-sources.jar 被视为可以从中提取源代码文件的候选 ZIP 文件系统。当条目指定类似于 /path/to/bar/classes//path/to/bar/target/classes/ 的目录时,则选择 /path/to/bar/src/main/java//path/to/bar/src/java//path/to/bar/src/ 中的其中一个目录作为候选(按优先级排序)。最后,运行本机可执行文件的当前目录也被视为候选。

这些查找策略只是暂时的,将来可能需要扩展。但是,可以通过其他方式使缺少的源代码可用。一个选择是解压缩额外的应用程序源 JAR 文件或将额外的应用程序源代码树复制到缓存中。另一个选择是配置额外的源代码搜索路径。

在 GNU 调试器中配置源代码路径 #

默认情况下,GDB 将使用本地目录根目录 sources 来定位应用程序类、GraalVM 类和 JDK 运行时类的源文件。如果源代码缓存未位于运行 GDB 的目录中,则可以使用以下命令配置所需的路径。

(gdb) set directories /path/to/sources/

set directories 命令的参数应标识源代码缓存的位置作为绝对路径或相对于 gdb 会话的工作目录的相对路径。

请注意,当前实现尚未在 jdk.graal.compiler* 包子空间中找到 GraalVM JIT 编译器的一些源代码。

可以通过解压缩应用程序源 JAR 文件或将应用程序源代码树复制到缓存中来补充缓存在 sources 中的文件。您需要确保添加到 sources 的任何新子目录都对应于正在包含其源代码的类的顶级包。

还可以使用 set directories 命令将额外目录添加到搜索路径。

(gdb) set directories /path/to/my/sources/:/path/to/my/other/sources

请注意,GNU 调试器不理解 ZIP 格式文件系统,因此您添加的任何额外条目都必须标识包含相关源代码的目录树。同样,添加到搜索路径中的目录的顶级条目必须对应于正在包含其源代码的类的顶级包。

检查 Linux 上的调试信息 #

请注意,这仅对那些想要了解调试信息实现的工作原理或想要排查调试期间遇到的可能与调试信息编码相关的问题的用户有用。

objdump 命令可用于显示嵌入到本机可执行文件中的调试信息。以下命令(都假设目标二进制文件称为 hello)可用于显示所有生成的内容。

objdump --dwarf=info hello > info
objdump --dwarf=abbrev hello > abbrev
objdump --dwarf=ranges hello > ranges
objdump --dwarf=decodedline hello > decodedline
objdump --dwarf=rawline hello > rawline
objdump --dwarf=str hello > str
objdump --dwarf=loc hello > loc
objdump --dwarf=frames hello > frames

info 部分包含所有已编译 Java 方法的详细信息。

abbrev 部分定义了描述 Java 文件(编译单元)和方法的 info 部分中记录的布局。

ranges 部分详细说明了方法代码段的起始地址和结束地址。

decodedline 部分将方法代码范围段的子段映射到文件和行号。此映射包括内联方法的文件和行号条目。

rawline 段提供了有关如何使用 DWARF 状态机指令生成行表的详细信息,这些指令对文件、行和地址转换进行编码。

loc 部分提供了有关地址范围的详细信息,在这些地址范围内,已知在 info 部分声明的参数和局部变量具有确定性值。这些详细信息标识了值的位置,是在机器寄存器中,还是在堆栈上或内存中的特定地址。

str 部分提供了一个用于查找从 info 部分的记录中引用的字符串的查找表。

frames 部分列出了编译方法中的转换点,在这些转换点上,将推送或弹出(固定大小)堆栈帧,从而使调试器能够识别每个帧的当前和先前堆栈指针及其返回地址。

请注意,嵌入在调试记录中的一些内容是由 C 编译器生成的,属于库中或与 Java 方法代码捆绑在一起的 C lib 引导代码中的代码。

当前支持的目标 #

原型目前仅针对 Linux 上的 GNU Debugger 实现。

  • Linux/x86_64 支持已过测试,应能正常工作。

  • Linux/AArch64 支持已存在,但尚未完全验证(断点应能正常工作,但堆栈回溯可能不正确)。

Windows 支持仍在开发中。

使用隔离进行调试 #

在原生镜像中使用 隔离 会影响普通对象指针 (oops) 的编码方式。反过来,这意味着调试信息生成器必须向 gdb 提供有关如何将编码的 oop 转换为内存中地址的信息,在该地址中存储对象数据。在要求 gdb 处理编码的 oops 与解码的原始地址时,有时需要小心。

如果禁用了隔离,oops 本质上将是直接指向对象内容的原始地址。这通常是相同的,无论 oop 是嵌入在静态/实例字段中,还是从位于寄存器中的局部或参数变量中引用,或者保存到堆栈中。这并不完全那么简单,因为一些 oops 的最低 3 位可能用于保存记录对象的某些瞬态属性的“标签”。但是,提供给 gdb 的调试信息意味着它将在将 oop 解引用为地址之前删除这些标签位。

使用隔离时,存储在静态或实例字段中的 oops 引用实际上是相对地址,即专门的堆基址寄存器(x86_64 上为 r14,AArch64 上为 r29)的偏移量,而不是直接地址(在少数特殊情况下,偏移量也可能设置了一些低标签位)。当这种“间接”oop 在执行期间被加载时,它几乎总是立即通过将偏移量添加到堆基址寄存器值来转换为“原始”地址。因此,作为局部或参数变量的值出现的 oops 实际上是原始地址。

请注意,在某些操作系统上,启用隔离会导致在使用 gdb 版本 10 或更早版本时打印对象出现问题。强烈建议您将调试器升级到更高版本。

当启用隔离时,编码到镜像中的 DWARF 信息告诉 gdb 在尝试解引用它们以访问底层对象数据时重新定位间接 oops。这通常是自动且透明的,但在您请求对象的类型时,gdb 显示的底层类型模型中可以看到它。

例如,考虑我们上面遇到的静态字段。在使用隔离的镜像中打印其类型显示此静态字段具有与预期不同的类型。

(gdb) ptype 'java.math.BigInteger'::powerCache
type = class _z_.java.math.BigInteger[][] : public java.math.BigInteger[][] {
} *

该字段被类型化为 _z_.java.math.BigInteger[][],这是一个继承自预期类型 java.math.BigInteger[][] 的空包装类。此包装类型本质上与原始类型相同,但定义它的 DWARF 信息记录包含告诉 gdb 如何转换指向此类型的指针的信息。

gdb 被要求打印存储在此字段中的 oop 时,很明显它是一个偏移量,而不是一个原始地址。

(gdb) p/x 'java.math.BigInteger'::powerCache
$1 = 0x286c08
(gdb) x/x 0x286c08
0x286c08:	Cannot access memory at address 0x286c08

但是,当 gdb 被要求通过字段解引用时,它会对 oop 应用必要的地址转换并获取正确的数据。

(gdb) p/x *'java.math.BigInteger'::powerCache
$2 = {
  <java.math.BigInteger[][]> = {
    <java.lang.Object> = {
      <_objhdr> = {
        hub = 0x1ec0e2,
        idHash = 0x2f462321
      }, <No data fields>},
    members of java.math.BigInteger[][]:
    len = 0x25,
    data = 0x7ffff7a86c18
  }, <No data fields>}

打印 hub 字段或数据数组的类型显示它们也使用间接类型进行建模。

(gdb) ptype $1->hub
type = class _z_.java.lang.Class : public java.lang.Class {
} *
(gdb) ptype $2->data
type = class _z_.java.math.BigInteger[] : public java.math.BigInteger[] {
} *[0]

调试器仍然知道如何解引用这些 oops。

(gdb) p $1->hub
$3 = (_z_.java.lang.Class *) 0x1ec0e2
(gdb) x/x $1->hub
0x1ec0e2:	Cannot access memory at address 0x1ec0e2
(gdb) p *$1->hub
$4 = {
  <java.lang.Class> = {
    <java.lang.Object> = {
      <_objhdr> = {
        hub = 0x1dc860,
        idHash = 1530752816
      }, <No data fields>},
    members of java.lang.Class:
    name = 0x171af8,
    . . .
  }, <No data fields>}

由于间接类型继承自相应的原始类型,因此可以在几乎所有可以使用标识原始类型指针的表达式的场景中使用标识间接类型指针的表达式。唯一可能需要小心的是,当将显示的数字字段值或显示的寄存器值强制转换为类型时。

例如,如果上面打印的间接 hub oop 传递给 hubname_raw,则强制转换为该命令内部的 Object 类型的转换将无法强制执行所需的间接 oops 转换。导致的内存访问失败。

(gdb) hubname_raw 0x1dc860
Cannot access memory at address 0x1dc860

在这种情况下,有必要使用一个略微不同的命令,该命令将它的参数强制转换为间接指针类型。

(gdb) define hubname_indirect
 x/s (('_z_.java.lang.Object' *)($arg0))->hub->name->value->data
end
(gdb) hubname_indirect 0x1dc860
0x7ffff78a52f0:	"java.lang.Class"

调试辅助方法 #

在调试信息不受完全支持的平台上,或者在调试复杂问题时,打印或查询有关原生镜像执行状态的高级信息可能会有所帮助。对于这些场景,原生镜像提供了调试辅助方法,这些方法可以通过指定构建时选项 -H:+IncludeDebugHelperMethods 嵌入到原生可执行文件中。在调试时,就可以像调用任何正常的 C 方法一样调用这些调试辅助方法。此功能与几乎所有调试器兼容。

在使用 gdb 进行调试时,可以使用以下命令列出嵌入到原生镜像中的所有调试辅助方法。

(gdb) info functions svm_dbg_

在调用方法之前,最好直接查看 Java 类 DebugHelper 的源代码,以确定每个方法期望哪些参数。例如,调用下面的方法将打印有关原生镜像执行状态的高级信息,类似于为致命错误打印的信息。

(gdb) call svm_dbg_print_fatalErrorDiagnostics($r15, $rsp, $rip)

使用 perf 和 valgrind 的注意事项 #

调试信息包括顶级和内联编译方法代码的地址范围的详细信息,以及从代码地址到相应源文件和行的映射。perfvalgrind 能够使用此信息来执行其一些记录和报告操作。例如,perf report 能够将 perf record 会话期间采样的代码地址与 Java 方法关联起来,并在其输出直方图中打印从 DWARF 派生的方法名称。

    . . .
    68.18%     0.00%  dirtest          dirtest               [.] _start
            |
            ---_start
               __libc_start_main_alias_2 (inlined)
               |          
               |--65.21%--__libc_start_call_main
               |          com.oracle.svm.core.code.IsolateEnterStub::JavaMainWrapper_run_5087f5482cc9a6abc971913ece43acb471d2631b (inlined)
               |          com.oracle.svm.core.JavaMainWrapper::run (inlined)
               |          |          
               |          |--55.84%--com.oracle.svm.core.JavaMainWrapper::runCore (inlined)
               |          |          com.oracle.svm.core.JavaMainWrapper::runCore0 (inlined)
               |          |          |          
               |          |          |--55.25%--DirTest::main (inlined)
               |          |          |          |          
               |          |          |           --54.91%--DirTest::listAll (inlined)
               . . .

不幸的是,其他操作要求通过 ELF(本地)函数符号表条目来标识 Java 方法,该条目定位了编译方法代码的开头。特别是,两种工具提供的汇编代码转储使用从最近符号的偏移量来标识分支和调用目标。省略 Java 方法符号意味着偏移量通常相对于某个不相关的全局符号显示,通常是用于 C 代码调用而导出的方法的入口点。

为了说明这个问题,以下摘自 perf annotate 的输出显示了方法 java.lang.String::String() 的编译代码的前几条注释说明。

    . . .
         : 501    java.lang.String::String():
         : 521    public String(byte[] bytes, int offset, int length, Charset charset) {
    0.00 :   519d50: sub    $0x68,%rsp
    0.00 :   519d54: mov    %rdi,0x38(%rsp)
    0.00 :   519d59: mov    %rsi,0x30(%rsp)
    0.00 :   519d5e: mov    %edx,0x64(%rsp)
    0.00 :   519d62: mov    %ecx,0x60(%rsp)
    0.00 :   519d66: mov    %r8,0x28(%rsp)
    0.00 :   519d6b: cmp    0x8(%r15),%rsp
    0.00 :   519d6f: jbe    51ae1a <graal_vm_locator_symbol+0xe26ba>
    0.00 :   519d75: nop
    0.00 :   519d76: nop
         : 522    Objects.requireNonNull(charset);
    0.00 :   519d77: nop
         : 524    java.util.Objects::requireNonNull():
         : 207    if (obj == null)
    0.00 :   519d78: nop
    0.00 :   519d79: nop
         : 209    return obj;
    . . .

最左侧的列显示了 perf record 运行期间获得的样本中每个指令记录的时间百分比。每个指令都以其在程序代码部分中的地址为前缀。反汇编交织了代码派生的源代码行,521-524 用于顶级代码,207-209 用于从 Objects.requireNonNull() 内联的代码。此外,该方法的开头用 DWARF 调试信息中定义的名称 java.lang.String::String() 进行标记。但是,地址 0x519d6f 处的分支指令 jbe 使用了从 graal_vm_locator_symbol 的非常大的偏移量。打印的偏移量确实标识了相对于符号位置的正确地址。但是,这未能明确说明目标地址实际上位于方法 String::String() 的编译代码范围内,换句话说,这是一个方法本地分支。

如果将选项 -H-DeleteLocalSymbols 传递给 native-image 命令,则工具输出的可读性会显著提高。使用此选项启用的等效 perf annotate 输出如下所示。

    . . .
         : 5      000000000051aac0 <String_constructor_f60263d569497f1facccd5467ef60532e990f75d>:
         : 6      java.lang.String::String():
         : 521    *          {@code offset} is greater than {@code bytes.length - length}
         : 522    *
         : 523    * @since  1.6
         : 524    */
         : 525    @SuppressWarnings("removal")
         : 526    public String(byte[] bytes, int offset, int length, Charset charset) {
    0.00 :   51aac0: sub    $0x68,%rsp
    0.00 :   51aac4: mov    %rdi,0x38(%rsp)
    0.00 :   51aac9: mov    %rsi,0x30(%rsp)
    0.00 :   51aace: mov    %edx,0x64(%rsp)
    0.00 :   51aad2: mov    %ecx,0x60(%rsp)
    0.00 :   51aad6: mov    %r8,0x28(%rsp)
    0.00 :   51aadb: cmp    0x8(%r15),%rsp
    0.00 :   51aadf: jbe    51bbc1 <String_constructor_f60263d569497f1facccd5467ef60532e990f75d+0x1101>
    0.00 :   51aae5: nop
    0.00 :   51aae6: nop
         : 522    Objects.requireNonNull(charset);
    0.00 :   51aae7: nop
         : 524    java.util.Objects::requireNonNull():
         : 207    * @param <T> the type of the reference
         : 208    * @return {@code obj} if not {@code null}
         : 209    * @throws NullPointerException if {@code obj} is {@code null}
         : 210    */
         : 211    public static <T> T requireNonNull(T obj) {
         : 212    if (obj == null)
    0.00 :   51aae8: nop
    0.00 :   51aae9: nop
         : 209    throw new NullPointerException();
         : 210    return obj;
    . . .

在此版本中,该方法的起始地址现在用混淆的符号名称 String_constructor_f60263d569497f1facccd5467ef60532e990f75d 以及 DWARF 名称进行标记。分支目标现在使用从该起始符号的偏移量进行打印。

不幸的是,perfvalgrind 无法正确理解 GraalVM 使用的混淆算法,也不能在反汇编中将混淆的名称替换为 DWARF 名称,即使已知符号和 DWARF 函数数据都标识了从同一地址开始的代码。因此,分支指令仍然使用符号加偏移量打印其目标,但至少这次使用了方法符号。

此外,由于地址 51aac0 现在被识别为方法的起始地址,因此 perf 在方法的第一行之前添加了 5 行上下文,这些上下文列出了该方法的 javadoc 注释的尾部。不幸的是,perf 对这些行进行了错误的编号,将第一个注释标记为 521 而不是 516。

执行命令 perf annotate 将为镜像中的所有方法和 C 函数提供反汇编列表。可以通过将方法的名称作为参数传递给 perf annotate 命令来注释特定方法。但是请注意,perf 要求使用混淆的符号名称作为参数,而不是 DWARF 名称。因此,为了注释方法 java.lang.String::String(),有必要运行命令 perf annotate String_constructor_f60263d569497f1facccd5467ef60532e990f75d

valgrind 工具 callgrind 也需要保留本地符号才能提供高质量的输出。当 callgrindkcachegrind 等查看器结合使用时,可以识别有关原生镜像执行的许多有价值的信息,并将这些信息与特定的源代码行关联起来。

使用 perf record 进行调用图记录 #

通常,当 perf 执行堆栈帧记录(使用 --call-graph 时),它使用帧指针来识别各个堆栈帧。这假设要分析的可执行文件在每次调用函数时都会实际保留帧指针。对于原生镜像,这可以通过使用 -H:+PreserveFramePointer 作为镜像构建参数来实现。

另一种解决方案是让 perf 使用 dwarf 调试信息(特别是 debug_frame 数据)来帮助展开堆栈帧。要使其正常工作,镜像需要使用 -g 构建(以生成调试信息),并且 perf record 需要使用参数 --call-graph dwarf 以确保使用 dwarf 调试信息(而不是帧指针)来进行堆栈展开。

与我们联系