1. unlock/release の呼び忘れ: 不注意な人への落とし穴
ReentrantLock や Semaphore のような同期ツールを使うときの最も厄介なミスのひとつが、unlock() や release() の呼び忘れです。ロックを解放しなければ、他のスレッドはその解放を…永遠に待ち続けます。プログラムはハングし、なぜ何も起きないのか画面を見つめることになります。
ReentrantLock を使った例:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
// あっ! unlock() を忘れた - これで全員が停止する!
count++;
}
}
一見何も問題なさそうですが、increment() を複数スレッドから何度か呼ぶと、最初の呼び出し以降、他のスレッドはロックが解放されるのを無限に待ち続けます。
この事態を避けるには try-finally を使います:
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
これで、メソッドの途中で例外が発生しても、ロックは必ず解放されます。
たとえるなら、誰かがトイレを占有して(内側から鍵を掛けて)おきながら、ドアを開け忘れて窓から出てしまったようなものです。他の人はその人が出てくるのをずっと待つことになります…そんなことはしないでください!
2. 間違ったオブジェクトでの同期: 「あっ、違う所にロックを掛けちゃった!」
Java のキーワード synchronized は、あるオブジェクトへのアクセスをブロックできます。しかし、ロック対象のオブジェクトを間違えると、期待どおりに同期されません。
誤り1: ローカル変数での同期
public void doSomething() {
Object lock = new Object();
synchronized (lock) {
// 毎回新しいオブジェクトになる - 同期はまったく効かない!
// スレッドは互いに待たない。
// クリティカルセクションは保護されていない!
}
}
ここでは各スレッドがそれぞれ独自の lock オブジェクトを作っています。その結果、実質的なロックは行われず、スレッドは同時にクリティカルセクションへ入ってしまいます。
正しいやり方:
private final Object lock = new Object();
public void doSomething() {
synchronized (lock) {
// これで全スレッドが同じオブジェクト lock を使い
// 互いにきちんと待つようになる。
}
}
誤り2: 文字列リテラルで同期する
public void doSomething() {
synchronized ("lock") {
// 文字列リテラルはインターンされるため、プログラムの別の箇所が
// 偶然同じ文字列で同期してしまう可能性がある!
}
}
結論:
同期は、他で使われない専用のプライベートなオブジェクトに対してのみ行いましょう。
3. 相互待ち(deadlock): 「君が先に、いや君が先に」で双方停止
Deadlock(相互待ち)は定番の問題です。2つ(以上)のスレッドが別々のロックを順に取得して互いを待ち、プログラムが固まってしまいます。
例:
public class DeadlockExample {
private final Object lockA = new Object();
private final Object lockB = new Object();
public void method1() {
synchronized (lockA) {
// 動作を再現しやすくするために少し待つ
try { Thread.sleep(50); } catch (InterruptedException e) {}
synchronized (lockB) {
// ...
}
}
}
public void method2() {
synchronized (lockB) {
try { Thread.sleep(50); } catch (InterruptedException e) {}
synchronized (lockA) {
// ...
}
}
}
}
一方のスレッドが method1() を呼び、もう一方が method2() を呼ぶと、前者は lockA を取得して lockB を待ち、後者はその逆を行います。結果として双方が永遠に相手を待ち続けます。
回避方法:
- すべてのスレッドで、常に同じ順序でロックを取得する。
- 同時に保持するロックの数を最小限にする。
- プログラムが固まった場合は診断ツール(たとえば jstack)を使う。
たとえ:
狭い廊下で二人が出会い、相手が先に譲るなら自分も譲ると考えている状況に似ています。結局どちらも動かず、誰かが先に折れるまで待ち続けます。
4. 過剰な同期: 「念のためロックは多めに?」— いつも正しいとは限らない!
バグを恐れて何でもかんでも同期してしまうことがあります。その結果、性能は落ちるのに得られる利益はゼロです。
例:
public synchronized void add(int value) {
// ここには同期不要の1行しかない!
System.out.println("追加: " + value);
}
この場合、同期は不要です。System.out.println による出力はすでにスレッドセーフであり、このメソッド自体も共有リソースを扱っていません。
どこが問題になる?
頻繁に呼ばれ、保護の不要なメソッドまで同期してしまうと、プログラムの性能は大きく低下します。本来並行して動けるはずのスレッドが、わざわざ列を作って待つことになるからです。
ベストプラクティス:
本当に必要な部分だけを同期する。クリティカルセクションは可能な限り小さく保つ。
5. volatile の誤用: 「可視性はあるが、原子性はない!」
Java の volatile 修飾子は、変数の変更がすべてのスレッドから見えることを保証します。しかし、原子性は保証しません。
誤り:
private volatile int counter = 0;
public void increment() {
counter++; // 原子操作ではない!
}
counter++ は、読み取り・加算・書き戻しから成る複合操作です。2つのスレッドが同時にこのコードを実行すると、最終的な値が期待より小さくなることがあります。
正しいやり方:
原子操作が必要なら synchronized、AtomicInteger などのスレッドセーフなクラスを使いましょう。
import java.util.concurrent.atomic.AtomicInteger;
private final AtomicInteger counter = new AtomicInteger();
public void increment() {
counter.incrementAndGet();
}
volatile を使うのはいつ?
単純なフラグ(例: 「終了する」)のように原子性が不要な場合です。
GO TO FULL VERSION