この記事はラクス Advent Calendar 2025の22日目の記事です。
はじめに
こんにちは、@rs_tukkiです。
先日、ClaudeにJavaのコード修正を提案してもらっている際にSemaphoreという見慣れないクラスが出てきました。
private Semaphore semaphore = new Semaphore(10, true);
調べてみると資源管理のための排他制御を実現するための仕組みらしいのですが、本来排他制御で使用するsynchronizedとの違いもあまり分かっていなかったので、
これを機に2つまとめて備忘として記事に残しておこうと思います。
1. synchronized とは
synchronizedはJavaの基本的な同期処理の際に使用する修飾子で、相互の排他ロックを実現するために使われます。
例えば、ファイルの読み書きやDB操作など、同時に1つしか実行させたくない処理があるとき、
マルチスレッド環境でもsynchronizedを指定することで、あるスレッドが実行しているなら他のスレッドはロックするといった処理が可能になります。
// メソッド全体をロック
public synchronized void write() {
// このメソッドは同時に1つのスレッドしか実行できない
}
// 特定のブロックのみロック
public void read() {
synchronized (this) {
// このブロックのみロック
}
}
特徴
上記のコードで示した通り、スレッドセーフにしたい処理はsynchronizedを指定するだけで実現できます。
ロックの解放も自動的に行われるため、シンプルな実装が可能というのが最大のメリットです。
| 特徴 | 説明 |
|---|---|
| 排他制御 | 同時に1つのスレッドのみがロックを取得可能 |
| wait/notify |
Objectのwait()/notify()と組み合わせて待機・通知が可能 |
| 暗黙的ロック | ロックの取得・解放は自動的に行われる |
synchronized + wait/notify パターンの例
wait/notifyはそれぞれjava.lang.Objectクラスの関数で、
wait()を実行すると、どこかでnotify()が実行されるまで処理を待機するという関数です。
下記の例では、synchronizedでput関数とtake関数がそれぞれ同時に1スレッドしか実行できなくしているのと同時に、
putしようとして満杯だったときやtakeしようとして空だったときはそれぞれ相手の関数が実行されて処理が可能になるまで待つといったことが実現できます。
import java.util.ArrayDeque;
import java.util.Deque;
public class BoundedBuffer<T> {
private final Deque<T> q = new ArrayDeque<>();
private final int capacity;
public BoundedBuffer(int capacity) {
this.capacity = capacity;
}
public void put(T item) throws InterruptedException {
synchronized (this) {
while (q.size() == capacity) {
wait(); // 満杯なので空きを待つ
}
q.addLast(item);
notify(); // 取り出し待ちを起こす
}
}
public T take() throws InterruptedException {
synchronized (this) {
while (q.isEmpty()) {
wait(); // 空なので投入を待つ
}
T item = q.removeFirst();
notify(); // put待ちを起こす
return item;
}
}
}
問題点
さて、上記のパターンはスレッドセーフを実現できており、単純なケースでは最適解となりますが、
この処理の実行が頻発し高負荷になった場合はパフォーマンスが問題になってきます。
- 粗粒度のロック: メソッド全体をロックしているため、同時に1つのスレッドしか処理できない
-
notify()の非効率性:
notify()は待機中のスレッドを1つしか起こさないため、多数の待機スレッドがある場合に非効率
これを解決するのに有効なのがSemaphoreというクラスです。
2. Semaphore とは
Semaphoreはjava.util.concurrentパッケージで提供される同期処理を行うためのクラスで、
指定した数の permit(実行許可) を提供します。
synchronizedとの最大の違いは同時に実行できるスレッド数を自由に決められる点で、
例えばnew Semaphore(3);と宣言した場合、同時に3つのスレッドがpermitを取得できるようになります。
import java.util.concurrent.Semaphore;
public class WriteSample {
private final Semaphore semaphore = new Semaphore(3); // permitを3つ持つ
public void write() {
try {
semaphore.acquire(); // permitを取得(なければ待つ)
// このメソッドは同時に3つのスレッドが実行できる
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
semaphore.release(); // permitを返却
}
}
}
主なメソッド
Semaphoreはsynchronizedとは異なり、単に宣言するだけでロックと解放を自動で行ってくれるというわけではありません。
acquire()やrelease()を使って、明示的にpermitの取得と返却を行う必要があります。
| メソッド | 説明 |
|---|---|
acquire() |
permitを取得(取得できるまでブロック) |
tryAcquire() |
permitを即座に取得試行(取得できなければfalseを返す) |
tryAcquire(long timeout, TimeUnit unit) |
タイムアウト付きで取得試行 |
release() |
permitを返却 |
availablePermits() |
利用可能なpermit数を返す |
特徴
Semaphoreはsynchronizeと比較して実装のシンプルさでは劣りますが、
new Semaphore(3, true)と宣言することでpermitの取得が先入れ先出しであることを保証させるなど、柔軟な利用が可能になっています。
| 特徴 | 説明 |
|---|---|
| 複数許可 | 同時に複数のスレッドがリソースにアクセス可能 |
| 公平性オプション | コンストラクタで公平性(先入れ先出しの保証)を指定可能 |
| 柔軟性 | パーミット数を動的に変更可能 |
3. 実際の改善例
さて、ここまでsynchronizedとSemaphoreについて説明してきました。
ここで、実際にSemaphoreを使うことで改善が見込めるパターンについて詳しく見ていきます。
改善前(synchronized + wait/notify)
import java.util.ArrayDeque;
import java.util.Deque;
public class BoundedBuffer<T> {
private final Deque<T> q = new ArrayDeque<>();
private final int capacity;
public BoundedBuffer(int capacity) {
this.capacity = capacity;
}
public void put(T item) throws InterruptedException {
synchronized (this) {
while (q.size() == capacity) {
wait(); // 満杯なので空きを待つ
}
q.addLast(item);
notify(); // 取り出し待ちを起こす
}
}
public T take() throws InterruptedException {
synchronized (this) {
while (q.isEmpty()) {
wait(); // 空なので投入を待つ
}
T item = q.removeFirst();
notify(); // put待ちを起こす
return item;
}
}
}
上記の実装では、(単純化していますが)queueに値を出し入れすることで
事前に指定した数だけアイテムを投入可能であることを宣言しています。
put(item)を実行することで中にアイテムが1つ入っていることを示し、
take()を実行することでアイテムが1つ取り出されたことを示しています。
問題点
この実装の問題点は、主にqueueの出し入れが短期間で頻発した際に起こります。
スレッドA: put() でロック取得 → キュー満杯 → wait() で待機
スレッドB: put() を呼び出したいが、スレッドAがロックを保持しているため待機
スレッドC: put() を呼び出したいが、スレッドAがロックを保持しているため待機
...
となることで、スレッドブロックが連鎖的に発生してしまいます。
順次take()でアイテムを取り出せばロックは解消されますが、put(item)やtake()自体はsynchronizedにより1スレッドずつしか実行できないため、すべてのスレッドが流れ終えるまでかなりの時間がかかってしまうというわけです。
改善後(Semaphore + 並行コレクション)
上記のような問題を解決できるのがSemaphoreを利用した以下の実装です。
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.Semaphore;
public class BoundedBuffer2<T> {
private final Queue<T> q = new ConcurrentLinkedQueue<>();
private final Semaphore spaces;
private final Semaphore items;
public BoundedBuffer2(int capacity, boolean fair) {
this.spaces = new Semaphore(capacity, fair);
this.items = new Semaphore(0, fair);
}
public void put(T item) throws InterruptedException {
spaces.acquire(); // 空き枠ができるまで待つ
q.add(item); // 並行キューなので排他なしでOK
items.release(); // アイテムが1つ増えたことを通知
}
public T take() throws InterruptedException {
items.acquire(); // アイテムが入るまで待つ
T item = q.poll();
spaces.release(); // 空き枠が1つ増えたことを通知
return item;
}
}
ここでは、Semaphoreでアイテム数と空き容量を別個に管理しています。
queueに格納可能な数のSemaphoreを設定し、現在の空き容量と同じ数のスレッドが処理を実行できると言うわけです。
また、ConcurrentLinkedQueue自体はスレッドセーフなQueueであるため、複数スレッドが同時に処理を行なっても問題ないようになっています。
改善点
上記の実装によって、synchronizedでは発生していた多重スレッド問題を解決できます。
スレッドA: acquire() でpermit取得 → キューに格納
スレッドB: put(item) を呼び出し → 即座に実行可能(ロック不要)→ release() でpermit返却
スレッドC: acquire() でpermit取得待ち → スレッドBのrelease()で即座に取得可能
...
この実装ではrelease()が行われるたびに他スレッドの処理が流れていますが、
put(item)やtake()はSemaphoreで実装しているため容量と同じ数だけ同時に実行でき、その分全てのスレッドがスムーズに流れてくれるというわけです。
4. 使い分けの指針
synchronizedとSemaphoreはどちらも排他制御を実現するためのものですが、
以下の比較を見ても使うべき状況は異なります。
| 観点 | synchronized | Semaphore |
|---|---|---|
| 同時アクセス数 | 1(排他制御) | 設定可能(N個) |
| 待機の粒度 | メソッド/ブロック全体 | リソース単位 |
| タイムアウト |
wait(timeout)で可能 |
tryAcquire(timeout)で可能 |
| 公平性 | 保証なし | コンストラクタで指定可能 |
| ロックの解放 | 自動(スコープ終了時) | 明示的にrelease()が必要 |
| 使いやすさ | シンプル | やや複雑だが柔軟 |
| パフォーマンス | 単純なケースで高速 | 高負荷時に有利 |
基本的には、高負荷の想定がない、または完全な排他制御が必須の場面ではsynchronizedを、
高負荷環境でも速度が求められ、かつある程度は同時アクセスを許容する資源管理、例えば大規模システムのDB接続を確立する場面ではSemaphoreを使うのが良いと思います。
synchronized を使うべき場合
- 単純な排他制御で十分な場合
- コードをシンプルに保ちたい場合
- リソースへの同時アクセスを完全に禁止したい場合
Semaphore を使うべき場合
- 2つ以上のスレッドで同時実行したい場合
- 公平性(先入れ先出し)を保証したい場合
- 高負荷時のスループットを重視する場合
5. まとめ
synchronizedはJavaで排他制御を実現する方法として広く使われていますが、そのシンプルさと裏腹に高負荷環境ではスレッドブロックが問題になることがあります。
Semaphoreと並行コレクションを組み合わせることで、より細粒度の制御が可能になり、複数スレッドが効率的に動作できるようになります。
同時実行数を制御する実装を加える場合は、アプリケーションの要件とパフォーマンス目標に応じてどちらを選ぶか考えるようにしましょう。