1. 先把"0.1+0.2≠0.3"这件事说透
BigDecimal的初始化、赋值和比大小,是 Java 后端绕不开的三件小事。之所以说"小事",是因为 API 就那么几个,IDE 一补全全出来了;之所以说"绕不开",是因为这三个动作里埋的雷,几乎每一个都曾让线上账目对不上、让对账脚本半夜报警。我见过太多人第一次接触BigDecimal是因为一段看起来完全正确的代码算出了0.30000000000000004,然后被要求"把所有涉及金额的 double 都换成 BigDecimal"。这个要求本身没错,但很多人换完之后只是把double改成BigDecimal,写法照抄,结果是精度问题从"算错"变成了"偶尔算错",排查难度反而更高。
先说清楚这个类到底是什么。BigDecimal是 Java 提供的任意精度十进制有符号数,由两部分构成:一个不限长度的整数unscaledValue(无标度值),和一个 32 位的整数scale(标度,可以理解为小数点后保留几位)。实际数值 =unscaledValue × 10^(-scale)。比如new BigDecimal("123.45"),内部存的是 unscaledValue=12345、scale=2。这个结构决定了它能精确表示任意十进制小数,也决定了后面所有"看起来奇怪"的行为——equals判不等、toString输出科学计数法、setScale必须指定舍入模式——全都源于这个双字段结构。
1.1 二进制浮点数的先天缺口
double用 IEEE 754 双精度格式存储,尾数只有 52 位,本质是符号 × 2^指数 × 尾数的形式。十进制里的 0.1 转成二进制是无限循环小数0.0001100110011...,52 位根本装不下,只能截断。所以内存里存的 0.1 其实是一个无限接近但绝不等于 0.1 的数。两个这样的近似值相加,误差被放大,就得到了那个著名的0.30000000000000004。
这不是 Java 的锅,C、Python、JavaScript 全都一样。判断标准很简单:只要一个数字在业务上要求"分毫不差",就不能用 float/double。金额、税率、库存数量、积分、汇率、利率、K 线价格,这些都属于此类。反过来说,物理仿真、图形渲染、机器学习里那种本身就有测量误差的场景,用 double 完全合适,硬套BigDecimal只会让性能掉一个量级还不讨好。
值得强调的是,double的问题不只是"算出来多几位小数"这么温和。0.1 + 0.2 == 0.3返回false,这意味着任何用==判等的代码都会静默失效;1.0 - 0.9得到0.09999999999999998,这意味着累加一万次之后偏差可能达到几毛钱。金额场景下这几毛钱就是资损。
1.2 从 double 到 BigDecimal 的迁移判断标准
不是所有double都要换。我用的判断标准有三条:第一,这个值是否会参与"必须精确相等"的比较;第二,这个值是否会做加减乘除后落库或展示给用户;第三,这个值是否存在多方对账需求。三条命中任意一条,就该换。反过来,如果只是用来算个超时时间、算个百分比用于日志打点,换成BigDecimal是过度设计。
这里有个容易被忽略的点:换了类型不等于换了语义。曾经有个案例,把金额字段从double改成BigDecimal之后,代码里还留着if (amount == BigDecimal.ZERO)这样的判断,编译期不报错,运行期永远为false,因为==比较的是对象引用。这类"半截子迁移"比不迁移更危险,因为看代码的人会误以为这里已经处理好了精度问题。
1.3 什么场景其实不需要 BigDecimal
同样要说清楚反面。第一,BigDecimal是不可变对象,每一次加减乘除都会 new 一个新对象,在十万级以上的循环里做累加,性能开销是double的十几倍到几十倍;第二,BigDecimal不能直接用+ - * /运算符,代码可读性会下降;第三,序列化体积更大,传输成本更高。
所以我在实际项目里的做法是:在领域模型的入口就把double挡在门外,用BigDecimal承载业务语义;在纯计算密集且不需要精度的中间环节,允许临时降级为double,但绝不跨越"落库"和"展示"这两道边界。这条边界一旦被突破,精度问题的暴露往往延迟到几个月后对账的时候。
2. 初始化的四种写法,只有两种能上生产
初始化是BigDecimal所有坑的源头。同一个数值,用不同方式构造出来的对象,equals结果可能不同,内部 scale 可能不同,打印出来可能一个是0.1一个是0.1000000000000000055511151231257827021181583404541015625。这一节把四种常见构造方式摊开讲。
2.1 new BigDecimal(double):编译器不报错但结果已经错了
BigDecimal bad = new BigDecimal(0.1); System.out.println(bad); // 0.1000000000000000055511151231257827021181583404541015625这是最典型的翻车现场。new BigDecimal(double)会把传入的 double 值的精确二进制展开原封不动转成十进制,得到的就是上面那一长串。很多人第一次看到这个输出会以为是打印问题,实际上是值本身就是这个。
IDEA 对new BigDecimal(0.1)这种字面量会给出警告,但对变量入参不会。所以在方法签名里设计setAmount(double amount)是极其危险的,等于把坑转移给了所有调用方。我的做法是:项目里禁用new BigDecimal(double),用静态检查规则(比如 Checkstyle 的自定义规则或 Sonar 的自定义规则)直接拦住。这条规则实施成本极低,收益极高。
注意:
new BigDecimal(0.1f)更糟。float 精度比 double 低,展开后的十进制位数虽然少,但偏离更大。
2.2 字符串与 valueOf 的等价性与非等价性
BigDecimal a = new BigDecimal("0.1"); // 精确,scale=1 BigDecimal b = BigDecimal.valueOf(0.1); // 也是 0.1,scale=1BigDecimal.valueOf(double)的内部实现是new BigDecimal(Double.toString(d)),也就是先把 double 转成字符串,再用字符串构造。Double.toString会输出能唯一标识该 double 的最短字符串,所以0.1就输出"0.1",最终得到精确的 0.1。这条路径是安全的,但要注意它绕了一圈字符串,性能略低于直接传字符串。
那到底选哪个?我的建议是分场景:从String来的数据(前端入参、配置、报文)一律用new BigDecimal(String);从double变量来的数据(遗留代码、第三方库返回值)一律用BigDecimal.valueOf(double)。两种都不用new BigDecimal(double)。
还有一个细节:new BigDecimal("1e3")是合法的,得到1E+3,scale=-3。负数标度在toString时会显示成科学计数法,如果这个值要拼到 SQL 或者展示给用户,必须用toPlainString()。这一点在 3.4 节展开。
2.3 整数、长整型与常量复用
new BigDecimal(100); // 安全,整数转换无精度损失 BigDecimal.valueOf(100L); // 安全,且 JDK 9+ 对 0~10 有缓存 BigDecimal.TEN; // 常量,直接用int、long转BigDecimal是精确的,因为没有小数部分。BigDecimal类里预定义了ZERO、ONE、TEN三个常量,JDK 9 之后valueOf(long)对 0 到 10 返回的是缓存实例。在循环里频繁用 0 做初始值累加时,用BigDecimal.ZERO而不是BigDecimal.valueOf(0),能少创建很多对象。
不过别过度迷信缓存。真正的性能瓶颈从来不在常量创建上,而在那些本可以避免的大数运算。我见过有人为了"性能",把金额用long存成分,运算完再转BigDecimal,逻辑复杂度翻了三倍,收益却完全感知不到。除非是每秒几十万笔的高频结算场景,否则不值得。
2.4 从数据库和 JSON 拿到的值怎么落地
数据库的DECIMAL(18,2)字段通过 JDBC 读出来直接就是BigDecimal,这是最舒服的路径——别中途转成 double 再转回来。ORM 框架里如果字段类型声明成了Double,那就是把精度在映射层丢掉了,后面再怎么处理都救不回来。
JSON 反序列化要小心。不同框架对数字的处理策略不同:有的默认把 JSON 数字解析成double再给BigDecimal赋值,这时候1.10可能变成1.1(scale 丢了);有的支持直接以字符串/字面量方式解析成BigDecimal,尺度保持原样。稳妥做法是前后端约定金额字段用字符串传输,后端接收时new BigDecimal(str),前端展示时拿到字符串原样显示,整条链路不出现浮点数。
3. 赋值这件事在 BigDecimal 里根本不存在
标题里写了"赋值",但严格说,BigDecimal里没有赋值,只有引用重定向。这个类被final修饰,内部的两个字段也全是final的,所有看起来像"修改"的方法,实际都返回了新对象。这个认知决定了你能不能写出正确的累加代码。
3.1 不可变对象:每一次运算都是新建
BigDecimal total = BigDecimal.ZERO; total.add(new BigDecimal("10.00")); // 这行代码什么也没做 System.out.println(total); // 0上面这段是新手最常写的错误。add返回了新对象,但没有接收,原来的total依然是 0。正确写法是total = total.add(...)。这个问题在单行代码里很显眼,但换成list.forEach(item -> total.add(item.getAmount()))就很隐蔽了——lambda 里的total还必须是final或等效 final 的,编译器会直接拦住,反而算是因祸得福。
不可变带来的好处是天然线程安全。BigDecimal可以安全地在多线程间共享,不需要任何同步措施。这也是它在并发结算场景里比double更受欢迎的原因之一——double的读写在某些架构上甚至不是原子的,更别说复合运算了。
另一个好处是可以放心当 Map 的 key、放进 Set 去重。但这里有个大坑,见 4.1 节:BigDecimal的hashCode包含了scale,1.0和1.00哈希不同,去重会失效。
3.2 scale 决定了你看到的字符串
scale是BigDecimal的双字段结构里那个容易被忽略的字段。它不影响数值大小,但影响打印结果和equals 结果。
BigDecimal x = new BigDecimal("1.0"); // scale = 1 BigDecimal y = new BigDecimal("1.00"); // scale = 2 BigDecimal z = new BigDecimal("1"); // scale = 0三者数值完全相等,compareTo全都返回 0,但打印出来分别是1.0、1.00、1,equals两两不相等。所以"赋值"这个动作在BigDecimal里其实包含了两层含义:赋数值和赋标度。前者靠构造,后者靠setScale。
业务上这两层都有意义。金额字段通常要求 scale=2,即使值是整数也要显示成10.00;数量字段可能要求 scale=3;汇率可能要求 scale=6。如果只关心数值不关心展示,scale 随便;一旦涉及展示、对账、导出 Excel,scale 必须统一。
3.3 setScale 的第二个参数才是业务规则的载体
BigDecimal v = new BigDecimal("10.005"); System.out.println(v.setScale(2, RoundingMode.HALF_UP)); // 10.01 System.out.println(v.setScale(2, RoundingMode.DOWN)); // 10.00 System.out.println(v.setScale(2, RoundingMode.HALF_EVEN)); // 10.00setScale(int)单参数版本在需要舍入时会直接抛ArithmeticException,因为它不知道该往哪边舍。这个设计很好,逼着你显式声明业务规则。可惜很多人被异常逼急了,直接加上RoundingMode.HALF_UP了事,从来没想过这个选择对不对。
几个舍入模式的业务含义对照:
| 模式 | 行为 | 典型场景 |
|---|---|---|
HALF_UP | 四舍五入,0.5 远离零 | 国内大多数金额计算、发票 |
HALF_EVEN | 银行家舍入,0.5 向偶数靠 | 国际金融、统计汇总、多次累加 |
HALF_DOWN | 五舍六入 | 少数特定行业规则 |
UP | 一律远离零 | 罚息、违约金(对己方有利) |
DOWN | 一律向零截断 | 折扣、优惠券(对用户不利需谨慎) |
CEILING/FLOOR | 向正无穷 / 负无穷 | 分页、配额计算 |
UNNECESSARY | 有舍入就抛异常 | 断言"这里不该有精度损失" |
HALF_EVEN值得多说一句。它在大量数据累加时能避免系统性偏差:HALF_UP会让所有 0.5 都向上,累加一万次就凭空多出五千个最小单位;HALF_EVEN让 0.5 一半向上、一半向下,长期期望偏差趋近于零。如果你的业务是做报表汇总、指标统计,用HALF_EVEN更合理;如果是单笔交易金额,用HALF_UP更符合用户直觉。这个选择必须写进技术方案,不能靠程序员拍脑袋。
3.4 stripTrailingZeros 与 toPlainString 的配合
stripTrailingZeros()用来去掉尾部的零,比如把1.500变成1.5。但它有两个坑。
第一个坑是零的处理。在某些 JDK 版本里,new BigDecimal("0.00").stripTrailingZeros()返回的是0E-2,打印出来是科学计数法,而不是0。这个行为在不同版本上表现不一致,不能依赖。安全的写法是先判零:
BigDecimal r = v.compareTo(BigDecimal.ZERO) == 0 ? BigDecimal.ZERO : v.stripTrailingZeros();第二个坑是toString()可能输出科学计数法。当 scale 为负数或者调整后的 scale 小于 -6 时,toString会输出1E+3这样的形式。如果你的值要拼进 SQL、写进 CSV、返回给前端,必须用toPlainString(),它永远输出普通十进制字符串。我一般在所有对外输出的位置统一用toPlainString(),避免某天数据量级变了突然冒出个1E+3让下游解析失败。
提示:
toEngineeringString()用的是工程计数法(指数是 3 的倍数),除非有特殊展示需求,一般不用。
4. 比大小:compareTo 与 equals 的分水岭
比大小是BigDecimal里最容易写出"看起来对、实际错"的代码的地方。核心就一句话:equals比的是"值 + 标度",compareTo比的是"数值大小"。这一句话能解释 90% 的相关 bug。
4.1 equals 为什么判 1.0 和 1.00 不等
BigDecimal a = new BigDecimal("1.0"); BigDecimal b = new BigDecimal("1.00"); System.out.println(a.equals(b)); // false System.out.println(a.compareTo(b) == 0); // trueequals的源码逻辑是:先比较scale是否相等,不等直接返回 false;相等再比较unscaledValue。1.0的 scale 是 1、unscaledValue 是 10;1.00的 scale 是 2、unscaledValue 是 100。数值一样,但两个字段都不一样,所以判不等。
这个行为在集合里会放大成灾难:
Set<BigDecimal> set = new HashSet<>(); set.add(new BigDecimal("1.0")); set.add(new BigDecimal("1.00")); System.out.println(set.size()); // 2因为hashCode的实现是31 * unscaledValue.hashCode() + scale,两个对象的哈希值不同,落到了不同的桶。用BigDecimal作为HashMap的 key 去查缓存、做幂等判断,就会出现"明明存在却查不到"的诡异现象。
修复方式很直接:入集合前统一setScale,或者在业务层约定所有参与比较的BigDecimal都经过同一个规范化方法处理。我在项目里通常会写一个normalize(BigDecimal v)方法,内部做v.setScale(2, RoundingMode.HALF_UP),所有进入集合、进入缓存 key 的值都先过一遍。这样从源头上消灭了标度不一致。
4.2 compareTo 返回值的使用姿势
compareTo返回-1、0、1,分别代表小于、等于、大于。JDK 7 之后规范明确保证只返回这三个值(早期版本可能返回任意负数或正数)。
int c = a.compareTo(b); if (c > 0) { /* a 大于 b */ } else if (c < 0) { /* a 小于 b */ } else { /* 相等 */ }注意不要写if (a.compareTo(b) == 1)。虽然 JDK 7+ 确实返回 1,但用> 0语义更清晰,也不依赖实现细节。更不要写if (a.compareTo(b) != -1)来表达"大于等于",这种写法可读性极差,正确性还依赖返回值恰好是 -1。
判零有个专门的写法:a.compareTo(BigDecimal.ZERO) == 0。不要用a.equals(BigDecimal.ZERO),因为它对0.00会返回 false。也不要写a.intValue() == 0,因为0.5的intValue()是 0,判零会误判。同理,判断正负用signum()更直观:返回 -1、0、1。
4.3 空值、区间与批量比较的封装
compareTo碰到null会直接 NPE。业务代码里从数据库、从报文里取出来的金额完全可能是null,所以每个比较点都要做判空。散落各处的判空很快就会失控,我一般封装成一个工具方法:
public static int nullSafeCompare(BigDecimal a, BigDecimal b) { if (a == null && b == null) return 0; if (a == null) return -1; if (b == null) return 1; return a.compareTo(b); }这样null被当作最小值处理,排序、取最大值都能直接用。
区间判断也值得封装。判断金额是否在某个范围内时,闭区间和开区间的边界很容易写错:
public static boolean inRange(BigDecimal v, BigDecimal min, BigDecimal max) { return v.compareTo(min) >= 0 && v.compareTo(max) <= 0; }批量场景下,取最大值/最小值用Collections.max(list)是可以的(BigDecimal实现了Comparable),但要注意 list 里不能有 null,且标度不一致不影响比较结果——这一点比equals友善得多。
5. 除法才是真正的高危操作
加减乘都不会抛异常(乘法的 scale 会累加,但不会失败),只有除法会。这是BigDecimal里唯一一个"运行期可能炸"的基础运算,值得单独一节。
5.1 Non-terminating decimal expansion 的触发条件
new BigDecimal("1").divide(new BigDecimal("3")); // java.lang.ArithmeticException: Non-terminating decimal expansion; // no exact representable decimal result.1 除以 3 是无限循环小数,BigDecimal无法精确表示,于是抛异常。这个设计初衷是好的——宁可报错也不给你一个悄悄截断的结果。但在实际项目里,这个异常往往在某个特定数据上才触发,测试环境跑了三个月都没事,上线第一天就炸。
触发条件很明确:除数除以被除数(约分后)的质因数中含有 2 和 5 以外的因子。3、7、11、13 这类都会触发。所以除以 2、4、5、8、10、20 这类数是安全的,除以 3、6、7、9 是危险的。这个规律记不住也没关系,记住"除法要么给精度,要么给MathContext"就够了。
5.2 三种 divide 重载的适用边界
divide有三个常用重载,用途完全不同:
// 1. 精确除法,除不尽抛异常 a.divide(b); // 2. 指定标度和舍入模式,最常用 a.divide(b, 2, RoundingMode.HALF_UP); // 3. 指定 MathContext,按有效数字位数控制 a.divide(b, new MathContext(10, RoundingMode.HALF_UP));日常业务里 99% 的情况用第二种。它把"保留几位"和"怎么舍"两个决策都摆到明面上,代码即文档。第一种只在你能保证整除时使用,比如除以 100、除以 4 这种。第三种MathContext控制的是有效数字位数而不是小数位数,适合科学计算场景,业务代码里很少见。
有个变体很实用:divideAndRemainder(b)一次返回商和余数数组,做整除分配、进制转换时用得上。
5.3 舍入模式的业务含义对照
除以 3 之后保留两位,用不同舍入模式的结果差异如下:
| 被除数/除数 | HALF_UP | HALF_EVEN | DOWN | UP |
|---|---|---|---|---|
| 1 / 3 | 0.33 | 0.33 | 0.33 | 0.34 |
| 2 / 3 | 0.67 | 0.67 | 0.66 | 0.67 |
| 10 / 4 | 2.50 | 2.50 | 2.50 | 2.50 |
| 11 / 4 | 2.75 | 2.75 | 2.75 | 2.75 |
| 5 / 2 | 2.50 | 2.50 | 2.50 | 2.50 |
| 1 / 8 | 0.13 | 0.12 | 0.12 | 0.13 |
最后一行 1/8 = 0.125,正好卡在边界上,HALF_UP给 0.13,HALF_EVEN因为 2 是偶数给 0.12。这就是银行家舍入的直观体现。选择哪个必须在技术方案里写清楚,最好在工具类里统一定义一个常量SCALE_MONEY = 2和ROUNDING = RoundingMode.HALF_UP,全项目共用,避免不同模块用不同规则。
6. 分摊、累加、求平均:三个真实计算模型的落地
光讲 API 没意思,讲三个我实际遇到过、也确实踩过坑的计算模型。这三个场景覆盖了BigDecimal在业务里的绝大多数用法。
6.1 分摊到分的尾差兜底
一百块钱平均分给三个人,每人 33.33,加起来 99.99,少了一分。这一分钱去哪了?这不是 bug,是舍入的必然结果。处理方式有两种:把尾差挂到第一个人头上,或者挂到最后一个人头上。
public static List<BigDecimal> average(BigDecimal total, int parts) { BigDecimal each = total.divide(BigDecimal.valueOf(parts), 2, RoundingMode.DOWN); BigDecimal assigned = each.multiply(BigDecimal.valueOf(parts)); BigDecimal diff = total.subtract(assigned); // 尾差 List<BigDecimal> result = new ArrayList<>(); for (int i = 0; i < parts; i++) { result.add(i == parts - 1 ? each.add(diff) : each); } return result; }关键点在于用DOWN而不是HALF_UP做基础值。用DOWN保证每份都不会超出平均值,尾差一定是非负的,挂到最后一份上不会出现负数。如果用HALF_UP,each可能偏大,diff变成负数,最后一份就成了负数金额,业务上很难解释。
还有一个更隐蔽的坑:分摊的份数可能很大。比如把一笔费用分摊到几百个商品上,如果每份都做四舍五入,累计误差可能达到几块钱。这时候必须用"先分配、再补差"的方式,并且补差放到最后一份。绝对不能每份独立舍入后直接返回,那样总和对不上,必然被财务打回来。
6.2 累加过程中的精度保持
循环里做累加,有两件事要注意:接收返回值和统一标度。
BigDecimal total = BigDecimal.ZERO; for (Order o : orders) { BigDecimal amt = o.getAmount() == null ? BigDecimal.ZERO : o.getAmount(); total = total.add(amt); // 必须接收返回值 } total = total.setScale(2, RoundingMode.HALF_UP); // 最后统一舍入标度的策略是"过程中不限制,最后统一舍入"。如果每加一次就setScale(2),误差会被逐次放大;如果在过程中就截断,累加十万条之后偏差可能达到几百元。正确做法是让BigDecimal自己维护精确值(它的标度会随着运算自动调整),只在最终输出时做一次舍入。
这里还有个性能考量:如果累加的数据量极大(比如百万级),每次add都创建新对象,内存压力不小。可以考虑用double做预聚合、再分批转BigDecimal汇总,但前提是单批数据量不大、且预聚合的误差可接受。这种优化必须有压测数据支撑,不能凭空猜。
6.3 平均值与百分比的先乘后除
算百分比时最常见的错误是先除后乘。
// 错误:0.33 * 100 = 33,丢了两位 BigDecimal pct = part.divide(total, 2, RoundingMode.HALF_UP) .multiply(BigDecimal.valueOf(100)); // 正确:先乘后除,中间过程保持精度 BigDecimal pct = part.multiply(BigDecimal.valueOf(100)) .divide(total, 2, RoundingMode.HALF_UP);divide的第二次参数是小数位数,先除的话中间结果只有 2 位有效小数,再乘 100 精度已经丢了。先乘后除能把中间值保持在整数域,除法只做一次,精度损失最小。
求平均值的思路类似。sum.divide(count, 2, HALF_UP)一次搞定,别先对每个元素舍入再平均——那样每个元素都引入了舍入误差,平均之后误差不会抵消,只会累积。这是统计学的基本常识,但在代码里特别容易忘。
7. 工程化收口:工具类、序列化与库表精度
前面讲的是"怎么用",这一节讲"怎么管"。BigDecimal的问题不在于单个方法难懂,而在于用的人多、写法分散、规则不统一。所以必须做工程化收口。
7.1 一个可以直接抄的工具类
public final class MoneyUtils { public static final int SCALE = 2; public static final RoundingMode ROUNDING = RoundingMode.HALF_UP; private MoneyUtils() {} /** 安全构造:null 与空串返回 ZERO */ public static BigDecimal of(String v) { if (v == null || v.trim().isEmpty()) return BigDecimal.ZERO; return new BigDecimal(v.trim()); } /** 安全构造:double 走 valueOf,避免二进制展开 */ public static BigDecimal of(double v) { return BigDecimal.valueOf(v); } /** 规范化:统一标度,用于比较和入集合 */ public static BigDecimal norm(BigDecimal v) { return v == null ? BigDecimal.ZERO : v.setScale(SCALE, ROUNDING); } /** 空安全比较 */ public static int cmp(BigDecimal a, BigDecimal b) { return norm(a).compareTo(norm(b)); } public static boolean gt(BigDecimal a, BigDecimal b) { return cmp(a, b) > 0; } public static boolean lt(BigDecimal a, BigDecimal b) { return cmp(a, b) < 0; } public static boolean eq(BigDecimal a, BigDecimal b) { return cmp(a, b) == 0; } /** 空安全加法 */ public static BigDecimal add(BigDecimal a, BigDecimal b) { return norm(a).add(norm(b)); } /** 空安全乘法,结果保留两位 */ public static BigDecimal mul(BigDecimal a, BigDecimal b) { return norm(norm(a).multiply(norm(b))); } /** 安全除法,除零返回 ZERO */ public static BigDecimal div(BigDecimal a, BigDecimal b) { if (b == null || b.compareTo(BigDecimal.ZERO) == 0) return BigDecimal.ZERO; return a.divide(b, SCALE, ROUNDING); } /** 去掉尾部零,零值特殊处理 */ public static BigDecimal strip(BigDecimal v) { if (v == null || v.compareTo(BigDecimal.ZERO) == 0) return BigDecimal.ZERO; return v.stripTrailingZeros(); } /** 对外输出,永不用科学计数法 */ public static String plain(BigDecimal v) { return norm(v).toPlainString(); } }这个类有几个设计取舍值得说。第一,所有方法都做 null 安全,因为在业务代码里 null 的传播速度极快,一处不防就是一片 NPE。第二,norm统一成 scale=2,意味着这个工具类只适用于金额,不适合数量、汇率。如果项目里多种标度并存,就按领域拆成MoneyUtils、RateUtils、QtyUtils,别做一个万能工具类。第三,div遇到除零返回 ZERO 而不是抛异常,这个选择有争议——有人认为除零应该报错。我的理由是:报表、统计场景下除数确实可能为 0,返回 0 比让整个页面 500 更友好,但要保证日志里有记录。
7.2 序列化到前端时的精度断层
后端算得好好的BigDecimal,一变成 JSON 就可能出事。原因很直接:JSON 规范里数字没有精度约定,前端 JavaScript 的Number就是双精度浮点,有效数字大约 15 到 17 位。一个 18 位的金额主键或者一个 scale=6 的汇率,超过这个范围就会被静默截断,而且不会有任何报错。
解决方案是:所有BigDecimal字段序列化成字符串。
public class OrderVO { @JsonSerialize(using = ToStringSerializer.class) private BigDecimal amount; }或者在全局配置里让序列化器把BigDecimal统一写成字符串。代价是前端要按字符串处理,做运算时得用专门的库。但相比精度丢失导致的资损,这点改造量完全值得。
反序列化方向同理:前端传金额用字符串,后端用@JsonDeserialize或者直接把 DTO 字段声明成String再new BigDecimal(str)。千万别让前端传数字,也别在后端声明double。
7.3 数据库字段与 Java 属性的精度对齐
最后说库表。DECIMAL(p, s)是 MySQL、PostgreSQL、Oracle 通用的精确数值类型,p 是总位数、s 是小数位。金额一般用DECIMAL(18, 2),18 位总长度足够支撑万亿级金额,2 位小数对应"分"。
三处必须对齐:数据库字段的 scale、Java 属性的 scale、业务层舍入的 scale。任何一处不一致都会导致"存进去是 10.00,读出来是 10"或者"算出 10.005,入库被截断成 10.00,对账差 5 厘"。我的做法是在技术方案里明确写死:金额统一DECIMAL(18,2),Java 层统一scale=2、HALF_UP,所有涉及金额的 DTO 字段加注释说明。
注意:Oracle 的
NUMBER类型如果不指定精度,行为依驱动版本而异,可能读出BigDecimal也可能读出Double,务必显式声明精度。
还有个细节:统计类 SQL 的返回值。SUM(amount)在不同数据库里返回的类型不一样,有的给BigDecimal、有的给Double、有的给String。如果用ResultSet.getObject()拿到的是Double,那在 JDBC 层就已经丢精度了,后面再怎么用BigDecimal都白搭。稳妥做法是把SUM结果在 SQL 里就CAST成DECIMAL,或者用getBigDecimal()显式取值。
我在几个项目里推行过这套规矩,推行初期阻力不小——有人觉得"不就是个金额嘛,至于搞个工具类"。等到第一次因为0.1+0.2对不上账、第一次因为equals判等失败导致重复扣款、第一次因为除法抛异常导致订单卡单,规矩就自然而然立起来了。这些坑不用全踩一遍,提前知道它们长什么样,就能少熬几个夜。