1. Обзор
В этой статье мы рассмотрим библиотеку Multiverse
, которая поможет нам реализовать концепцию программной транзакционной памяти
в Java.
Используя конструкции из этой библиотеки, мы можем создать механизм синхронизации для общего состояния, что является более элегантным и удобочитаемым решением, чем стандартная реализация с базовой библиотекой Java.
2. Зависимость от Maven
Для начала нам нужно добавить библиотеку multiverse-core
в наш pom:
<dependency>
<groupId>org.multiverse</groupId>
<artifactId>multiverse-core</artifactId>
<version>0.7.0</version>
</dependency>
3. Мультивселенный API
Давайте начнем с некоторых основ.
Программная транзакционная память (STM) — это концепция, перенесенная из мира баз данных SQL, где каждая операция выполняется в транзакциях, которые удовлетворяют свойствам ACID (атомарность, согласованность, изоляция, долговечность)
. Здесь удовлетворяются только атомарность, согласованность и изоляция, поскольку механизм работает в памяти.
Основным интерфейсом в библиотеке Multiverse является TxnObject
— каждый транзакционный объект должен его реализовать, и библиотека предоставляет нам ряд конкретных подклассов, которые мы можем использовать.
Каждая операция, которую необходимо поместить в критическую секцию, доступную только одному потоку и использующую любой транзакционный объект, должна быть заключена в метод StmUtils.atomic()
. Критическая секция — это место программы, которое не может выполняться более чем одним потоком одновременно, поэтому доступ к нему должен охраняться каким-либо механизмом синхронизации.
Если действие внутри транзакции завершается успешно, транзакция будет зафиксирована, и новое состояние будет доступно другим потокам. Если произойдет какая-то ошибка, транзакция не будет зафиксирована, а значит, и состояние не изменится.
Наконец, если два потока хотят изменить одно и то же состояние внутри транзакции, только один добьется успеха и зафиксирует свои изменения. Следующий поток сможет выполнить свое действие в рамках своей транзакции.
4. Реализация логики аккаунта с помощью STM
Давайте теперь посмотрим на пример .
Допустим, мы хотим создать логику банковского счета, используя STM, предоставляемую библиотекой Multiverse .
Наш объект Account
будет иметь метку времени lastUpdate типа
TxnLong
и поле баланса
, в котором хранится текущий баланс для данной учетной записи, и тип TxnInteger
.
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");
}
});
}
Если мы хотим получить текущий баланс для данной учетной записи, нам нужно получить значение из поля баланса, но его также необходимо вызвать с помощью атомарной семантики:
public Integer getBalance() {
return balance.atomicGet();
}
5. Тестирование аккаунта
Давайте проверим логику нашей учетной записи .
Во-первых, мы хотим просто уменьшить баланс со счета на заданную сумму:
@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);
}
Теперь давайте проверим проблему параллелизма, которая может возникнуть, когда два потока одновременно хотят уменьшить баланс.
Если один поток хочет уменьшить его на 5, а второй — на 6, одно из этих двух действий должно завершиться неудачей, поскольку текущий баланс данной учетной записи равен 10.
Мы собираемся отправить два потока в 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. Перенос с одной учетной записи на другую
Допустим, мы хотим перевести деньги с одного счета на другой. Мы можем реализовать метод transferTo()
в классе Account
, передав другой Account
, на который мы хотим перевести указанную сумму денег:
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);
Мы просто создаем два аккаунта, переводим деньги с одного на другой, и все работает как положено. Далее, допустим, что мы хотим перевести больше денег, чем доступно на счету. Вызов 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 один поток должен заблокировать учетную запись a
, а затем учетную запись b
. Допустим, тем временем другой поток хочет перевести деньги со счета b
на счет a
. Другой поток блокирует учетную запись b
, ожидая разблокировки учетной записи a .
К сожалению, блокировка учетной записи a
удерживается первым потоком, а блокировка учетной записи b
удерживается вторым потоком. Такая ситуация приведет к блокировке нашей программы на неопределенный срок.
К счастью, при реализации логики transferTo()
с использованием STM нам не нужно беспокоиться о взаимоблокировках, поскольку STM безопасен для взаимоблокировок. Давайте проверим это, используя наш метод transferTo() .
Допустим, у нас есть два потока. Первый поток хочет перевести деньги со счета a
на счет b
, а второй поток хочет перевести деньги со счета b
на счет a
. Нам нужно создать две учетные записи и запустить два потока, которые будут выполнять метод transferTo()
одновременно:
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 — это проект Maven, поэтому его должно быть легко импортировать и запускать как есть.