複数のスレッドによる同時読み書き操作は、データの一貫性を損なう原因となり得ます
以下に具体例を示します:
public class ConcurrentAccessDemo {
public static void main(String[] args) {
DataStore dataStore = new DataStore();
for (int i = 0; i < 5; i++) {
final int threadId = i;
new Thread(() -> {
try {
dataStore.setValue(threadId + "", threadId + "");
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "Writer-" + i).start();
}
for (int i = 0; i < 5; i++) {
final int threadId = i;
new Thread(() -> {
try {
dataStore.getValue(threadId + "");
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "Reader-" + i).start();
}
}
}
class DataStore {
private volatile Map<String, Object> dataMap = new HashMap<>();
public void setValue(String key, Object value) throws InterruptedException {
System.out.println(Thread.currentThread().getName() + "\t-----データ書き込み開始: " + key);
Thread.sleep(3000);
dataMap.put(key, value);
System.out.println(Thread.currentThread().getName() + "\t-----データ書き込み完了: " + key);
}
public void getValue(String key) throws InterruptedException {
System.out.println(Thread.currentThread().getName() + "\tデータ読み取り開始: " + key);
Thread.sleep(3000);
Object result = dataMap.get(key);
System.out.println(Thread.currentThread().getName() + "\tデータ読み取り完了: " + result);
}
}
上記のコードを実行すると、あるスレッドがデータの書き込みを完了する前に、別のスレッドがそのデータを読み取ろうとする状況が発生します。これはデータベースのトランザクションにおける原子性違反に似ており、書き込みが完了する前に読み取りが行われることで、不整合が生じます。
ReadWriteLockの導入
ReadWriteLockの主な目的は、同時読み取り操作を保証しつつ、書き込み時の一貫性を維持することです。
- 書き込みロック:排他ロックであり、他のスレッドは書き込みロックも読み取りロックも取得できません。
- 読み取りロック:共有ロックであり、複数のスレッドが同時に保持できます。
- 要約:読み取りと読み取りは共存可能、読み取りと書き込みは排他、書き込みと書き込みは排他的
修正後のコードは以下の通りです:
class DataStore {
private volatile Map<String, Object> dataMap = new HashMap<>();
private ReadWriteLock accessLock = new ReentrantReadWriteLock();
public void setValue(String key, Object value) throws InterruptedException {
accessLock.writeLock().lock(); // 書き込みロックの取得
try {
System.out.println(Thread.currentThread().getName() + "\t-----データ書き込み開始: " + key);
Thread.sleep(3000);
dataMap.put(key, value);
System.out.println(Thread.currentThread().getName() + "\t-----データ書き込み完了: " + key);
} catch (Exception e) {
e.printStackTrace();
} finally {
accessLock.writeLock().unlock(); // 書き込みロックの解放
}
}
public void getValue(String key) throws InterruptedException {
accessLock.readLock().lock(); // 読み取りロックの取得
try {
System.out.println(Thread.currentThread().getName() + "\tデータ読み取り開始: " + key);
Thread.sleep(3000);
Object result = dataMap.get(key);
System.out.println(Thread.currentThread().getName() + "\tデータ読み取り完了: " + result);
} catch (Exception e) {
e.printStackTrace();
} finally {
accessLock.readLock().unlock(); // 読み取りロックの解放
}
}
}
この実装により、書き込み操作の一貫性が保証され、同時に複数の読み取り操作が可能になります。これにより、書き込み中の読み取り操作によるデータ不整合が防がれます。
ロックのデグレード(降格)
ロックのデグレードとは、書き込みロックを保持した状態で読み取りロックを取得し、その後に書き込みロックを解放するプロセスです。
なぜデグレードが可能なのか?なぜ書き込みロックを解放する前に読み取りロックを取得できるのか?
書き込みロックは排他的であり、読み取りロックは共有的です。ReadWriteLockはスレッド間で相互排他的に動作します。ロックデグレードの前提は、すべてのスレッドがデータの変化に敏感であることですが、書き込みロックは一度に一つのスレッドしか保持できないため、デグレードが発生します。
書き込みロックを保持しているスレッドは、他のスレッドが読み取りロックまたは書き込みロックを取得することはできません。この状態で読み取りロックを取得しても、他のスレッドの書き込みロックと競合することはありません。なぜなら、書き込みロックが解放されるまで、他のスレッドはいかなるロックも取得できないからです。
注意:以下の状況はロックデグレードではありません
書き込みロックを先に解放し、その後に読み取りロックを取得する場合、取得前に他のスレッドが書き込みロックを取得し、読み取りロックの取得をブロックする可能性があります。これにより、データの変化を感知できなくなります。そのため、データの変化がないことを保証したまま読み取りロックを取得し、その後に書き込みロックを解放する必要があります。
書き込みロックが解放されるまで、他のスレッドからは書き込み操作によるデータ更新は見えません。しかし、書き込みロックが解放されると、データ更新は他のスレッドから見えるようになります。
考察
書き込みロックを先に解放し、その後に読み取りロックを取得しても問題ない場合があります。ただし、一時的に他のスレッドの書き込みロックによってブロックされる可能性があります。
しかし、これは他のスレッドの書き込み操作によるデータ更新が見えなくなることを意味するわけではありません。書き込みロックが解放されれば、データ更新は依然として見えるため、「読み取りロックの取得がブロックされ、現在のスレッドが他のスレッドTのデータ更新を感知できなくなる」という表現には若干の誤りがあります。
ロックデグレードの重要な前提は、読み取り操作が書き取り操作よりも優先されることです。例えば、クエリシステムではデータの常時読み取り可能性を保証する必要があります。新しいスレッドが読み取りロックを要求した際に、現在書き込みロックを保持しているスレッドはすぐにデグレードを行い、すべての読み取りロックの取得を確実にし、後続の書き込みロックをブロックする必要があります。