ソース、バイトコード、デバッグ
他の言語: English Español 한국어 Português 中文
Javaプログラムをデバッグする際、開発者はしばしばソースコードと直接対話しているという印象を持ちます。 これは驚くことではありません - Javaのツール群は複雑さを隠すのに非常に優れた仕事をしており、 実行時にソースコードが存在しないかのような感覚を抱かせてくれます。
Javaを始めたばかりの方は、コンパイラがソースコードをバイトコードに変換し、 それがJVMによって実行されるという図を覚えているかもしれません。 あなたはまた、それが本当だとしたら、なぜ私たちはバイトコードではなくソースコードを調査し、 ステップ実行するのか、そしてJVMが私たちのソースについて何を知っているのか、と疑問に思うかもしれません。
この記事は、私が以前にデバッグについて書いた記事とは少し異なります。 特定の問題をデバッグする方法に焦点を当てるのではなく、応答しないアプリやメモリリークなどの問題をデバッグする方法に焦点を当てるのではなく、 Javaとデバッガーがどのように動作するかを探ります。 最後までお付き合いください - いつものように、いくつかの便利なトリックが含まれています。

バイトコード
まずは簡単におさらいをしましょう。 Javaの書籍やガイドにあるダイアグラムは確かに正しいものです - JVMはバイトコードを実行します。
例として以下のクラスを考えてみましょう:
package dev.flounder;
public class Calculator {
int sum(int a, int b) {
return a + b;
}
}
これをコンパイルすると、sum()
メソッドは以下のようなバイトコードに変換されます:
int sum(int, int);
descriptor: (II)I
flags: (0x0000)
Code:
stack=2, locals=3, args_size=3
0: iload_1
1: iload_2
2: iadd
3: ireturn
JDKに含まれるjavap -v
コマンドを使用することで、クラスのバイトコードを检查することができます。
IntelliJ IDEAを使用している場合、IDEからも同様に操作できます:プロジェクトをビルドした後、
クラスを選択して、表示 (View) | バイトコードの表示 (Show Bytecode)をクリックします。
クラスファイルはバイナリであるため、その生の内容を引用すると情報が伝わりません。
可読性のため、この記事の例はjavap -v
の出力形式に従います。
バイトコードは一連のコンパクトでプラットフォームに依存しない命令から構成されます。 上記の例では:
iload_1
とiload_2
は変数をオペランドスタックにロードしますiadd
はオペランドスタックの内容を加算し、結果の値を1つだけオペランドスタックに残しますireturn
はオペランドスタックから値を返します
命令以外にも、バイトコードファイルには定数、パラメータの数、ローカル変数、オペランドスタックの深さなどの情報も含まれます。 これが全てあれば、JVM言語(Java、Kotlin、Scalaなど)で書かれたプログラムをJVMが実行するために必要なものはすべて揃います。
デバッグ情報
バイトコードはソースコードと全く異なる見た目になるため、それを参照しながらデバッグすることは非効率的になります。 そのため、Javaデバッガのインターフェース - 例えば、JDKにバンドルされているJDB(コンソールデバッガ)やIntelliJ IDEAにあるデバッガ - は、 バイトコードではなくソースコードを表示します。 これにより、実行されている下層のバイトコードについて考えることなく、あなたが書いたコードをデバッグすることができます。
例えば、あなたがJDBとやり取りするときは、次のような感じになるかもしれません:
Initializing jdb ...
> stop at dev.flounder.Calculator:5
Deferring breakpoint dev.flounder.Calculator:5.
It will be set after the class is loaded.
> run
run dev/flounder/Main
Set uncaught java.lang.Throwable
Set deferred uncaught java.lang.Throwable
VM Started: Set deferred breakpoint dev.flounder.Calculator:5
Breakpoint hit: "thread=main", dev.flounder.Calculator.sum(), line=5 bci=0
> locals
Method arguments:
a = 1
b = 2
IntelliJ IDEAは、エディタと デバッグ (Debug)ツールウィンドウの中でデバッグ関連の情報を表示します:


見ての通り、両方のデバッガは正確な変数名を使用し、 私たちのコードスニペットの上で有効な行を参照しています。
ランタイムはソースファイルにアクセスできないので、このデータを他の場所で収集する必要があります。 ここでデバッグ情報が出てきます。 デバッグ情報(デバッグシンボルとも呼ばれる)は、 バイトコードとアプリケーションのソースを関連付けるコンパクトなデータで、コンパイル時に.classファイルに含まれます。
デバッグ情報には3つのタイプがあります:
次の章で、各デバッグ情報のタイプとデバッガがそれをどのように使用するかについて簡単に説明します。
行番号
行番号の情報は、バイトコードファイル内のLineNumberTable属性に格納されており、 その内容は以下のようになります:
LineNumberTable:
line 5: 0
line 6: 2
上記の表は、デバッガに次のような情報を伝えます:
- 行5にはオフセット0の命令が含まれています
- 行6にはオフセット2の命令が含まれています
この種のデバッグ情報は、外部ツール(デバッガやプロファイラなど)が、 プログラムがソースコードのどの行を実行しているかを正確に追跡するのに役立ちます。
重要な点として、行番号情報は例外のスタックトレースのソース参照にも使用されます。 以下の例では、私が他のチュートリアルからコードを 行番号情報付きと無しでそれぞれコンパイルしました。得られた実行可能ファイルが生成したスタックトレースは次のようになります:
Exception in thread "main" java.lang.NumberFormatException: For input string: ""
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:672)
at java.base/java.lang.Integer.parseInt(Integer.java:778)
at dev.flounder.Airports.parse(Airports.java:53)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
at dev.flounder.Airports.main(Airports.java:39)
Exception in thread "main" java.lang.NumberFormatException: For input string: ""
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:672)
at java.base/java.lang.Integer.parseInt(Integer.java:778)
at dev.flounder.Airports.parse(Airports.java)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
at dev.flounder.Airports.main(Airports.java)
行番号情報が無いとコンパイルした実行可能ファイルは、プロジェクトコードに対応するコールの行番号がない スタックトレースを生成しました。標準ライブラリや依存関係からの呼び出しは、 それらが別々にコンパイルされて影響を受けなかったため、まだ行番号を含んでいます。
スタックトレース以外にも、行番号が関与する同様のシチュエーションに直面することがあります。 例えば、IntelliJ IDEAのフレーム (Frames)タブなどです:


したがって、もし実際の行番号の代わりに-1
が表示され、あなたがそれが好きでない場合は、
あなたのプログラムが行番号情報を持ってコンパイルされていることを確認してください。
IntelliJ IDEAのフレーム (Frames)タブでバイトコードオフセットを直接確認することができます。
これには、
次のレジストリキーを追加します:
debugger.stack.frame.show.code.index=true


変数名
行番号情報と同様に、変数名もクラスファイルに格納されます。 私たちの例における変数テーブルは次のようになります:
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 this Ldev/flounder/Calculator;
0 4 1 a I
0 4 2 b I
テーブルには以下の情報が含まれています:
- Start:この変数のスコープが始まるバイトコードのオフセット。
- Length:この変数がスコープ内に残る命令の数。
- Slot:この変数が検索用に格納されるインデックス。
- Name:ソースコード中での変数の名前。
- Signature:Javaの型シグニチャ記法で表された変数のデータ型。
変数がデバッグ情報から欠落している場合、デバッガの一部の機能が期待通りに動作しないかもしれません。
実際の変数名の代わりに slot_1
、slot_2
などが表示されます。


ソースファイル名
この種のデバッグ情報は、どのソースファイルがクラスのコンパイルに使用されたかを示します。
行番号情報と同様、これがクラスファイルに存在すると、 外部ツールだけでなく、プログラムが生成するスタックトレースにも影響します。
Exception in thread "main" java.lang.NumberFormatException: For input string: ""
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:672)
at java.base/java.lang.Integer.parseInt(Integer.java:778)
at dev.flounder.Airports.parse(Airports.java:53)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
at dev.flounder.Airports.main(Airports.java:39)
Exception in thread "main" java.lang.NumberFormatException: For input string: ""
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:672)
at java.base/java.lang.Integer.parseInt(Integer.java:778)
at dev.flounder.Airports.parse(Unknown Source)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
at dev.flounder.Airports.main(Unknown Source)
ソースファイル名が無い場合、対応するスタックトレースコールは Unknown Source
と表示されます。
コンパイラフラグ
開発者としては、実行可能ファイルにデバッグ情報を含めるかどうか、そして、含める場合はどのタイプを含めるかを制御することができます。これは-g
コンパイラ引数を使って管理することができます。その方法は以下の通りです:
javac -g:lines,vars,source
以下はその文法です:
コマンド | 結果 |
javac | 行番号とソースファイル名を含むアプリケーションをコンパイルします(ほとんどのコンパイラのデフォルト) |
javac -g | 利用可能な全てのデバッグ情報を含んでアプリケーションをコンパイルします: 行番号、変数、ソースファイル名 |
javac -g:lines,source | 指定されたタイプのデバッグ情報を含んでアプリケーションをコンパイルします - この例では行番号とソースファイル名 |
javac -g:none | デバッグ情報を含まずにアプリケーションをコンパイルします |
デフォルト設定はコンパイラにより異なる場合があります。特定の指示がない限りデバッグ情報を完全に除外するものもあります。
MavenやGradleのようなビルドシステムを使用している場合、同じオプションをコンパイラ引数を通じて渡すことができます。例えば:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<compilerArgs>
<arg>-g:vars,lines</arg>
</compilerArgs>
</configuration>
</plugin>
tasks.compileJava {
options.compilerArgs.add("-g:vars,lines")
}
なぜデバッグ情報を削除すべきなのか?
デバッグ情報を含むことが開発中のデバッグプロセスを可能にし、これは開発に便利です。そのため、デバッグシンボルは通常開発ビルドに含まれます。プロダクションビルドでは、それらはしばしば除外されます。しかし、最終的にはあなたが取り組んでいるプロジェクトの種類によります。
以下に考慮すべきいくつかの事項を示します:
セキュリティ
デバッガはあなたのプログラムを改ざんするために使うことができるため、デバッグ情報を含めるとアプリケーションはわずかにハッキングやリバースエンジニアリングに対して脆弱になります。これは一部のアプリケーションでは望ましくない可能性があります。
デバッグ情報がないと、デバッガを使ってプログラムに干渉するのは多少困難になるかもしれませんが、それを完全に保護するものではありません。部分的なデバッグ情報や欠落したデバッグ情報でもデバッグは可能であり、これだけではあなたのプログラムの内部情報にアクセスを試みる個人を防ぐことはありません。したがって、リバースエンジニアリングのリスクが懸念事項である場合は、ソースコードの難読化などの追加の対策を講じるべきです。
実行可能ファイルのサイズ
実行可能ファイルに含まれる情報が多いほど、それは大きくなります。具体的にどれだけ大きくなるかは様々な要素によります。特定のクラスファイルのサイズは、命令の数や定数プールのサイズにより支配され、全体の見積もりを提供することは非現実的です。しかし、差が大きくなり得ることを示すために、私は以前にスタックトレースを比較するために使用したAirports.javaを使って実験しました。結果はデバッグ情報がない場合の/4,460バイトに対し、それがありますと5,664バイトでした。
ほとんどの場合、デバッグシンボルを含めても問題ありません。しかし、実行可能ファイルのサイズが懸念事項である場合、特に組み込みシステムの場合など、デバッグシンボルをバイナリから除外したくなるかもしれません。
デバッグのためのソースの追加
通常、必要なソースはプロジェクト内に存在するため、IDEはそれらを見つけるのに問題はありません。しかし、ソースコードがプロジェクト外に存在する必要がある状況もあります。例えば、あなたのコードが使うライブラリにステップインするときなどです。
この場合、手動でソースファイルを追加する必要があります:それらをソースルートの下に置くか、依存関係として指定します。デバッグ中、IntelliJ IDEAはこれらのファイルを自動的に検出し、JVMで実行されるクラスと一致させます。
プロジェクトが不足している場合
ほとんどの場合、元のプロジェクトを使って同じIDEでアプリケーションをビルド、起動、デバッグします。しかし、1つや2つのソースファイルだけがあり、プロジェクト自体が存在しない場合はどうでしょうか?
以下に必要最小限のデバッグ設定を示します:
- 空のJavaプロジェクトを作成します
- ソースファイルをソースルートの下に追加するか、依存関係として指定します
- デバッグエージェントと一緒にターゲットアプリケーションを起動します。Javaでは、これは通常、VMオプションを追加することによって行います。例えば:
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005
- 正しい接続詳細を持ったRemote JVM Debugの 実行設定を作成します。 この実行設定を使ってデバッガをターゲットアプリケーションにアタッチします。
この設定を使えば、元のプロジェクトにアクセスすることなくプログラムをデバッグすることができます。 IntelliJ IDEAは利用可能なソースを実行時のクラスと一致させ、デバッグセッションで使用することを可能にします。このようにして、単一のプロジェクトやライブラリクラスでもデバッグのエントリーポイントとなります。
実際の例を手で試してみたい場合は、Debugger.godMode() – デバッガーを使ってJVMアプリケーションをハッキングするをご覧ください。ここでは、ソースコードにアクセスすることなくプログラムの挙動を変更するために、この技術を使用しています。
ソースの不一致
デバッグ中に遭遇する混乱を招く状況のひとつは、アプリケーションが空の行で停止したように見える時や、エディタとフレーム (Frames)タブの行番号が一致しない場合です。


これは、デコンパイルされたコードをデバッグする時(これについては別の記事で説明します)や、JVMが実行するバイトコードとソースコードが完全に一致しない時に発生します。
バイトコードと特定のソースファイルとの間の唯一のリンクは、ファイルとそのクラスの名前であるため、デバッガはこの情報に依存する必要があります。これはほとんどの状況に対してうまく機能します。しかし、ディスク上のファイルのバージョンはアプリケーションのコンパイルに使用されたものと異なる可能性があります。部分一致の場合、デバッガは一貫性のない部分を特定し、素早く失敗するのではなくそれらを調整しようとします。差異の範囲によりますが、これは便利なこととなります。例えば、手元にあるソースが最もマッチするものでない場合などです。
運良く、正確なバージョンのソースが他の場所に存在する場合、それらをプロジェクトに追加し、デバッグセッションを再実行することでこの問題を解決することができます。
まとめ
本稿では、ソースファイル、バイトコード、デバッガとの関係について探求しました。日常的なコーディングには必ずしも必要ではありませんが、裏側で何が起こっているのかをより明確に理解することで、エコシステムへの理解を深め、非標準的な状況や設定問題から逃れるための手助けとなることがあります。私はあなたが本稿の理論とヒントを有用だと感じたことを願っています。
まだまだたくさんの話題がこのシリーズにはあるので、次回をお楽しみに。特定のトピックをカバーしてほしい、またはアイディアとフィードバックがあれば、ぜひお知らせください!