Цена в базе осталась прежней, но приложение считает, что она изменилась, и повторно отправляет событие. Причина может скрываться в масштабе BigDecimal: equals() сравнивает не только числовое значение, но и количество знаков после запятой, поэтому 1.0 и 1.00 для него не равны. Разберём, как эта особенность влияет на коллекции, денежные расчёты и сравнение данных из разных источников, а также как нормализовать суммы и избежать подобных ошибок. Разобрать BigDecimal
Уровень сложностиСредний
Время на прочтение10 мин
Охват и читатели4.7K
Туториал
Привет, Хабр!
Принято считать, что с деньгами в 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: сумма с долями копейки — это ошибка в данных или в расчёте, и молча округлять её в конструкторе хуже, чем упасть.
Второй источник неожиданных масштабов появляется при создании 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.
Последний сюрприз связан с выводом. Чтобы избавиться от разных масштабов, иногда вызывают 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 собрали в дайджесте.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | А чё, так можно было? Опечатка в исходниках .NET, которой больше 20 лет | 0 | 14.73 | 15-09-2026 |
| 2 | [Перевод] Раньше ПО работало шустро, потому что иначе было никак | 0 | 7 | 28-06-2026 |
| 3 | Когда контекстное окно кончается, а проект — нет | 5 | 7 | 23-06-2026 |
| 4 | А чё, так можно было? Три недооценённых атрибута.NET | 0 | 9.91 | 14-09-2026 |
| 5 | Почему нейросеть ломает перспективу: FLUX 2 024 года обошёл Nano Banana 2.1 и GPT Image 2.5 | 0 | 17.05 | 21-10-2026 |
| 6 | Оптимизация генерации ресурсов в Java, три Ryzen и 225 замеров | 0 | 9.18 | 09-10-2026 |
| 7 | Journal Entries in FinTech: The Engine Behind Clean, Scalable Financial Data #business #miscellaneous | 0 | 13.74 | 08-04-2025 |
| 8 | Юрист Хаминский сказал, почему опасно покупать жилье в 2 раза дешевле рынка | 0 | 5 | 03-07-2026 |
| 9 | Налоговый контроль становится цифровым. Как «честный знак» меняет правила | 0 | 7.95 | 17-10-2026 |