
如果你维护过Java后端里跟钱打交道的服务大概率见过这么一条报警java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result。我印象很深的一次是分账系统上线后第一周订单量刚起来就有人在群里喊金额算错了。排查到最后监控指向的是一行再普通不过的代码——BigDecimal.divide(BigDecimal)而触发原因只是这一天的分账总金额除以渠道数之后出现了一个除不尽的无限循环小数。这篇文章就把这件事彻底讲透这个异常到底怎么产生的、BigDecimal为什么会这样设计、有哪些解决方案、以及真正落到生产环境时scale和舍入模式该怎么定、代码该怎么封装。适合所有用过BigDecimal但没深入研究过除法精度机制的Java开发者尤其是电商、支付、财务系统里天天跟金额、费率、分摊计算打交道的人。1. 现场还原一个除不尽引发的线上报警1.1 异常第一次出现时的代码与堆栈当时我们有一段分账逻辑大致长这样BigDecimal totalAmount new BigDecimal(5710.00); int channelCount 3; BigDecimal eachShare totalAmount.divide(new BigDecimal(channelCount));线上跑起来之后某一天的总金额刚好是5710元要平均分给3个渠道执行到divide那一行直接抛出了异常。完整的堆栈信息是这样的java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result at java.math.BigDecimal.divide(BigDecimal.java:1690) at com.example.SplitService.split(SplitService.java:45)当时团队里不少人的第一反应是除0了或者金额溢出了其实都不是。5710除以3等于1903.333...这是一个无限循环小数。BigDecimal不愿意把这个无限小数截断成一个近似值于是选择了直接抛异常。这类异常还有一个特点它不依赖并发、不依赖资源、不依赖特殊环境纯粹由数据长什么样决定。同一个服务跑了一年可能都没事但只要某天某个金额除以3、除以7、除以9就瞬间炸掉。1.2 为什么这类问题常常要等到上线才暴露复盘的时候我们发现这个问题在测试环境几乎没有出现的概率。为什么因为我们写测试用例时习惯用好看的数字比如6000除以3等于2000或者10除以2等于5。这些数据的商要么是整数要么是有限小数BigDecimal都能精确表示自然也就不会触发异常。但线上数据不一样。真实订单的金额由商品价格、优惠券、满减、运费、支付渠道手续费共同决定经常出现像5710.00、3.33、98.77这种金额一旦除以3、6、7、9、11、13这些因子商就很容易变成无限循环小数。这类问题的本质是数据驱动型Bug不是逻辑写错而是输入数据落在了一个没有覆盖到的边界上。不跑一批真实业务形态的数据很难提前发现。能做的就是让每个除法调用点都具备明确、可预测的精度处理策略异常永远不应该靠运气避免。2. 根因拆解BigDecimal的精确在除法场景下的另一面2.1 BigDecimal的数据结构和无限小数的本质要理解这个异常先要搞清楚BigDecimal内部是怎么表示数字的。它其实由两部分组成未标度值unscaled value一个任意精度的整数内部用BigInteger存储。标度scale表示小数点右移的位置也就是小数位数。举例来说123.45在BigDecimal内部实际是未标度值12345scale为2。也就是说BigDecimal可以精确表示任何一个有限位数的十进制小数因为它本质是整数乘以10的负n次方。问题出在除法上。两个有限位数的十进制数相除商的十进制展开未必是有限位数的。最简单的例子就是1除以3BigDecimal one new BigDecimal(1); BigDecimal three new BigDecimal(3); // 1 / 3 0.333333... 无限循环永远写不完十进制下0.333...的小数位数是无穷的因此无论BigDecimal内部用多少位的整数去存都无法给出一个完全精确的结果。它不像0.5、0.25、0.125这种分母只含2和5因子的有限小数可以写到底。这就是异常信息的字面含义Non-terminating decimal expansion意思是商的十进制展开不会终止。no exact representable decimal result意思是不存在一个精确可表示的十进制结果。2.2 默认不给你舍入这个设计其实是有意为之很多新手会问既然除不尽那你自动四舍五入不就行了Java为什么非要抛异常这个设计是故意的。BigDecimal从诞生起就明确了自己的定位为需要精确十进制运算的场景服务典型就是金融计算。金融场景里一个静默丢失精度的结果比抛异常危险得多。如果divide偷偷把0.333...截断成0.33调用方根本不知道精度丢了账目对不上时查起来极其痛苦。所以JDK选择了一种更严格的做法如果你要的是一个无限不循环的精确结果我就不干了把决定权交还给调用方要求你显式声明我要保留几位小数、用什么方式舍入。这就像三个人分100块钱如果不说清楚零头怎么处理谁都不敢拍板平分必须先定规则再分钱。2.3 和double的对比一个忍气吞声一个当场翻脸对比一下double的表现double d 10.0 / 3.0; System.out.println(d); // 输出 3.3333333333333335double不会抛异常它会默默返回一个最接近的二进制浮点数。这个结果在大多数日常计算里够用但在金融场景里是灾难二进制浮点数连0.1这种十进制小数都无法精确表示更别说算分账、算利息了。BigDecimal选择的是另一条路宁可让你程序崩溃也不给你一个有损结果。虽然第一次遇到时会觉得它轴但做久了金额相关系统就会认同这种选择。异常是保护机制不是缺陷。3. 解决方案主线用scale和RoundingMode给除法划定边界3.1 最稳重的写法三参数divide解决Non-terminating decimal expansion最标准、最常用的方式是调用BigDecimal提供的三参数divide重载BigDecimal dividend new BigDecimal(5710.00); BigDecimal divisor new BigDecimal(3); BigDecimal result dividend.divide(divisor, 2, RoundingMode.HALF_UP); System.out.println(result); // 1903.33三个参数分别是第二个参数scale计算结果保留几位小数。这里的2表示保留两位小数对应金额的分。第三个参数roundingMode舍入模式。HALF_UP就是日常说的四舍五入。这样写之后5710除以3的结果就是1903.33。虽然丢掉了一点点精度但业务上完全可接受分账精确到分剩余那不到一分钱本来就是需要规则去处理的零头。写三参数divide的时候有个细节值得注意它是被除数.divide(除数, scale, roundingMode)不是除数.divide(被除数, ...)第一个参数是除数而不是被除数。这个顺序写反是新手最容易犯的错误不但结果完全不对编译还不会报错。3.2 MathContext也能用但有效位数的语义容易踩坑除了三参数divide还有一类方法是传入MathContextBigDecimal result dividend.divide(divisor, new MathContext(4, RoundingMode.HALF_UP));MathContext由两个关键属性组成precision精度有效数字位数和roundingMode舍入模式。注意这里的precision是有效数字的个数不是小数位数。这两个概念差别很大。举个例子BigDecimal dividend new BigDecimal(5710.00); BigDecimal divisor new BigDecimal(3); BigDecimal result dividend.divide(divisor, new MathContext(4, RoundingMode.HALF_UP)); System.out.println(result); // 1903看到没有结果变成了1903而不是1903.33。因为5710这个数本身只有4位有效数字指定精度为4之后结果最多也只能有4位有效数字小数点后面的内容全被吞掉了。业务金额计算里我一般不推荐用MathContext替代三参数divide。金额计算的语义是保留到小数点后几位用scale表达更直接不容易产生误解。MathContext更适合科学计算、工程计算这种关心有效数字位数的场景。3.3 整除检测与商余分离另一个思路如果业务场景不只是分钱还需要知道谁多拿谁少拿那可以用divideToIntegralValue和remainder这对组合BigDecimal divisor new BigDecimal(3); BigDecimal quotient dividend.divideToIntegralValue(divisor); // 5710 / 3 1903 BigDecimal remainder dividend.remainder(divisor); // 5710 % 3 1这个方案的核心是先算整除的商整数部分再算余数。余数可以单独处理——比如分账场景里给某个渠道多分1分钱保证总账平掉。这种做法不直接解决除不尽的问题而是绕开了它不追求精确的无限循环商只取整数结果和余数。对于按人头均分后做零头调整这类需求用这套组合反而更干净。4. 舍入模式选型八种RoundingMode怎么选4.1 一张表看懂八种模式RoundingMode是java.math包下的枚举一共8个值。我整理了一个表格以6.5保留到整数位为例展示它们的差异模式6.5保留到整数位-6.5保留到整数位含义典型场景UP7-7远离零方向舍入反向取整较少直接使用DOWN6-6向零方向舍入直接截断小数部分CEILING7-6向正无穷方向舍入手续费上限等FLOOR6-7向负无穷方向舍入保底金额等HALF_UP7-7四舍五入0.5进位最常用的金额舍入HALF_DOWN6-6五舍六入0.5才进位少见偶尔用于分组计费HALF_EVEN6-6银行家舍入0.5时取偶数统计分析、国际结算UNNECESSARY抛异常抛异常要求精确可除否则抛异常断言场景你可以看到UP和DOWN是一组一个远离零、一个靠近零CEILING和FLOOR是一组一个向正无穷、一个向负无穷它们跟正负号有关容易混淆HALF_UP、HALF_DOWN、HALF_EVEN是一组区别在于恰好等于0.5这种中间值如何处理。拿HALF_EVEN举例它也叫银行家舍入规则是当被舍去的部分恰好是0.5时看保留位的数字如果是偶数就进位到偶数如果是奇数就保留为偶数。比如6.5保留到整数位保留位是6偶数所以不进位结果是67.5保留到整数位保留位是7奇数进位变成8。这种模式在统计大量数据时可以避免系统性偏差。UNNECESSARY很有意思它要求结果必须是精确可除的否则直接抛异常。这相当于一个断言——如果你调用divide时用了这个模式就相当于告诉JDK我确信能整除你帮我验证一下。在测试代码里偶尔会用到。4.2 财务和统计场景的偏好在财务相关系统里HALF_UP应该是最常见的默认选项。原因很简单多数业务合同和财务制度里写的是四舍五入而不是四舍六入五取偶或者银行家舍入。代码里的舍入规则跟合同条款对齐跟对账口径对齐比理论最优更重要。HALF_EVEN则更受统计和科学计算场景的欢迎。它在大量样本的均值、占比计算中能避免连续进位带来的系统偏差让误差的期望趋近于零。但如果你做的是电商订单金额、退款金额这类跟用户钱包直接相关的计算我不建议盲目追求更公平而选择HALF_EVEN——因为你的上下游系统如果都用HALF_UP你这里一个公平舍入反而会造成1分钱级别的对账差异。CEILING和FLOOR在费率、手续费、保底金额这类场景很有用。比如平台规定单笔手续费最低0.1元那不足0.1元的都按0.1元收用CEILING往上涨反过来算用户最低可得金额时用FLOOR往下降避免给多。4.3 同一个工程里舍入模式必须统一舍入模式选型最大的坑不是选错而是不统一。一个系统里如果订单结算用HALF_UP优惠券分摊用HALF_EVEN退款计算又用了FLOOR同一个金额经过不同的链路计算最后对账一定对不上。我的建议是在工程里把舍入模式收敛成少数几个常量通过工具类统一调用不要在每一处业务代码里直接裸写HALF_UP或HALF_EVEN。这样后续要调整舍入规则时只改一个地方就行。5. 落地经验scale怎么定、工具类怎么封装、还有哪些深坑5.1 scale的确定逻辑scale不是随便定的它应该跟业务的最小货币单位强相关。我常用的经验是订单金额、支付金额、退款金额scale定为2对应分。汇率scale定为6到8因为汇率计算通常会用到更多小数位。费率、利率、百分比scale定为4到6如0.0035这种。中间计算结果如果最终结果需要保留2位中间步骤建议保留4位也就是最终精度加2。这样做是为了避免多次舍入造成误差累积。每一步都舍入到2位经过五六次运算之后误差可能放大到几分钱。一个很容易被忽略的点是三参数divide只对这笔除法的结果生效如果后续还要继续乘、加、减中间结果的scale不一定是最终想要的。所以更稳妥的做法是在全链路保留更高精度到最后落库前统一舍入一次而不是每一步都急着舍入。5.2 封装一个团队统一的除法工具类既然每个除法调用点都要显式指定scale和roundingMode那就应该把所有调用收敛到一个工具类里避免团队里有些人写了、有些人漏了。我推荐封装成这样public final class BizMath { /** 金额计算默认精度分 */ public static final int MONEY_SCALE 2; /** 金额计算默认舍入模式四舍五入 */ public static final RoundingMode MONEY_ROUND RoundingMode.HALF_UP; private BizMath() { } public static BigDecimal divide(BigDecimal dividend, BigDecimal divisor) { return divide(dividend, divisor, MONEY_SCALE, MONEY_ROUND); } public static BigDecimal divide(BigDecimal dividend, BigDecimal divisor, int scale, RoundingMode roundingMode) { if (dividend null || divisor null) { throw new IllegalArgumentException(BigDecimal参数不能为null); } if (divisor.signum() 0) { throw new ArithmeticException(除法除数为0); } return dividend.divide(divisor, scale, roundingMode); } }这个工具类把几个关键点都处理了默认情况下所有除法自动应用MONEY_SCALE和MONEY_ROUND从根上避免Non-terminating decimal expansion。对null和除数为0做了前置检查。divisor.signum() 0比divisor.equals(BigDecimal.ZERO)更可靠因为signum()不管scale是0还是2只要数值是0都会正确识别。保留了三参数重载特殊场景可以自行传入scale和roundingMode。我在团队里立过一条规矩禁止在业务代码里裸调divide(BigDecimal)所有的除法必须走BizMath.divide。这条规矩执行起来不难因为工具类提供了和原生API几乎一样的调用方式业务代码几乎不需要额外心智负担。配合代码扫描规则很容易自查。5.3 同链条上的常见坑除了除法本身BigDecimal周边还有几个坑和这个异常经常一同出现顺手也提一下第一个是new BigDecimal(double)的问题。很多人不知道new BigDecimal(0.1)得到的不是精确的0.1而是一个非常长的二进制近似值打印出来是0.1000000000000000055511151231257827021181583404541015625。金额计算里永远要用字符串构造new BigDecimal(0.1)。第二个是equals和compareTo的差异。BigDecimal的equals会同时比较数值和scale所以new BigDecimal(2.0).equals(new BigDecimal(2.00))返回false。断言金额相等时如果用了equals很容易因为scale不同出现莫名其妙的失败。正确的做法是用compareToresult.compareTo(expected) 0。第三个是和JSON序列化的配合。很多JSON库默认会把BigDecimal序列化成数字反序列化时如果精度控制不好可能把原本的1903.33变成1903.329999999...之类的值。稳妥的做法是在JSON序列化配置里对BigDecimal字段统一做ToString处理或者用字符串类型传递金额字段。第四个是性能。BigDecimal比double慢得多但在正常业务量下这个差距完全不是瓶颈。真正的性能风险是写出类似在for循环里反复new BigDecimal(String)做大量计算的代码。真要追求性能可以先预编译常量或者考虑改用long表示分在特定场景下做运算但这类优化只应在明确瓶颈之后做。5.4 单元测试清单验证BigDecimal除法解决方案是否正确单测应该至少覆盖这几类场景Test void testDivideNonTerminating() { BigDecimal dividend new BigDecimal(5710.00); BigDecimal divisor new BigDecimal(3); BigDecimal result dividend.divide(divisor, 2, RoundingMode.HALF_UP); // 注意用 compareTo 而不是 equals Assertions.assertEquals(0, result.compareTo(new BigDecimal(1903.33))); } Test void testDivideExact() { BigDecimal result new BigDecimal(10.00).divide(new BigDecimal(4), 2, RoundingMode.HALF_UP); Assertions.assertEquals(0, result.compareTo(new BigDecimal(2.50))); } Test void testHalfUpBoundary() { // 2.005 保留两位小数HALF_UP 应得到 2.01 BigDecimal result new BigDecimal(2.005).setScale(2, RoundingMode.HALF_UP); Assertions.assertEquals(0, result.compareTo(new BigDecimal(2.01))); } Test void testDivideByZero() { Assertions.assertThrows(ArithmeticException.class, () - { new BigDecimal(1).divide(BigDecimal.ZERO, 2, RoundingMode.HALF_UP); }); }测试用例里我特别建议加一条任意金额除以3、除以7的用例。这种数字最能暴露精度处理不到位的问题。我后来养成了一个习惯任何金额计算代码写完之后先跑一遍金额 / 3、金额 / 7的用例能过再谈上线。再说回开头那次分账事故。修复方式其实很简单就是给divide加上scale和RoundingMode再把所有除法调用收敛到工具类里。但真正有价值的是当时定下的那条原则在金钱相关代码里宁可让异常早点爆出来也不要让它憋着给你一个有损结果。BigDecimal的固执逼得每个调用方都必须思考精度规则这正是它比double更值得信赖的原因。后来我再遇到Non-terminating decimal expansion第一反应反而不是谁写错了而是这个业务到底有没有定义清楚金额的零头归谁。把这个问题想清楚了代码怎么写都不会太差。