2367 字
约 7 分钟
13
S04-X11 · BigDecimal 的等值比较与金额表示选型

a.equals(b) 为 false,a.compareTo(b) 为 0,a 和 b 打印出来都是 1.0——这是金额代码里最经典的一次误判。原因不在浮点误差(BigDecimal 本来就没有),而在 equals 同时比了值和标度。

金额表示则还有第二个岔路口:用 BigDecimal 还是用 long 存"分"。两件事都不复杂,但选错的代价要等对账时才发现。


1. equals 比"值 + 标度",compareTo 只比数值

BigDecimal 内部是 intVal(无标度整数)+ scale(小数点位数)。equals 的实现等价于:

public boolean equals(Object x) {
    if (!(x instanceof BigDecimal bd)) return false;
    return scale == bd.scale && intVal.equals(bd.intVal);   // 先比标度,再比数值
}

public int hashCode() { return 31 * intVal.hashCode() + scale; }   // 与 equals 一致

所以 1.0(intVal=10, scale=1)与 1.00(intVal=100, scale=2)数值相等、标度不同,equals 判不等;compareTo 走的是对齐标度后的数值比较,返回 0。

BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");

System.out.println(a.equals(b));               // false —— scale 1 vs 2
System.out.println(a.compareTo(b));            // 0
System.out.println(a.hashCode() == b.hashCode());  // false,hashCode 也带标度
System.out.println(Set.of(a).contains(b));     // 典型输出:false
比较方式 比什么 1.0 vs 1.00 适用
equals 值 + 标度 false 需要"表示形式也一致"时
compareTo 仅数值 0 等值判断的默认选择
compareTo + 容差 数值差是否小于 epsilon 0 允许四舍五入误差的场景
== 对象引用 false 只用于判 null

== 比较两个 BigDecimal 只有一种合法用法:if (x == null)。BigDecimal 不可变,但 a.add(b) 返回的是新对象,a == b 永远为 false。

顺带一个容易忽略的连带效应:BigDecimal 放进 HashSet/HashMap 时同样按标度判重,1.0 与 1.00 会是两个 key。要按数值判等,必须先用 setScale 统一,或者改用 TreeSet + compareTo(TreeSet 用比较器判等)。


2. 构造:字符串优先,double 是陷阱

System.out.println(new BigDecimal(0.1));
// 典型输出:0.1000000000000000055511151231257827021181583404541015625
System.out.println(new BigDecimal("0.1"));
// 0.1

传 double 时,BigDecimal 忠实记录了那个 double 的二进制精确值,于是 0.1 变成了 55 位小数。这不是"精度丢失",是"精度被完整暴露"——后果是你以为的 0.1 变成了一个稍大的数,四舍五入到分时可能进位成 0.11。

System.out.println(new BigDecimal(1.005).setScale(2, RoundingMode.HALF_UP));   // 典型输出:1.00
System.out.println(new BigDecimal("1.005").setScale(2, RoundingMode.HALF_UP)); // 1.01

三种常见入口的差别:

入口 语义 0.1 的结果 建议
new BigDecimal(double) 该 double 的精确二进制值 55 位小数 禁用
new BigDecimal(String) 按字面量精确解析 0.1 首选
BigDecimal.valueOf(double) 内部走 Double.toString 0.1 已有 double 时用它
BigDecimal.valueOf(long) 整数放大 — 分转元可用

valueOf(double) 就是 new BigDecimal(Double.toString(d))。它看着更友好,但入口处已被双精度筛过一遍,只适合"外部只给 double"的兼容场景。


3. 舍入、除法、序列化与数据库列

除法不带精度会直接抛异常,因为结果可能是无限循环小数:

BigDecimal one = BigDecimal.ONE;
BigDecimal three = new BigDecimal("3");
System.out.println(one.divide(three));
// ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result

System.out.println(one.divide(three, 4, RoundingMode.HALF_UP));   // 0.3333
System.out.println(one.divide(three, new MathContext(4)));         // 0.3333,按有效位数

MathContext(precision) 指定的是有效位数而非小数位数,new MathContext(2) 会把 1234 变成 1.2E+3。金额计算里几乎总是该用 setScale(n, RoundingMode),MathContext 留给科学计算。

舍入模式 行为 1.005 → 2 位 财务口径
HALF_UP 四舍五入,远离零 1.01 国内账务、发票的默认
HALF_EVEN 银行家舍入,遇 5 取偶 1.00 统计、避免系统性偏高
DOWN 向零截断 1.00 手续费上限、折扣计算
UP 远离零进位 1.01 罚款、最低收费
CEILING / FLOOR 向正/负无穷 — 区间计算

HALF_UP 与 HALF_EVEN 的差别只在大批量数据上体现:HALF_UP 会把 5 全部进位,累计偏差单向累积;HALF_EVEN 让 5 一半进一半舍,长期期望误差趋近 0。选哪个是业务口径问题,不是精度问题——一旦定了,全链路(下单、退款、对账、报表)必须统一。

输出与传输也有坑。toString 在指数过大时用科学计数法,toPlainString 永远是普通小数:

BigDecimal v = new BigDecimal("1E+3");          // scale = -3
System.out.println(v.toString());               // 1E+3
System.out.println(v.toPlainString());          // 1000
System.out.println(v.stripTrailingZeros());     // 1E+3 —— 仍可能带指数

BigDecimal x = new BigDecimal("600.00");
System.out.println(x.stripTrailingZeros().toPlainString());   // 600

stripTrailingZeros 把 600.00(intVal=60000, scale=2)规整成 6E+2(intVal=6, scale=-2),不接 toPlainString 就会在 JSON 里留下前端解析不了的值。JDK 8 起 0.00.stripTrailingZeros() 返回 0。

序列化层要盯三处:

环节 风险 做法
Jackson 序列化 默认可能输出 1E+2 开 WRITE_BIGDECIMAL_AS_PLAIN 或统一 toPlainString
Jackson 反序列化 float 落到 double 再落 BigDecimal 开 USE_BIG_DECIMAL_FOR_FLOATS,字段声明为 BigDecimal
数据库列 DOUBLE 列存金额 用 DECIMAL(18,2)(或按业务定精度),禁止 FLOAT/DOUBLE
ORM 映射 未声明 precision/scale JPA 用 @Column(precision = 18, scale = 2)

DECIMAL 是定点数,MySQL 内部按字符串存十进制,读出来就是精确值;DOUBLE 是二进制浮点,0.1 存进去再取出来就已经不是 0.1。一旦金额列是 DOUBLE,应用层用 BigDecimal 也救不回来。


4. 金额选型:BigDecimal 还是 long 存"分"

两套方案都正确,判据在业务形态而不是性能偏好:

判据 选 BigDecimal 选 long(分/最小币种单位)
小数位是否固定 不固定(利率、汇率、单价) 固定 2 位(人民币记账)
是否需要乘除与比例 需要(分摊、折扣、税费) 少,多数是加减
数值范围 极大/极小都行 受 long 上限约束(约 9.2×10^18 分)
跨语言与存储 JSON 里是字符串或数字,需约定 各语言都是整数,天然无损
数据库列 DECIMAL(p,s) BIGINT
计算开销 对象分配 + 标度对齐,比 long 慢 原生整数运算
出错方式 标度不一致、忘记指定舍入 单位换算漏乘 100、溢出

工程上更稳的组合是:存储与传输用最小单位整数,展示与复杂计算用 BigDecimal,边界处只做一次换算。

public record Money(long cents) {
    public static Money ofYuan(String yuan) {
        return new Money(new BigDecimal(yuan).movePointRight(2).longValueExact());
    }
    public BigDecimal toYuan() {
        return BigDecimal.valueOf(cents, 2);      // 等价于 setScale(2),不经过 double
    }
    public Money plus(Money o) { return new Money(Math.addExact(cents, o.cents)); }
}

BigDecimal.valueOf(long unscaledVal, int scale) 是整数转小数最干净的入口,不走 double、不产生多余标度。longValueExact() 则在溢出或存在小数部分时抛 ArithmeticException,比 longValue() 静默截断安全得多。


5. 排错:六个金额算错的现场

现象 根因 处理
if (a == b) 判等永远 false 比的是引用 改 compareTo(b) == 0
1.0 与 1.00 判不相等 equals 比标度 统一 setScale,或改 compareTo
分页汇总与明细差几分钱 各处舍入模式不一致 全链路统一 HALF_UP,汇总在末位舍入
divide 抛 ArithmeticException 未指定精度或舍入模式 传 scale + RoundingMode
前端显示 1E+2 toString 科学计数法 输出统一 toPlainString()
入库金额多了几位小数 列是 DOUBLE 或 precision 未声明 改 DECIMAL(18,2),映射声明精度

还有一类不易察觉的:MathContext 用错。new BigDecimal("1234.5").round(new MathContext(2)) 得到 1.2E+3,如果这个值再去做金额比较,标度是 -2,与 1200(scale 0)不相等——有效位数与小数位数是两个维度,混用会在链路末端炸出一个"金额相等却对不上"的 bug。


6. 边界:什么时候不该用 BigDecimal

场景 更合适的选择 理由
纯展示、不参与计算的统计值 double 无需精确,性能更好
固定 2 位小数的记账 long 存分 无对象开销,跨语言无损
科学计算、物理量 double 误差可接受,BigDecimal 慢一个量级
只做加减且量极大 long 避免每笔都分配对象
需要精确到 20 位以上的金融衍生品定价 BigDecimal + 显式 MathContext 精度需求驱动,但要写清口径
缓存与共享 BigDecimal 可安全共享 不可变,无需防御性拷贝

BigDecimal 不可变,共享本身没问题;真正的问题是共享时标度不一致:同一笔金额在 A 服务是 100(scale 0),在 B 服务是 100.00(scale 2),跨服务判等就失败。约定一个出口标度比任何工具方法都有效。


7. 自测

  1. new BigDecimal("1.0").equals(new BigDecimal("1.00")) 为什么是 false?内部比了哪两个字段?
  2. new BigDecimal(0.1) 打印出来的长串小数是什么?为什么说它不是"精度丢失"?
  3. valueOf(double) 与 new BigDecimal(double) 的区别在哪?两者都适合什么场景?
  4. 什么情况下 divide 会抛 ArithmeticException?两种修法分别是什么?
  5. HALF_UP 与 HALF_EVEN 在批量对账中的差别是什么?选哪个由什么决定?
  6. 金额存 long(分)而不是 BigDecimal,判据有哪些?各自的风险点是什么?
S04-X11 · BigDecimal 的等值比较与金额表示选型
https://www.clxhxhhr.top/posts/5757/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。