前回はText Blocksを使って複数行文字列をすっきり書く方法を学びました。
今回は「スレッドセーフ」について解説します。 以前マルチスレッドを学んだ際に「競合状態」という問題に触れましたが、今回はその対策である synchronized と volatile の使い方を詳しく解説します。
スレッドセーフとは
複数のスレッドが同じデータに同時にアクセスすると、意図しない結果になることがあります。これを「競合状態(Race Condition)」と呼びます。
たとえば、2つのスレッドが同時に count = count + 1 という処理を行うと、どちらも古い値を読み取ってしまい、2回増やしたはずが1しか増えない場合があります。「スレッドセーフ」とは、複数のスレッドから同時にアクセスされても正しく動作することを指します。
サンプルコード(競合状態の問題)
ファイル作成
「Java30」フォルダに RaceConditionTest.java という名前でファイルを作成します

コード入力
以下のコードを入力します。
public class RaceConditionTest {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("期待値: 20000");
System.out.println("実際の値: " + count);
}
}

コンパイル
javac RaceConditionTest.java

実行
java RaceConditionTest
実行結果は次のようになります(実行のたびに異なります)。
期待値: 20000
実際の値: 10765

2つのスレッドがそれぞれ10000回ずつカウントアップしているので20000になるはずですが、競合状態のため毎回異なる値になります。
サンプルコード(synchronizedで解決する)
ファイル作成
「Java30」フォルダに SynchronizedTest.java という名前でファイルを作成します

コード入力
以下のコードを入力します。
public class SynchronizedTest {
static int count = 0;
// synchronizedメソッド: 同時に1つのスレッドしか実行できない
public static synchronized void increment() {
count++;
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("期待値: 20000");
System.out.println("実際の値: " + count);
}
}

コンパイル
javac SynchronizedTest.java

実行
java SynchronizedTest
実行結果は次のようになります。
期待値: 20000
実際の値: 20000
synchronized をつけることで、常に正確に20000になります。

サンプルコード(volatileとAtomicInteger)
ファイル作成
「Java30」フォルダに VolatileTest.java という名前でファイルを作成します

コード入力
以下のコードを入力します。
import java.util.concurrent.atomic.AtomicInteger;
public class VolatileTest {
// volatile: 常にメインメモリから値を読み書きする
static volatile boolean running = true;
// AtomicInteger: スレッドセーフなカウンター
static AtomicInteger atomicCount = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
// volatileの例: フラグによるスレッドの停止
Thread worker = new Thread(() -> {
int localCount = 0;
while (running) {
localCount++;
}
System.out.println("ワーカー終了(カウント: " + localCount + ")");
});
worker.start();
Thread.sleep(10); // 10ミリ秒待つ
running = false; // スレッドを停止させる
worker.join();
// AtomicIntegerの例
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
atomicCount.incrementAndGet();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
atomicCount.incrementAndGet();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("AtomicInteger 期待値: 20000");
System.out.println("AtomicInteger 実際の値: " + atomicCount.get());
}
}

コンパイル
javac VolatileTest.java

実行
java VolatileTest
実行結果は次のようになります。
ワーカー終了(カウント: 16593273)
AtomicInteger 期待値: 20000
AtomicInteger 実際の値: 20000

コードの解説
競合状態のサンプルでは、t1.join() と t2.join() を呼ぶことで、両スレッドの終了を待ってから結果を表示しています。join() を使わないと、スレッドが終わる前に count を表示してしまう場合があります。
synchronized をメソッドにつけると、そのメソッドは同時に1つのスレッドしか実行できなくなります。1つのスレッドが increment() を実行している間は、他のスレッドはそこに入れずに待ちます。これを「ロック」と呼びます。スレッドセーフにはなりますが、待ち時間が増えるため処理速度は低下します。
volatile は変数の読み書きを必ずメインメモリから行うことを保証します。スレッドはパフォーマンス向上のために各自のキャッシュに値を持つことがありますが、volatile をつけることでキャッシュを使わず常に最新の値を見るようになります。ただし volatile だけではカウントアップのような「読み→更新→書き」の一連操作はスレッドセーフになりません。
AtomicInteger は java.util.concurrent.atomic パッケージのクラスで、incrementAndGet() のようなカウントアップ操作をスレッドセーフに行えます。synchronized よりも軽量で高速に動作するため、カウンターのような単純な値の更新に向いています。
まとめ
複数スレッドが同じデータにアクセスする場合は競合状態に注意が必要です。synchronized をメソッドにつけることでロックをかけ、同時に1つのスレッドしか実行できないようにできます。volatile は変数の可視性を保証しますが、複合操作のスレッドセーフは保証しません。単純なカウンターには AtomicInteger が synchronized よりも軽量でおすすめです。マルチスレッドプログラミングは難しいですが、この3つの使い分けを覚えておくだけでも多くの場面に対応できます。
2026/07/21(火) ぱんまる


コメント