31. Реализовать потокобезопасный перевод денег без дедлоков
Условие задачи:
Есть сервис MoneyTransferService, работающий в многопоточной среде.
Необходимо реализовать метод:
transfer(Account from, Account to, BigDecimal amount)
который:
атомарно списывает деньги со счёта
fromи зачисляет их на счётto;не позволяет счёту
fromуйти в минус;корректно работает при одновременных переводах;
не допускает дедлоков;
принимает только положительную сумму перевода.
Код:
public class MoneyTransferService {
public void transfer(
Account from,
Account to,
BigDecimal amount
) {
// TODO
}
}
public class Account {
private final int id;
private BigDecimal balance;
public Account(int id, BigDecimal initial) {
this.id = id;
this.balance = initial;
}
public int getId() {
return id;
}
public BigDecimal getBalance() {
return balance;
}
public void debit(BigDecimal amount) {
balance = balance.subtract(amount);
}
public void credit(BigDecimal amount) {
balance = balance.add(amount);
}
}
Спойлеры к решению
Подсказки
💡 Блокировать два счёта нужно всегда в одинаковом порядке.
💡 Обычно порядок можно определить по
id.💡 Если уникальность
id не гарантирована, одного сравнения по id недостаточно — нужен дополнительный порядок для счетов с одинаковыми id.💡 Отрицательную или нулевую сумму перевода нужно отклонять.
Решение
public class MoneyTransferService {
private static final Object TIE_LOCK = new Object();
public void transfer(
Account from,
Account to,
BigDecimal amount
) {
Objects.requireNonNull(from);
Objects.requireNonNull(to);
Objects.requireNonNull(amount);
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException(
"Сумма перевода должна быть положительной"
);
}
if (from == to) {
return;
}
if (from.getId() < to.getId()) {
transferWithLocks(from, to, from, to, amount);
return;
}
if (from.getId() > to.getId()) {
transferWithLocks(to, from, from, to, amount);
return;
}
int fromHash = System.identityHashCode(from);
int toHash = System.identityHashCode(to);
if (fromHash < toHash) {
transferWithLocks(from, to, from, to, amount);
} else if (fromHash > toHash) {
transferWithLocks(to, from, from, to, amount);
} else {
synchronized (TIE_LOCK) {
transferWithLocks(from, to, from, to, amount);
}
}
}
private void transferWithLocks(
Account first,
Account second,
Account from,
Account to,
BigDecimal amount
) {
synchronized (first) {
synchronized (second) {
if (from.getBalance().compareTo(amount) < 0) {
throw new IllegalArgumentException(
"Недостаточно средств на счёте " + from.getId()
);
}
from.debit(amount);
to.credit(amount);
}
}
}
}
Главная проблема при блокировке двух объектов — возможность дедлока.
Например, два потока выполняют противоположные переводы:
Поток 1: A → B
Поток 2: B → A
Если первый поток сначала заблокирует A, а второй — B, оба могут начать ждать друг друга.
Поэтому счета блокируются в едином порядке:
if (from.getId() < to.getId()) {
// сначала from, затем to
} else {
// сначала to, затем from
}
Внутри обеих блокировок выполняются и проверка баланса, и изменение счетов:
if (from.getBalance().compareTo(amount) < 0) {
throw new IllegalArgumentException("Недостаточно средств");
}
from.debit(amount);
to.credit(amount);
Это принципиально важно. Если проверить баланс до получения блокировки, два потока могут одновременно увидеть достаточно средств и оба выполнить списание.
Например:
Баланс: 100
Поток 1 хочет списать 80
Поток 2 хочет списать 80
Без атомарной проверки оба потока могли бы увидеть 100, после чего баланс ушёл бы в минус.
Дополнительная обработка:
System.identityHashCode(account)
нужна для счетов с одинаковыми id. Она задаёт стабильный порядок блокировок и для них.
Теоретически identityHashCode двух разных объектов также может совпасть. Для такого редкого случая используется общий:
TIE_LOCK
Благодаря этому порядок получения блокировок остаётся однозначным и дедлок не возникает.
Проверка:
if (amount.compareTo(BigDecimal.ZERO) <= 0)
тоже необходима: отрицательный перевод фактически поменял бы направление движения денег и мог бы нарушить инвариант баланса.
Сложность одного перевода — O(1), дополнительная память — O(1).
Потокобезопасность такого решения предполагает, что изменение balance выполняется только под блокировкой соответствующего Account. В реальном коде методы изменения баланса лучше не предоставлять для произвольного внешнего вызова, а инкапсулировать их внутри потокобезопасной модели счёта.