homeup mail to
sub title

サン認定Javaプログラマ試験反省
( 20050124 )

Japanese/English

先日、サン認定Javaプログラマの資格試験(310-035J)に合格したことはお伝えしたが、合格ライン52%で正答率60%というぎりぎりでの合格だったので、反省も込めて記憶に残っている限りでの問題を振り返ってみたい。飽くまで記憶に残っている限りでの問題なので、それぞれのサンプルコードがこのまま出題されたという保証はまったくない。むしろ実際の問題でポイントになっていた部分をうまく入れ込むように、僕が勝手にサンプルコードを作り出したとお考え頂きたい。

まずスレッド関係の問題は正直個人的に不得意分野なので勉強し直す必要がありそうだ。例えば次のような引っ掛け問題にまんまと引っ掛かってしまった。


public class ThreadTest implements Runnable {
	private int x;
	private int y;
	public static void main(String[] args) {
		ThreadTest t1 = new ThreadTest();
		(new Thread(t1)).start();
		(new Thread(t1)).start();
	}
	public synchronized void run() {
		for (;x<20;) {
			x++;
			y++;
			System.out.println(x + " " + y);
		}
	}
}

1行目の「implements Runnable」は「extends Thread」でも同じことになるが、このプログラムの出力がどうなるかというのが問題だ。正解は「1 1」「2 2」「3 3」...「20 20」という同じ値の数字が20行にわたって出力される。一見、二つの異なるスレッドがsynchronizedで同期化されたメソッドを呼び出しているのだから、「1 1」「1 1」「2 2」「2 2」...となりそうな感じがするが、二つの異なるスレッドは実は同一ThreadTestインスタンスt1を実行している。

したがってt1が最初に呼び出されるとrunメソッド自体がsynchronizedで宣言されているため、この最初の呼び出しがこのrunメソッドのロックを獲得する。そして「20 20」まで出力すると、その時点でt1スレッドのメンバー変数「x」の値は20になってしまう。二つ目のスレッドが「(new Thread(t1)).start()」でt1のrunメソッドのロックを獲得しても、xがすでに20になっているので、「for(;x<20;)」の条件がfalseになり、for文の中身が実行されることはない。この問題のポイントは、runメソッドがsynchronized宣言されているので、二つのスレッドが順不同でこのメソッドを実行することはないという点にある。


class TestRun implements Runnable {
	int i;
	public void run() {
		try {
			Thread.sleep(5000);
			i=10;
		} catch (InterruptedException ex) {
		}
	}
}
public class ThreadTest1 {
	public static void main(String[] args) {
		try {
			TestRun tr = new TestRun();
			Thread th = new Thread(tr);
			th.start();
			******
			int j = tr.i;
			System.out.println(j);
		} catch (Exception ex) {
		}
	}
}

この問題は、プログラムの出力が「10」になるようにするには、「******」の行にどんなコードを書けばよいかという問題である。一見、何もコードを入れなくても、出力は「10」になりそうだが、この問題のポイントは、実はこのプログラムが2つのスレッドから構成されていることを見破ることだ。「th.start();」という文で、TestRunクラスのインスタンスがスレッドとして呼び出されていることは分かりやすいが、このプログラムにはもう一つのスレッドが隠れている。それはThreadTest1クラスそのものである。

ThreadTest1クラスのmain()メソッドの中からTestRunクラスのインスタンスをスレッドとして起動したとしても、TestRunのスレッドがThreadTest1のスレッドよりも早く終了する保障はどこにもない。実際に「******」の部分に何もコードを書かずにこのプログラムを実行すると、TestRunスレッドの中の「Thread.sleep(5000);」という文でTestRunスレッドが5秒間待たされている間に、ThreadTest1スレッドが先に終了してしまうので、出力は「0」になってしまう。TestRunスレッドの中で、「i=10;」が実行される前に、メインのスレッドThreadTest1が終了してしまうのだ。

したがって「******」には、TestRunスレッドが完了するまで、ThreadTest1スレッドの方を待たせておくようなコードを書く必要がある。正解は「th.join();」である。join()メソッドは、スレッドの実行が完了するまで、呼び出しもとのスレッドに制御を戻さないためのメソッドなのである。


public class ThreadTest1 {
	public static void main(String[] args) {
		final StringBuffer s1 = new StringBuffer();
		final StringBuffer s2 = new StringBuffer();
		new Thread() {
			public void run() {
				synchronized(s1) {
					s1.append("A");
					synchronized(s2) {
						s2.append("B");
						System.out.print(s1);
						System.out.print(s2);
					}
				}
			}
		}.start();
		new Thread() {
			public void run() {
				synchronized(s2) {
					s2.append("C");
					synchronized(s1) {
						s1.append("D");
						System.out.print(s2);
						System.out.print(s1);
					}
				}
			}
		}.start();
	}
}

ここでは2つの異なるスレッドが実行されている。ポイントは、各スレッドの内部でs1、s2のStringBufferオブジェクトが同期オブジェクトとしてロックされていることだ。しかも入れ子になってロックされているので、一方のスレッドが実行されるや、そのスレッドはStringBufferオブジェクトを2つともロックしてしまうことになる。すると残る問題は、どちらのスレッドが最初に実行されるかということだ。

Javaの言語仕様上は、先に呼び出したからといって、そのスレッドが必ず先に呼び出される保証はない。したがって、内部クラスとして定義されているこれら2つのスレッドは、どちらが先に実行されても不思議はない。つまりこのプログラムを実行したときの結果は、先に定義されているスレッドが先に実行された場合の「ABBCAD」、後で定義されているスレッドが先に実行された場合の「CDDACB」のどちらかしかないというのが正解だ。ところがWindowsXPでこのプログラムをコンパイルすると、結果は100%「ABBCAD」となる。WindowsXPのスレッド処理がそういう設計になっているからだとしか言いようがないが、UNIXで実行すれば「CDDACB」になるのだろうか。検証できないので不明である。

ちなみに二つのStringBufferオブジェクトがfinalで定義されている理由は、内部クラスからアクセスされるからである。finalをはずしてコンパイルすると、内部クラスからアクセスする変数は必ずfinalで宣言してくださいという主旨のエラーメッセージが表示されてコンパイルエラーになる。また、final宣言されているのに、StringBufferの中身を変更できることを不思議に思うJava初心者もいるかもしれないが、final宣言することで変更できないのは、s1、s2が参照するStringBufferオブジェクトであって、s1、s2が相変わらず同じStringBufferオブジェクトを参照してさえいれば、s1、s2の中身を変更することになんら問題はない。StringBufferの中身というのは、StringBufferオブジェクトのメンバー変数の値にすぎず、StringBufferオブジェクトそのものではないからだ(性格が変わっても、私は私という例えが適切かもしれない)。


public class ExceptionTest {
	String str = "";
	public static void main(String[] args) {
		ExceptionTest et = new ExceptionTest();
		for (int i=0; i<3 ; i++) {
			et.calc(i);
		}
		System.out.println(et.str);
	}
	public void calc(int i) {
		try {
			if (i % 3 == 2) throw new Exception();
			str += "O";
		} catch (Exception ex) {
			str += "E";
			return;
		} finally {
			str += "F";
		}
	}
}

最後はスレッド関連の問題ではなく、例外処理のfinally句についての引っ掛け問題。このプログラムを実行すると出力はどうなるだろうか。正解は「OFOFEF」である。ポイントは、iの値が2の状態でcalc()メソッドが呼び出されたときにfinally句が実行されずに、catch句の中のreturn;文で制御が戻ってしまうのではないかと勘違いしてしまうことだ。ところが、驚くべきことにたとえcatch句の中にreturnがあろうが、それでもfinally句は必ず実行されるのである。したがって出力される文字列の最後には、ちゃんと「F」がくっついた状態でこのプログラムの実行は終了する。僕はこの問題にまんまと引っ掛かってしまった。


public class StringTest {
	public static void main(String[] args) {
		String s1 = "ABC";
		s1.toLowerCase();
		s1 += s1.replace('a', 'z');
		System.out.println(s1);
	}
}

最後は非常に短いプログラムで、実行したときの出力はどうなるでしょうという問題だ。正解は「ABCABC」である。ありがちな間違いは「abczbc」だろう。僕も1回目の回答ではこう答えてしまったが、見直しのときに自分の間違いに気づいた。ポイントは「s1.toLowerCase();」の行である。この行はs1の文字列の中身をまったく変更しないということに気づかなければならない。仮に「s1 = s1.toLowerCase();」となっていれば、当然s1の中身の文字列は変更されるが、「s1.toLowerCase();」とだけ実行してもs1の中身は依然として「ABC」のままなのである。これも一種の引っ掛け問題だろう。

このようにサン認定Javaプログラマ試験には、ちょっとせこいんじゃないのと言いたくなるような引っ掛け問題がたくさん出題される。Stringクラスの振る舞いや、スレッドの同期の基礎理論を正確に理解しておかないと、用意された落とし穴すべてにはまって不合格になり、2万円以上の受験料を不意にしてしまうことになる。

ただ、Javaで作られたシステムの品質は、結局のところクラス設計の良し悪しにかかっているような気がするので、この試験で出題される落とし穴にはまらない細心さと同時に、オブジェクト指向の正しい設計手法を身につけないと、保守性・再利用性の良いJavaのシステムはできないだろうと考える。


無断転載禁止

サラリーマンを考える 日本的なるものを考える 日常生活を考える
「おじさん」を考える 映画/音楽/書物を考える 情報システムを考える
愛と苦悩の日記 筆者のYouTubeチャンネル

homeup mail to