概要
synchronized はシンプルで扱いやすい排他制御ですが、ロック取得にタイムアウトを設けたい、読み取りは並行で許可したいといった要件には対応できません。java.util.concurrent.locks パッケージの ReentrantLock と ReadWriteLock は、こうした場面で synchronized の代わりに使える柔軟なロック機構です。この記事では、ReentrantLock の基本的な lock/unlock パターンから tryLock によるタイムアウト付きロック取得、ReadWriteLock による読み取り並行・書き込み排他の実装まで、実務で使いどころの多いパターンを整理します。finally での unlock を忘れたときの影響もあわせて確認します。
使いどころ
キャッシュの読み取りは複数スレッドで並行に許可し、更新時だけ排他ロックをかけて整合性を保つ
外部システム連携で tryLock を使い、一定時間内にロックが取れなければリトライやエラーハンドリングに回す
バッチの進捗テーブル更新で ReentrantLock を使い、同一レコードへの同時書き込みを防止する
コード例
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReentrantLockDemo {
// ReentrantLock によるスレッドセーフカウンター
static class SafeCounter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock(); // 必ず finally で解放
}
}
public boolean tryIncrement(long timeoutMs) throws InterruptedException {
if (lock.tryLock(timeoutMs, TimeUnit.MILLISECONDS)) {
try {
count++;
return true;
} finally {
lock.unlock();
}
}
return false;
}
public int getCount() { return count; }
}
// ReadWriteLock によるキャッシュ
static class CachedData {
private String data = "初期データ";
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
public String read() {
rwLock.readLock().lock();
try {
return data;
} finally {
rwLock.readLock().unlock();
}
}
public void write(String newData) {
rwLock.writeLock().lock();
try {
data = newData;
} finally {
rwLock.writeLock().unlock();
}
}
}
public static void main(String[] args) throws Exception {
var counter = new SafeCounter();
var threads = new Thread[5];
for (var i = 0; i < 5; i++) {
threads[i] = new Thread(() -> {
for (var j = 0; j < 1000; j++) counter.increment();
});
threads[i].start();
}
for (var t : threads) t.join();
System.out.println("結果: " + counter.getCount() + " (期待: 5000)");
// ReadWriteLock: 読み取りは並行、書き込みは排他
var cache = new CachedData();
var r1 = new Thread(() ->
System.out.println("[reader-1] " + cache.read()), "reader-1");
var r2 = new Thread(() ->
System.out.println("[reader-2] " + cache.read()), "reader-2");
r1.start(); r2.start();
r1.join(); r2.join();
var w1 = new Thread(() -> cache.write("更新データ"), "writer-1");
w1.start(); w1.join();
System.out.println("[main] " + cache.read());
}
}Version Coverage
var と record を組み合わせ、ロック結果を LockResult(boolean acquired, int value) のように型安全に扱える。
// Java 17: record でロック結果を値として返す
record LockResult(boolean acquired, int value) {}
// tryLock 成功時
return new LockResult(true, count);
// 失敗時
return new LockResult(false, count);Library Comparison
注意点
lock() を呼んだら必ず finally ブロックで unlock() する。例外発生時に unlock が漏れるとデッドロックの原因になる
tryLock() の戻り値を確認せずにクリティカルセクションに入るとロックなしで共有データを操作してしまう
ReadWriteLock の readLock 内で writeLock を取ろうとするとデッドロックになる。ロックのアップグレードは標準 API ではサポートされていない
ReentrantLock は synchronized と異なり、ロックの解放を開発者が管理する責任を負う。コードレビューで unlock 漏れを重点的にチェックすべき
FAQ
まず synchronized で十分かを検討します。タイムアウトや Condition が必要な場合のみ ReentrantLock に切り替えるのが、コードの簡潔さを保つ方針です。
読み取りが大半を占める場面では有利ですが、書き込みが頻繁だと writeLock の取得待ちが増え、ReentrantLock と大差なくなります。読み書き比率で判断してください。
公平性ありにすると待機時間の長いスレッドが優先されますが、スループットは低下します。スレッド飢餓が問題になる場合のみ true にしてください。