32. Реализовать перевод средств между аккаунтами
Условие задачи:
Есть сервис перевода денег между аккаунтами. Все данные хранятся в памяти, обращения к БД и сетевого взаимодействия нет.
Необходимо реализовать метод:
void transferAmount(Account from, Account to, BigDecimal amount)
который переводит указанную сумму с одного аккаунта на другой.
Требования:
проверить входные параметры;
сумма перевода должна быть положительной;
нельзя переводить деньги между одним и тем же аккаунтом;
на исходном аккаунте должно быть достаточно средств;
баланс не должен терять точность при денежных вычислениях;
перевод должен атомарно изменять оба баланса;
при конкурентных переводах не должно возникать дедлоков.
Код:
class Account {
// TODO: можно добавлять поля
}
class TransferService {
void transferAmount(
Account from,
Account to,
BigDecimal amount
) {
// TODO
}
}
Спойлеры к решению
Подсказки
BigDecimal, а не double или float.💡 Проверь
from, to и amount на null.💡 Сумма должна быть строго больше нуля.
💡 Проверка достаточности средств и изменение обоих балансов должны выполняться атомарно.
💡 Если сервис вызывается из нескольких потоков, блокируй аккаунты всегда в одном порядке, например по уникальному
id.Решение
class Account {
private final long id;
private BigDecimal balance;
public Account(long id, BigDecimal initialBalance) {
Objects.requireNonNull(initialBalance);
if (initialBalance.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException(
"Начальный баланс не может быть отрицательным"
);
}
this.id = id;
this.balance = initialBalance;
}
public long getId() {
return id;
}
public synchronized BigDecimal getBalance() {
return balance;
}
synchronized void debit(BigDecimal amount) {
balance = balance.subtract(amount);
}
synchronized void credit(BigDecimal amount) {
balance = balance.add(amount);
}
}
class TransferService {
public void transferAmount(
Account from,
Account to,
BigDecimal amount
) {
Objects.requireNonNull(from, "Source account must not be null");
Objects.requireNonNull(to, "Target account must not be null");
Objects.requireNonNull(amount, "Amount must not be null");
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException(
"Amount must be positive"
);
}
if (from == to || from.getId() == to.getId()) {
throw new IllegalArgumentException(
"Cannot transfer to the same account"
);
}
Account first = from.getId() < to.getId() ? from : to;
Account second = first == from ? to : from;
synchronized (first) {
synchronized (second) {
if (from.getBalance().compareTo(amount) < 0) {
throw new IllegalStateException(
"Insufficient funds"
);
}
from.debit(amount);
to.credit(amount);
}
}
}
}
Для суммы используется BigDecimal.
Использовать double для денег нежелательно, поскольку двоичное представление чисел с плавающей точкой не может точно представить многие десятичные значения:
0.1 + 0.2
может дать значение, отличающееся от точного 0.3.
BigDecimal выполняет десятичные вычисления без такой потери точности:
BigDecimal amount = new BigDecimal("10.50");
При создании денежных значений из литералов также лучше использовать строку или BigDecimal.valueOf():
new BigDecimal("10.50");
BigDecimal.valueOf(10.50);
а не:
new BigDecimal(10.50);
Перед переводом проверяется, что средств достаточно:
if (from.getBalance().compareTo(amount) < 0) {
throw new IllegalStateException("Insufficient funds");
}
Для BigDecimal значения сравниваются через compareTo(), а не через equals(), поскольку:
10.0 и 10.00
численно равны, хотя equals() считает их различными из-за разного scale.
Оба аккаунта блокируются в одном порядке:
Account first = from.getId() < to.getId() ? from : to;
Account second = first == from ? to : from;
Например, для одновременных переводов:
Account 1 → Account 2
Account 2 → Account 1
оба потока сначала попытаются получить блокировку аккаунта с меньшим id. Это исключает циклическое ожидание и тем самым предотвращает дедлок.
Проверка баланса и обе операции выполняются внутри одной критической секции:
from.debit(amount);
to.credit(amount);
поэтому другой перевод не сможет вклиниться между проверкой остатка и списанием.
Такое решение предполагает, что id аккаунта уникален и не меняется после создания.
Для фиксированной валюты альтернативой может быть хранение суммы в минимальных денежных единицах, например копейках, через long. Но если требуется работать с десятичными денежными значениями непосредственно, BigDecimal — наиболее естественный выбор.
Время выполнения перевода без учёта ожидания блокировок — O(1), дополнительная память — O(1).