Привет, Хабр!
Принято считать, что с деньгами в 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 собрали в дайджесте.
