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. 自测
new BigDecimal("1.0").equals(new BigDecimal("1.00"))为什么是false?内部比了哪两个字段?new BigDecimal(0.1)打印出来的长串小数是什么?为什么说它不是"精度丢失"?valueOf(double)与new BigDecimal(double)的区别在哪?两者都适合什么场景?- 什么情况下
divide会抛ArithmeticException?两种修法分别是什么? HALF_UP与HALF_EVEN在批量对账中的差别是什么?选哪个由什么决定?- 金额存
long(分)而不是BigDecimal,判据有哪些?各自的风险点是什么?