Java並行処理の核心:java.util.concurrent.locksパッケージ徹底解説

この記事から得られる知識

  • synchronizedキーワードと比較したjava.util.concurrent.locks.Lockの利点と柔軟性。
  • ReentrantLockの公平性ポリシーや割り込み可能なロック取得など、高度なロック制御方法。
  • Conditionインターフェースを用いた、より複雑なスレッド間協調(待機・通知)の実装方法。
  • 読み取り性能を劇的に向上させるReentrantReadWriteLockの適切な使い方。
  • Java 8で導入された高性能ロックStampedLockの楽観的読み取り(Optimistic Reading)という革新的な概念。
  • 各ロック機構の特性を理解し、シナリオに応じて最適なロックを選択する判断基準。

Javaにおけるマルチスレッドプログラミングは、アプリケーションのパフォーマンスと応答性を向上させるための強力な手段です。しかし、複数のスレッドが共有リソースに同時にアクセスすると、データの不整合や予期せぬエラーを引き起こす可能性があります。これを防ぐのが「排他制御」や「同期」と呼ばれる仕組みです。

Javaで最も基本的な排他制御はsynchronizedキーワードですが、より複雑で高度な制御が求められる場面では力不足になることがあります。そこで登場するのが、Java 5から導入されたjava.util.concurrent.locksパッケージです。このパッケージは、開発者がロックの取得と解放を明示的に制御できる、柔軟かつ高機能なロックAPI群を提供します。

この記事では、java.util.concurrent.locksパッケージの主要なコンポーネントであるLock, ReentrantLock, ReentrantReadWriteLock, そしてJava 8で加わったStampedLockについて、その仕組みと具体的な使い方を詳細に解説します。synchronizedとの違いを明確にしながら、各ロックがどのような問題を解決し、どのような場面で真価を発揮するのかを深く探求していきます。


第1章: Lockインタフェースとsynchronizedの違い

Javaにおける同期の基本はsynchronizedキーワードです。メソッドやコードブロックに付与するだけで、手軽に排他制御を実現できます。しかし、その手軽さの反面、いくつかの制約も存在します。java.util.concurrent.locks.Lockインタフェースは、これらの制約を克服し、より柔軟なロック管理を提供するために設計されました。

synchronizedの限界とLockの優位性

synchronizedは暗黙的なロック機構です。ロックの取得と解放はJVMによって自動的に行われ、開発者が介入する余地はほとんどありません。 これに対し、Lockは明示的なロックであり、lock()メソッドで取得し、unlock()メソッドで解放します。 この明示的な制御が、高度な並行処理パターンを可能にするのです。

機能synchronizedLock (ReentrantLockなど)
ロックの取得・解放暗黙的(ブロックの開始と終了で自動)明示的lock()unlock()メソッドを呼び出す)
割り込み可能性ロック待機中にスレッドが割り込まれても中断できない割り込み可能lockInterruptibly()で待機中に中断できる)
ロック取得の試行不可(ロックが解放されるまでブロックされる)試行可能tryLock()で即時または時間指定でロック取得を試み、失敗時にブロックしない)
公平性 (Fairness)保証されない(非公平)選択可能(公平・非公平ポリシーを選択できる)
複数条件の待機オブジェクトごとに1つの待機セットのみ (wait/notify)複数可能Conditionオブジェクトで複数の待機セットを管理できる)

基本的なLockの使い方: try-finallyパターン

Lockを使用する上で最も重要なルールは、unlock()メソッドを必ずfinallyブロック内で呼び出すことです。これにより、ロックをかけた処理の途中で例外が発生した場合でも、ロックが確実に解放され、デッドロックを防ぐことができます。これはLockを使う上での絶対的な作法と言えます。

<?xml version="1.0" encoding="UTF-8"?>
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class Counter { private final Lock lock = new ReentrantLock(); private int count = 0; public void increment() { lock.lock(); // ロックを取得 try { // ここがクリティカルセクション count++; System.out.println(Thread.currentThread().getName() + " がカウントをインクリメント: " + count); } finally { lock.unlock(); // finallyブロックで確実にロックを解放 } }
}

第2章: ReentrantLock – 再入可能な万能ロック

ReentrantLockは、Lockインタフェースの最も基本的かつ広く利用される実装です。 その名の通り「再入可能(Reentrant)」な性質を持ち、synchronizedと同様のセマンティクスを提供しつつ、はるかに高機能です。

「再入可能」とは?

再入可能性とは、あるスレッドが既に取得しているロックを、再度取得しようとしてもブロックされない性質を指します。スレッドは自身が保持しているロックを、解放することなく何度も取得できます。これは、再帰的な関数呼び出しなど、同じスレッドがロックされたメソッドを複数回呼び出す場合にデッドロックを防ぐために不可欠です。

<?xml version="1.0" encoding="UTF-8"?>
public class ReentrantExample { private final ReentrantLock lock = new ReentrantLock(); public void outer() { lock.lock(); try { System.out.println("outerメソッド: ロック取得"); inner(); // 同じスレッドが再度ロックを取得しようとする } finally { lock.unlock(); System.out.println("outerメソッド: ロック解放"); } } public void inner() { lock.lock(); try { System.out.println("innerメソッド: ロック取得(再入)"); } finally { lock.unlock(); System.out.println("innerメソッド: ロック解放"); } }
}

上記の例では、outerメソッド内でinnerメソッドを呼び出しています。innerメソッドも同じロックを取得しようとしますが、ReentrantLockが再入可能であるため、同じスレッドによるロック取得は成功し、処理はブロックされません。ロックは取得した回数だけ解放する必要があります。

公平性ポリシー: Fair vs Unfair

ReentrantLockのコンストラクタは、公平性ポリシーを指定するブール値の引数を取ることができます。

  • new ReentrantLock() または new ReentrantLock(false): 非公平ロック (Unfair Lock) – デフォルト。ロックが解放されたとき、待機しているスレッドの順番に関係なく、いずれかのスレッドがロックを取得します。パフォーマンスが高い傾向にありますが、特定のスレッドが長期間ロックを取得できない「スレッド飢餓(Starvation)」が発生する可能性があります。
  • new ReentrantLock(true): 公平ロック (Fair Lock) – ロック待機時間が最も長いスレッドが、次にロックを取得する権利を得ます(FIFO: First-In, First-Out)。 スレッド飢餓を防ぎますが、スレッドのスケジューリングに伴うオーバーヘッドがあるため、一般的に非公平ロックよりもスループットは低下します。

公平性の選択

ほとんどのアプリケーションでは、パフォーマンス上の理由からデフォルトの非公平ロックで十分です。公平ロックは、スレッド飢餓が許容されない厳密な要件がある場合にのみ使用を検討すべきです。

高度なロック操作

ReentrantLockは、lock()以外にも柔軟なロック取得メソッドを提供します。

  • tryLock(): ロックが利用可能な場合のみ即座にロックを取得しtrueを返します。他のスレッドに保持されている場合は待機せず、即座にfalseを返します。ポーリング形式のロック取得に適しています。
  • tryLock(long timeout, TimeUnit unit): 指定した時間だけロック取得を試みます。時間内に取得できればtrue、できなければfalseを返します。タイムアウトにより、無期限の待機を避けることができます。
  • lockInterruptibly(): ロックを待機している間に、他のスレッドから割り込み(interrupt())があった場合にInterruptedExceptionをスローして待機を中断します。応答性の高いアプリケーションの構築に役立ちます。

第3章: Condition – スレッド間協調のための高度な待機/通知

Objectクラスのwait(), notify(), notifyAll()は、Javaの伝統的なスレッド間協調メカニズムです。しかし、これらは1つのオブジェクト(ロック)に対して1つの待機キューしか持てないという制約があります。java.util.concurrent.locks.Conditionインタフェースは、この問題を解決し、よりきめ細かく柔軟なスレッド間通信を可能にします。

Object.wait/notify と Condition の違い

Conditionは、特定のLockインスタンスに紐付けられます。 1つのLockから複数のConditionインスタンスを生成でき、これにより、異なる条件ごとにスレッドを待機させることが可能になります。 例えば、「バッファが空である」という条件で待機するスレッドと、「バッファが満杯である」という条件で待機するスレッドを、別々の待機キューで管理できます。

Conditionオブジェクトは、LockインスタンスのnewCondition()メソッドを呼び出して取得します。

Condition の使い方: 生産者消費者問題

生産者消費者問題は、Conditionの有用性を示す典型的な例です。ここでは、固定サイズのバッファを共有し、生産者スレッドがデータを追加し、消費者スレッドがデータを取り出すシナリオを考えます。

  • 生産者は、バッファが満杯の場合は待機しなければならない。
  • 消費者は、バッファが空の場合は待機しなければならない。
<?xml version="1.0" encoding="UTF-8"?>
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class BoundedBuffer<E> { private final Lock lock = new ReentrantLock(); // バッファが満杯でないことを示すCondition private final Condition notFull = lock.newCondition(); // バッファが空でないことを示すCondition private final Condition notEmpty = lock.newCondition(); private final Queue<E> queue; private final int capacity; public BoundedBuffer(int capacity) { this.capacity = capacity; this.queue = new LinkedList<>(); } public void put(E e) throws InterruptedException { lock.lock(); try { while (queue.size() == capacity) { System.out.println("バッファが満杯です。生産者は待機します。"); notFull.await(); // バッファが満杯なので待機 } queue.add(e); System.out.println("生産者がデータを追加しました: " + e); notEmpty.signal(); // バッファにデータが入ったので、消費者を1つ起こす } finally { lock.unlock(); } } public E take() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { System.out.println("バッファが空です。消費者は待機します。"); notEmpty.await(); // バッファが空なので待機 } E e = queue.poll(); System.out.println("消費者がデータを取り出しました: " + e); notFull.signal(); // バッファに空きができたので、生産者を1つ起こす return e; } finally { lock.unlock(); } }
}

このコードでは、notFullnotEmptyという2つのConditionを使っています。

  • await(): Object.wait()に相当します。現在のスレッドを待機させ、ロックを解放します。他のスレッドがsignal()またはsignalAll()を呼び出すまで待機します。
  • signal(): Object.notify()に相当します。このConditionで待機しているスレッドを1つだけ再開させます。
  • signalAll(): Object.notifyAll()に相当します。このConditionで待機している全てのスレッドを再開させます。

signal()を使うことで、put操作はtake操作を待つスレッドのみを、take操作はput操作を待つスレッドのみを効率的に再開させることができます。これは、無関係なスレッドまで再開させてしまうnotifyAll()よりも効率的です。


第4章: ReentrantReadWriteLock – 読み取り性能の最適化

多くのアプリケーションでは、共有リソースへのアクセスは「読み取り」が「書き込み」よりもはるかに頻繁に発生します。このようなシナリオでReentrantLocksynchronizedを使用すると、読み取り操作同士も互いにブロックしてしまい、パフォーマンスのボトルネックになります。ReentrantReadWriteLockは、この問題を解決するための特殊なロックです。

読み取りロックと書き込みロック

ReentrantReadWriteLockは、内部的に2つのロックを管理します。

  • 読み取りロック (Read Lock): 共有ロックです。複数のスレッドが同時に読み取りロックを取得できます。書き込みロックが保持されていない限り、読み取りロックはいつでも取得可能です。
  • 書き込みロック (Write Lock): 排他ロックです。1つのスレッドしか書き込みロックを取得できません。書き込みロックが保持されている間、他のどのスレッドも読み取りロックや書き込みロックを取得することはできません。

この仕組みにより、「読み取りは並行に、書き込みは排他的に」というアクセス制御が実現され、読み取り中心のワークロードで高いスループットを達成できます。

ReentrantReadWriteLockの使い方

ReentrantReadWriteLockインスタンスから、readLock()writeLock()メソッドを使ってそれぞれのロックを取得します。

<?xml version="1.0" encoding="UTF-8"?>
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantReadWriteLock;
class ThreadSafeCache { private final Map<String, String> cache = new HashMap<>(); private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock(); private final ReentrantReadWriteLock.ReadLock readLock = rwl.readLock(); private final ReentrantReadWriteLock.WriteLock writeLock = rwl.writeLock(); public String get(String key) { readLock.lock(); try { // 複数のスレッドが同時にこのブロックを実行できる return cache.get(key); } finally { readLock.unlock(); } } public void put(String key, String value) { writeLock.lock(); try { // 一度に1つのスレッドしかこのブロックを実行できない cache.put(key, value); } finally { writeLock.unlock(); } }
}

ロックのダウングレード

ReentrantReadWriteLockは、「ロックのダウングレード」をサポートしています。これは、書き込みロックを保持したまま読み取りロックを取得し、その後で書き込みロックを解放する操作です。

これは、あるデータを更新した後、他のスレッドに書き込みを許可する前に、更新したデータを安全に読み取りたい場合に役立ちます。

注意: ロックのアップグレードは不可

逆に、読み取りロックを保持したまま書き込みロックを取得する「ロックのアップグレード」はサポートされていません。これを行おうとするとデッドロックを引き起こす可能性があるためです。書き込みが必要な場合は、一度読み取りロックを解放してから、改めて書き込みロックを取得する必要があります。

第5章: StampedLock – Java 8からの超高性能ロック

Java 8でjava.util.concurrent.locksパッケージに追加されたStampedLockは、ReentrantReadWriteLockをさらに進化させた、極めて高いパフォーマンスを発揮する可能性を秘めたロックです。 最大の特徴は「楽観的読み取り(Optimistic Reading)」という新しいモードを導入した点です。

StampedLockは、ロックの各操作で「スタンプ」と呼ばれるlong型の値を返します。このスタンプを使ってロックの解放や状態の検証を行います。

3つのロックモード

  1. 書き込みロック (Writing): writeLock()で取得。排他的なロックで、ReentrantReadWriteLockの書き込みロックと同様です。
  2. 悲観的読み取りロック (Pessimistic Reading): readLock()で取得。共有ロックで、ReentrantReadWriteLockの読み取りロックと同様です。書き込みロックがなければ取得できます。
  3. 楽観的読み取り (Optimistic Reading): tryOptimisticRead()で取得。これは実際にはロックを行いません。書き込みが行われていないだろう、という楽観的な仮定のもとで処理を進めます。

楽観的読み取り (Optimistic Reading) の威力

楽観的読み取りは、非常に短い読み取り操作において、ロックのオーバーヘッドを完全に排除できる可能性がある革新的な手法です。

手順は以下のようになります。

  1. tryOptimisticRead()を呼び出してスタンプを取得します。この時点では何もロックされません。
  2. 共有リソースから値を読み取り、ローカル変数にコピーします。
  3. validate(stamp)メソッドを呼び出して、読み取り中に書き込みロックが取得されなかったか(つまり、データが変更されなかったか)を検証します。
  4. 検証が成功すれば(trueが返る)、読み取った値は一貫性が保たれていると判断でき、そのまま使用できます。
  5. 検証が失敗した場合(falseが返る)、データが読み取り中に変更されたことを意味します。この場合は、通常の悲観的読み取りロック(readLock())を取得し直して、データを再度読み取ります。
<?xml version="1.0" encoding="UTF-8"?>
import java.util.concurrent.locks.StampedLock;
class Point { private double x, y; private final StampedLock sl = new StampedLock(); void move(double deltaX, double deltaY) { long stamp = sl.writeLock(); // 書き込みロックを取得 try { x += deltaX; y += deltaY; } finally { sl.unlockWrite(stamp); // スタンプを使って解放 } } double distanceFromOrigin() { long stamp = sl.tryOptimisticRead(); // 楽観的読み取りを試みる double currentX = x; double currentY = y; if (!sl.validate(stamp)) { // 読み取り中に書き込みがあったか検証 System.out.println("楽観的読み取り失敗。悲観的読み取りに切り替え。"); stamp = sl.readLock(); // 検証失敗。悲観的読み取りロックを取得 try { currentX = x; currentY = y; } finally { sl.unlockRead(stamp); // 悲観的読み取りロックを解放 } } return Math.sqrt(currentX * currentX + currentY * currentY); }
}

書き込みの競合が少ない場合、ほとんどの読み取り操作はロックなしで完了するため、スループットが劇的に向上します。

StampedLock の重要な注意点

  • 再入不可能 (Non-reentrant): StampedLockは再入可能ではありません。ロックを保持しているスレッドが同じロックを再度取得しようとするとデッドロックします。
  • Condition非対応: Conditionをサポートしていません。
  • 複雑な解放処理: 解放メソッドがモードごとに異なります(unlockWrite, unlockRead)。また、try-finallyの使い方も従来のロックとは異なるパターンになる場合があり、注意深く使用する必要があります。
StampedLockは非常に強力ですが、その特性を正しく理解し、慎重に適用する必要があります。

第6章: 低レベルAPI LockSupport

LockSupportは、java.util.concurrentパッケージの他の同期コンポーネントを構築するための、より低レベルなスレッドブロッキングプリミティブです。 一般的なアプリケーション開発で直接使用することは稀ですが、その存在を知っておくことはフレームワークの理解に繋がります。

主要なメソッドはpark()unpark(Thread thread)です。

  • park(): 現在のスレッドをブロック(待機)させます。
  • unpark(Thread thread): 指定されたスレッドのブロックを解除します。

LockSupportは、各スレッドに「パーミット(許可証)」が1つだけ関連付けられていると考えることができます。park()はパーミットを消費してスレッドをブロックし、unpark()はパーミットを与えます。パーミットが既にある状態でpark()が呼ばれると、スレッドはブロックされずに即座に処理を続行します。この「パーミット」の仕組みにより、unpark()park()より先に呼ばれても、その後のpark()呼び出しがブロックされないという、Object.wait/notifyにはない柔軟性を提供します。

このクラスは、カスタムロックや同期機構を自作するような、非常に高度な場合にのみ使用が検討されるべき低レベルAPIです。


まとめ: どのロックをいつ使うか?

java.util.concurrent.locksパッケージは、Javaプログラマに強力で柔軟な同期ツールを提供します。しかし、それぞれのツールにはトレードオフがあり、状況に応じた適切な選択が重要です。

ロック機構主な特徴最適なユースケース注意点
synchronizedシンプル、構文が簡潔、JVMによる自動管理単純な排他制御が必要で、高度な機能が不要な場合。競合が少ない、またはクリティカルセクションが短い場面。機能が限定的(タイムアウト、割り込み不可など)。
ReentrantLock高機能(タイムアウト、割り込み、公平性)、Conditionによる高度なスレッド間協調synchronizedの機能では不十分な、複雑な同期制御が必要な場合。公平性や割り込み可能性が求められる場面。unlock()finallyで確実に呼び出す必要がある。コードが冗長になりがち。
ReentrantReadWriteLock読み取りの並列実行によるスループット向上共有リソースへのアクセスが読み取り中心 (read-heavy)のシナリオ。キャッシュ、設定情報など。書き込みの競合が多いと性能が低下する可能性。ロックのアップグレードは不可。
StampedLock楽観的読み取りによる超高性能な読み取り読み取りが圧倒的に多く、書き込みが稀なシナリオ。パフォーマンスが最重要視される場面。再入不可能。APIが複雑で、誤用しやすい。Condition非対応。

適切なロックメカニズムを選択することは、堅牢で高性能な並行アプリケーションを構築するための鍵です。まずは最もシンプルなsynchronizedから始め、必要に応じてReentrantLockReentrantReadWriteLock、そして究極のパフォーマンスが求められる場面ではStampedLockへと、要求に応じてツールをステップアップさせていくのが良いアプローチでしょう。これらのロックを使いこなし、Javaの並行処理プログラミングをマスターしましょう。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です