スタックトレース

Since: May./04th/2005

ここまで、例外情報を出力するために、Throwable#printStackTrace() を使ってきました。ここで、スタックトレースの中身の読み方と、取得方法について見直しておきます。

スタックトレースとはなんだろうか

スタックトレースの出力結果は、スタックというものの中身をプリントしたものです。Java は実行時に、スタック、ヒープ、レジスタと呼ばれるメモリ領域を使用します。ヒープにはオブジェクトやクラスなどのデータが含まれています。スタックには、実行中の命令が含まれます。詳細は、JVM のメモリ構造を参照ください。ここでも簡単に解説しておきます。

JVM の働き

JVM (Java Virtual Machine)は、Javaアプリケーションを実行する製品です。アプリケーションサーバなどもJVM上のアプリケーションであり、サーバ機能や管理機能などがJVM上のアプリケーションとして実装されています。JVMは、アプリケーションに対して、OS以下の実行環境を隠蔽する機能も持っており、これが"Write Once, Run Anywhere"を実現しているのです。

JVM自身はOS上のプロセスの一つとして、メインメモリ上にアドレス空間を構築します。アドレス空間とは、当該のプロセス管理下のメモリ管理単位です。OSは、ディスク上のプログラムをメモリ上にロードすることでプロセスを起動し、プロセスごとに物理メモリを割り振り(ディスパッチして)、使用状況を管理します。

メモリ上にロードされたJVMのアドレス空間には、Javaアプリケーションをロードするための領域が確保されます。代表的な領域が、ヒープとスタックです。

ヒープ

ヒープは、主として、クラス定義、メソッド定義などの静的な情報と、オブジェクトのインスタンスを割り当てる領域です。そのサイズは、JVMのオプションで指定可能です。オプション-Xmsは、ヒープの最小サイズを指定し、-Xmxは、最大サイズを指定します。JVMは、最初に-Xmsの引数で指定されたサイズのヒープを確保して、オブジェクトを割り振っていき、不足すると自動的にサイズを拡張します。その上限を与えるのが-Xmxの引数です。

オブジェクトは、JVM上の管理テーブルで参照とメモリ上のアドレスが紐付けられており、プログラムからは、参照を通してしかアクセスできません。よって、実行中プログラムの後続の処理の中に、当該オブジェクトへの参照がなくなると、そのオブジェクトへは二度と再びアクセスすることが適いませんので、死んだオブジェクトだと認識できます。

ガベージ・コレクタ

ガベージ・コレクタ(GC)は、JVMが実装する機能の一つであり、ヒープに対する自動メモリ管理機能です。GCが起動すると、まず、割り振り済みのオブジェクトの中から生きているものをマークします。次に、マークされていない死んだオブジェクトを管理テーブルから削除して当該オブジェクトが占有していたメモリ領域に、新たなオブジェクトを割り当てられるようにします。これをスウィープと呼びます。最後に、大きなオブジェクトを割り当てられるように、生きているオブジェクトを整理して、断片化した領域をなくします。ヒープ領域の余った領域が、-Xmsよりも大きい場合は、ヒープサイズを自動的に縮小(シュリンク)します。これをコンパクトと呼びます。

GCの基本的な機能は、マーク=スイープ=コンパクトです。JVMのメーカによって、GCの機能には差がありますが、Sun MicrosystemsのJVMは、マーク=スイープ=コンパクトを実行するフルGCの他に、コンカレントGC、インクリメンタルGC、ジェネレーションGCの機能を備えています。ベースGCが起動すると、アプリケーションの処理を一時停止しなければなりません。これを、"Stop The World"と呼びます。ここに挙げたその他のGCの仕組みは、後ろに行くほど、停止時間を最短にして、CPU上の処理量を最小にします。

スタック

オブジェクトのインスタンスがヒープに割り当てられる一方で、メソッド呼び出しのような命令は、スタックと呼ばれる領域に作成されます。この仕組み古くからOSなどでも実装されてきた仕組みで、OSの場合は、マシン語のインストラクション(命令)をコマンド・スタックと呼ばれる領域に積み上げていくことで、実行順序を管理しています。このように管理/実行されるコンピュータをスタック・マシンと呼び、スタックに積む単位をフレームと呼びます。

JVMのスタックには、メソッド呼び出しが作成され、そのメソッドの上に、当該メソッドから呼び出されるメソッドが積まれていきます。その最大サイズは、オプション-Xssで指定できます。スタックは、一つのJVMに対して複数作成されます。その単位はスレッドごとです。

JVMプロセスの中で、アプリケーションを実行する単位をスレッドと呼びます。一つのJVM上で、複数のスレッドを作成/実行することによって、並列処理が可能になります。例えば、Webアプリケーションの場合は、最初に複数のスレッドを作成して蓄えておき、クライアントからのコネクションごとにスレッドを割り当てて、処理が終わるとプールに戻して次のコネクションが再利用します。プールに蓄えておくことで、毎回作成する負荷をなくします、また、複数のスレッドによって、同時に複数の処理を、平行して走らせています。

スタックは、スレッドごとの命令を管理するためのもので、各スレッドごとに一つずつ作成されるということです。一方、ヒープは、JVMごとに一つだけで、複数のスレッドから共有されます。

そもそもスタックとは、酪農家で藁や干草を積み上げた山のことを指します。新しく刈り取った束はスタックの上に積まれ、上から順番に食べられてなくなります。データ構造としては、後入れ先出し行列(LIFOキュー: Last-In-First-Out)として実装されます。

JVMの場合は、スタックに積み上げられたメソッド呼び出しの、一番上にいるものが現在実行中のメソッドです。実行が完了すると、スタックから取り除かれ、呼び出し元のメソッドが一番上になり、新たに実行中のメソッドとなります。つまり、メソッドが完了すると、呼び出し元にコンピュータの制御が移るわけです。

スタックトレースは、このスタックに積まれたメソッド呼び出しをリストしたものになります。スタックが呼び出し順にリストされることで、例外発生にいたる履歴を表しているわけです。

スタックの役割

スタックトレースはデバッグのための重要な情報です。アプリケーションの外から見た動作をログから把握して、JVM内部の動作をスタックトレースから把握します。実行時のメモリ上の状態を見るためには、コアダンプなどを解析する必要がありますが、業務アプリケーションの場合は、スタックトレースから例外発生時の動作を掴み、デバッガ上で再現することで変数の値を追跡すれば、たいていデバッグできます。それでもデバッグできず、JVM上のバグの可能性を判別する必要にいたって始めて、ヒープのダンプをフォーマットして解析します。

スタックトレースが有意義なのは、明示的な例外発生時だけではありません。意図しない動作があった場合にも、動作履歴を確認するために、例外オブジェクトのメソッドprintStackTrace()やリリース 1.4で新たに導入されたgetStackTrace()を用いて、明示的にスタックトレースを出力するようにお勧めします。

スタックトレースで注意する点

スタックトレースには、ソースコード中の行番号まで表示されるので、スタックトレースが読めるようになると、デバッグが格段に楽になります。但し、行番号が表示されないケースもあります。

コンパイルオプション-g/-O

javac コマンド-g オプションを指定すると、デバッグ情報を制御することができます。例えば、「-g:none」と指定した場合は、生成されるクラス・ファイルに一切のデバッグ情報が書き込まれなくなります。そのため、ファイル・サイズを圧縮することができます。ただし、その場合、行番号やソース・コード上の変数名なども書き込まれなくなるため、スタック・トレースにもそれらの情報が出力されなくなってしまいます。

-g
局所変数を含むすべてのデバッグ情報を生成します。デフォルトでは、行番号およびソースファイル情報だけが生成されます。
-g:none
デバッグ情報を生成しません。
-g:{keyword list}
コンマで区切られたキーワードリストにより指定された、特定の種類のデバッグ情報だけを生成します。次のキーワードが有効です。
source
ソースファイルのデバッグ情報
lines
行番号のデバッグ情報
vars
局所変数のデバッグ情報

また、SDK 1.3 以下では -O オプションを指定することで、クラスファイルの最適化を施すことができます。最適化すると、冗長なコードが短縮されて、実行時間を短縮する効果があります。その一方で、コンパイル時間が長くなり、クラスファイルのサイズが大きくなる可能性があります。その副作用として、デバッグ情報が書き込まれなくなり、行番号が表示されなくなります。

いずれにしても、テスト/デバッグ時には指定しないなどの使い分けが必要です。

難読化

JVMの基本機能としては搭載されていませんが、サードベンダーのパッケージには、難読化という処理を施すものがあります。難読化は、クラスを javap -c でディスアセンブルすることで、ソースコードを復元されないようにすることです。ソースを復元することを、リバースエンジニアリングと呼びます。ソースがばれることは、セキュリティ/知的財産権(IP)/ライセンス管理などの観点で好ましくありません。

難読化ツールによっては、ソースコードを復元されづらくするだけではなく、サイズの圧縮と最適化の機能を含むものもあります。一般に、難読化ツールを噛ませることによって、スタックトレースからは、ソースコード名や行番号が出力されなくなります。

JIT/HotSpot

JVMの主要な機能に、JIT (Just-In-Time)コンパイルというものがあります。JVMは、クラスファイルに記述されたバイトコードを読み取り、実行時にマシン語にコンパイルして、実行可能モジュールを作成します。古いJITコンパイラは、最初に全てのコードをコンパイルしてから実行開始します。そのため、起動に時間が掛かります。そこから進化したHotSpot VM は、実行中に負荷の高い処理を動的に識別し、まとめてコンパイルしておくことで、以降の処理時間を短縮します。そのため、立ち上がり直後のパフォーマンスと、安定後のパフォーマンスに差異があります。

JIT/HotSpotを有効にしたJVMの場合、スタックトレースに行番号が出力されません。この場合も、デバッグモードのときはJITをオフにしておき、パフォーマンスが必要な本番稼動時はオンにしておくなどの使い分けが必要です。JITの有効化/無効化のオプションいついては、ご利用のJVMベンダー提供ドキュメントを参照してください。

スタックトレースの標準エラー出力: printStackTrace()

次のリストは、printStackTrace() の実装例です。引数が整数でない場合に、スタックトレースをプリントしています。

class ExceptionDemo1 {
	public static void main(String[] args) {
		int x = 10;
		int y = 0;
		System.out.println(x/y);
	}
}
>javac ExceptionDemo1.java

>java ExceptionDemo1
Exception in thread "main" java.lang.ArithmeticException: / by zero
        at ExceptionDemo1.main(ExceptionDemo1.java:5)

ここで出力されているスタックトレースからは、発生した例外が java.lang.ArithmeticException であり、発生箇所がクラス ExceptionDemo1 のメソッド main() だと分かります。また、ソースコード ExceptionDemo1.java の 5 行目で発生していることまでわかります。

次に、もう少し複雑な例を見てみましょう。次の例は、整数を判定するコードです。Jakarta Commons の Validator)の断片を加工しました。

class ExceptionPrintDemo {
	public static Integer formatInt(String value) {
		if (value == null) {
			return null;
		}
		try {
			return new Integer(value);
		} catch(NumberFormatException e) {
			e.printStackTrace();
			return null;
		}
	}
	public static void main(String[] args) {
		if (formatInt(args[0]) != null) {
			System.out.println(args[0] + "は整数です。");
		} else {
			System.out.println(args[0] + "は整数ではありません。");
		}
	}
}
>javac ExceptionPrintDemo.java

>java ExceptionPrintDemo 1
1は整数です。

>java ExceptionPrintDemo 1.1
java.lang.NumberFormatException: For input string: "1.1"
        at java.lang.NumberFormatException.forInputString(Unknown Source)
        at java.lang.Integer.parseInt(Unknown Source)
        at java.lang.Integer.<init>(Unknown Source)
        at ExceptionPrintDemo.formatInt(ExceptionPrintDemo.java:7)
        at ExceptionPrintDemo.main(ExceptionPrintDemo.java:14)
1.1は整数ではありません。
java.lang.NumberFormatException: For input string: "1.1"
java.lang.NumberFormatExceptionが発生している。詳細メッセージは、For input string: "1.1"
at java.lang.NumberFormatException.forInputString(Unknown Source)
スタックの一番上に積まれているフレームに含まれるメソッドが、java.lang.NumberFormatException.forInputString()。ソースコードは不明。この例外が発生したメソッドを示している。
at java.lang.Integer.parseInt(Unknown Source)
二番目に積まれたフレームはjava.lang.Integer.parseInt()。ソースコードは不明。
at java.lang.Integer.(Unknown Source)
三番目に積まれたフレームはjava.lang.Integer.<init>()。ソースコードは不明。
at ExceptionPrintDemo.formatInt(ExceptionPrintDemo.java:7)
四番目に積まれたフレームはExceptionPrintDemo.formatInt()。ソースコード ExceptionPrintDemo.java の 7 行目で、上のメソッドを呼び出している。
at ExceptionPrintDemo.main(ExceptionPrintDemo.java:14)
五番目に積まれたフレームはExceptionPrintDemo.main()。ソースコード ExceptionPrintDemo.java の 14 行目で、上のメソッドを呼び出している。

この例では、例外 NumberFormatException を直接発生させたのは、コアパッケージ java.lang の NumberFormatException#forInputString() であり、それを呼び出したのは Integer.parseInt() だとされています。これらはコアパッケージに含まれるクラスであり、行番号が出力されていません。

自分で作ったコードで、最初に挙がっているのが、クラス ExceptionPrintDemo のメソッド formatInt() です。ソースコード ExceptionPrintDemo.java の 7 行目だと特定されています。実際に 7 行目を見てみると、return new Integer(value); となっており、この引数 value が例外の直接の原因であることが分かります。

プログラム化されたアクセス: getStackTrace()

Throwable#printStackTrace() は、標準エラー出力に出力されますが、基本的には文字列です。プログラムの中から、スタックトレースにアクセスして、回復処理を実装しようとすると、java.util.StringTokenizerString#substring() などを用いて、文字列処理をする必要がありとても大変です。そうでなくとも、Apache Jakarta Log4J)や、SDK 1.4 で追加された Logging API (JSR47) のようなロガーを通してエラーメッセージの本文中に、スタックトレースの断片を含めたいことがあるでしょう。

このために、SDK 1.4では、Throwable#getStackTrace() が追加されました。メソッド Throwable#getStackTrace() の戻り値は、クラス java.lang.StackTraceElement 型の配列です。各要素がスタックに詰まれた要素(スタックフレーム)の一つを表します。0番目の要素が、スタックの一番上、すなわち、例外を発生させたメソッドを含みます。

次のコードは、getStackTrace を使うように変更したものです。ここでは、クラス名、メソッド名、行番号を出力しています。StackTraceElement 型オブジェクトに対する API の詳細については、Sun Microsystems の API仕様書を参照してください。

class ExceptionPrintDemo2 {
	public static Integer formatInt(String value) {
		if (value == null) {
			return null;
		}
		try {
			return new Integer(value);
		} catch(NumberFormatException e) {
			StackTraceElement[] stElem = e.getStackTrace();
			for (int i = 0; i < stElem.length; i++) {
				System.out.print(stElem[i].getClassName() + ": ");
				System.out.print(stElem[i].getMethodName() + ": ");
				System.out.println(stElem[i].getLineNumber() + ";");
			}
			return null;
		}
	}
	public static void main(String[] args) {
		if (formatInt(args[0]) != null) {
			System.out.println(args[0] + "は整数です。");
		} else {
			System.out.println(args[0] + "は整数ではありません。");
		}
	}
}
>javac ExceptionPrintDemo2.java

>java ExceptionPrintDemo2
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 0
        at ExceptionPrintDemo2.main(ExceptionPrintDemo2.java:19)

>java ExceptionPrintDemo2 1.1
java.lang.NumberFormatException: forInputString: -1;
java.lang.Integer: parseInt: -1;
java.lang.Integer: <init>: -1;
ExceptionPrintDemo2: formatInt: 7;
ExceptionPrintDemo2: main: 19;
1.1は整数ではありません。

クラスの中に、ソースコードの行数とのマッチングテーブルが含まれていない場合は、-1 が返されていることが分かります。

このプログラム化されたアクセスは、プロファイラなどでも使用されます。JDK 1.5 で追加された、 Monitoring and Management Specification for the Java Virtual Machine (JSR174) では、java.lang.management.ThreadInfo#getStackTrace() を通してスタックトレースにアクセスできます。



Copyright © 2005 SUGAI, Manabu. All Rights Reserved.
SEO [PR] !uO z[y[WJ Cu