前阵子在排查一个对账问题系统算出来的总金额和渠道方返回的金额总是差一分钱查到最后发现是某段历史代码用 double 做了累计。那会儿我已经把 BigDecimal 当成“金额运算唯一合法类型”用了很多年看到这种还是头大。类似的事情相信不少人都遇过double 的浮点误差在单次运算里毫不起眼但一旦累加、乘除、跨系统传输误差就会从一厘一毫开始最后变成账不平、对不齐、甚至线上资损。这篇文章就把我这些年用 BigDecimal 的经验完整梳理一遍什么时候用它、怎么构造、运算有哪些细节、比较千万别踩 equals 的坑、输出又该怎么控制格式最后会给出一个可以直接抄的封装工具类。适合所有写过钱、折扣、汇率、积分的后端开发看刚入门的新手也能靠这一篇把金额计算的基础补牢。1. 先搞清楚为什么不能用 double 算钱1.1 double 的精度是怎么丢的0.10.2 不等于 0.3先从一个最简单的实验说起。在 Java 里执行 System.out.println(0.1 0.2)你猜输出什么不是 0.3而是 0.30000000000000004。很多人第一次看到这个结果会以为是编译器问题其实这是 IEEE 754 浮点数标准的正常表现。二进制能精确表示 1/2、1/4、1/8 这类分母是 2 的幂的小数但 0.1 换算成二进制是一个无限循环小数double 只有 53 位有效二进制位存不下只能截断于是任何基于 double 的十进制小数运算本质上都是在拿近似值算近似值。单个运算的误差小到肉眼看不出来但钱这个东西最怕累计。比如每笔订单抽佣 0.1 元一天十万单这个误差反复累加就可能凭空多出几元钱的差额。更麻烦的是这种误差不是固定的你没法用一个常量去补偿。所以金融、电商、财务类系统有一条共识涉及金额的计算一律禁止 double。这不是开发规范里严不严的问题是账能不能平的问题。1.2 BigDecimal 的底层结构十进制整数加小数点位置BigDecimal 为什么能精确因为它压根不碰二进制浮点这套逻辑。BigDecimal 内部其实是用一个 BigInteger任意精度整数存有效数字再用一个 int 类型的 scale 记录小数点从右往左移几位。比如 123.45内部就是 BigInteger 12345 配 scale21000 则可能是 BigInteger 1000 配 scale0。整个运算过程都在十进制世界里完成自然能精确表达 0.1。想通这一点后面很多行为就都能预测了。为什么 new BigDecimal(0.1) 是干净的 0.1因为字符串0.1被直接解析成了整数 1 和 scale1。为什么 new BigDecimal(0.1) 会得到一长串尾数因为传进去的 double 本身就是近似值BigDecimal 只是把这个近似值如实抄写成了十进制。理解了这个模型你对“该用什么方式构造 BigDecimal”的判断就不再依赖死记硬背了。2. 创建 BigDecimal三条绕不开的规矩2.1 别用 new BigDecimal(double)用了等于慢性自杀这是 BigDecimal 最经典的坑没有之一。你写 new BigDecimal(0.1)打印出来的结果是一长串0.1000000000000000055511151231257827021181583404541015625。原因我在前面解释过了double 传进来的并不是十进制的 0.1而是它在二进制世界里的最近邻居。BigDecimal 又设计得很“老实”来什么存什么不帮你圆场。于是你以为是精确计算实际上只是把 double 的误差搬进了 BigDecimal 里后面所有运算都还是错着算。这种 bug 隐蔽性极高因为它不会直接报错只是结果“差不多对”在多系统对账时才会露出马脚。我见过最离谱的情况是有人专门把 double 结果转成 BigDecimal 去“修精度”结果误差从源头就带进来了越修越歪。2.2 推荐姿势new BigDecimal(String) 和 BigDecimal.valueOf正确做法是让 BigDecimal 一开始就拿到精确的十进制表示。业务里最常用的方式有三种第一数据库里 DECIMAL 类型通过 JDBC 取出来本来就是 BigDecimal别再转回去第二接口和 JSON 传过来的金额字段按字符串接收然后 new BigDecimal(str)第三如果代码里确实只有一个 double 值那就用 BigDecimal.valueOf(double)它内部会先调用 Double.toString 把这个 double 转成最短的可读十进制字符串再按字符串构造相当于帮你在入口处做了一次“去噪”。很多人问 BigDecimal.valueOf(0.1) 和 new BigDecimal(0.1) 是不是完全等价底层差不多但 valueOf 的优势是能从 double 安全过渡。要记住一条铁律凡是能拿到字符串的地方就绝不把字符串转成 double 再转 BigDecimal路径越短越不容易出错。2.3 int、long 和常量的转换注意点int 和 long 是可以在二进制里精确表示的整数所以 new BigDecimal(10)、new BigDecimal(10L) 是安全的。但千万别混着来new BigDecimal(10.0) 和 new BigDecimal(10) 完全是两个世界前者的 10.0 被当成 double 处理一样可能带着二进制尾巴。另一个容易被忽略的点JDK 提供了 BigDecimal.ZERO、ONE、TEN 三个静态常量能用的时候别自己 new。还有个实际场景要注意某些 ORM 框架返回的动态类型可能是 Double也可能是 BigDecimal。你在写代码时如果先强转成 double 再构造精度就已经伤害了。更稳妥的做法是写一个统一的转换入口拿到 Object 后判断类型BigDecimal 直接返回Number 的先转成字符串再构造。这样不管上游怎么变边界都控制住。3. BigDecimal 运算方法add/subtract/multiply/divide 的隐藏细节3.1 加法和减法返回值是新的 BigDecimalBigDecimal 是不可变对象这和 String 一样。也就是说 a.add(b) 不会修改 a 本身而是返回一个新对象。很多人第一次写累加循环时都会踩这个坑BigDecimal total BigDecimal.ZERO; for (int i 0; i items.size(); i) { total.add(items.get(i).getPrice()); // 写错了total 没变 }上面这段跑完 total 还是 0正确写法是 total total.add(price)。为什么设计成这样不可变对象天然线程安全可以放心地在一个 BigDecimal 实例上做并发只读操作也能安全地作为 Map 的 key。用的时候只要习惯“每次运算都重新赋值”就好别嫌麻烦这个设计救过无数次并发事故。加法还有一个细节结果的 scale 是参与运算的两个数中 scale 较大的那个。new BigDecimal(1.1).add(new BigDecimal(2.22)) 结果是 3.32而不是自带一堆小数。这点挺符合直觉但减法和乘法就不是了。3.2 乘法的 scale 规则两个参数的小数位相加乘法结果的 scale 等于两个操作数 scale 之和。10.00 乘以 0.08结果是 0.8000不是 0.8。为什么会这样因为从整数视角看1000 × 8 8000而两个数的 scale 分别是 2 和 2加起来就是 4所以结果要移动 4 位小数点得到 0.8000。很多金额计算在乘完折扣、税率之后会冒出一长串 0 或者意想不到的位数根源就是乘法这个机制。所以业务代码里几乎每次乘法之后都要跟着一次 setScale统一把小数位数规整到约定精度比如两位。这不能省宁可多写一行也不要把带着 4 位小数的中间结果直接传给下一个系统。3.3 除法最容易踩雷必须指定 scale 与 RoundingMode除法是 BigDecimal 所有方法里最“情绪化”的一个。如果两个数能整除比如 10.00 除以 4.00结果是 2.5一切正常但如果除不尽比如 10.00 除以 3.00运行时会直接抛异常Non-terminating decimal expansion; no exact representable decimal result。它不是给你截断而是直接拒绝执行。很多人第一次跑这段代码时一脸懵以为 BigDecimal 有问题其实是它想提醒你除不尽的数必须由你告诉它怎么舍。业界标准姿势是显式指定两位小数的 scale 和舍入方式BigDecimal result a.divide(b, 2, RoundingMode.HALF_UP);这里的 2 表示结果保留两位小数RoundingMode 是舍入策略。舍入策略常用的就这么几种HALF_UP 就是咱们常说的四舍五入业务上最常见HALF_DOWN 是五舍六入HALF_EVEN 是银行家舍入四舍六入五成双对数据的统计偏差控制严格一些金融对账里偶尔会碰到UP 和 DOWN 分别是远离零、靠近零一般用在特殊规则里。示例保留1位小数HALF_UPHALF_DOWNHALF_EVEN1.551.61.51.61.451.51.41.42.252.32.22.2看到没有同样的数在不同舍入策略下结果不一样。所以在写除法、或者任何需要舍入的地方必须和产品、财务确认清楚到底用哪种规则否则你以为的四舍五入和财务核算口径可能根本不是一回事。4. 比较大小BigDecimal 的 equals 和 compareTo 要分清4.1 equals 的隐藏条件1.0 和 1.00 竟然不相等BigDecimal 的 equals 方法有两个判断条件一是数值上相等二是 scale 也必须相等。所以 new BigDecimal(1.0).equals(new BigDecimal(1.00)) 的结果是 false。1.0 是 scale11.00 是 scale2两个对象在 equals 眼里就是不同的。这个设计有它的道理BigDecimal 本身兼顾了“数值”和“精度”两个信息equals 连精度一起比在数学上更严谨。但业务系统里你根本不 care 精度你只想知道两个金额值一不一样这时候 equals 就是个大坑。更隐蔽的是 hashCode它也是基于 unscaledValue 和 scale 计算的所以 1.0 和 1.00 的 hashCode 不同。如果你把 BigDecimal 当 HashMap 的 key同一个金额的两个不同 scale 版本会变成两条记录幂等逻辑、缓存去重都会出莫名其妙的 bug。4.2 业务比较请统一使用 compareTo业务场景下比较两个 BigDecimal 永远用 compareTo它只比较数值大小无视 scaleBigDecimal a new BigDecimal(1.0); BigDecimal b new BigDecimal(1.00); System.out.println(a.compareTo(b) 0); // true System.out.println(a.compareTo(b) 0); // false System.out.println(a.compareTo(b) 0); // false再强调一遍判断是否等于 0别用 equals(BigDecimal.ZERO)用 compareTo(BigDecimal.ZERO) 0。判断两个金额谁大谁小也一样统一用大于 0、小于 0 的返回码判断。很多线上事故都是“感觉 equals 差不多是吧”造成的实际上 12.30 和 12.3 的 equals 就是 false对象关系型数据库里一单响应式实现幂等判断时就翻车了。5. 输出与格式化BigDecimal 到 String 的几个大坑5.1 setScale 的两种场景补位与舍入先说 setScale 的一个基本规律如果新 scale 比当前 scale 大比如从 1 位变成 2 位这是在补零不会改变数值也不要求你传舍入策略如果新 scale 比当前 scale 小比如从 4 位变成 2 位这是舍入必须指定 RoundingMode否则抛 ArithmeticException。为了代码一致性我建议不管哪种情况都显式传 RoundingMode.HALF_UP省得不同数据跑出不同表现。实际业务里最常见的组合就是“计算完小数位不对、统一规整一下”比如BigDecimal unitPrice new BigDecimal(19.99); BigDecimal qty new BigDecimal(3); BigDecimal amount unitPrice.multiply(qty); // scale0还是什么看输入 BigDecimal settled amount.setScale(2, RoundingMode.HALF_UP);这样无论中间过程怎么飘最终金额都稳定地保留两位小数后续比较、存储、传输才有共同语言。5.2 别让 BigDecimal 悄悄输出科学计数法BigDecimal 继承自 Number但它的 toString 有个很反直觉的规则当数字绝对值极大或极小时会输出科学计数法。new BigDecimal(100000000000000000).toString() 可能会得到 1E17new BigDecimal(0.0000001).toString() 可能是 1E-7。这本身是 Java 的标准行为但问题在于你把它直接放进日志、协议包、Excel 导出文件里别人看到的就不是“100000000000000000”而是一串 1E17既不方便人读也容易让下游解析器判断类型出错。所以往外输出时一定要用 toPlainString()它会老老实实输出不带指数的字符串。还有个关联的坑是 stripTrailingZeros()这个方法是去掉数值末尾多余的 0比如 1.2300 变成 1.23适合展示但它对整十的数会返回一个 scale 为负数的结果比如 100 处理后 toString 可能又变成 1E2。因此凡是“去掉多余的 0 再给人看”的场景正确写法是 stripTrailingZeros().toPlainString()别漏了 toPlainString 这一步。5.3 与前端和数据库交互字符串和 decimal 类型怎么选数据库层面金额字段别用 float 或 double用 DECIMAL(18,2) 这类定点类型JDBC 取出来直接就是 BigDecimal。接口层面JSON 序列化器对 BigDecimal 的默认输出可能是数值建议配置成字符串。以 Jackson 为例可以全局开启 WRITE_BIGDECIMAL_AS_PLAIN或者给金额字段加 JsonSerialize(using ToStringSerializer.class)。为什么字符串传输能保证前端拿到什么就传回什么你不会因为 JSON 解析中间多跳了一次 double 而损失精度。前端展示要保留几位小数、要不要保留末尾的 0是产品问题不是技术问题后端只需要在边界处把规整好的字符串给出去就行。我给团队定的规矩很简单进入系统时用字符串构造 BigDecimal离开系统时用 toPlainString 输出字符串中间计算全部留在 BigDecimal 世界里不跨界。6. 实操封装一套够用的 BigDecimal 金额工具类6.1 工具类要覆盖哪些场景封装工具类不是炫技而是把最容易写错的地方收口到几个固定入口。结合业务中大量出现的场景最少要覆盖安全构造、加减乘除、大小比较、判零、格式化输出。尤其要处理 null因为数据库字段、接口参数都可能传 null如果每次运算前都手工判空代码既啰嗦又容易漏。我见过不少团队把 null 判断散落在业务代码里隔几行写一句 if (amount ! null)出了事故才发现某个角落忘了判。工具类统一处理 null 后调用方就不用再担心空指针了逻辑会清爽很多。6.2 核心代码一个可用的 MoneyUtils下面这个是我目前一直在用的简化版足够覆盖绝大多数业务public final class MoneyUtils { private static final int DEFAULT_SCALE 2; private MoneyUtils() { } public static BigDecimal safe(BigDecimal value) { return value null ? BigDecimal.ZERO : value; } public static BigDecimal add(BigDecimal a, BigDecimal b) { return safe(a).add(safe(b)); } public static BigDecimal subtract(BigDecimal a, BigDecimal b) { return safe(a).subtract(safe(b)); } public static BigDecimal multiply(BigDecimal a, BigDecimal b) { BigDecimal result safe(a).multiply(safe(b)); return result.setScale(DEFAULT_SCALE, RoundingMode.HALF_UP); } public static BigDecimal divide(BigDecimal a, BigDecimal b, int scale) { return safe(a).divide(safe(b), scale, RoundingMode.HALF_UP); } public static boolean greaterThan(BigDecimal a, BigDecimal b) { return safe(a).compareTo(safe(b)) 0; } public static boolean isZero(BigDecimal a) { return safe(a).compareTo(BigDecimal.ZERO) 0; } public static String toPlainString(BigDecimal a) { return safe(a).stripTrailingZeros().toPlainString(); } }几个设计点说一下。safe() 方法把 null 默认成 ZERO避免了一堆空指针判断。multiply 里直接带上了 setScale因为乘法最容易出现多余小数位统一在出口处收口。divide 强制要求调用方传 scale不传默认值这样反而不容易出错——调用方必须想清楚自己要保留几位小数。比较和判零全部走 compareTo从根本上绕开 equals 的 scale 问题。这个工具类本身无状态BigDecimal 又不可变可以放心地做成单例或者静态方法。6.3 什么时候不应该用 BigDecimal说了这么多 BigDecimal 的好话也得泼盆冷水它不是万能的。首先BigDecimal 的性能比 double 慢一两个数量级简单四则运算可能差距不大但百万级循环里做累加或者做大矩阵、数值仿真用 BigDecimal 会很吃力。其次BigDecimal 没有原生的三角函数、对数、开方这些高级运算真遇到科学计算还是得靠 double 或专用库。最后有些中间件和数据库有自己的十进制类型比如 MongoDB 的 Decimal128它和 Java BigDecimal 并不是一模一样跨系统时要先确认转换规则。所以说到底BigDecimal 的主场只有一个凡是与钱、精度、对账相关的十进制运算它就是唯一推荐选项。拿它去做通用科学计算属于杀鸡用牛刀拿 double 去算钱等于埋雷各回各家才对。7. 常见问题排查BigDecimal 业务事故对照表7.1 经典事故复盘幂等为什么失效我印象很深的一个事故是这样的订单系统对外提供退款接口请求里带着原始金额和幂等键。幂等表用 HashMapBigDecimal, Object 做金额维度的去重。有一天测试反馈同一笔订单连续请求两次居然都当成新单处理金额也查不出来了。排查后发现第一次请求解析出来的是“12.30”第二次是“12.3”数值明明一样但 equals 认为不是同一个 keyHashMap 里放了两条记录。改成 compareTo 判断后问题消失。这类问题不只在 HashMap 里出现还把 Set、数据库 distinct、缓存 key 都可能踩到。凡是把 BigDecimal 放进容器、做去重、做幂等判断一律要问自己一句这里用的是 equals 还是 compareTo代码评审时看到 equals 用得比较就要提醒对方确认 scale 是否恒定。老话讲事出反常必有妖BigDecimal 的妖就在 scale 上。7.2 高频问题速查表现象根本原因解决方法new BigDecimal(0.1) 出现长尾数构造入参是近似 double改用字符串构造或 valueOfdivide 抛 ArithmeticException除不尽且未指定舍入策略指定 scale 和 RoundingMode1.0 和 1.00 用 equals 不相等equals 包含 scale 判断业务比较统一用 compareTo金额乘折扣后出现 4 位小数乘法 scale 相加出口处 setScale 规整打印大数变成 1E18toString 用了科学计数法统一用 toPlainString累加后结果没变化忘记给变量重新赋值total total.add(...)这个表基本覆盖了日常开发里 90% 的 BigDecimal 相关 bug。建议收藏等代码里真出问题时再对照排查比翻官方文档来得快。7.3 实操心得金额字段的几条铁律写后端这么多年踩过不少坑之后我给自己定了几条看着简单但极其有效的规矩凡是字段名叫 amount、price、fee、balance、tax、discount 的一律用 BigDecimal不接受任何理由代码里不出现 new BigDecimal(double) 这种构造见到就改所有除法必须注明保留位数和舍入方式不允许存在“裸 divide”日志和接口输出统一走 toPlainString禁止直接 toString数据库表结构里金额列不允许出现 float/double 类型。这些规矩听起来有点像强迫症但它确实帮我避开了大量肉眼不可见的精度问题。团队新人多、业务快、接口乱的时候一刀切的规范比临时解释“这里其实有坑”要可靠得多。如果你所在项目还有老代码在用 double 算金额建议先盘点出所有影响金额的路径排上重构计划哪怕一次只替换一个接口也比一直带雷上线要强。我个人的体会是BigDecimal 本身不复杂复杂的是它和 double、String、equals 这些老朋友之间的边界。只要能守住“入口用字符串、出口用 toPlainString、比较用 compareTo、除法必带精度”这几条底线你在金额计算上出事故的概率会大幅下降。最后再分享一个小技巧建一个 BigDecimal 工具类的单测把上面那些坑点全写成断言以后谁把代码改坏了跑一次测试当场就能抓住。