调试信息功能

目录 #

简介 #

要构建带有调试信息的原生可执行文件,请在编译应用程序时为 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 运行时源文件时定位 JDK src.zip。它还使用类路径上的条目来建议 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(默认为当前工作目录)指定的生成可执行文件的目标目录进行解释。例如,以下命令的变体指定了使用当前进程 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 程序。主方法 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
}

数组对象包含通过父类 Object 从类 _objhdr 继承的嵌入字段。_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 的一个实例。请注意,该字段由 gdb 使用指向底层 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 的名称字段引用的 String 的内容。

请注意,因此,这使得调试器观察到的每个对象都可以向下转型为其动态类型。也就是说,即使调试器只看到(例如)java.nio.file.Path 的静态类型,我们也可以轻松地向下转型到动态类型,这可能是一个子类型,如 jdk.nio.zipfs.ZipPath,从而使得检查仅从静态类型无法观察到的字段成为可能。首先将值转换为对象引用。然后使用路径表达式通过 hub 字段和 hub 的名称字段解引用到名称 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 命令的更简单版本进行打印,而无需进行类型转换。例如,上面检索到的 $1 作为 String 数组具有已知类型。

(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.

一个包含 Object 数据的静态字段示例是 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 源文件的查找方案取决于每个类路径条目中找到的内容。给定一个 JAR 文件条目,如 /path/to/foo.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)。

缓存的应用程序源文件的查找方案取决于每个类路径条目中找到的内容。给定一个 JAR 文件条目,如 /path/to/foo.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 部分定义了 info 部分中描述 Java 文件(编译单元)和方法的记录的布局。

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

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

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

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

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

frames 部分列出了已编译方法中推入或弹出(固定大小)堆栈帧的转换点,允许调试器识别每个帧的当前和前一个堆栈指针及其返回地址。

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

当前支持的目标 #

该原型目前仅在 Linux 上的 GNU Debugger 上实现。

  • Linux/x86_64 支持已测试并应正常工作。

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

Windows 支持仍在开发中。

使用 Isolates 进行调试 #

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

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

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

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

当启用 isolates 时,编码到镜像中的 DWARF 信息会告诉 gdb 在每次尝试解引用间接 oop 以访问底层对象数据时重新建立基址。这通常是自动且透明的,但在您请求对象类型时 gdb 显示的底层类型模型中是可见的。

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

(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]

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

(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 类型转换无法强制执行所需的间接 oop 转换。结果的内存访问失败

(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;
    . . .

在此版本中,方法的起始地址现在除了 DWARF 名称外,还标有混淆的符号名称 String_constructor_f60263d569497f1facccd5467ef60532e990f75d。分支目标现在使用距该起始符号的偏移量进行打印。

不幸的是,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 调试信息(而不是帧指针)进行堆栈展开。

联系我们