«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 Multiverse

Давайте начнем с основ.

Программная транзакционная память (STM) — это концепция, перенесенная из мира баз данных SQL, где каждая операция выполняется в рамках транзакций, удовлетворяющих свойствам ACID (атомарность, согласованность, изоляция, долговечность). Здесь удовлетворяются только атомарность, согласованность и изоляция, поскольку механизм работает в памяти.

Основным интерфейсом в библиотеке Multiverse является TxnObject — каждый транзакционный объект должен его реализовать, и библиотека предоставляет нам ряд конкретных подклассов, которые мы можем использовать.

Каждая операция, которую необходимо поместить в критическую секцию, доступную только одному потоку и использующую любой транзакционный объект, должна быть заключена в метод StmUtils.atomic(). Критическая секция — это место программы, которое не может выполняться более чем одним потоком одновременно, поэтому доступ к нему должен охраняться каким-либо механизмом синхронизации.

Если действие внутри транзакции завершается успешно, транзакция будет зафиксирована, а новое состояние будет доступно другим потокам. Если произойдет какая-то ошибка, транзакция не будет зафиксирована, а значит, и состояние не изменится.

Наконец, если два потока хотят изменить одно и то же состояние внутри транзакции, только один из них добьется успеха и зафиксирует свои изменения. Следующий поток сможет выполнить свое действие в рамках своей транзакции.

4. Реализация логики учетной записи с помощью STM

Давайте теперь рассмотрим пример.

Допустим, мы хотим создать логику банковского счета, используя STM, предоставленную библиотекой Multiverse. Наш объект Account будет иметь метку времени lastUpdate типа TxnLong и поле баланса, в котором хранится текущий баланс для данной учетной записи, и тип TxnInteger.

TxnLong и TxnInteger — это классы из Мультивселенной. Они должны выполняться внутри транзакции. В противном случае будет выброшено исключение. Нам нужно использовать 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, поэтому его должно быть легко импортировать и запускать как есть.