スレッドの注意点まとめ | Java | プログラミング

どんなプログラム言語でもそうなのですが、マルチスレッド下でプログラムを組むときは、シングルスレッドとは違うところに色々気を使わないといけません。
今回は、Javaマルチスレッドプログラムでは基本的なことですが(自分だけかもしれませんが)よく忘れて、不可解な動作に首を傾げてしまうポイントについて説明していきます。

ヒープ領域にあるデータの更新タイミングをちゃんと把握する

Javaプログラムからアクセスできるメモリ領域には、大きくわけて、スタック領域(以下スタック)とヒープ領域(以下ヒープ)の2種類が存在します。
スタックは、ローカル変数や、メソッドの引数・戻り値情報を持ち、ヒープは
newされたオブジェクトや、ロードされたclass情報等を持っています。
この内、スタックは、スレッド毎に存在するメモリ領域で、複数のスレッド間では独立しています。(Javaプログラムでは、他のスレッドのスタックにはアクセスできない)
なので、マルチスレッドであろうが、シングルスレッドであろうが、特に意識しなくても問題を起こす事は、そう無いはずです。
しかしヒープは、スタックと違い、全てのスレッド間でメモリ領域を共有しています。

全てのスレッドで1つのヒープを共有しているということは、各スレッドがヒープ上のデータを、自由に参照・更新できるということです。
どのスレッドが、いつ、どのタイミングで、どのデータの参照・更新を行うかをしっかり把握しておかないと、全く意図しない動作・結果になります。
マルチスレッドで発生する問題のほとんどが、このタイミングを把握しきれずに、「いつの間にかデータが変わってた」、「アクセスして欲しくない時にデータにアクセスされた」
という問題だと思います。

ヒープ領域にあるデータは直ぐには反映されない

ヒープ上のデータにアクセスする際の、もう1つの注意点は、スレッドからヒープへのアクセス時には、キャッシュが利用されるという点です。
全てのスレッドはキャッシュ領域というものがあり、ヒープのデータを参照する際や、ヒープのデータを更新する際の全ての操作において、キャッシュが使われます。
つまり、ヒープ上のデータを参照しても、キャッシュ上の古いデータを参照している可能性があり、ヒープ上のデータを更新しても、キャッシュ上の
データを更新しているだけで、別のスレッドからは、更新前の古いデータが見えている可能性があります。
このキャッシュは、あるタイミングでヒープと同期をとり、最新の状態になりますが、そのタイミングを知ることはできません。

このキャッシュがある限り、スレッド間のデータの即時反映は、全く保障されなくなってしまうのですが、synchronizedやvolatileを使う場合は、この限りではありません。
volatileの変数は、キャッシュを介さずに、直接ヒープに対して読み・書きを行います。また、synchronizedブロック・関数で、同じミューテクスを使用する場合は、
ロックする前にヒープからデータを読み、アンロックする際にヒープにデータを書き出します。
安全性を第一にとるのであれば、各スレッド間で共有されるデータは、全てsynchronizedやvolatileを指定して行ったほうが良いでしょう。

また、finalで宣言されたメンバ変数に関しては、コンストラクタの終了後に全てのスレッドから正しく参照されることが保障されています。
それまでは、null・0・false等の初期値が参照される可能性があります。

インクリメントは危険

「ヒープ領域のデータの更新のタイミングの把握をする」、「スレッド間のデータは即時にヒープに反映されない」ということを踏まえると、ヒープ上のデータをインクリメントすることすら危険な行為です。
以下のコードは、incメソッドでヒープ上のデータをインクリメントをし、getメソッドでヒープ上のデータを取得するサンプルです。

private int data = 0;	// ヒープ上のデータ

public void inc() {
	data++; // インクリメント
}

public int get() {
	return data;	// データを取得
}

見た目は1行なのですが、実際の処理は「ヒープからの参照」、「値の加算」、「ヒープへの反映」等の幾つかのステップを行うので、
このステップ中に他のスレッドから割り込まれてしまい、値を書き換えられてしまう可能性があるからです。
正確にインクリメントを行うには、synchronizedを使って同期化をしないといけません。
また、他のスレッドから参照されるのであれば、キャッシュから読ませないために、volatileの宣言も必要です。
以下のように修正します。

private volatile int data = 0;	// ヒープ上のデータ。キャッシュを介さない

public synchronized void inc() {
	data++; // インクリメント。メソッド自体が同期化されている
}

public int get() {
	return data;	// データを取得。常にヒープ上のデータが参照される
}

戻り値を利用した比較は危険

非同期で値を更新しているデータに対して比較を行う際は、一旦変数に、その瞬間のスナップショットを保存してから比較しないと、2つ以上の複合条件の時に意図しない結果になる場合があります。
例えば、以下のようなコードを書いたとします。

if(obj.getNum() != 1000 && obj.getNum() != 5000) {
	System.out.println("Num=" + obj.getName());
}

想定では、戻り値が1000でも5000でもない時に、ブロック内の処理が実行されるのですが、いざ複数のスレッド下で、まったく同期化されていない状態の時に、このコードを実行しても、
結果にNum=1000やNum=5000といった値が表示される場合があります。
これは、各メソッドの実行毎にスレッドの制御が移ってしまい、別のスレッドで、変数objの値を変更されてしまう可能性があるためです。
メソッドの戻り値を複合条件で比較したい場合は、必ず一旦変数に代入し、その値を比較するようにします。

int num = obj.getNum();
if(num != 1000 && num != 5000) {
	System.out.println("Num=" + num);
}

long・doubleはアトミックな型ではない

有名な話ですが、longは型自体がアトミックな型ではないので、マルチスレッド下で同時アクセス・同時更新を行うと、
代入しても古い情報が参照される場合があるのはもちろん、変数のデータ自体が壊れる可能性があります。
例えば、以下のようなコードがあります。

private long value;
public void setValue(long value) {
	this.value = value;
}
public long getValue() {
	return value;
}

このコードを、マルチスレッド下で使用した場合には、中のデータ自体が壊れてしまう場合があります。
これはlongの表現が上位32bitと下位32bitで分けてデータを表現している処理系がある(ほとんど?)ので、上位32bitを処理している最中に、下位32bitが
別スレッドから更新されたりして、結果64bitになった時に、想定外の値になってしまうからです。特に32bitに収まりきらないデータを扱う際には
よく発生します。
こういう場合は、以下のように記述します。

private volatile long value;
public void setValue(long value) {
	this.value = value;
}
public long getValue() {
	return value;
}

volatileは、スレッドキャッシュを使わず、ヒープに直接データを書き込む・読み込むという他に、フィールド変数への代入をアトミックに行うことを保障する、という機能もあります。

まとめ

インクリメント・代入・比較等の、普段何気なく使っている構文の全てが、マルチスレッド下では不可解な挙動を起こす原因になる場合があります。
シングルスレッドと同じような意識・コーディングをしていると、「何故か、たまにエラーになってしまう」という、デバッグしても再現しないタイプの
バグを含んだコードが、いとも簡単にできてしまいます。
他のスレッドから参照・更新されるデータは、必ず同期化されているか確認してください。