- JAVA
Java synchronizedで排他制御|スレッドセーフを実行結果で検証
Javaのsynchronizedとは、同じロック(モニター)を使うコードを「同時に1スレッドだけ」が実行できるようにする排他制御のキーワードです。 あわせて、ロックを外す前の書き込みが、次にロックを取ったスレッドから確実に見えること(可視性)も保証します。
この記事では、Java 21.0.10(OpenJDK)で全コードを実行し、出力をそのまま掲載しています。同期なしのカウンタで値が合わない様子を複数回の実行で再現し、this・static・専用Objectのどれをロックにするかで結果が変わる例、synchronized・AtomicInteger・LongAdder・ReentrantLockの処理時間の比較表、volatileで守れること・守れないこと、デッドロックをjcmdで見つけてtryLockで回避する手順、さらに仮想スレッドの pinning を Java 21 と Java 25 で比べた結果までを1本にまとめました。
Java synchronized 排他制御の早見表
| やりたいこと | 書き方 | ロック対象 | 補足 |
|---|---|---|---|
| メソッド全体を排他にする | public synchronized void inc() |
this(そのインスタンス) |
同じインスタンスを使うスレッド同士で排他 |
| メソッドの一部だけ排他にする | synchronized (this) { ... } |
指定したオブジェクト | ロックする範囲を短くできる |
| static 変数を守る | static synchronized void inc() |
クラス名.class |
インスタンスが別々でも排他 |
| ロックを外から使わせない | synchronized (LOCK) { ... }(private final Object LOCK) |
専用オブジェクト | ライブラリやクラス設計で使いやすい |
| 整数カウンタを速く数える | AtomicInteger.incrementAndGet() |
なし(CAS命令) | 今回の計測で synchronized より速い傾向 |
| 合計だけ必要な高頻度カウンタ | LongAdder.increment() / sum() |
なし(セルに分散) | コア数が多いほど有利。今回の2コアでは Atomic と同程度以下 |
| 待ち時間の上限・割り込み・公平性が必要 | ReentrantLock.tryLock(時間) |
Lock オブジェクト | unlock() は finally で必ず呼ぶ |
| フラグの変更を他スレッドに見せる | volatile boolean running |
なし | count++ は守れない(後述の実行結果) |
| Map を共有する | ConcurrentHashMap + merge |
内部で細かく分割 | HashMap は合計がずれた(実行結果) |
| List を共有する | Collections.synchronizedList |
リスト自身 | 反復時は synchronized (list) が必要 |
| デッドロックを見つける | jcmd <PID> Thread.print |
― | Found one Java-level deadlock が出る |
| デッドロックを避ける | ロック順をそろえる/tryLock |
― | 両方とも実行結果を掲載 |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Java synchronizedで排他制御|スレッドセーフを実行結果で検証」
動作環境と実行方法
この記事のコードは1ファイルで完結しており、java ファイル名.java で実行できます(Java 11以降の単一ファイル実行)。実行環境は OpenJDK 21.0.10、Linux x86_64、2コア(Intel Xeon 2.10GHz)、外部ライブラリなしです。pinning の比較のみ OpenJDK 25.0.4.1 でも実行しています。
処理時間はCPUのコア数や負荷で大きく変わります。 表の数値は「この環境での傾向」として読み、ご自身の環境でも同じコードで測ってみてください。
同期なしのカウンタで結果が合わない理由(再現)
4スレッドで100万回ずつ足すと
まず、何も対策しないカウンタを4スレッドで同時に増やします。期待値は 4 × 1,000,000 = 4,000,000 です。
import java.util.ArrayList;
import java.util.List;
public class NoSyncCounter {
static int count = 0; // 同期なしの共有変数
public static void main(String[] args) throws InterruptedException {
int threads = 4, loops = 1_000_000;
for (int run = 1; run <= 5; run++) {
count = 0;
List<Thread> list = new ArrayList<>();
for (int t = 0; t < threads; t++) {
Thread th = new Thread(() -> {
for (int i = 0; i < loops; i++) count++; // 読み取り→+1→書き込み
});
list.add(th);
th.start();
}
for (Thread th : list) th.join();
System.out.printf("%d回目: 期待値=%,d 実際=%,d (不足 %,d)%n",
run, threads * loops, count, threads * loops - count);
}
}
}
実行結果(Java 21.0.10、1回目の起動):
$ java NoSyncCounter.java
1回目: 期待値=4,000,000 実際=1,746,981 (不足 2,253,019)
2回目: 期待値=4,000,000 実際=4,000,000 (不足 0)
3回目: 期待値=4,000,000 実際=4,000,000 (不足 0)
4回目: 期待値=4,000,000 実際=4,000,000 (不足 0)
5回目: 期待値=4,000,000 実際=4,000,000 (不足 0)
もう一度起動すると、1回目は 3,914,683(不足 85,317)と別の値になり、2回目以降はまた 4,000,000 でした。
値が合わない回と、たまたま合う回が混ざるのがポイントです。2回目以降が合っているのは、JIT(実行時コンパイラ)がループを最適化し、1スレッドの処理が短時間で終わって他のスレッドと重ならなくなったためと考えられます。正しくなったわけではありません。
JITを止めてインタープリタだけで動かす -Xint を付けると、毎回ずれます。
$ java -Xint NoSyncCounter.java
1回目: 期待値=4,000,000 実際=2,105,020 (不足 1,894,980)
2回目: 期待値=4,000,000 実際=1,837,199 (不足 2,162,801)
3回目: 期待値=4,000,000 実際=1,709,731 (不足 2,290,269)
4回目: 期待値=4,000,000 実際=1,340,518 (不足 2,659,482)
5回目: 期待値=4,000,000 実際=2,502,188 (不足 1,497,812)
「テストで1回通ったから大丈夫」とは言えないのが、マルチスレッドの不具合の難しいところです。
count++ は3つの操作でできている
count++ は1行ですが、実際には「読み取る → 1足す → 書き戻す」の3ステップです。2つのスレッドが同じ値を読んでしまうと、片方の書き込みが上書きされて消えます(競合状態、レースコンディション)。
sequenceDiagram
participant A as スレッドA
participant M as count(メモリ)
participant B as スレッドB
A->>M: 読む(100)
B->>M: 読む(100)
A->>A: 100 + 1 = 101
B->>B: 100 + 1 = 101
A->>M: 書く(101)
B->>M: 書く(101)
Note over M: 2回足したのに 101
この「読み取り→更新→書き込み」を途中で割り込まれない1つのまとまり(アトミックな操作)にするのが排他制御です。
synchronized の基本:メソッドとブロック
synchronized メソッドと synchronized ブロック
import java.util.ArrayList;
import java.util.List;
public class SyncCounter {
private int count = 0;
// ① synchronized メソッド:this をロックにする
public synchronized void increment() { count++; }
// ② synchronized ブロック:同じく this をロックにする(①と同じ意味)
public void incrementBlock() {
synchronized (this) { count++; }
}
public synchronized int get() { return count; }
public static void main(String[] args) throws InterruptedException {
int threads = 4, loops = 1_000_000;
for (int run = 1; run <= 3; run++) {
SyncCounter c = new SyncCounter();
List<Thread> list = new ArrayList<>();
for (int t = 0; t < threads; t++) {
boolean useBlock = t % 2 == 0; // メソッド版とブロック版を混ぜても排他される
Thread th = new Thread(() -> {
for (int i = 0; i < loops; i++) {
if (useBlock) c.incrementBlock(); else c.increment();
}
});
list.add(th);
th.start();
}
for (Thread th : list) th.join();
System.out.printf("%d回目: 期待値=%,d 実際=%,d%n", run, threads * loops, c.get());
}
}
}
実行結果:
$ java SyncCounter.java
1回目: 期待値=4,000,000 実際=4,000,000
2回目: 期待値=4,000,000 実際=4,000,000
3回目: 期待値=4,000,000 実際=4,000,000
synchronized メソッドと synchronized (this) ブロックは、同じ this をロックにするので互いに排他されます。 メソッド版とブロック版を混ぜても、毎回 4,000,000 になりました。
get() にも synchronized を付けているのは、読み取り側も同じロックを通すことで、最新の値が見えることを保証するためです(後述の可視性)。
メソッドとブロックの使い分け
メソッド版は修飾子1つで書け、ブロック版はロックする範囲とロック対象を自由に選べます。
ロックを持っている時間が長いほど、他のスレッドの待ち時間が増えます。 計算や共有データの更新だけをブロックに入れ、ログ出力・ファイル書き込み・通信などはブロックの外に出すと、待ちが減ります。
// 共有データの更新だけをロックの中に入れ、ログ出力は外に出す
public void addItem(String item) {
int size;
synchronized (this) {
items.add(item);
size = items.size();
}
System.out.println("追加: " + item + " / 件数=" + size);
}
補足:synchronized は再入可能です。 同じスレッドが持っているロックをもう一度取っても、自分を待つことはありません(ReentrantLock も同様)。
ロック対象の違い:this・static・専用 Object
インスタンスが別々だと this は守れない
synchronized で最もつまずきやすいのが「何をロックにしているか」です。次のコードでは、static 変数 total を3つの方法で守ろうとしています。各スレッドは自分専用のインスタンスを使います。
import java.util.ArrayList;
import java.util.List;
public class LockTargets {
static int total = 0; // static(クラス全体で共有)の変数
private static final Object LOCK = new Object(); // 専用ロック
// うまくいかない例:インスタンスメソッドの synchronized は「this」をロックする。
// インスタンスが別々だと、static 変数を守れない
synchronized void addWithThis() { total++; }
// OK例1:static synchronized は「LockTargets.class」をロックする
static synchronized void addWithClass() { total++; }
// OK例2:専用の static final Object をロックにする
void addWithLockObject() {
synchronized (LOCK) { total++; }
}
static int run(String mode) throws InterruptedException {
total = 0;
List<Thread> list = new ArrayList<>();
for (int t = 0; t < 4; t++) {
LockTargets own = new LockTargets(); // スレッドごとに別インスタンス
Thread th = new Thread(() -> {
for (int i = 0; i < 1_000_000; i++) {
switch (mode) {
case "this" -> own.addWithThis();
case "class" -> addWithClass();
case "lock" -> own.addWithLockObject();
}
}
});
list.add(th);
th.start();
}
for (Thread th : list) th.join();
return total;
}
public static void main(String[] args) throws InterruptedException {
for (String mode : List.of("this", "class", "lock")) {
for (int r = 1; r <= 3; r++) {
System.out.printf("%-5s %d回目: 期待値=4,000,000 実際=%,d%n", mode, r, run(mode));
}
}
}
}
実行結果:
$ java LockTargets.java
this 1回目: 期待値=4,000,000 実際=3,902,916
this 2回目: 期待値=4,000,000 実際=3,556,817
this 3回目: 期待値=4,000,000 実際=3,535,324
class 1回目: 期待値=4,000,000 実際=4,000,000
class 2回目: 期待値=4,000,000 実際=4,000,000
class 3回目: 期待値=4,000,000 実際=4,000,000
lock 1回目: 期待値=4,000,000 実際=4,000,000
lock 2回目: 期待値=4,000,000 実際=4,000,000
lock 3回目: 期待値=4,000,000 実際=4,000,000
synchronized を付けているのに this 版だけ値が合いません。 4つのスレッドがそれぞれ別の this をロックしているため、お互いを待たずに同じ total を書き換えているからです。
ルールは「守りたいデータと同じ範囲で共有されるオブジェクトをロックにする」です。
| 守りたいデータ | ロックにするもの | 書き方 |
|---|---|---|
| インスタンス変数(フィールド) | そのインスタンス | synchronized メソッド/synchronized (this) |
| static 変数 | クラス/static な専用オブジェクト | static synchronized/synchronized (LOCK) |
| 複数クラスで共有するデータ | そのデータを持つオブジェクト、または共通の専用ロック | synchronized (sharedObj) |
ロック対象に向かないもの
| ロック対象 | 起きること | こうするとよい |
|---|---|---|
String リテラル("LOCK") |
同じ文字列は JVM 内で共有されるため、無関係なクラスと同じロックになる | private static final Object LOCK = new Object() |
Integer などのラッパー |
count++ のたびに別オブジェクトに入れ替わり、ロックが変わる。-128〜127 はキャッシュで共有される |
専用 Object か AtomicInteger |
final でないフィールド |
途中で代入し直すと、スレッドごとに別のロックを見る | ロック用フィールドは final にする |
this(公開クラス) |
外部のコードも synchronized (obj) で同じロックを取れる |
外から触らせたくないときは private final Object lock |
| ロックしないアクセスが混ざる | 書き込み側だけ synchronized でも、読み取り側が同期しないと最新値が見えないことがある | 読み書きの両方で同じロックを使う |
synchronized・AtomicInteger・LongAdder・ReentrantLock の比較
同じカウンタを4つの方法で数える
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.LongAdder;
import java.util.concurrent.locks.ReentrantLock;
public class CounterBench {
interface Counter { void inc(); long get(); }
static class SyncCounter implements Counter {
private long n;
public synchronized void inc() { n++; }
public synchronized long get() { return n; }
}
static class AtomicCounter implements Counter {
private final AtomicInteger n = new AtomicInteger();
public void inc() { n.incrementAndGet(); }
public long get() { return n.get(); }
}
static class AdderCounter implements Counter {
private final LongAdder n = new LongAdder();
public void inc() { n.increment(); }
public long get() { return n.sum(); }
}
static class LockCounter implements Counter { // unlock は finally で
private final ReentrantLock lock = new ReentrantLock();
private long n;
public void inc() { lock.lock(); try { n++; } finally { lock.unlock(); } }
public long get() { lock.lock(); try { return n; } finally { lock.unlock(); } }
}
static long[] measure(Counter c, int threads, int loops) throws InterruptedException {
List<Thread> list = new ArrayList<>();
long start = System.nanoTime();
for (int t = 0; t < threads; t++) {
Thread th = new Thread(() -> { for (int i = 0; i < loops; i++) c.inc(); });
list.add(th); th.start();
}
for (Thread th : list) th.join();
return new long[]{ (System.nanoTime() - start) / 1_000_000, c.get() };
}
public static void main(String[] args) throws InterruptedException {
int threads = Integer.parseInt(args[0]);
int loops = 5_000_000;
Map<String, java.util.function.Supplier<Counter>> kinds = new LinkedHashMap<>();
kinds.put("synchronized", SyncCounter::new);
kinds.put("AtomicInteger", AtomicCounter::new);
kinds.put("LongAdder", AdderCounter::new);
kinds.put("ReentrantLock", LockCounter::new);
for (var e : kinds.entrySet()) measure(e.getValue().get(), threads, loops); // ウォームアップ
System.out.printf("スレッド数=%d, 1スレッドあたり%,d回%n", threads, loops);
for (var e : kinds.entrySet()) {
long[] times = new long[5]; long result = 0;
for (int r = 0; r < 5; r++) { long[] m = measure(e.getValue().get(), threads, loops); times[r] = m[0]; result = m[1]; }
Arrays.sort(times);
System.out.printf("%-14s 結果=%,d 中央値=%,d ms (最小 %,d / 最大 %,d)%n",
e.getKey(), result, times[2], times[0], times[4]);
}
}
}
スレッド数別の比較表(中央値、5回計測)
引数を 1・2・4・8 に変えた結果です。4つとも合計値はすべて期待どおりで、違いは処理時間だけです。
| 方法 | 1スレッド | 2スレッド | 4スレッド | 8スレッド |
|---|---|---|---|---|
| synchronized | 137 ms | 124 ms | 1,484 ms | 1,506 ms |
| AtomicInteger | 52 ms | 129 ms | 285 ms | 617 ms |
| LongAdder | 60 ms | 202 ms | 584 ms | 762 ms |
| ReentrantLock | 120 ms | 509 ms | 641 ms | 1,019 ms |
(OpenJDK 21.0.10、2コア環境。各スレッド500万回加算。ばらつきが大きいので最小・最大も上の出力で確認してください)
この結果から読み取れることは次のとおりです。
- 単純な整数カウンタなら
AtomicIntegerがどのスレッド数でも速い傾向でした。ロックを取らず、CPUのCAS(比較して交換)命令で1回の更新を行うためです。 - synchronized はスレッド数がコア数(2)を超えると時間が大きく伸びました。 待ちが増えると、スピンからOSレベルの待機に切り替わるためと考えられます。
- LongAdder は今回の2コアでは AtomicInteger より遅い結果でした。LongAdder は競合が多いとき更新先を複数のセルに分ける仕組みで、コア数が多く、書き込みが非常に頻繁なときに効果が出ます。少ないコアではセル管理の手間のほうが目立ちます。
- 数値は環境で大きく変わります。選ぶ基準は速度より「何をしたいか」です(次の表)。
どれを選ぶかの判断表
| やりたいこと | おすすめ | 理由 |
|---|---|---|
| 1つの数値を増減するだけ | AtomicInteger / AtomicLong |
ロック不要で速く、コードも短い |
| 合計を読むのはたまに、書き込みは非常に多い(アクセス数の集計など) | LongAdder |
多コアで競合が多いとき有利。sum() は厳密な瞬間値ではない点に注意 |
| 複数のフィールドをまとめて矛盾なく更新 | synchronized |
「在庫を減らして予約数を増やす」のような複数の変更を1つにまとめられる |
| 待ち時間の上限、割り込み、公平性、条件変数が必要 | ReentrantLock |
tryLock・lockInterruptibly・newCondition() が使える |
| 読み取りが大半で、書き込みが少ない | ReentrantReadWriteLock / StampedLock |
読み取り同士は並行に動ける |
| Map・List・Queue を共有 | ConcurrentHashMap など java.util.concurrent のコレクション |
自分でロックを書かずに済む |
| Java 21 の仮想スレッドでI/Oを待つ処理 | ReentrantLock |
Java 21〜23 では synchronized 内のブロックで pinning が起きる(後述) |
AtomicInteger は「1つの変数」しか守れません。 次のように2つの Atomic 変数を順に更新すると、その間に他のスレッドが割り込めます。複数の値の整合性が必要なときは synchronized か Lock を使います。
// 2つの値の整合性が必要な例(在庫と予約数)
private int stock = 10;
private int reserved = 0;
public synchronized boolean reserve() {
if (stock == 0) return false;
stock--; // この2行をまとめて1つの操作にする
reserved++;
return true;
}
synchronized はブロックを抜けると自動でロックが外れますが、ReentrantLock は unlock() を自分で呼ぶ必要があります。 上の LockCounter のように lock() → try → finally { unlock(); } の型で書けば、例外が出てもロックが残りません。
volatile の役割(可視性)と限界
volatile なしでは停止フラグが見えないことがある
volatile は「書き込みをすぐ他のスレッドから見えるようにする」ためのキーワードです。次のコードは、メインスレッドが running = false にしてもループが止まるかを確かめます。
public class VisibilityDemo {
static boolean running = true; // volatile なし
static volatile boolean vRunning = true; // volatile あり
public static void main(String[] args) throws InterruptedException {
// 1) volatile なし:別スレッドの書き込みが見えないことがある
Thread t1 = new Thread(() -> {
long n = 0;
while (running) { n++; } // JIT が running の読み出しをループ外に出すことがある
System.out.println("t1 停止 n=" + n);
});
t1.setDaemon(true);
t1.start();
Thread.sleep(500);
running = false;
t1.join(2000);
System.out.println("volatileなし: 2秒後もt1が動いている? " + t1.isAlive());
// 2) volatile あり:書き込みがすぐ見える
Thread t2 = new Thread(() -> {
long n = 0;
while (vRunning) { n++; }
System.out.println("t2 停止 n>0? " + (n > 0));
});
t2.start();
Thread.sleep(500);
vRunning = false;
t2.join(2000);
System.out.println("volatileあり: 2秒後もt2が動いている? " + t2.isAlive());
}
}
実行結果:
$ java VisibilityDemo.java
volatileなし: 2秒後もt1が動いている? true
t2 停止 n>0? true
volatileあり: 2秒後もt2が動いている? false
volatile なしのループは、running = false にしても止まりませんでした(デーモンスレッドにしてあるので、プログラム自体は終了します)。JITが「ループ内で running は変わらない」と判断し、読み出しをループの外に出したためと考えられます。volatile を付けると、毎回メモリから読み直すので、すぐに止まりました。
volatile でも count++ は守れない
では、カウンタを volatile にすれば値は合うでしょうか。
import java.util.ArrayList;
import java.util.List;
public class VolatileCount {
static volatile int count = 0; // volatile でも count++ は複合操作
public static void main(String[] args) throws InterruptedException {
for (int run = 1; run <= 3; run++) {
count = 0;
List<Thread> list = new ArrayList<>();
for (int t = 0; t < 4; t++) {
Thread th = new Thread(() -> { for (int i = 0; i < 1_000_000; i++) count++; });
list.add(th); th.start();
}
for (Thread th : list) th.join();
System.out.printf("%d回目: 期待値=4,000,000 実際=%,d%n", run, count);
}
}
}
実行結果:
$ java VolatileCount.java
1回目: 期待値=4,000,000 実際=2,849,528
2回目: 期待値=4,000,000 実際=3,478,788
3回目: 期待値=4,000,000 実際=2,903,509
volatile は「見えること(可視性)」を保証しますが、「読み取り→+1→書き込み」をまとめる力(原子性)はありません。 2つのスレッドが同じ値を読む競合はそのまま残ります。
| 性質 | 意味 | volatile | synchronized | AtomicInteger |
|---|---|---|---|---|
| 可視性 | 書き込みが他スレッドから見える | ○ | ○ | ○ |
| 原子性 | 複数ステップを割り込まれずに実行 | ×(読み・書き単体のみ) | ○ | ○(1変数の操作のみ) |
| 排他 | 同時に1スレッドだけ実行 | × | ○ | ―(ロックなし) |
| 向いている用途 | 停止フラグ、設定値の差し替え | ― | 複数の値の更新 | カウンタ、ID採番 |
スレッドセーフなコレクション:ConcurrentHashMap と synchronizedList
ArrayList・HashMap を共有したときの実行結果
import java.util.*;
import java.util.concurrent.*;
public class CollectionsDemo {
static void runParallel(Runnable task) throws InterruptedException {
List<Thread> list = new ArrayList<>();
for (int t = 0; t < 4; t++) { Thread th = new Thread(task); list.add(th); th.start(); }
for (Thread th : list) th.join();
}
public static void main(String[] args) throws Exception {
// 1) ArrayList を4スレッドで add
for (int r = 1; r <= 3; r++) {
List<Integer> list = new ArrayList<>();
runParallel(() -> { for (int i = 0; i < 100_000; i++) list.add(i); });
System.out.printf("ArrayList %d回目: 期待値=400,000 size=%,d%n", r, list.size());
}
// 2) Collections.synchronizedList
List<Integer> sync = Collections.synchronizedList(new ArrayList<>());
runParallel(() -> { for (int i = 0; i < 100_000; i++) sync.add(i); });
System.out.printf("synchronizedList: size=%,d%n", sync.size());
// 3) HashMap と ConcurrentHashMap で件数を数える(キー10種類)
Map<String, Integer> hm = new HashMap<>();
runParallel(() -> { for (int i = 0; i < 100_000; i++) hm.merge("key" + (i % 10), 1, Integer::sum); });
System.out.printf("HashMap 合計=%,d (期待値 400,000)%n", hm.values().stream().mapToInt(Integer::intValue).sum());
Map<String, Integer> chm = new ConcurrentHashMap<>();
runParallel(() -> { for (int i = 0; i < 100_000; i++) chm.merge("key" + (i % 10), 1, Integer::sum); });
System.out.printf("ConcurrentHashMap 合計=%,d%n", chm.values().stream().mapToInt(Integer::intValue).sum());
}
}
実行結果:
$ java CollectionsDemo.java
ArrayList 1回目: 期待値=400,000 size=329,088
ArrayList 2回目: 期待値=400,000 size=292,573
ArrayList 3回目: 期待値=400,000 size=238,920
synchronizedList: size=400,000
HashMap 合計=394,359 (期待値 400,000)
ConcurrentHashMap 合計=400,000
ArrayList と HashMap は複数スレッドからの更新を想定していないため、件数が足りなくなりました。 Collections.synchronizedList と ConcurrentHashMap は毎回期待どおりです。
ConcurrentHashMap の merge・compute・putIfAbsent は1つのキーに対する「読んで更新」をまとめて行うメソッドです。次のように get と put を分けると、その間に割り込まれます。
// 直す前:get と put の間に他スレッドが割り込める
Integer cur = map.get(key);
map.put(key, cur == null ? 1 : cur + 1);
// 直した後:1回の操作で更新する
map.merge(key, 1, Integer::sum);
synchronizedList は反復時に自分でロックする
Collections.synchronizedList は add や get の1回1回は排他しますが、for 文での反復(複数回の呼び出し)全体は守りません。 反復中に別スレッドが追加したときの結果を確かめました(CollectionsDemo の main に追加して実行)。
// 1万件入りの items に、別スレッドが 0.5ms ごとに2,000件追加している間に反復する
// (1)そのまま for 文で反復 (2)synchronized (items) で囲んで反復
synchronized (items) {
for (Integer v : items) sum += v;
}
実行結果:
反復(同期なし): java.util.ConcurrentModificationException
反復(synchronized(items)): 例外なし size=14000
反復するときは synchronized (items) { ... } でリスト自身をロックします。 これは Collections.synchronizedList の Javadoc にも書かれている使い方です。
読み取りが大半の List なら CopyOnWriteArrayList、生産者・消費者のやり取りなら ArrayBlockingQueue / LinkedBlockingQueue も選択肢です。
デッドロックの再現と jcmd での確認
2つのロックを逆順に取るとデッドロックになる
デッドロックとは、2つ以上のスレッドがお互いの持つロックを待ち合い、どちらも進めなくなる状態です。
public class DeadlockDemo {
static final Object STOCK = new Object(); // 在庫のロック
static final Object ORDER = new Object(); // 注文のロック
static void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { } }
public static void main(String[] args) {
Thread a = new Thread(() -> {
synchronized (STOCK) {
System.out.println("A: STOCK を取得、ORDER を待つ");
sleep(100);
synchronized (ORDER) { System.out.println("A: 完了"); }
}
}, "worker-A");
Thread b = new Thread(() -> {
synchronized (ORDER) {
System.out.println("B: ORDER を取得、STOCK を待つ");
sleep(100);
synchronized (STOCK) { System.out.println("B: 完了"); }
}
}, "worker-B");
a.start(); b.start();
}
}
実行結果(ここで止まったまま、「完了」は表示されません):
$ java DeadlockDemo.java
A: STOCK を取得、ORDER を待つ
B: ORDER を取得、STOCK を待つ
A は STOCK を持って ORDER を待ち、B は ORDER を持って STOCK を待つため、どちらも先に進めません。
jcmd Thread.print(jstack)で原因を特定する
止まったままのプロセスを、別のターミナルから調べます。jps -l でプロセスIDを確認し、jcmd <PID> Thread.print でスレッドダンプを取ります(jstack <PID> でも同じ内容が出ます)。
$ jps -l
5330 jdk.compiler/com.sun.tools.javac.launcher.Main
$ jcmd 5330 Thread.print
出力の末尾に、JVMが検出したデッドロックが表示されました(実際の出力から抜粋)。
Found one Java-level deadlock:
=============================
"worker-A":
waiting to lock monitor 0x00007fbdac0020d0 (object 0x00000000a7ce9578, a java.lang.Object),
which is held by "worker-B"
"worker-B":
waiting to lock monitor 0x00007fbdb80016d0 (object 0x00000000a7ce9568, a java.lang.Object),
which is held by "worker-A"
Java stack information for the threads listed above:
===================================================
"worker-A":
at DeadlockDemo.lambda$main$0(DeadlockDemo.java:12)
- waiting to lock <0x00000000a7ce9578> (a java.lang.Object)
- locked <0x00000000a7ce9568> (a java.lang.Object)
...
"worker-B":
at DeadlockDemo.lambda$main$1(DeadlockDemo.java:19)
- waiting to lock <0x00000000a7ce9568> (a java.lang.Object)
- locked <0x00000000a7ce9578> (a java.lang.Object)
...
Found 1 deadlock.
ダンプ上部の各スレッドの欄には、java.lang.Thread.State: BLOCKED (on object monitor) と表示されていました。
| ダンプの表示 | 読み方 |
|---|---|
Found one Java-level deadlock |
JVM がデッドロックを検出した |
waiting to lock <0x...a7ce9578> |
このオブジェクトのロックを待っている |
- locked <0x...a7ce9568> |
このオブジェクトのロックを持っている |
DeadlockDemo.java:12 |
待っている行。ここを直す |
BLOCKED (on object monitor) |
synchronized の入口で待っている |
スレッドに名前を付けておく(new Thread(r, "worker-A"))と、ダンプが読みやすくなります。 スレッドプールでは ThreadFactory で名前を付けられます。
回避策1:ロックを取る順番をそろえる
最もシンプルな回避策は、すべてのスレッドでロックを取る順番を同じにすることです。
// DeadlockDemo の worker-B も「STOCK → ORDER」の順で取るように変える
Thread b = new Thread(() -> {
synchronized (STOCK) {
sleep(100);
synchronized (ORDER) { System.out.println("B: 完了"); }
}
}, "worker-B");
実行結果(A・B ともに「完了」が表示され、プログラムが終了しました):
A: STOCK を取得、ORDER を待つ
A: 完了
B: 完了
回避策2:ReentrantLock の tryLock で待ち時間に上限を付ける
順番をそろえにくいときは、ReentrantLock.tryLock(時間) で「取れなければいったん手放してやり直す」ようにします。
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class TryLockDemo {
static final ReentrantLock STOCK = new ReentrantLock();
static final ReentrantLock ORDER = new ReentrantLock();
// 2つのロックを両方取れたときだけ処理する。取れなければ手放してやり直す
static void work(String name, ReentrantLock first, ReentrantLock second) throws InterruptedException {
int retry = 0;
while (true) {
if (first.tryLock(50, TimeUnit.MILLISECONDS)) {
try {
Thread.sleep(100); // 処理中に相手もロックを取る
if (second.tryLock(50, TimeUnit.MILLISECONDS)) {
try {
System.out.println(name + ": 完了 (リトライ " + retry + " 回)");
return;
} finally { second.unlock(); }
}
} finally { first.unlock(); }
}
retry++;
Thread.sleep(ThreadLocalRandom.current().nextInt(10, 60)); // ずらして再挑戦
}
}
public static void main(String[] args) throws InterruptedException {
long start = System.nanoTime();
Thread a = new Thread(() -> { try { work("A", STOCK, ORDER); } catch (InterruptedException e) { } });
Thread b = new Thread(() -> { try { work("B", ORDER, STOCK); } catch (InterruptedException e) { } });
a.start(); b.start(); a.join(); b.join();
System.out.printf("両方完了: %d ms%n", (System.nanoTime() - start) / 1_000_000);
}
}
実行結果(2回目の実行では A と B の順番が入れ替わり、469 ms でした):
$ java TryLockDemo.java
B: 完了 (リトライ 1 回)
A: 完了 (リトライ 2 回)
両方完了: 432 ms
ロックの順番が逆のままでも、両方のスレッドが完了しました。 再挑戦の前にランダムな待ち時間を入れているのは、2つのスレッドが同じタイミングで取り合いを繰り返す状態(ライブロック)を避けるためです。
ほかにも「2つのデータを常に一緒に更新するならロックを1つにまとめる」「ロック中に外部のメソッドを呼ばない(呼び出し先が内部でロックを取ることがある)」も有効です。
仮想スレッドと synchronized(pinning)
Java 21 で正式機能になった仮想スレッドでは、synchronized の中でブロック(sleep・I/O 待ちなど)すると、仮想スレッドがキャリアスレッド(実際に動かしているOSスレッド)から外れられなくなる「pinning(ピン留め)」が起きます。Java 21 では -Djdk.tracePinnedThreads で発生箇所を確認できます。
public class PinningTrace {
static final Object LOCK = new Object();
public static void main(String[] args) throws Exception {
Thread vt = Thread.ofVirtual().start(() -> {
synchronized (LOCK) { // モニターを持ったまま
try { Thread.sleep(50); } catch (InterruptedException e) { } // ブロックする
}
});
vt.join();
System.out.println("終了");
}
}
実行結果(Java 21.0.10):
$ java -Djdk.tracePinnedThreads=short PinningTrace.java
VirtualThread[#19]/runnable@ForkJoinPool-1-worker-1 reason:MONITOR
PinningTrace.lambda$main$0(PinningTrace.java:6) <== monitors:1
終了
reason:MONITOR と <== monitors:1 が、synchronized を持ったままブロックした行を示しています。
同じ PinningTrace.java を OpenJDK 25.0.4.1 で実行すると、トレースは何も出ず「終了」だけが表示されました。Java 24 で取り込まれた JEP 491「Synchronize Virtual Threads without Pinning」により、モニターの取得待ちや Object.wait() の間も仮想スレッドがキャリアから外れられるようになり、-Djdk.tracePinnedThreads 自体も削除されたためです。
処理時間でも確かめました。100個の仮想スレッドが、それぞれ別のロックの synchronized の中で 100ms 眠ります。
import java.util.concurrent.Executors;
public class PinningTime {
public static void main(String[] args) {
long start = System.nanoTime();
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100; i++) {
Object lock = new Object(); // タスクごとに別のロック(競合はしない)
ex.submit(() -> {
synchronized (lock) {
try { Thread.sleep(100); } catch (InterruptedException e) { }
}
});
}
} // close() で全タスクの完了を待つ
System.out.printf("Java %s: 100タスク × sleep 100ms = %,d ms%n",
Runtime.version().feature(), (System.nanoTime() - start) / 1_000_000);
}
}
実行結果(同じ2コア環境で、Java 21.0.10 と 25.0.4.1 を交互に2回ずつ):
Java 21: 100タスク × sleep 100ms = 5,033 ms
Java 25: 100タスク × sleep 100ms = 158 ms
Java 21: 100タスク × sleep 100ms = 5,034 ms
Java 25: 100タスク × sleep 100ms = 116 ms
| バージョン | synchronized 内で sleep(100タスク) | -Djdk.tracePinnedThreads |
|---|---|---|
| Java 21〜23 | 約5秒(キャリア2本ずつしか待てない) | pinning の発生行を出力 |
| Java 24 以降(LTS は 25) | 約0.1秒 | 削除(指定しても何も出ない) |
ネイティブコードを経由してブロックする場合の pinning は Java 25 でも残り、その検出には JFR の jdk.VirtualThreadPinned イベントが引き続き使えます(JEP 491 の記載)。
Java 21 のまま仮想スレッドでI/Oを多く待つなら、その区間の synchronized を ReentrantLock に置き換えると同時実行数を保てます。Java 25 に上げられる場合は、書き換えなしで済む場面が増えます。仮想スレッドの処理時間の実測や JFR での確認方法は、当サイトの「Java 仮想スレッド入門|10万タスク実測と使いどころ判断表」で詳しく解説しています。
スレッドセーフにするためのチェックリスト
| チェック項目 | 確認する内容 |
|---|---|
| 共有データを洗い出したか | static 変数、シングルトンのフィールド、複数スレッドに渡したオブジェクト |
| 共有しない設計にできないか | ローカル変数・不変オブジェクト(record、final フィールド)にすれば同期は不要 |
| 読み書きの両方を同じロックで守っているか | 書き込みだけ synchronized になっていないか |
| ロック対象と範囲は適切か | データと同じ範囲で共有されるオブジェクト/I/O はロックの外 |
| 複数ロックの順番は決まっているか | 逆順で取る箇所がないか |
| 止まったときの調べ方を知っているか | jcmd <PID> Thread.print で BLOCKED と deadlock を探す |
よくある質問
Q. synchronized メソッドと synchronized ブロックはどちらがよいですか?
A. ロック対象が this で、メソッド全体を守るなら同じ意味です。ログ出力など守らなくてよい処理が含まれるときや、this 以外(専用 Object など)をロックにしたいときはブロックを使います。
Q. static 変数を synchronized で守るには?
A. static synchronized メソッドか、private static final Object LOCK を使った synchronized (LOCK) ブロックにします。インスタンスメソッドの synchronized は this をロックするため、インスタンスが別々だと static 変数は守れません(本文の実行結果で this 版だけ値がずれました)。
Q. volatile を付ければスレッドセーフになりますか?
A. フラグのように「書いて、読む」だけの変数なら十分です。count++ のように「読んで更新して書く」操作は守れないため、AtomicInteger か synchronized を使います(本文で volatile のカウンタが最大約115万ずれる結果を掲載しています)。
Q. デッドロックが起きているか、どう確認しますか?
A. jps -l でプロセスIDを調べ、jcmd <PID> Thread.print(または jstack <PID>)を実行します。デッドロックがあれば Found one Java-level deadlock と、待っているロック・持っているロック・行番号が表示されます。
まとめ
- synchronized は同じロックを使うコードを1スレッドずつ実行させ、可視性も保証する。同期なしのカウンタは、合う回と合わない回が混ざった。
- ロック対象が重要。インスタンスが別々だと
thisでは static 変数を守れず、static synchronizedか専用のstatic final Objectで守れた。 - 単純なカウンタは
AtomicIntegerが速い傾向(2コア環境)。複数の値をまとめて更新するなら synchronized、待ち時間の上限が必要ならReentrantLock。 - volatile は可視性だけ。停止フラグには有効だが、
count++は volatile でもずれた。 - コレクションは
ConcurrentHashMapのmergeやsynchronizedList+ 反復時のsynchronized (list)を使う。 - デッドロックは
jcmd Thread.printで特定し、ロック順の統一かtryLockで回避できる。 - 仮想スレッドの pinning は Java 24(JEP 491)以降ほぼ解消。手元でも Java 21 の約5秒が Java 25 で約0.1秒になった。Java 21 では
ReentrantLockへの置き換えが有効。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Java synchronizedで排他制御|スレッドセーフを実行結果で検証」
株式会社RJCでは、Javaの排他制御やスレッドセーフな設計を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。