В мире Java не утихает извечный спор: нужно ли всегда использовать BigDecimal в качестве поля для хранения денег?

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

Проблема в том, что этот разговор чаще ведётся по принципу догмы, а не инженерного мышления. Уместны утверждения вроде «double сломан», «BigDecimal медленный», «всегда используй фиксированную точку» или «всегда используй IEEE 754» — каждое преподносится как абсолютная истина. Реальность куда интереснее.

Мы начнём с того, что происходит внутри IEEE 754, затем разберём подводные камни BigDecimal и арифметику с фиксированной точкой, рассмотрим библиотеки, которые решают эти задачи за вас, и завершим production ловушками (сериализация, тестирование, конкурентность), способными незаметно свести на нет правильный числовой выбор. Разделы содержат рабочий код.

Проблема чисел с плавающей точкой

Большинство сюрпризов с плавающей точкой восходят к одному факту: Java-типы float и double используют двоичную арифметику, а не десятичную. Понять, почему это важно и когда это не имеет значения, — основа для любого выбора, о котором пойдёт речь в этой статье.

IEEE 754

float и double в Java следуют стандарту IEEE 754: float использует binary32, double использует binary64. Ключевое слово здесь — двоичный. Значения хранятся в виде знакового бита, показателя степени и мантиссы — всё в основании 2. Это означает, что многие числа, тривиально простые в десятичной записи, не имеют точного представления в двоичной.

Рассмотрим классический пример:

public class FloatingPointProblem {

    public static void main(String[] args) {
        double x = 0.1 + 0.2;                              (1)
        System.out.println(x);          // 0.30000000000000004
        System.out.println(x == 0.3);   // false             (2)
    }
}
  1. 0.1 не может быть представлено точно в двоичном виде; компилятор сохраняет ближайшее значение binary64

  2. Проверка на равенство завершается неудачей из-за накопленной погрешности представления

Это не означает, что double сломан. Это означает, что десятичный литерал 0.1 не может быть представлен точно в двоичном виде — так же как 1/3 нельзя точно представить в десятичном. Когда вы пишете 0.1 в Java, компилятор сохраняет ближайшее значение binary64: 0.1000000000000000055511151231257827021181583404541015625. Каждая последующая операция увеличивает эту крошечную начальную погрешность.

double быстр, компактен и создан специально для численных вычислений, но в десятичном смысле он по своей природе не совсем точен. Это не баг — это компромисс по дизайну, и во многих областях он абсолютно оправдан.

Наивные проверки на равенство

Самая распространённая ошибка при работе с плавающей точкой — не сама по себе неточность, а проверка равенства через ==.

Никогда не делайте так:

if (result == expected) { ... }  // dangerous with floating-point

Вместо этого сравнивайте с допуском (который часто называют epsilon):

boolean nearlyEqual(double a, double b, double epsilon) {
  return Math.abs(a - b) <= epsilon;
}

Это хорошо работает, когда ваши значения находятся в известном диапазоне. Но если величины существенно различаются (скажем, от 0.0001 до 1_000_000), фиксированный epsilon либо слишком жёсткий для больших значений, либо слишком мягкий для малых. В таком случае используйте относительный допуск:

boolean nearlyEqualRelative(double a, double b, double relTol) {
    double diff = Math.abs(a - b);
    double norm = Math.max(Math.abs(a), Math.abs(b));
    return diff <= relTol * norm;
}

Выбор между абсолютным и относительным допуском зависит от ваших данных. В научных приложениях часто применяют относительный допуск; финансовое округление при отображении, как правило, использует фиксированное число десятичных знаков. Главное: определитесь со стратегией сравнения прежде, чем написать хотя бы один if.

strictfp, сопроцессор x87 FPU и тихое исправление в Java 17

Java 1.0 требовала строгой семантики IEEE 754, однако сопроцессор Intel x87 FPU вычислял промежуточные значения с 80-битной расширенной точностью, что делало строгий режим медленным. В Java 1.2 был введён компромисс:

  • режим по умолчанию , допускавший расширенный диапазон показателя степени и дававший слегка различающиеся результаты на разном железе)

  • строгий режим, точный IEEE 754, включаемый с помощью strictfp

Практически никто не использовал strictfp, поэтому результаты вычислений с плавающей точкой могли отличаться от платформы к платформе — именно тот вид недетерминизма, который превращает отладку числового кода в кошмар.

JEP 306 в Java 17 решил эту проблему. Современное железо (SSE2, AVX) поддерживает строгий IEEE 754 нативно и без каких-либо потерь производительности, поэтому JEP 306 восстановил всегда строгую семантику как поведение по умолчанию. Модификатор strictfp теперь является no-op: он принимается ради обратной совместимости, но не имеет никакого эффекта.

Практический вывод: на Java 17+ арифметика с плавающей точкой полностью воспроизводима на всех платформах. Если вы встречаете strictfp в унаследованном коде, его можно безопасно удалить.

Мины NaN и -0.0

IEEE 754 определяет два специальных значения, нарушающих привычные представления о поведении чисел.

NaN (Not a Number)* обладает уникальным свойством: оно не равно самому себе. Это предписано стандартом, и Java точно следует ему:

double nan = Double.NaN;
System.out.println(nan == nan);              // false            (1)
System.out.println(Double.compare(nan, nan)); // 0               (2)
System.out.println(Double.isNaN(nan));        // true
  1. Оператор == возвращает NaN != NaN, как предписывает IEEE 754

  2. Double.compare() считает два значения NaN равными для согласованности сортировки

Оператор == возвращает NaN != NaN, однако Double.compare() считает два значения NaN равными для согласованности сортировки, а Double.valueOf(NaN).equals(Double.valueOf(NaN)) возвращает true ради HashMap согласованности. Если вы используете double как ключи в логике или в условных ветвях, всегда сначала проверяйте с помощью Double.isNaN().

-0.0 (отрицательный ноль)* — другая ловушка:

System.out.println(0.0 == -0.0);              // true            (1)
System.out.println(Double.compare(0.0, -0.0)); // 1 (0.0 > -0.0) (2)
System.out.println(1.0 / 0.0);                // Infinity
System.out.println(1.0 / -0.0);               // -Infinity       (3)

1

2

3

  1. Арифметическое равенство говорит 0.0 == -0.0

  2. Double.compare и Double.valueOf().equals() различить их

  3. Деление на -0.0 даёт -Infinity, а не +Infinity

Практическое правило: если ваши double значения могут быть NaN или -0.0, явно проверяйте их перед использованием в коллекциях, сравнениях или в качестве делителей.

Когда double — правильный выбор

Многие разработчики тянутся к BigDecimal на всякий случай, даже когда приближённые вычисления вполне допустимы. double — отличный выбор, когда:

Погрешность допустима

Если результат — рейтинговая оценка, метрика сходства или процент, отображаемый с одним знаком после запятой, отклонение в несколько ULP несущественно. ( ULP (Unit in the Last Place) — наименьшая допустимая разность между двумя соседними значениями double при заданном порядке величины. Это естественная единица измерения погрешности вычислений с плавающей точкой.)

Область задачи изначально приближённа

Показания датчиков, координаты GPS, результаты симуляций и статистические агрегаты уже содержат шум. Десятичная точность здесь ничего не добавляет.

Важна производительность

double арифметика напрямую отображается на инструкции аппаратного FPU. Это на порядки быстрее, чем BigDecimal.

Округление происходит на границах

Вычисления ведутся в double и округляются до двух знаков после запятой только при отображении или сохранении. Внутренние вычисления требуют скорости, а не десятичной точности.

Типичный пример — вычисление среднего:

double average(double[] values) {
    double sum = 0.0;
    for (double v : values) sum += v;
    return sum / values.length;
}

Для десятка значений это работает отлично. Но что происходит при суммировании десяти тысяч или десяти миллионов? Каждое сложение вносит погрешность в несколько ULP, и эти погрешности накапливаются. На длинных последовательностях итоговый результат может значительно отклониться от математически точного ответа — не потому, что double работает неправильно, а потому что наивное суммирование численно нестабильно. Хорошая новость: отказываться от double для решения этой проблемы не нужно. В следующем разделе представлен набор хорошо известных алгоритмов, которые сохраняют вычисления в double и при этом существенно снижают накопленную погрешность.

Снижение погрешностей вычислений с плавающей точкой

Все описанные ниже алгоритмы работают с обычными double, без BigDecimal . Выбор между ними определяется компромиссом между точностью, производительностью и сложностью реализации.

Компенсированное суммирование Кахана

Наиболее известный метод. При прибавлении малого числа к большой текущей сумме младшие биты малого числа теряются. Суммирование Кахана использует отдельную переменную компенсации , которая отслеживает накопленную погрешность округления и возвращает её в следующее сложение.

double kahanSum(double[] values) {

    double sum = 0.0;
    double compensation = 0.0;                                   (1)
    for (double value : values) {
        double y = value - compensation;
        double t = sum + y;
        compensation = (t - sum) - y;                            (2)
        sum = t;
    }
    return sum;
}
  1. Отслеживает накопленную погрешность округления по итерациям

  2. Восстанавливает потерянные младшие биты — суть алгоритма

Граница погрешности практически не зависит от n, тогда как у наивного суммирования она равна O(n). Однако у метода Кахана есть слабое место: если следующее слагаемое превышает текущую сумму, логика компенсации даёт сбой. Вариант Нёймайера устраняет этот недостаток.

Улучшенный алгоритм Кахана–Бабушки по Нёймайеру

Нёймайер (1974) предложил вариант, обрабатывающий случай, когда новое слагаемое больше текущей суммы: для этого проверяются относительные величины и компенсация корректируется соответствующим образом:

double neumaierSum(double[] values) {

    double sum = 0.0;
    double compensation = 0.0;

    for (double value : values) {
        double t = sum + value;
        if (Math.abs(sum) >= Math.abs(value)) {                  (1)
            compensation += (sum - t) + value;
        } else {
            compensation += (value - t) + sum;                   (2)
        }
        sum = t;
    }

    return sum + compensation;                                   (3)
}
  1. Сумма больше: теряются младшие цифры значения

  2. Значение больше: теряются младшие цифры суммы

  3. Коррекция применяется один раз в конце

Практическая разница: для последовательности [1.0, 1e100, 1.0, -1e100], суммирование Кахана даёт 0.0, тогда как Нёймайер корректно возвращает 2.0. Это важно в реальных задачах, где данные имеют значения совершенно разного масштаба: финансовые портфели, где микроскопические корректировки смешиваются с крупными переводами, или научные наборы данных, охватывающие несколько порядков величины.

Попарное суммирование

Попарное, или каскадное, суммирование использует принципиально иной подход: массив рекурсивно делится пополам, каждая половина суммируется, затем два результата складываются. Погрешность растёт как O(log n) — хуже, чем O(1) у Кахана, но на практике очень близко, и с важным преимуществом: число арифметических операций такое же, как у наивного суммирования, а алгоритм естественным образом распараллеливается. Реализация представляет собой простую рекурсию «разделяй и властвуй» с небольшим базовым случаем (как правило, 16–32 элемента суммируются наивно) для амортизации накладных расходов рекурсии. Попарное суммирование является алгоритмом по умолчанию в NumPy и Julia для их sum функций и применяется внутри многих реализаций FFT.

Слитное умножение-сложение (FMA)

Начиная с Java 9, Math.fma(a, b, c) вычисляет a*b+c с одним шагом округления вместо двух. Это соответствует операции fusedMultiplyAdd стандарта IEEE 754-2008. При обычном a*b+c умножение округляется один раз, а сложение — ещё раз, и на каждом шаге теряется точность. FMA вычисляет точное произведение внутренне и округляет лишь один раз при прибавлении c.

double standard = a * b + c;    // two rounding steps
double fma = Math.fma(a, b, c); // one rounding step, more accurate

FMA особенно ценен для скалярных произведений, вычисления многочленов (метод Горнера) и любых линейных комбинаций, где накапливаются произведения. На аппаратуре с нативной поддержкой FMA (большинство современных CPU x86 и ARM) накладных расходов нет, а нередко это быстрее, чем последовательность из двух инструкций.

Cам JDK. В Javadoc для DoubleStream.sum() явно указано, что реализация «может использовать компенсированное суммирование или иной метод для снижения границы погрешности». Порядок сложения намеренно не задан, чтобы обеспечить гибкость. Аналогично, Collectors.summingDouble() внутренне использует подход на основе компенсации (массив из двух элементов для хранения суммы и компенсации).

Apache Commons Numbers. Класс Sum из commons-numbers-core реализует алгоритмы Sum2S и Dot2S (Ogita, Rump, Oishi, SIAM J. Sci. Comput, 2005) — наиболее практичную готовую к использованию компенсированную арифметику в экосистеме Java. Вспомогательный класс Precision предоставляет готовые к production сравнения по epsilon и ULP (см. Apache Commons Numbers).

Алгоритм

Граница погрешности

Стоимость vs наивный

Распараллеливается

Лучше всего для

Наивное суммирование

O(n)

1x

Да

Быстро и грубо

Попарное суммирование

O(log n)

~1x

Да

Универсальное применение (NumPy, Julia)

Компенсированное суммирование Кэхэна

O(1)

~4x

Нет (зависимость через итерации цикла)

Последовательное вычисление с высокой точностью

Улучшенный метод Нёймайера

O(1)

~4x

Нет

Данные с разным порядком величин

Метод Кляйна второго порядка

O(1), ещё точнее

~6x

Нет

Максимальная точность

Apache Commons Sum (Sum2S)

O(1)

~4x

Нет

Продакшн-код на Java

То, что BigDecimal действительно решает

BigDecimal — это не более высокая точность в каком-то абсолютном смысле. Это десятичная, произвольно точная, управляемая и проверяемая арифметика. Именно эти четыре свойства и имеют значение.

Внутри BigDecimal хранится как целое число без масштаба вместе со значением масштаба:

unscaled value = 1999, scale = 2  →  19.99

Это означает, что 19.99 хранится точно как 19.99, а не как ближайшее двоичное приближение. Каждая арифметическая операция сохраняет десятичную семантику, а режим округления вы задаёте явно на каждом шаге.

Конструирование BigDecimal из double

Это одна из наиболее распространённых BigDecimal ошибок:

new BigDecimal(0.1);

Захватывает неточное двоичное значение; результат — 0.100000000000000005551…​, а не 0.1.

Результат — 0.1000000000000000055511151231257827021181583404541015625, поскольку конструктор точно воспроизводит представление binary64 для 0.1. Если вам нужна точная десятичная семантика, создавайте объект из String или используйте valueOf:

new BigDecimal("0.1");     // exact: 0.1
BigDecimal.valueOf(0.1);   // also correct: uses Double.toString internally

Управляемое округление

Там, где BigDecimal по-настоящему раскрывается, — это явное управление округлением. В задачах налогообложения, выставления счетов и бухгалтерского учёта правило округления — не рекомендация, а законодательное требование.

Вычисление НДС:

BigDecimal vat = amount.multiply(rate).setScale(2, RoundingMode.HALF_UP);

Выполнение деления (которое нередко даёт бесконечную десятичную дробь):

BigDecimal result = a.divide(b, 2, RoundingMode.HALF_UP);

Если не указать масштаб и режим округления, divide выбросит ArithmeticException если результат является бесконечной дробью (например, 1/3).

Если не указать масштаб и режим округления, divide выбросит ArithmeticException если результат является бесконечной дробью. Это сделано намеренно: BigDecimal не допускает молчаливой потери точности. Вы обязаны явно указать, сколько десятичных знаков вам нужно и как именно округлять.

Ловушка иммутабельности

Пожалуй, это самая распространённая BigDecimal ошибка в продакшн-коде:

BigDecimal amount = new BigDecimal("19.995");
amount.setScale(2, RoundingMode.HALF_UP);                        (1)
System.out.println(amount);                 // still 19.995

ОШИБКА: возвращаемое значение игнорируется; BigDecimal иммутабелен, каждый метод возвращает новый экземпляр

BigDecimal иммутабелен. Каждый метод (setScale, add, multiply, divide) возвращает новый экземпляр. Исходный объект не изменяется никогда. Инструменты статического анализа, такие как SonarQube, помечают это (правило S2201), однако ошибка с удивительной регулярностью проскальзывает сквозь код-ревью. Исправление тривиально:

amount = amount.setScale(2, RoundingMode.HALF_UP);.

Ловушка equals() vs compareTo()

Эта ошибка постоянно встречается в энтерпрайз-коде:

BigDecimal a = new BigDecimal("2.0");
BigDecimal b = new BigDecimal("2.00");

System.out.println(a.equals(b));      // false                   (1)
System.out.println(a.compareTo(b));   // 0                       (2)
  1. equals() сравнивает и значение и масштаб: 2.0 (scale 1) не равно 2.00 (scale 2)

  2. compareTo() сравнивает только числовое значение; используйте этот метод для проверки числового равенства

equals() сравнивает и значение и масштаб. Поскольку 2.0 имеет scale 1, а 2.00 имеет scale 2, они не считаются равными согласно equals(). Это затрагивает всё: HashMap ключи, Set принадлежность множеству, проверки в тестах. Правило простое: используйте compareTo() == 0 для проверки числового равенства значений BigDecimal . Если необходимо использовать BigDecimal в качестве Map ключа, предварительно нормализуйте значение с помощью stripTrailingZeros(), однако учтите, что в старых версиях Java (до JDK 8u) stripTrailingZeros() для нулевых значений вёл себя непоследовательно.

Стоимость производительности

BigDecimal операции размещают объекты в куче, выполняют внутреннюю арифметику произвольной точности и управляют метаданными масштаба и точности на каждом шаге. По сравнению с double, который компилируется в одну инструкцию FPU, накладные расходы огромны. Бенчмарки Питера Лори (Vanilla Java / Chronicle Software) показали разницу в пропускной способности в 100 раз и более для плотных арифметических циклов.

Но это не значит, что BigDecimal неправильный выбор. Это значит, что он имеет реальную стоимость, и эта стоимость должна быть оправдана областью применения. Если вы вычисляете счета при 100 транзакциях в секунду, BigDecimal вполне подходит. Если вы запускаете механизм сопоставления заявок, обрабатывающий миллионы обновлений цен в секунду, — нет.

Никогда не используйте System.nanoTime() циклы; устранение мёртвого кода JIT, паузы GC и предсказание ветвлений делают их бессмысленными. Используйте JMH:

@Benchmark
public void doubleCalc(Blackhole bh) {
    double result = (100.10 + 200.20) / 2.0;
    bh.consume(result);                                          (1)
}

@Benchmark
public void bigDecimalCalc(Blackhole bh) {
    BigDecimal a = new BigDecimal("100.10");
    BigDecimal b = new BigDecimal("200.20");
    BigDecimal result = a.add(b).divide(BigDecimal.valueOf(2), 2, RoundingMode.HALF_UP);
    bh.consume(result);
}

1

Blackhole.consume() предотвращает устранение мёртвого кода JIT; без него вы не измеряете ничего

Быстрое double округление

В блоге Питера Лори (Vanilla Java / Chronicle Software) было показано, что округление double до фиксированного числа десятичных знаков может выполняться с очень разной скоростью в зависимости от техники. Его оригинальный бенчмарк измерял три подхода для округления до двух десятичных знаков: округление через приведение типов (~6 нс), Math.round() на основе (~17 нс) и BigDecimal.setScale() (~932 нс).

Подход на основе приведения типов — самый быстрый. Вот оригинальный код из его блога для двух десятичных знаков:

static double roundToTwoPlaces(double d) {
    return ((long) (d < 0 ? d  100 - 0.5 : d  100 + 0.5)) / 100.0;  (1)
}

1

Сдвигает десятичную точку, добавляет смещение +/-0.5 для округления до большего, усекает через приведение типов, сдвигает обратно; обрабатывает отрицательные значения через ветвление по знаку

Этот паттерн легко обобщается:

static double roundHalfUp(double value, int decimalPlaces) {
    double factor = Math.pow(10, decimalPlaces);
    return value >= 0
        ? Math.floor(value * factor + 0.5) / factor
        : Math.ceil(value * factor - 0.5) / factor;
}

Работает корректно как для положительных, так и для отрицательных значений в пределах безопасного целочисленного диапазона double (до 2^53). Требует только округления HALF_UP ; для банковского округления, т.е., HALF_EVEN, используйте Math.rint() или BigDecimal с RoundingMode.HALF_EVEN. Для обеспечения прослеживаемости, требуемой аудитом, BigDecimal с явным RoundingMode проще обосновать перед регулятором.

Подход

Скорость (бенчмарк Лори)

Режимы округления

Лучше всего для

Трюк с приведением / Math.floor

~6 нс

Только HALF_UP

Горячие пути, фиксированный масштаб

Math.round(value * factor) / factor

~17 нс

HALF_UP (Java 7+)

Читаемый компромисс

BigDecimal.valueOf(v).setScale(n, mode)

~932 нс

Все режимы

Аудит, регуляторные требования

decimal4jDoubleRounder.round(v, n)

Быстрый (внутренне основан на приведении типов)

Настраиваемый

Готов к продакшену, протестирован

Используйте BigDecimal.valueOf(value) , а не new BigDecimal(value) в подходе на основе BigDecimal, чтобы избежать захвата неточного двоичного представления.

Арифметика с фиксированной точкой

Если трюки с округлением, описанные выше, кажутся слишком накладными, существует более радикальная альтернатива. Многие высокопроизводительные финансовые системы не используют BigDecimal вовсе. Они применяют арифметику с фиксированной точкой на long.

Идея проста до неприличия: вместо того чтобы хранить 19.99 как значение с плавающей точкой или десятичное значение, храните 1999 как целое число, представляющее центы. Вся арифметика выполняется над целыми числами — быстрыми, детерминированными и не требующими выделения памяти.

public record Money(long cents) {

    public Money plus(Money other) {
        return new Money(Math.addExact(cents, other.cents));      (1)
    }

    public Money multiply(long multiplier) {
        return new Money(Math.multiplyExact(cents, multiplier));  (1)
    }

    public BigDecimal toBigDecimal() {
        return BigDecimal.valueOf(cents, 2);                      (2
    }
}

1

Math.addExact и Math.multiplyExact выбрасывают ArithmeticException при переполнении вместо оборачивания — критически важно для финансовых систем

2

Преобразует обратно в BigDecimal на границе системы, сохраняя масштаб

Преимущества арифметики с фиксированной точкой весомы: отсутствие выделения памяти в куче, отсутствие нагрузки на GC, никаких накладных расходов произвольной точности, детерминированные результаты и производительность, сопоставимая с сырым double. Недостатки столь же реальны: необходимо самостоятельно управлять масштабом, явно обрабатывать переполнение, реализовывать округление вручную и выполнять преобразования на границах системы.

Применение процентной ставки, например НДС, в схеме с фиксированной точкой требует осторожности. Стандартный подход использует базисные пункты (сотые доли процента, то есть 22% = 2200 базисных пунктов):

static long applyVat(long netCents, long vatBasisPoints) {
    long numerator = Math.multiplyExact(netCents, 10_000 + vatBasisPoints);
    return (numerator + 5_000) / 10_000;                         (1)
}

1

Возвращает итоговую сумму с НДС (нетто + НДС); + 5_000 перед делением на 10_000 реализует округление «половина вверх» на чистой целочисленной арифметике, без какого-либо использования чисел с плавающей точкой

Всё остаётся в целочисленной арифметике. Именно этот подход применяется во многих низколатентных торговых системах и платёжных процессорах.

Библиотеки и фреймворки в реальных проектах

Экосистема Java предлагает несколько зрелых библиотек для работы с числовой точностью. Каждая занимает свою нишу. Ниже — практическое руководство по выбору.

JavaMoney и Moneta

JSR 354 определяет стандартный API для денежных сумм и валют в Java — он же JavaMoney. Эталонная реализация, Moneta, предоставляет два конкретных типа:

  • Money: основан на BigDecimal. Максимальная точность, полный контроль над десятичной арифметикой.

  • FastMoney: основан на long с фиксированной шкалой в 5 знаков после запятой. Максимальная производительность.

Это в точности отражает компромисс, который мы обсуждали. API JSR 354 (MonetaryAmount) абстрагируется над обоими вариантами, позволяя выбрать реализацию под конкретный сценарий без изменения бизнес-логики. API также обеспечивает работу с валютами, политики округления, форматирование и конвертацию курсов.

Сценарий применения: Корпоративные приложения, которым нужен стандартизированный денежный тип с поддержкой валют, форматированием и конвертацией. Оптимален, когда требуется чётко определённый уровень абстракции и нет необходимости выжимать каждую наносекунду.

Joda-Money

Созданная Стивеном Колборном — автором Joda-Time и java.time, Joda-Money — намеренно более простая альтернатива JSR 354. Библиотека предоставляет Money, класс с фиксированной шкалой на основе BigDecimal, и BigMoney, произвольной точности. Аналога FastMoney здесь нет; упор сделан на чистый, минималистичный API для приложений, где работа с деньгами — вторичная задача (электронная коммерция, SaaS-биллинг, отчётность).

import org.joda.money.Money;
import org.joda.money.CurrencyUnit;

Money price = Money.of(CurrencyUnit.EUR, 19.99);
Money total = price.multipliedBy(3);
Money vat   = total.multipliedBy(0.22, RoundingMode.HALF_UP);

Сценарий применения: Более простая альтернатива JSR 354 — когда нужен надёжный денежный тип, но не требуется полная поверхность JSR API (провайдеры конвертации, пользовательские валюты, монетарные запросы). Ветка 2.x требует Java 21+; ветка 1.x работает с Java 8+.

decimal4j

decimal4j реализует арифметику с фиксированной точкой на long с настраиваемой шкалой до 18 знаков после запятой, подключаемыми режимами округления и API без выделения объектов, разработанным для низколатентных систем. Библиотека предоставляет как иммутабельные, так и мутабельные типы decimal; шкала кодируется непосредственно в типе (например, Decimal2f для 2 знаков после запятой).

import org.decimal4j.immutable.Decimal2f;

Decimal2f price = Decimal2f.valueOf("19.99");
Decimal2f total = price.multiply(3);

Мутабельный вариант, MutableDecimal2f, полностью исключает выделение объектов, изменяя внутреннее состояние и возвращая this, что удобно в критически нагруженных циклах, где давление на GC имеет значение.

Библиотека также предоставляет DoubleRounder, утилиту для быстрого округления значений double до фиксированного числа знаков после запятой (см. Быстрое double Округление).

Сценарий применения: Низколатентные системы, которым нужна арифметика с фиксированной точкой и чистый API без необходимости вручную управлять сырой long арифметикой. Торговые системы, движки ценообразования, финансовые микросервисы.

Apache Commons Numbers

commons-numbers-core — не денежная библиотека; это инструментарий для обеспечения числовой точности. Его ключевые компоненты в нашем контексте:

Sum

Компенсированное суммирование и скалярное произведение с использованием тех же алгоритмов Sum2S/Dot2S, описанных ранее. Полноценная замена наивным циклам суммирования там, где требуется повышенная точность.

Precision

Утилиты для сравнения чисел с плавающей точкой: equals(double, double, double eps) для сравнения на основе эпсилон, equals(double, double, int maxUlps) для сравнения на основе ULP и округления с настраиваемым RoundingMode. Также предоставляет EPSILON (машинный эпсилон, 2^-53) и SAFE_MIN (наименьшее нормализованное double, 2^-1022) в виде именованных констант.

import org.apache.commons.numbers.core.Precision;

boolean eq = Precision.equals(0.1 + 0.2, 0.3, 1);               (1)
boolean eq2 = Precision.equals(a, b, 1e-10);                    (2)

1

Сравнение на основе ULP: равны, если находятся в пределах 1 ULP

2

Сравнение на основе эпсилон

Сценарий применения: Любое приложение, выполняющее вычисления с плавающей точкой и нуждающееся в надёжном сравнении, компенсированном суммировании или компенсированном скалярном произведении. Научные вычисления, аналитика, ML-пайплайны, численное моделирование.

Сравнение библиотек

В таблицу также включена ta4j, Technical Analysis for Java, чей Num интерфейс абстрагируется над DecimalNum (BigDecimal) и DoubleNum (double), позволяя менять точность на производительность без изменения бизнес-логики — тот же подход, что Moneta использует с Money против FastMoney.

Библиотека

Базовый тип

Без выделения объектов

Поддержка валют

Лучший вариант для

Moneta Money

BigDecimal

Нет

Да (JSR 354)

Корпоративный сектор, бухгалтерия

Moneta FastMoney

long (5 зн.)

В основном

Да (JSR 354)

Умеренная производительность с JSR API

Joda-Money

BigDecimal

Нет

Да (ISO 4217)

Небольшие проекты, биллинг

decimal4j

long (0–18 зн.)

Да (мутабельный вариант)

Нет

Низкая латентность, фиксированная шкала

Commons Numbers Sum

double (компенсированное)

Да

Нет

Точное суммирование / скалярные произведения

Commons Numbers Precision

double

Да

Нет

Сравнение, округление

ta4j DecimalNum/DoubleNum

BigDecimal / double

Нет / Да

Нет

Технический анализ, бэктестинг

Руководство по выбору

Выбор подходящего числового типа — инженерное решение, а не вопрос убеждений.

Тип

Точность

Скорость

Когда использовать

double

~15 десятичных цифр, приближённо

Максимальная (аппаратный FPU)

Аналитика, ML, симуляции, графика, ранжирование, метрики

float

~7 десятичных цифр, приближённо

Быстро, вдвое меньше памяти

GPU-шейдеры, обработка изображений/звука, большие ML-векторы, массивные датасеты

BigDecimal

Произвольная, точная десятичная

Наименьшая (~100× медленнее double)

Бухгалтерия, выставление счетов, налоги, аудит, регуляторные требования, сверки

long fixed-point

Точно в пределах фиксированного масштаба

Очень быстро, без выделения памяти

Трейдинг, платёжные процессоры, ценообразование с минимальными задержками

decimal4j

Точно, 0–18 десятичных знаков

Быстро, без мусора

Структурированная fixed-point с API округления, меньше шаблонного кода, чем при работе напрямую long

Ловушки в продакшене

Выбрать правильный числовой тип — лишь половина дела. Вторая половина — убедиться, что этот выбор выдержит столкновение с JSON-сериализаторами, тестовыми фреймворками и многопоточной средой выполнения.

JSON-сериализация

Сериализация числовых значений в JSON — один из наиболее распространённых источников незаметной потери точности: спецификация JSON определяет лишь один числовой тип и не имеет понятия ни о масштабе, ни о точности.

BigDecimal и потеря масштаба. Когда Jackson сериализует BigDecimal, он по умолчанию записывает его как JSON-число. Это означает, что new BigDecimal("19.10") превращается в 19.1 в JSON: завершающий ноль, несущий смысловую нагрузку в BigDecimal (scale = 2 против scale = 1), молча отбрасывается. При десериализации вы получаете BigDecimal с другим масштабом. Если в коде используется equals() (который учитывает масштаб при сравнении) — это сломается. Если финансовая система опирается на масштаб для определения числа десятичных знаков при округлении — это баг.

Стандартное решение — сериализовать BigDecimal как JSON-строку:

public class Invoice {

    @JsonFormat(shape = JsonFormat.Shape.STRING)                  (1)
    private BigDecimal amount;
}

1

Сохраняет точное текстовое представление: "19.10" остаётся "19.10" в JSON

Как вариант, можно настроить Jackson ObjectMapper глобально:

ObjectMapper mapper = new ObjectMapper();
mapper.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS);
mapper.configure(SerializationFeature.WRITE_BIGDECIMAL_AS_PLAIN, true);

WRITE_BIGDECIMAL_AS_PLAIN запрещает Jackson использовать экспоненциальную запись (например, 1.2E+3), а USE_BIG_DECIMAL_FOR_FLOATS гарантирует, что входящие JSON-числа десериализуются как BigDecimal , а не как Double.

double и лишние десятичные знаки. Когда Jackson сериализует double, можно увидеть 19.989999999999998 вместо 19.99. Это ошибка представления, проявляющаяся на границе сериализации. Если вы используете double внутри и округляете на границе, убедитесь, что округление происходит до сериализации, а не после.

long fixed-point. Если внутреннее представление — long центы, потребуется собственный сериализатор/десериализатор, конвертирующий между 1999 (внутреннее) и 19.99 (JSON). Это дополнительный код, зато он даёт полный контроль и исключает любую неоднозначность.

Тестирование кода с плавающей точкой

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

JUnit 5 предоставляет assertEquals с дельтой:

import static org.junit.jupiter.api.Assertions.assertEquals;

@Test
void testAverage() {
    double result = average(new double[]{0.1, 0.2, 0.3});
    assertEquals(0.2, result, 1e-15);                            (1)
}

1

Дельта = допуск: слишком жёсткая — тест упадёт на CI; слишком мягкая — пропустит регрессии

AssertJ предлагает более выразительный API:

import static org.assertj.core.api.Assertions.assertThat;
import org.assertj.core.data.Offset;
import org.assertj.core.data.Percentage;

@Test
void testAverageAssertJ() {

    double result = average(new double[]{0.1, 0.2, 0.3});
    assertThat(result).isCloseTo(0.2, Offset.offset(1e-15));     (1)
    assertThat(result).isCloseTo(0.2, Percentage.withPercentage(0.0001)); (2)
}

1

Абсолютный допуск

2

Относительный допуск: удобен, когда порядок результатов варьируется от теста к тесту

Для BigDecimal тестов, используйте compareTo, а не equals, в утверждениях или предварительно нормализуйте масштаб:

@Test
void testVatCalculation() {
    BigDecimal result = calculateVat(new BigDecimal("100.00"), new BigDecimal("0.22"));
    assertThat(result.compareTo(new BigDecimal("22.00"))).isZero();
}

double не является атомарной операцией

JLS §17.7 утверждает, что запись в non-volatile double или long трактуется как два отдельных 32-битных записи. Читающий поток может наблюдать разорванное значение (старшие биты от одной записи, младшие — от другой), образующее бессмысленный битовый паттерн.

private double sharedPrice;            // UNSAFE: another thread may read a torn value
private volatile double sharedPrice;   // SAFE: volatile guarantees atomic read/write

На 64-битных JVM, double запись оказывается атомарной на уровне железа, однако JLS этого не гарантирует. Если вы используете общий double в нескольких потоках без volatile, ваш код некорректен с точки зрения спецификации.

Параллельные потоки

Javadoc для DoubleStream.sum() явно предупреждает, что порядок сложения намеренно не определён. Это влечёт прямое следствие: DoubleStream.parallel().sum() может возвращать разные результаты при разных вызовах с одними и теми же входными данными.

double[] values = {0.1, 0.2, 0.3, 1e15, -1e15, 0.4};
double seqSum = DoubleStream.of(values).sum();             // sequential: consistent
double parSum = DoubleStream.of(values).parallel().sum();  // parallel: may differ between runs

Разница, как правило, ничтожна — в пределах нескольких ULP для хорошо обусловленных данных. Но для плохо обусловленных сумм (когда большие положительные и отрицательные слагаемые почти компенсируют друг друга) расхождение может быть существенным. Если приложению необходимы воспроизводимые результаты между запусками или на разных машинах — используйте последовательные потоки либо детерминированный алгоритм суммирования, например Kahan или Neumaier.

Это напрямую связано с разделом о алгоритмах суммирования: сложение с плавающей точкой не ассоциативно, поэтому разный порядок разбиения на части даёт разные результаты.

Конкурентное накопление с DoubleAdder

Когда несколько потоков должны обновлять общую сумму (счётчики метрик, текущие итоги, агрегаты реального времени), наивные подходы несостоятельны. Блок synchronized сериализует все потоки. Использование AtomicReference<BigDecimal> с CAS-циклом выделяет новый BigDecimal при каждой повторной попытке. Оба подхода создают серьёзную конкуренцию под нагрузкой.

DoubleAdder (добавлен в Java 8) решает эту проблему с помощью полосовых ячеек: внутри он хранит несколько частичных сумм в разных областях памяти, поэтому потоки редко конкурируют за одну и ту же кэш-строку. Итоговая сумма вычисляется только при вызове sum().

import java.util.concurrent.atomic.DoubleAdder;

DoubleAdder totalRevenue = new DoubleAdder();
totalRevenue.add(19.99);           // called from many threads, minimal contention
double current = totalRevenue.sum(); // aggregates all stripes when read

Компромисс: DoubleAdder.sum() не является атомарным: он читает полосы последовательно, поэтому если другие потоки пишут конкурентно, результат будет приближённым. Для точных снимков состояния в конкретный момент времени по-прежнему необходима внешняя синхронизация. Но для метрик, дашбордов и мониторинга (где приближённо-и-быстро лучше, чем точно-и-медленно) DoubleAdder — правильный примитив.

Для BigDecimal накопления встроенного аналога не существует. Распространённый паттерн — LongAdder на фиксированных целых центах с преобразованием в BigDecimal только при чтении.

Project Valhalla и типы-значения

Одним из главных аргументов в пользу double или long вместо BigDecimal всегда была производительность — а именно стоимость выделения памяти в куче и давление на GC при каждой арифметической операции. Project Valhalla способен фундаментально изменить это соотношение.

JEP 401 (Value Classes and Objects), находящийся в стадии preview, вводит классы-значения— классы, которые отказываются от идентичности объектов в обмен на потенциальное размещение на стеке и инлайнинг. Экземпляр класса-значения сравнивается по значениям полей, а не по ссылочной идентичности, а JVM вправе убрать заголовок объекта и разместить его на стеке или встроить прямо в массивы.

Что это означает для числовых типов? Рассмотрим обёртку Money:

value class Money {  // preview syntax, requires --enable-preview
    private final long cents;
    // ... arithmetic methods ...
}

При семантике значений Money мог бы быть встроен в массивы без заголовка объекта на каждый элемент, передаваться в регистрах вместо кучи и полностью устраняться анализом ускользания. Давление на GC, из-за которого BigDecimal дорог в плотных циклах, могло бы в значительной мере исчезнуть.

Последствия выходят за рамки пользовательских типов. BigDecimal сам вряд ли станет классом-значением, поскольку его поле stringCache несёт изменяемое состояние и жёсткие ограничения совместимости. Новые специализированные десятичные типы можно проектировать как классы-значения с самого начала, и стандартные типы вроде Optional и LocalDate тоже могут выиграть.

Классы-значения сейчас находятся в стадии preview, aka JEP 401, ранние сборки доступны начиная с JDK 23. Это следующий крупный архитектурный сдвиг в JVM, который изменит все компромиссы по производительности, обсуждавшиеся на протяжении этой статьи.

Заключение

Утверждение «никогда не используйте double для денег» слишком тривиально. Более честная формулировка такова: никогда не используйте числовой тип, не разобравшись в его модели точности, поведении при округлении, характеристиках переполнения, профиле производительности и в том, как всё это взаимодействует с требованиями вашей предметной области.

double быстр и приближён — идеален для вычислений, где десятичная точность несущественна. BigDecimal точен и прозрачен — незаменим там, где правила округления закреплены законодательно. Фиксированная точка на long быстра и детерминирована — нередко лучший компромисс для высокопроизводительных финансовых систем.

Настоящая инженерная ошибка — не выбор double вместо BigDecimal. Ошибка — выбор любого из них без понимания того, почему.

Для дальнейшего изучения:

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

Комментарии (1)


  1. kamaz1
    30.06.2026 17:27

    а я думал что деньги хранятся в центах/копейках в типах int...