Привет, Хабр!

Принято считать, что с деньгами в Java всё решено давно: double для сумм нельзя, берём BigDecimal, и вопрос закрыт.

Половина вопроса действительно закрыта. BigDecimal не теряет копейки на сложении и не превращает 0,1 + 0,2 в 0,30000000000000004.

Вторая половина начинается со сравнения. BigDecimal хранит число как пару: целое «немасштабированное» значение и масштаб, число знаков после запятой. Единица может быть записана как 1, 1.0 и 1.00, и для одной части стандартной библиотеки это одно число, а для другой — три разных.

Коллекции, записи record, ключи кэша и проверки «изменилась ли цена» оказываются по разные стороны этой границы. Понять, по какую сторону границы находится конкретный метод, можно только из документации к нему.

Все примеры ниже прогонялись на JDK 25. Ничего экзотичнее HashMap, JSON и столбца numeric в них нет, и поэтому они встречаются почти в каждом сервисе, который считает деньги.

Четыре единицы

Проблему проще всего увидеть, если положить одну и ту же единицу, записанную по‑разному, в несколько коллекций:

var values = List.of(new BigDecimal("1"), new BigDecimal("1.0"),
                     new BigDecimal("1.00"), new BigDecimal("1.000"));

new HashSet<>(values).size();               // 4
new TreeSet<>(values).size();               // 1
values.stream().distinct().count();         // 4
values.stream().sorted().distinct().count(); // 4
  • HashSet и distinct() опираются на equals и hashCode, а equals у BigDecimal сравнивает и значение, и масштаб. Хеши у 1.0 и 1.00 тоже разные: 311 и 3102.

  • TreeSet опирается на compareTo, который сравнивает только значение и видит одну единицу.

  • Сортировка перед distinct() ничего не меняет: distinct() по‑прежнему смотрит на equals.

Документация об этом предупреждает, причём в классе, а не в отдельном методе. В JDK 25 описание BigDecimal говорит, что естественный порядок «считает членов одной когорты равными», где когорта — все записи одного и того же числа с разным масштабом.

А в примечании к API сказано, что использовать BigDecimal как ключ SortedMap или элемент SortedSet надо с осторожностью, потому что естественный порядок у него не согласован с equals.

Осторожность понадобится и с HashMap, только там ошибка будет обратной:

Map<BigDecimal, String> tariffs = new HashMap<>();
tariffs.put(new BigDecimal("990.00"), "базовый");
tariffs.get(new BigDecimal("990"));   // null

Тариф за 990 рублей существует, но найти его по сумме 990 нельзя. Для справочников, где сумма служит ключом поиска, проще всего заменить HashMap на TreeMap: он ищет через compareTo и находит тариф по 990, 990.0 и 990.00 одинаково, а платит за это логарифмическим временем поиска вместо константного, что для справочника на сотню строк, который читают при каждом расчёте и почти никогда не меняют, незаметно ни в одном профиле.

С записями record то же самое: record Price(BigDecimal amount, String currency) получает сгенерированный equals, который сравнивает компоненты через их equals, и цены 1.0 RUB и 1.00 RUB для него разные.

Откуда берётся масштаб, если его никто не задавал

В коде редко пишут new BigDecimal("1.00") руками, и кажется, что разные масштабы в одной программе — экзотика. На деле масштаб приходит из всех источников сразу, и у каждого он свой.

Арифметика сохраняет масштабы и складывает их. 1.10 + 2.5 даёт 3.60, потому что у сложения масштаб результата — наибольший из двух. 1.10 × 1.10 даёт 1.2100: у умножения масштабы складываются. Через пару операций одна и та же сумма может прийти с масштабом 2, 4 или 6, смотря по какому пути её посчитали.

База данных отдаёт масштаб столбца. Я записал через pgjdbc 42.7.8 в PostgreSQL одно и то же значение 1.1 в два столбца, numeric(10,2) и numeric без ограничения, и прочитал обратно:

numeric(10,2): 1.10 scale 2, equals(1.1) false
numeric:       1.1 scale 1, equals(1.1) true

Значение, которое приложение записало, вернулось другим по equals. Код вида «если цена изменилась, обновить и отправить событие» сравнивает новую цену из запроса с той, что лежит в базе, и видит изменение там, где его нет. Каждое такое сохранение уходит в базу и в очередь событий, хотя цена не менялась. Выглядит такой код совершенно безобидно:

if (!incoming.price().equals(stored.price())) {
    prices.update(id, incoming.price());
    events.publish(new PriceChanged(id, stored.price(), incoming.price()));
}

Цена из запроса пришла как 1.1, из numeric(10,2) прочиталась как 1.10, и событие PriceChanged с одинаковыми старой и новой ценой уходит подписчикам при каждом сохранении. Замена условия на compareTo(...) != 0 убирает ложные события, не трогая остальной код.

JSON тоже переносит масштаб. Jackson 2.18 при разборе {"amount": 1.10} в поле типа BigDecimal сохраняет текст числа как есть: получается 1.10 с масштабом 2, и equals(new BigDecimal("1.1")) возвращает false. Если клиент сегодня шлёт 1.1, а после обновления фронтенда начнёт слать 1.10, равенство в коде молча поменяется.

Обратная беда случается, когда JSON разбирают не в типизированный объект, а в Map. Тогда Jackson по умолчанию превращает дробные числа в Double, и {"amount": 0.10000000000000000001} оказывается в коде как 0.1 типа Double.

Хвост пропал ещё до того, как кто‑то успел обернуть число в BigDecimal. Флаг DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS заставляет Jackson и в этом случае создавать BigDecimal.

Чаще всего это всплывает на нуле. BigDecimal.ZERO.equals(new BigDecimal("0.00")) возвращает false. Проверка «баланс нулевой?» через equals на значении из numeric(10,2) никогда не сработает, а signum() == 0 срабатывает всегда.

Как сравнивать суммы

Для сравнения значений есть compareTo: a.compareTo(b) == 0 означает «одно и то же число» при любых масштабах. Для проверки на ноль и знак удобнее signum(). Это закрывает отдельные сравнения в коде, но не коллекции и записи record, которые зовут equals сами.

Для них надёжнее привести масштаб к одному значению на границе, там, где сумма входит в доменную модель. Для денег естественный масштаб задаёт валюта: у рубля 2 знака после запятой, у иены ни одного, и Currency.getDefaultFractionDigits() это знает. Запись record с компактным конструктором делает нормализацию обязательной:

record Money(BigDecimal amount, Currency currency) {
    Money {
        amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.UNNECESSARY);
    }

    static Money of(String amount, String code) {
        return new Money(new BigDecimal(amount), Currency.getInstance(code));
    }
}

Теперь Money.of("1.1", "RUB") и Money.of("1.10", "RUB") равны по сгенерированному equals, в HashSet из 990 и 990.00 остаётся один элемент, а иена хранится как 500 без дробной части.

RoundingMode.UNNECESSARY здесь выбран намеренно. Money.of("10.005", "RUB") бросает ArithmeticException: Rounding necessary: сумма с долями копейки — это ошибка в данных или в расчёте, и молча округлять её в конструкторе хуже, чем упасть.

Конструктор, который принимает double

Второй источник неожиданных масштабов появляется при создании BigDecimal из double. Конструктор new BigDecimal(0.1) переводит в десятичный вид двоичное число, которое в double хранится вместо 0,1:

new BigDecimal(0.1) = 0.1000000000000000055511151231257827021181583404541015625
valueOf(0.1)      = 0.1

Документация называет результат этого конструктора «несколько непредсказуемым» и приводит тот же пример. BigDecimal.valueOf(double) идёт через Double.toString и даёт ожидаемое 0.1. Если сумма приходит из внешнего API как double, правильный путь — valueOf, а лучше сразу строка: new BigDecimal("0.1").

Деление, которое падает на тройке

Деление в BigDecimal по умолчанию требует точного результата. new BigDecimal("100").divide(new BigDecimal("4")) возвращает 25. А деление на 3 бросает исключение:

100 / 3 -> Non-terminating decimal expansion; no exact representable decimal result.

Беда в том, что тесты обычно пишут на круглых числах. Скидка 25% от 100, комиссия на сумму 1000, деление на 2 и на 4 — всё точно, всё зелёное. Первая сумма, у которой частное не укладывается в конечную десятичную дробь, приходит уже в работающий сервис.

Делить надо всегда с явным масштабом и режимом округления: divide(divisor, 2, RoundingMode.HALF_EVEN, или с MathContext, если важна точность, а не копейки: MathContext.DECIMAL128 даёт 34 значащие цифры.

Есть и промежуточный вариант, который выглядит как исправление: divide(divisor, RoundingMode.HALF_EVEN) без масштаба.

Исключения он не бросает, но масштаб результата берёт у делимого. 100.00 / 3 даёт 33.33, а 100 / 3 даёт 33, и 1 / 3 даёт ноль.

Тот же метод, та же сумма, разная запись делимого — и ответ отличается на треть рубля или на всё целиком. Масштаб и здесь лучше передавать явно.

Режим округления тоже стоит выбирать осознанно. HALF_UP округляет по‑школьному: 10,005 становится 10,01. HALF_EVEN, банковское округление, округляет половину к чётной цифре: 10,005 даёт 10,00, а 10,015 даёт 10,02.

На одной сумме разница в копейку. Я округлил до копеек миллион случайных сумм с тремя знаками после запятой и сложил:

var rnd = new Random(1);
BigDecimal exact = BigDecimal.ZERO, up = BigDecimal.ZERO, even = BigDecimal.ZERO;
for (int i = 0; i < 1_000_000; i++) {
    var x = BigDecimal.valueOf(rnd.nextInt(100_000), 3);
    exact = exact.add(x);
    up = up.add(x.setScale(2, RoundingMode.HALF_UP));
    even = even.add(x.setScale(2, RoundingMode.HALF_EVEN));
}

С HALF_UP итог ушёл вверх на 503,59 рубля, с HALF_EVEN — на 3,02. Половинки при школьном округлении всегда уходят вверх, а при банковском делятся поровну. Какой режим нужен, решают бухгалтерия и договор, но решить это надо один раз и записать в одном месте, а не в каждом вызове setScale.

Сто рублей на троих

Даже правильное деление с округлением не решает задачу, когда сумму надо разложить на части. 100 рублей на троих — это 33,33 каждому, и при проверке оказывается, что 33,33 × 3 = 99,99. Копейка пропала, и сверка с банком её найдёт.

Раскладывать сумму надо в целых копейках и отдавать остаток первым частям:

static List<BigDecimal> split(BigDecimal total, int parts) {
    var cents = total.movePointRight(2).toBigIntegerExact();
    var n = BigInteger.valueOf(parts);
    var base = cents.divide(n);
    int extra = cents.mod(n).intValue();
    var result = new ArrayList<BigDecimal>();
    for (int i = 0; i < parts; i++) {
        var c = i < extra ? base.add(BigInteger.ONE) : base;
        result.add(new BigDecimal(c, 2));
    }
    return result;
}

Для 100,00 на три части метод возвращает [33.34, 33.33, 33.33], и сумма частей равна 100,00. toBigIntegerExact() упадёт, если на вход пришли доли копейки, по той же логике, что и RoundingMode.UNNECESSARY в Money.

Как BigDecimal выглядит снаружи

Последний сюрприз связан с выводом. Чтобы избавиться от разных масштабов, иногда вызывают stripTrailingZeros(): 1.10 становится 1.1, и кажется, что это та самая нормализация. Только new BigDecimal("100").stripTrailingZeros() даёт не 100, а 1E+2. Масштаб стал отрицательным, и toString() выводит число в экспоненциальной записи.

Jackson сериализует BigDecimal через тот же toString():

по умолчанию:              {"amount":1E+2,"currency":"RUB"}
WRITE_BIGDECIMAL_AS_PLAIN: {"amount":100,"currency":"RUB"}

1E+2 — корректное число в JSON, и JSON.parse в браузере прочитает его как 100. Но выгрузка в CSV, шаблон чека, регулярка в соседнем сервисе, который ждёт цифры и точку, получат строку с буквой E.

Для вывода есть toPlainString(), для Jackson — флаг JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN. А для нормализации лучше подходит setScale с масштабом валюты, как в Money, потому что он не меняет вид числа.

Как найти такие сравнения в своём проекте

Искать equals на суммах глазами по всему коду бессмысленно, но эту работу давно умеют делать анализаторы. В Error Prone есть проверка BigDecimalEquals с формулировкой «у BigDecimal#equals удивительное поведение: он сравнивает ещё и масштаб» и соседняя BigDecimalLiteralDouble про new BigDecimal(double) с литералом. Обе включены по умолчанию на уровне предупреждения, и если Error Prone уже стоит в сборке, достаточно посмотреть на его вывод или поднять эти две проверки до ошибки.

Тесты тоже могут помочь, если не прятать масштаб в фикстурах. Сравнение суммы из базы с ожидаемой через JUnit падает с сообщением, которое выглядит издевательски:

JUnit:   expected: <1.1> but was: <1.10>
AssertJ: expected: 1.1  but was: 1.10

Для сравнения значений в AssertJ есть isEqualByComparingTo, и assertThat(fromDb).isEqualByComparingTo("1.1") проходит.

Но полезнее другое: если такой тест упал, значит, в коде рядом почти наверняка есть equals, который падает так же, только без сообщения.

Тест на «сумма вернулась из базы и равна той, что записали» стоит написать нарочно, с ожиданием в другом масштабе, чем у столбца.

Число и его запись

У BigDecimal два понятия равенства, и оба описаны в документации: equals сравнивает запись числа, compareTo — само число. Проблемы начинаются, когда код, коллекция или сгенерированный equals у record выбирает одно, а автор держит в голове другое. А масштаб приходит отовсюду: из арифметики, из столбца базы, из JSON, из stripTrailingZeros.

Надёжнее всего вообще не сравнивать суммы с разными масштабами. Для этого стоит:

  • приводить масштаб к масштабу валюты в момент, когда число становится деньгами;

  • делить всегда с явным округлением;

  • раскладывать суммы в целых копейках;

  • выводить через toPlainString.

Тогда equals и compareTo снова начинают отвечать одинаково.

А что у вас в проекте сравнивает суммы через equals — и как давно это проверяли на значениях из базы?

Если суммы после чтения из базы ведут себя не так, как ожидалось, важно понимать, что происходит на стыке Java и хранилища данных.

Разобравшись с BigDecimal, стоит углубиться и в работу с JDBC: так будет проще контролировать путь данных от SQL‑запроса до бизнес‑логики и находить причины неожиданных изменений.

Продолжить разбираться в Java помогут бесплатные открытые уроки:

  • 28 октября в 20:00. «JDBC: как Java‑приложения взаимодействуют с базами данных». Записаться

  • 19 ноября в 20:00. «Пишем веб‑сервер на Java». Записаться

Больше вебинаров по бэкенду от преподавателей Otus собрали в дайджесте.