Multiverseを使用したJavaのソフトウェアトランザクションメモリ
1. 概要
この記事では、Multiverseライブラリについて説明します。これは、JavaでSoftware Transactional Memoryの概念を実装するのに役立ちます。
このライブラリの構造を使用して、共有状態に同期メカニズムを作成できます。これは、Javaコアライブラリを使用した標準実装よりもエレガントで読みやすいソリューションです。
2. メーベン依存
開始するには、multiverse-coreライブラリをpomに追加する必要があります。
org.multiverse
multiverse-core
0.7.0
3. マルチバースAPI
いくつかの基本から始めましょう。
ソフトウェアトランザクショナルメモリ(STM)は、SQLデータベースの世界から移植された概念です。各操作は、ACID (Atomicity, Consistency, Isolation, Durability)のプロパティを満たすトランザクション内で実行されます。 ここで、only Atomicity, Consistency and Isolation are satisfied because the mechanism runs in-memory.
The main interface in the Multiverse library is theTxnObject –各トランザクションオブジェクトはそれを実装する必要があり、ライブラリは使用できる特定のサブクラスをいくつか提供します。
クリティカルセクション内に配置する必要があり、1つのスレッドのみがアクセスでき、トランザクションオブジェクトを使用する必要がある各操作は、StmUtils.atomic()メソッド内でラップする必要があります。 クリティカルセクションは、複数のスレッドで同時に実行できないプログラムの場所であるため、同期メカニズムによってアクセスを保護する必要があります。
トランザクション内のアクションが成功すると、トランザクションはコミットされ、新しい状態は他のスレッドからアクセス可能になります。 エラーが発生した場合、トランザクションはコミットされないため、状態は変更されません。
最後に、2つのスレッドがトランザクション内の同じ状態を変更する場合、1つだけが成功し、その変更をコミットします。 次のスレッドは、トランザクション内でアクションを実行できます。
4. STMを使用したアカウントロジックの実装
Let’s now have a look at an example。
Multiverseライブラリによって提供されるSTMを使用して銀行口座ロジックを作成するとします。 Accountオブジェクトには、TxnLongタイプのlastUpadateタイムスタンプと、特定のアカウントの現在の残高を格納し、TxnIntegerのbalanceフィールドがあります。 )sタイプ。
TxnLongとTxnIntegerは、Multiverseのクラスです。 トランザクション内で実行する必要があります。 そうでない場合、例外がスローされます。 トランザクションオブジェクトの新しいインスタンスを作成するには、StmUtilsを使用する必要があります。
public class Account {
private TxnLong lastUpdate;
private TxnInteger balance;
public Account(int balance) {
this.lastUpdate = StmUtils.newTxnLong(System.currentTimeMillis());
this.balance = StmUtils.newTxnInteger(balance);
}
}
次に、adjustBy()メソッドを作成します。これにより、指定された量だけ残高が増加します。 そのアクションは、トランザクション内で実行する必要があります。
内部で例外がスローされると、トランザクションは変更をコミットせずに終了します。
public void adjustBy(int amount) {
adjustBy(amount, System.currentTimeMillis());
}
public void adjustBy(int amount, long date) {
StmUtils.atomic(() -> {
balance.increment(amount);
lastUpdate.set(date);
if (balance.get() <= 0) {
throw new IllegalArgumentException("Not enough money");
}
});
}
特定のアカウントの現在の残高を取得する場合は、balanceフィールドから値を取得する必要がありますが、アトミックセマンティクスで呼び出す必要もあります。
public Integer getBalance() {
return balance.atomicGet();
}
5. アカウントのテスト
Accountロジックをテストしてみましょう。 まず、指定された金額だけアカウントから残高を減らします。
@Test
public void givenAccount_whenDecrement_thenShouldReturnProperValue() {
Account a = new Account(10);
a.adjustBy(-5);
assertThat(a.getBalance()).isEqualTo(5);
}
次に、口座から引き出して残高をマイナスにしたとしましょう。 アクションはトランザクション内で実行され、コミットされなかったため、そのアクションは例外をスローし、アカウントをそのままにしておく必要があります。
@Test(expected = IllegalArgumentException.class)
public void givenAccount_whenDecrementTooMuch_thenShouldThrow() {
// given
Account a = new Account(10);
// when
a.adjustBy(-11);
}
次に、2つのスレッドが同時にバランスをデクリメントする場合に発生する可能性のある同時実行の問題をテストしてみましょう。
特定のアカウントの現在の残高が10であるため、1つのスレッドが5ずつ、2番目のスレッドを6ずつ減らしたい場合、これら2つのアクションのいずれかが失敗します。
2つのスレッドをExecutorServiceに送信し、CountDownLatchを使用してそれらを同時に開始します。
ExecutorService ex = Executors.newFixedThreadPool(2);
Account a = new Account(10);
CountDownLatch countDownLatch = new CountDownLatch(1);
AtomicBoolean exceptionThrown = new AtomicBoolean(false);
ex.submit(() -> {
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
a.adjustBy(-6);
} catch (IllegalArgumentException e) {
exceptionThrown.set(true);
}
});
ex.submit(() -> {
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
a.adjustBy(-5);
} catch (IllegalArgumentException e) {
exceptionThrown.set(true);
}
});
両方のアクションを同時に開始すると、いずれかが例外をスローします。
countDownLatch.countDown();
ex.awaitTermination(1, TimeUnit.SECONDS);
ex.shutdown();
assertTrue(exceptionThrown.get());
6. あるアカウントから別のアカウントへの転送
ある口座から別の口座に送金したいとします。 指定された金額を送金する他のAccountを渡すことにより、AccountクラスにtransferTo()メソッドを実装できます。
public void transferTo(Account other, int amount) {
StmUtils.atomic(() -> {
long date = System.currentTimeMillis();
adjustBy(-amount, date);
other.adjustBy(amount, date);
});
}
すべてのロジックはトランザクション内で実行されます。 これにより、指定されたアカウントの残高よりも高い金額を転送したい場合、トランザクションがコミットされないため、両方のアカウントが損なわれないことが保証されます。
転送ロジックをテストしてみましょう。
Account a = new Account(10);
Account b = new Account(10);
a.transferTo(b, 5);
assertThat(a.getBalance()).isEqualTo(5);
assertThat(b.getBalance()).isEqualTo(15);
単純に2つのアカウントを作成し、一方から他方へお金を転送すると、すべてが期待どおりに機能します。 次に、アカウントで利用できるよりも多くのお金を送金したいとします。 transferTo()呼び出しはIllegalArgumentException,をスローし、変更はコミットされません。
try {
a.transferTo(b, 20);
} catch (IllegalArgumentException e) {
System.out.println("failed to transfer money");
}
assertThat(a.getBalance()).isEqualTo(5);
assertThat(b.getBalance()).isEqualTo(15);
aアカウントとbアカウントの両方の残高は、transferTo()メソッドを呼び出す前と同じであることに注意してください。
7. STMはデッドロックセーフです
標準のJava同期メカニズムを使用している場合、ロジックがデッドロックに陥りやすく、デッドロックから回復する方法がありません。
デッドロックは、アカウントaからアカウントbに送金する場合に発生する可能性があります。 標準のJava実装では、1つのスレッドがアカウントaをロックしてから、アカウントbをロックする必要があります。 その間に、他のスレッドがアカウントbからアカウントaに送金したいとします。 他のスレッドは、アカウントaがロック解除されるのを待っているアカウントbをロックします。
残念ながら、アカウントaのロックは最初のスレッドによって保持され、アカウントbのロックは2番目のスレッドによって保持されます。 このような状況では、プログラムが無期限にブロックされます。
幸い、STMを使用してtransferTo()ロジックを実装する場合、STMはデッドロックセーフであるため、デッドロックについて心配する必要はありません。 transferTo()メソッドを使用してテストしてみましょう。
2つのスレッドがあるとしましょう。 最初のスレッドはアカウントaからアカウントbに送金したいと考えており、2番目のスレッドはアカウントbからアカウントaに送金したいと考えています。 2つのアカウントを作成し、transferTo()メソッドを同時に実行する2つのスレッドを開始する必要があります。
ExecutorService ex = Executors.newFixedThreadPool(2);
Account a = new Account(10);
Account b = new Account(10);
CountDownLatch countDownLatch = new CountDownLatch(1);
ex.submit(() -> {
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
a.transferTo(b, 10);
});
ex.submit(() -> {
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
b.transferTo(a, 1);
});
処理を開始すると、両方のアカウントに適切な残高フィールドがあります。
countDownLatch.countDown();
ex.awaitTermination(1, TimeUnit.SECONDS);
ex.shutdown();
assertThat(a.getBalance()).isEqualTo(1);
assertThat(b.getBalance()).isEqualTo(19);
8. 結論
このチュートリアルでは、Multiverseライブラリと、それを使用してソフトウェアトランザクショナルメモリの概念を利用してロックフリーでスレッドセーフなロジックを作成する方法について説明しました。
実装されたロジックの動作をテストし、STMを使用するロジックにデッドロックがないことを確認しました。
これらすべての例とコードスニペットの実装は、GitHub projectにあります。これはMavenプロジェクトであるため、そのままインポートして実行するのは簡単です。