おすすめリンク - Java Performance Tuning News(日本語版)
さて、このページではJavaに関する話をしようと思います。
パフォーマンス改善を中心にした話なので、ちょっと難しい話も出てきますが
頑張って着いてきて(笑)下さい。
まず最初に話したい事は、これから説明するJavaプログラムのパフォーマンス改善策は
あくまで最終手段であるということです。
まずやるべき事、それはアプリケーションの中で
「どの部分にどの位の時間が掛かっているか」を計測する事です。
そして、一番時間が掛かっている部分から改善することを心掛けましょう。
おそらく多くの場合、それはデータベースへアクセスしている箇所のはずです。
ですから、まずデータベースの設計を見直します。
次に、データベースへ発行しているSQL文をチューニングします。
それらをチューニングした上で、さらにパフォーマンスを改善したい場合にのみ
プログラムの改良を考えてみて下さい。
Javaからプログラムを始めた人にとって、「参照」という概念は理解しにくいものです。
しかし、これを理解しておかないと後々やっかいな目に遭うことは確実です。
簡単に説明をしてみます。
プリミティブ型という言葉は聞いたことがあるでしょうか。
これは、int や float などJava言語によって予め決められている型のことです。
プリミティブ型は、Javaで唯一「参照」のできない型になっています。
どういう事かというと…
int val1 = 10; int val2 = val1; val1 = 30;
1行目で、val1 に 10 という値が代入されます。
続く2行目では、val2 に val1 の値を代入しています。
そして3行目で val1 に 30 を代入した後、val2 の値はいくつでしょうか?
当然、10 ですよね。val1 と val2 は違う変数だからです。
言葉だけでは解りにくいので、図を交えて説明します。
val1 と val2 は変数宣言をした時点でそれぞれ独立したものとして扱われます。
val2 が val1 と常に同じ内容を指すようにする事はJavaでは不可能です。
では次に、以下の例を見て下さい。
Date date1 = new Date(); Date date2 = date1; date1.setTime(1000);
先ほどと同じように date1 に現在の日付を表すDateオブジェクトを代入し、
date2 に date1 の値を代入しています。
さてここで、date2.getTime()
は一体いくつを返すでしょうか?
答えは 1000 です。先程の例と違いますね。
これは、「date1 と data2 が同じオブジェクトを参照している」からなのです。
図で説明してみます。
先程の図とかなり違うことが解りますよね。
Javaでは、プリミティブ型以外の全ての型は「参照」を使って値を表現します。
図中の矢印が「参照」を表しています。
Javaが出現した当初、「Javaは(C++と違って)ポインタが無くなった」という風な記事をよく見かけました。 この表現は当然間違っています。 Javaでは、プリミティブ型ではポインタを利用できず、 それ以外の全てのオブジェクトはポインタ(参照)を利用して変数を扱います。 ですから、Javaを使う上でポインタを意識する必要は無くなったかも知れませんが、 ポインタの概念を理解する必要は当然ある訳です。
new Date()
によって、一つのオブジェクト(Date型)が生成されます。
そして、date1 = ...
の部分によって、date1 はこのオブジェクトを「参照」するようになります。
次に、date2 = date1
の部分です。
これによって、date2 は「date1 が参照しているものと同じオブジェクトを参照する」ようになります。
最後に、date1.setTime(1000)
の部分です。
これは、「date1 が参照しているオブジェクトのsetTimeメソッドを呼び出す」という事をやっています。
date1 と date2 は全く同じものを参照しているので、date1.getTime()
も date2.getTime()
も共に 1000 を返すことになるのです。
そしてこのとき、date1 == date2
は true を返します。
これを「date1とdate2は同一である」と言います。
ではここで、次の文を実行してみます。
date1 = new Date(2000); date2 = new Date(3000);
結果は以下のようになります。
ここで例えば date1.setTime(4000)
とやれば図の上方にあるDateオブジェクトの内容が変更されdate2.setTime(4000)
とやれば図の下方にあるDateオブジェクトの内容が変更されます。date1 == date2
は false を返します。
両者の中身は同じかもしれませんが、参照しているオブジェクトは全くの別物だからです。
ただし、date1.equals(date2)
は true を返します。
equalsメソッドは通常「中身が同じならばtrueを返す」ことになっているからです。
これを「date1とdate2は同値である」と言います。
ここで、一番最初に生成したDateオブジェクトはどこからも参照されていません。
オブジェクトがこういう状態になると、そのオブジェクトはガーベッジ・コレクション(GC)の対象となります。
次にGC処理が走った時、このオブジェクトは消滅するでしょう。
では最後に、以下の文を実行します。
date1 = null; date2 = null;
結果は以下のようになります。
date1 と date2 は共に、どのオブジェクトも参照していません。
この状態で date1.setTime(10000)
を実行するとどうなるでしょうか?
そう、date1が参照しているオブジェクトは存在しないのでNullPointerException
が発生することになるのです。
キーと値を関連付けて格納するのがMapインターフェイスです。
実装するクラスにはいくつかの種類がありますが
これらの使い分けについて考えてみます。
ここでは代表的だと思われる3つのクラスのみ扱います。
おそらく、普通の人はMapといえば迷わずこのクラスを使うと思います。
が、いくつかの点には気を付ける必要があります。
putの際にNullPointerExceptionが発生する可能性があります。
他の2つのクラスと大きく異なっている点がここです。
マルチスレッドでも正常に動く反面、同期化のためのオーバーヘッドも掛かるのです。
シングルスレッドで使うなら、後述するHashMapを使った方が効率が上がります。
詳しい事は同期化についてを読んでみて下さい。
実をいうと、Hashtableの使用というのは現在推奨されていません。
JDK1.2以降では、後述するHashMapを利用するようにします。
HashMapでも同期化を取ることは可能ですので
このクラスを利用する必要性はほぼ無いはずです。
Hashtableとほぼ同じですが
キー、値共にnullを使用できます。同期化はされません。
JDK1.2以降に登場したコレクションクラスの一部です。
Hashtableよりはこちらを利用しましょう。
キーがソートされて格納されるMapです。同期化はされません。
TreeMap最大のメリットがこれです。
getメソッドによる値の取得が、多くの場合他の実装に比べて圧倒的に高速化されます。
もちろん良い所があれば弱点もあります。
逆にputやremoveには時間が掛かるのです。
2点から考えて、頻繁にput/removeを繰り返すようなマップにTreeMapは向いていません。
tを使う頻度が高く、しかもマップが大規模な場合には
の威力を発揮できるクラスです。
この機能を使いたかったら迷わずこのクラスを選択すべきです。TreeMap.subMap(fromKey, toKey)
メソッドにより、
fromKey〜toKey のキー範囲を持つ部分的なマップを返します。
キーの順序付けがされているからこそ利用できる機能です。
今までは「どのクラスを使用すべきか」という点について考えてきましたが
ここからは「実装時に注意する点」について考えていきます。
Hash系マップについての記述を以下に記します。
これらの動作はほぼ同じなので、これ以降2つをまとめてHashMapと記述します。
String
など、java標準パッケージのクラスをキーに使う分にはいいのですが
自分で定義したクラスをキーにする場合には注意が必要です。
その独自クラスに「ある」メソッドを定義しないと正しい結果が得られない可能性があるのです。int hashCode()
と boolean equals(Object)
の2つです。
例を挙げてみます。以下のようなクラスを定義します。
class MyClass { int iVal_; float fVal_; String sVal_; public MyClass(int iVal, float fVal, String sVal) { iVal_ = iVal; fVal_ = fVal; sVal_ = sVal; } public int hashCode() { return iVal_; } public boolean equals(Object obj) { MyClass myObj = (MyClass)obj; return (iVal_ == myObj.iVal_ && fVal_ == myObj.fVal_ && sVal_.equals(myObj.sVal_)); } public String toString() { return sVal_; } }
メチャクチャ適当ですな(笑)。で、以下のようなロジックを考えます。
便宜上の為、行番号を付けることにします。
1: Map map = new HashMap(); 2: MyClass obj1 = new MyClass(1,2,"abc"); 3: MyClass obj2 = new MyClass(1,3,"def"); 4: MyClass obj3 = new MyClass(2,4,"ghi"); 5: MyClass obj4 = new MyClass(1,3,"def"); 6: map.put(obj1, "ABC"); 7: map.put(obj2, "DEF"); 8: map.put(obj3, "GHI"); 9: map.put(obj4, "JKL"); 10: MyClass key1 = new MyClass(1,2,"abc"); 11: MyClass key2 = new MyClass(1,3,"def"); 12: MyClass key3 = new MyClass(2,4,"ghi"); 13: MyClass key4 = new MyClass(1,3,"def"); 14: System.out.println(map.get(key1).toString()); 15: System.out.println(map.get(key2).toString()); 16: System.out.println(map.get(key3).toString()); 17: System.out.println(map.get(key4).toString());
では、処理を追って説明します。
putメソッドにより、mapオブジェクトの MyClass(1,2,"abc")
というキーに"ABC"
という String
オブジェクトが関連付けられます。
ここから少し複雑になってきます。
単純に考えれば、ここでの処理は
「mapオブジェクトの MyClass(1,3,"def")
というキーに"DEF"
という String
オブジェクトが関連付けられる」となります。
結果としてそうなるのですが、ここではその内部処理を追ってみましょう。
その前に理解しておく事があります。
誰もが知ってるこの事ですが、その「同じだという判断」を
どのように行なっているかが重要なのです。
HashMap.put
内部では、全キーをループして同じキーが無いかどうか調べています。
つまり、putメソッドの呼出時間はマップが保持するキーの総数に比例して大きくなります。
(正確に言えばそうではないのですが…詳しくは後述します)
以下、HashMap.put
メソッド内のロジックから抜粋です(JDK1.4.1)。
解りやすくするために多少加工してあります。
public Object put(Object key, Object value) { int hash = key.hashCode(); int i = hash & (table.length-1); for (Entry e = table[i]; e != null; e = e.next) { if (e.hash == hash && e.equals(key)) { // 同じキーが存在したので、対応する値をvalueに置き替える。 return; } } // 同じキーが存在しないので、マップに新しくキーと値を登録する。 return; }
まず最初に、キーの hashCode()
メソッドを呼び出します。
これがこのキーのハッシュ値(int型)になります。
次に、マップが保持する全キーをループさせます。
ループ変数は e
であり、これはマップに保持されているキーに相当します。
ループ内のif文は、2つの式の論理積(&&)で構成されています。
1つ目の式でハッシュ値を比較し、2つ目の式でequalsメソッドを呼び出しています。
どちらも等しければ、これら2つのキーは同じと見なされるのです。
なぜこんな「2段構え」の作りになっているのでしょうか。
これは、パフォーマンスを向上させる為の工夫なのです。
実際問題、このif文はequalsメソッドの呼び出しだけ行なっても全く同じ結果を返します。
ただ、equalsメソッドは一般的にそれなりのコスト(=呼出時間)が掛かります。
それに比べてハッシュ値は変数の比較ですから、ほぼコストは無いに等しい程小さいのです。
一般に if (a && b)
の構文では、aがfalseの場合はbを「評価しません」。
上の例で言えば、ハッシュ値が異なった場合にはequalsメソッドは「呼び出されません」。
これによって、不必要なequalsメソッド呼出を避けているのです。
では、以上を踏まえた上でもう一度流れを追ってみましょう。
まず、MyClass.hashCode()
が呼び出され、obj1
のハッシュ値は1になります。
まだマップにキーは存在しないので、マップにはobj1 -> "ABC"
という対応が登録されます。さらに…そう、このとき一緒に1というハッシュ値もobj1 -> "1"
という形で登録されているのです。
obj2
のハッシュ値が1と計算されます。
そして、今度はマップにキーが存在するのでループが行なわれます。
ここではまだ1組しか登録されてないのでループとは言えないかも知れませんが…
ループ変数 e
には、先ほど登録した obj1
が初期値として与えられます。
ここでe.hash == hash
は真になります。e.hash
(obj1のハッシュ値)と hash
(obj2のハッシュ値)は共に1だからです。
よって、e.equals(key)
が呼び出されます。
解りやすく書けば、obj1.equals(obj2)
となります。これは真にはなりません。
以上をもって、obj1
は obj2
と同じではないことが判明しました。
マップには obj2 -> "DEF"
という対応が登録されます。
obj3
のハッシュ値が2と計算されます。
ループは obj1
と obj2
の2回繰り返されます。順番は特定できません。
今度はハッシュ値が先程までと異なるので、eqaulsメソッドは一度も呼び出されません。
obj3
は、obj1, obj2
と同じではないと判断されたので
マップには obj3 -> "GHI"
という対応が登録されます。
先程軽く触れましたが、実際にはこの処理は最適化されるので 必ずしも2回ループが繰り返される訳ではありませんが、 説明をわかりやすくする為にここではそれを考慮しない事にします。
obj4
のハッシュ値は1と計算されます。
ループは obj1, obj2, obj3
の3回繰り返されます。
今度はハッシュ値が1なので、obj1, obj2
のequalsメソッドが呼び出されます。obj1.equals(obj4)
は成り立ちませんが、obj2.equals(obj4)
は真になるのでobj2
は obj4
と同じだと判断されました。
マップ内で obj2
に対応する値が "DEF"
から "JKL"
に置き換えられます。
つまり、 obj2 -> "JKL"
という対応になった訳です。
getの内部では、ほとんどputと同じような処理を行なっています。
これも、解りやすくする為にJDKのソースを加工しています。
public Object get(Object key) { int hash = key.hashCode(); int i = hash & (table.length-1); for (Entry e = table[i]; e != null; e = e.next) { if (e.hash == hash && e.equals(key)) { return e.value; } } return null; }
取得したいキーがマップ内に存在すればその値(e.value
)を返し、
存在しなければ null
を返します。
getの結果は上から順番に、"ABC", "JKL", "GHI", "JKL"
となります。
7行目で登録した obj2 -> "DEF"
という対応付けは
9行目で obj2 -> "JKL"
という対応に上書きされているので
15,17行目のgetはどちらも "JKL"
を返します。key2, key4
はいずれも obj2
と同じだと見なされるからです。
ただし、くれぐれも (key2 == obj2)
は真ではありません。
Javaの==演算子は、2つのオブジェクトが「同一」である場合のみ真を返します。key2
と obj2
は異なるオブジェクトなので「同一」ではありませんがkey2.equals(obj2)
が真になるから2つは同じ(同値)と見なされたのです。
「オブジェクトの「参照」とは」 ← ここら辺の説明を図解入りでしていますのでご参考までに。MyClass
オブジェクトは、3つのフィールドが全て同じ値のときに「同値」と判断したいので
上記のようなequalsメソッドをオーバーライドしましたが
別にそうである必要はありません。例えば、
public boolean equals(Object obj) { return (iVal_ == (MyClass)obj.iVal_); }
としたって良いのです。こうすると、key1, key2, key4
は全て同じ(同値)だと見なされます。
getの結果は上から順番に、"JKL", "JKL", "GHI", "JKL"
となるでしょう。
以上のことをまとめると、キーに使用するクラスでは
適切なhashCodeメソッドを実装することがパフォーマンス向上への近道です。
そのとき守らなければならない事として、a.equals(b)
が成り立つ場合、必ず a.hashCode() == b.hashCode()
が成り立たなければなりません。
この逆は必ずしも守る必要はありません。
つまり、2つのハッシュ値が同じであってもその2つが同じ(同値)である必要は無いわけです。
ですが、Javaのマニュアルにも書いてある通り
「等しくないオブジェクトについては異なる整数値が生成されるようにすれば、
ハッシュテーブルのパフォーマンスを上げることができる」のです。
詳しくはHashMap, Hashtable の最適化を読んでみて下さい。
話は戻りますが、独自クラスではなくString型オブジェクトをキーにする場合は
キーとなる文字列の長さはなるべく短くしましょう。
なぜなら、String.hashCode()の呼出時間は文字列長に比例して長くなるからです。
なるべく短い文字列を使うことがパフォーマンスの向上につながります。
さて、そういった訳でhashCode()のオーバーライドは
パフォーマンス向上の為には欠かせない手段なのですが、
「別にパフォーマンスなんか気にしないよ」って場合でも
オーバーライドをする必要があります。
上で紹介した MyClass
から hashCode()
を取り払ってみましょう。
すると、14〜17行目のgetは全て null
を返してしまいます。
その理由は、デフォルトの(=Objectクラスに実装されている)hashCodeメソッドが使われた事にあります。
この場合、2行目で宣言した obj1
と10行目で宣言した key1
は異なるハッシュ値を返すからです。
ですから、いくら MyClass
のequalsメソッドをオーバーライドしていても
ハッシュ値が異なる為にequalsメソッドは呼び出されません。
そのため、getは「key1と同じキーがマップ内に存在しない」と判断してnullを返すのです。
同じように、MyClass
から equals()
のみを取り払っても
結果は同じになります。
今度はデフォルトのequalsメソッドがobj1とkey1を同じと見なさないからです。
要するに、MyClass
には hashCode
, equals
の
両メソッドをオーバーライドしないと意味が無いのです。
これら2つのメソッドはObjectクラスのメソッドなので、
独自クラスでオーバーライドをしなくてもHashMapのキーに使用できてしまいます。
これが混乱の元となります。必ずオーバーライド(再定義)しましょう。
このクラスは「順序付け」されたオブジェクトを格納する場合に使われます。
実装時の条件は以下のうち「どちらか一つ」を満たしている必要があります。
一般的なのは前者の方法でしょう。
キーに使用する独自クラスに定義するメソッドは int compareTo(Object)
です。
さらに boolean equals(Object)
メソッドをオーバーライドした方が「良い」とされているようです。
詳しい理由は僕には難しすぎて理解できませんでした。(笑)
public boolean equals(Object obj) { return compareTo(obj)==0 ? true : false; }
とりあえず、こんな感じで定義しておけば問題無いでしょう。
上の方の説明で、HashMapはget/putメソッドの中で
全キーをループして同一のキーが無いかどうか調べている
と書きましたが、正確に言うとそうではありません。
処理速度向上のためのちょっとしたトリックが入っているのです。
それが、ハッシュ値に応じたテーブル分割です。
以前僕は「Hashtableは大規模なマップには使えない」と思っていましたが
そうでは無かったのです。
TreeMapは高速なgetができる反面、put/remove に掛かるコストは相当高くなる可能性があります。
HashMapのキーに「適切なハッシュ値を返すクラスのオブジェクト」を使うことによって
get/put/remove どのコストも最小限に抑えたマップが実現できるのです。
まずは、そのマップが最大どれくらいのキーを格納するのかを予想します。
実はこれが一番重要なのです。
HashMapクラスのオブジェクトを生成するとき、初期容量をパラメータとして渡します。
これは予想される最大容量の 4/3倍 の値を与えるのが最適です。
例えば、最大で10000個のキーを格納すると予想する場合は Map map = new HashMap(13334);
と宣言すれば良いのです。
なんで4/3倍しなきゃならないんだという疑問もあると思いますが、
これには「負荷係数」という概念が関わってくるのです。
マップにキーを追加する際、容量が一定値を超えそうになるとMap.put内部では
容量を2倍に拡張する処理を行います。
この一定値は「マップの最大容量*負荷係数」で定められています。
デフォルトの負荷係数は0.75なので、例えば先程の例で初期容量を10000ピッタリにしてしまうと
キーを7500個入れた段階で容量拡張処理が行なわれてしまうのです。
容量拡張処理は、特に容量が大きい場合には相当のコストが掛かります。
「生成時にサイズの指定をするなんてスマートじゃないやり方は嫌」という意見もあるかもしれませんが
マップのコストパフォーマンスを上げたいなら、この事態を避ける為にも
初期容量の適切な設定が重要になってくるのです。
次に重要なのが、ハッシュ値の生成方法です。
あまりコストを掛けないということも大事ですが、
等しくないオブジェクトについては異なる値が生成されるようにするのが何より大事です。
そしてまた難しい話になるのですが、ただ単純に異なる値を生成するだけでは駄目なんです。
例えば、初期容量を65536(=2^16)としてマップを生成した場合を考えます。
そのマップに登録する値のハッシュ値を65536で「割った余り」が異なる値を生成する必要があるのです。
具体的に言うと…
このマップに登録した10000個のオブジェクトのハッシュ値がそれぞれ
1, 2, 3, ... , 10000
みたいになっていれば良いのですが
1, 65537(=1+65536), 131073(=1+65536*2), ...
みたいな感じだといけません。
1, 65537, 131073 は65536で割った余りが全て1という同じ値になってしまうからです。
もし初期容量が4096(=2^12)ならば、ハッシュ値を4096で割った余りが異なる値を返すようにします。
ですから、予めマップの最大容量が決まっていた方が
より適切なハッシュ値を返す事が可能になるのです。
繰り返しになりますが、マップの最大容量は出来る限りマップ生成時に決定しましょう。
なんか非常に数学的な話になってしまいましたが、皆さん解ったでしょうか?
よほどの事が無い限り、ここまでシビアにハッシュ値を計算する必要性というのは無いと思いますので
どうしてもマップを高速化させたい時にでも思い出して下さい。
最後に重要な話。
結構忘れがちですが、一度計算したハッシュ値は
そのオブジェクトの内容が変化されない限り保持するようにしましょう。
2回目以降の hashCode()
の呼出時には以前に計算したハッシュ値を返すようにするのです。
HashMapは頻繁に hashCode()
を呼び出すのでこれは重要な要素となります。
ちなみにString等のJava標準クラスでは必ずこれらの処理を行っています。
Javaにはfinalという修飾子があります。
これはCでいうところのconstと同じで、そのオブジェクトへ値を代入できないようにします。
最も多く使われるのは定数宣言としてのfinalでしょう。
さらに、クラスやメソッドにもfinal宣言を適用する事ができます。
そのクラスの派生クラスを作成する事を禁止します。
クラス内で宣言された全てのメソッドは暗黙的にfinalとなります。
ちなみに、Java標準ライブラリに含まれるクラスは大抵final宣言されています。
このメソッドをオーバーライドする事を禁止します。
そして、Javaコンパイラはこのメソッドを「インライン化」する可能性があります。
インライン化されたロジックは高速になります。
つまり、メソッドやクラスをfinal宣言するということは
オーバーライドを禁止するという目的以上に
高速化(最適化)の為の意味合いが強いのです。
しかし、高速化のためだけにfinalにしてしまうのは危険です。
どれだけ高速化されるのかもはっきり判らない訳ですし。
仕様の観点から見てfinal宣言するかどうかを検討した方が良いと思います。
Javaは言語レベルでの同期化機構を提供しています。
これはマルチスレッドプログラミングをする上で圧倒的なメリットとなります。
しかし、同期化にはロックという処理を使うために速度が犠牲になるので
設計上どうしても必要な部分にだけ同期化を利用するのが正しいやり方です。
try-catchなどと同様のやり方で、ロジックの一部分を同期化します。
synchronized (obj) { ... (ブロック) ... }
のように使います。動作は以下のようになります。
既に他のスレッドによってobjに関するロックが掛けられていた場合
ここでプログラムはロックが解除されるまで待機します。
もしそのロックが永遠に解除されない場合、
このプログラムは永遠に待機したままになります。
これをデッドロックといいます。
普通に(?)ブロックを実行します。
ブロック文の実行が終了すると、objに掛けていたロックを解除して
プログラムは次へ進みます。
ここで気を付けなければいけないのは、objに関するロックを掛けていても
他のスレッドはobjを参照、更新できてしまいます。
例えば、あるスレッドAで以下の文を実行する事を考えます。
synchronized (pos) { for (; pos.x<100 ; pos.x++) { System.out.println("(A) " + pos.x); } }
この文ではposオブジェクトに関するロックを使用しています。
そして別のスレッドBでは以下の文を実行します。
for (; pos.x<150 ; pos.x++) { System.out.println("(B) " + pos.x); }
2つのスレッドが同時に走ったとき、
スレッドAのループはスレッドBのループと同時(並列)に動きます。
スレッドAのsynchronized文でposに関するロックを掛けていても、それは
スレッドBのposオブジェクトに行なう操作を制限しないからです。
そのため、このときの出力結果は予測できないものになります。
ここで、スレッドBを以下の文に置き換えたとします。
synchronized (pos) { for (; pos.x<150 ; pos.x++) { System.out.println("(B) " + pos.x); } }
こうすれば、スレッドAのループとスレッドBのループが同時(並列)に動くことは無くなります。
どちらも最初にsynchronized文による同期化を行っているので
どちらかが先に実行された時点でobjに関するロックが掛かり
もう片方のスレッドは待機することになります。
結論としては、同期を取る必要があるオブジェクトを操作する部分は
全てsynchronized文で囲ってやらないと完全な同期は保証されないのです。
メソッド自身を同期化することもできます。具体的には
synchronized void func();
のように、メソッド宣言にsynchronized修飾子を付けます。
これは、そのメソッドの全文をsynchonized文で囲ったのと同じ効果があります。
以下に記述するように、メソッドの種類によって挙動に変化があります。
そのメソッドのクラス(Classオブジェクト)に対するロックを掛けます。 例えば、
class TestClass { static synchronized void sfunc1() { ... } static synchronized void sfunc2() { ... } }
と宣言すると、複数のスレッドから同時にsfunc1を呼び出しても
実際に実行されるのはその内の一つだけになります。
そして、複数のスレッドからsfunc1とsfunc2を同時に呼び出しても
この2つのメソッドは同時に実行されません。
仮にsfunc1の方が早く呼び出されたとしたら、sfunc1の実行が完了するまで
sfunc2は待機することになります。
なぜなら、sfunc1メソッドでは「TestClassに関するロック」を掛けるからです。
sfunc2メソッドでも、メソッドの先頭文を実行する前に
「TestClassに関するロック」を掛けようとするので、ここで待機することになるのです。
そのメソッドが呼び出されたオブジェクト(this)に関するロックを掛けます。例えば、
class TestClass { public synchronized void nfunc1() { ... } public synchronized void nfunc2() { ... } }
という宣言があるとします。
同一のTestClassオブジェクトからnfunc1, nfunc2 が同時に呼び出された場合、
それらは同時に実行されません。
別々のTestClassオブジェクトから呼び出された場合は、これらは同時に実行される可能性があります。
thisの意味や使い方がよく解らない、という人はそれらを勉強した後で
マルチスレッドプログラミングに取り組むのが妥当な判断だと思います。
プログラムの基本とも言える文字列操作。
Javaではその一つとしてStringクラスが用意されていますが
これだけで全て済ますというのはあまり良い考えではありません。
なぜなら、Stringは「変更不可能」なオブジェクトだからです。
えっ?と思われる方もいるかも知れませんが、一旦作成(new)したStringオブジェクトは
その内容を変更する事が出来ません。
例えば、以下のようなロジックを考えます。
str1 += str2;
str1, str2 はどちらもString型のオブジェクトです。
このとき、内部では以下のような処理をしています。
StringBuffer strbuff = new StringBuffer(); strbuff.append(str1); strbuff.append(str2); str1 = strbuff.toString();
そうです。Stringクラス単独では文字列の連結処理を行えないので
StringBufferクラスのインスタンスを作成して利用することで
擬似的にStringの連結を行っているのです。
これは大変なオーバーヘッドになります。
StringBufferクラスのコンストラクタでは、
与えられた初期容量の分だけメモリを確保します。
そして、appendにより文字列を連結する際に
その容量を超えてしまう場合にはメモリの再確保を行います。
これは新たなオブジェクトの生成処理が行われることを意味します。
先程挙げたロジックで、生成される可能性のあるオブジェクトを調査してみましょう。
当然のようにここで1回。そしてStringBufferのコンストラクタ内部では
初期容量を確保するためにもう1回オブジェクト生成を行います。
str1の文字長が上で作成したStringBufferの容量より大きい場合、
ここで容量拡張のためにオブジェクト生成を行います。
デフォルトの初期容量は16なので、str1が16文字以上の場合はここでまたオブジェクトが生成されます。
(str1+str2)の文字長がStringBufferの容量より大きい場合、
ここで容量拡張のためにオブジェクト生成を行います。
先程のappendで容量拡張が行われた場合、容量は(str1の文字長)+16になっています。
つまり、str2が16文字以上の場合はここでまたオブジェクトが生成されます。
そしてここでもオブジェクト生成が行なわれます。
StringBuffer.toString()メソッドは、呼び出しの度にStringオブジェクトを生成するのです。
たった1行の処理で、最大5回ものオブジェクト生成を行っています。
そしてこの処理が終わると、最大4つの「決して使われない」オブジェクトが
メモリ内に残ることになるのです。
このオブジェクトはヒープ領域と呼ばれる場所に溜まり、
ヒープ領域が一杯になるとGCが発動してこれらのオブジェクトは領域から開放されます。
以上のことを踏まえると、文字列の連結などを頻繁に行うロジックでは
StringBufferを使用するのが正しいやり方と言えます。
そしてここでも、適切な初期容量を指定することが高速化への近道となるのです。
Javaでは、オブジェクトの生成(new)はコストの掛かる処理です。
なぜそれほどコストが掛かるのか、それはJavaが採用している
GC(ガーベッジ・コレクション)機構に原因があります。
GCは、オブジェクトが使われなくなると自動的にその領域を開放します。
とても便利なこの機能ですが、それを行うために相当のコストが掛かるのです。
そして、生成されたオブジェクトの数が増える度にそのパフォーマンスは低下していきます。
領域が使われなくなったかどうかの判断をするためには
全ての生成されたオブジェクトをループする必要があるからです。
とは言ったものの、実はJDK1.2からは世代別GCという規格が採用されています。
これによって、オブジェクトの大量生成はそれほどコストの掛かる処理ではなくなったようですが
依然GCは高いコストを要求する処理だということだけは忘れてはなりません。
一般的な大規模アプリケーションで、全実行時間におけるGCの割合は約10%程と言われています。
つまり、24時間フルに稼動するシステムでCPU使用率が20%の場合、
約30分間をGCに費やすという事です。
デフォルトの実行ではGC中に全スレッドが停止しますから、
これを考慮しないと大変なことになるのは明白です。
多くのJavaプログラムを眺めていて気になるのが、例外の使い方です。
try/catch の仕様すら解ってないなんてのは論外ですが、何でもかんでも例外を使おうとするロジックも良くありません。
例外の生成には、通常のオブジェクト以上のコストが掛かるからです。
例外を発生させると、その内部ではスタックトレースを作成しています。
printStackTraceメソッドを呼び出すとズラズラッと並ぶアレです。
スタックトレースはprintStackTraceメソッドを呼び出さなくても
例外クラスのコンストラクタ内部で自動的に作られてしまうので、
例外を発生させる事自体がコストの高い処理になるのです。
特に、サーブレット等のコンテナ下で動くアプリケーションの場合、
スタックトレースの階層はとんでもなく深いものになってしまいます。
例として、僕が開発しているTomcatサーブレット上のコンテンツ内で例外を発生させると
その階層は40近くになります。
これは内部的に見ると、StackTraceElement(JDK1.4以降)クラスのインスタンスが40個作成された事になります。
StackTraceElementは内部に3つのStringクラスを持っていますから、
たった一つの例外を発生させただけで160以上のオブジェクトが生成される計算になる訳です。
起こり得る可能性が高い例外に関しては、
例外クラスではなく戻り値で対処するのが賢い方法といえるのです。
もしくは、独自のエラークラスを作ってそこにエラー情報を格納するやり方でも良いでしょう。
Struts等ではこの手法を使っています。