偵錯資訊功能

目錄 #

簡介 #

若要建置具有偵錯資訊的原生可執行檔,請在編譯應用程式時為 javac 提供 -g 命令列選項,然後提供給 native-image 建置器

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

這會啟用來源層級偵錯,然後偵錯工具 (GDB) 會將機器指令與 Java 檔案中的特定來源程式碼行關聯起來。產生的映像檔將包含 GNU Debugger (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

使用此旗標會導致產生的映像檔大小略微增加。

注意:Native Image 偵錯目前在 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 的 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 命令列印,而無需進行強制轉換。例如,上面擷取為 $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.

類別 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 Debugger 中設定原始碼路徑 #

預設情況下,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 Debugger 不理解 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 lib 啟動程式碼。

目前支援的目標 #

目前僅針對 Linux 上的 GNU Debugger 實作原型

  • Linux/x86_64 支援已測試,應該可以正常運作

  • 存在 Linux/AArch64 支援,但尚未完全驗證 (中斷點應該可以正常運作,但堆疊回溯可能不正確)

Windows 支援仍在開發中。

使用隔離區進行偵錯 #

在原生映像檔中使用隔離區會影響普通物件指標 (oops) 的編碼方式。反之,這表示偵錯資訊產生器必須向 gdb 提供有關如何將編碼的 oop 轉換為記憶體位址的資訊,而物件資料會儲存在該位址。當要求 gdb 處理編碼的 oops 與解碼的原始位址時,有時需要謹慎處理。

如果停用隔離區,oops 本質上會是指向物件內容的原始位址。這通常是相同的,無論 oop 是嵌入在靜態/執行個體欄位中,還是從位於暫存器中或儲存到堆疊中的區域或參數變數中參考。情況並非如此簡單,因為某些 oops 的最底部 3 個位元可以用來保留「標籤」,這些標籤會記錄物件的某些暫時屬性。但是,提供給 gdb 的偵錯資訊表示它會在取消參考 oop 作為位址之前移除這些標籤位元。

使用隔離區時,儲存在靜態或執行個體欄位中的 oop 參考實際上是相對位址,是從專用堆積基底暫存器 (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,而從 Objects.requireNonNull() 內嵌的程式碼為 207-209。此外,方法的開頭會以 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 除錯資訊 (而不是框架指標) 來進行堆疊回溯。

與我們聯繫