有读者问过一个问题Java 中0.1 0.2为什么不等于0.3也有面试官会在候选人简历上看到“熟悉 BigDecimal”之后顺带追问一句浮点误差到底是怎么产生的BigDecimal 又是通过什么原理解决的这个问题包含了三个层级计算机组成原理中的浮点数表示、Java 语言中的浮点运算机制、以及BigDecimal的内部设计。每一层都能展开成不少内容也确实是后端开发中容易踩坑的地方。本文就围绕这条主线把整个链路完整梳理一遍。在业务系统中涉及金额、利率、费率、百分比等字段时浮点数误差会造成实打实的问题。无论是电商订单金额、账户余额还是财务报表中的汇总数据直接用double/float计算都可能在边界场景中产生一分钱级别的差异。虽然差异看似很小但在对账系统中会直接导致账不平严重时还影响线上资金安全。下面分步骤拆解误差产生的原因、BigDecimal的解决思路以及日常开发中最稳妥的用法。1. 浮点数误差的本质1.1 计算机如何表示小数计算机底层是二进制存储结构所有数据最终都会转换为 0 和 1 的组合。整数的二进制转换非常直观比如十进制整数 10 对应二进制1010。但小数的表示方式完全不同。以十进制小数 0.625 为例它的二进制转换过程是0.625 × 2 1.25 → 取整数部分 1剩余 0.25 0.25 × 2 0.5 → 取整数部分 0剩余 0.5 0.5 × 2 1.0 → 取整数部分 1剩余 0.0所以 0.625 的二进制是0.101刚好可以精确表示。但换一个数字比如 0.1情况就不同了0.1 × 2 0.2 → 0 0.2 × 2 0.4 → 0 0.4 × 2 0.8 → 0 0.8 × 2 1.6 → 1剩余 0.6 0.6 × 2 1.2 → 1剩余 0.2 0.2 × 2 0.4 → 0 ...可以看到这个过程会无限循环下去。0.1 的二进制表示是无限循环小数无法在有限的存储空间里被精确表达。就像十进制中1/3只能用0.3333...无限循环表示一样二进制中也存在大量无法精确表达的小数。1.2 IEEE 754 标准与存储精度限制Java 中的float和double遵循 IEEE 754 浮点数标准。这两个类型在内存中的存储结构包含三部分类型符号位指数位尾数位总位数float1 bit8 bit23 bit32 bitdouble1 bit11 bit52 bit64 bit符号位用来表示正负指数位用来表示数值范围尾数位用来表示精度。因为尾数位是有限的像 0.1 这种无限循环二进制小数在存储时只能截断或舍入到一定位数所以实际存储的值已经是近似值了。当我们用double做加减法时近似值之间的运算结果自然也是近似值。这就是浮点误差的根源。2. 真实场景中的误差复现2.1 最基本的误差演示下面这段代码是最常见的误差演示public class FloatErrorDemo { public static void main(String[] args) { System.out.println(0.1 0.2); System.out.println(1.0 - 0.9); System.out.println(0.3 * 3); System.out.println(1.0 / 10.0); } }输出结果0.30000000000000004 0.09999999999999998 0.8999999999999999 0.1注意看最后一行1.0 / 10.0结果恰好是 0.1但前面的几个算式都出现了精度偏差。这是因为每个浮点数都只是一个近似表示近似值之间的运算结果可能会放大误差也可能“碰巧”得到一个正好可以表示的数。2.2 金额计算中的典型问题如果直接用浮点数做金额计算问题会更加明显public class MoneyErrorDemo { public static void main(String[] args) { double price 99.9; int count 3; double total price * count; System.out.println(总价 total); double orderAmount 0.1; double discount 0.05; double actualPay orderAmount - discount; System.out.println(实付金额 actualPay); double accountBalance 1000.0; for (int i 0; i 10; i) { accountBalance - 0.1; } System.out.println(账号余额 accountBalance); } }输出结果总价299.69999999999993 实付金额0.05 账号余额999.9999999999999第三个例子尤其危险从 1000 元中扣减 10 次 0.1 元正确结果应该是 999 元但浮点计算的结果是999.9999999999999。这种误差在金融系统中是不可接受的。2.3 为什么 float 比 double 更严重float的尾数位只有 23 位精度大约为 7 位有效数字double的尾数位有 52 位精度大约为 15 到 16 位有效数字。在同样的运算场景下float的误差会更大。public class FloatVsDoubleDemo { public static void main(String[] args) { float f 0.1f; double d 0.1; System.out.println(float: f); System.out.println(double: d); System.out.println(float float: (f f f f f f f f f f)); System.out.println(double double: (d d d d d d d d d d)); } }输出结果float: 0.1 double: 0.1 float float: 1.0000001 double double: 0.9999999999999999注意直接打印float和double时输出看起来都是 0.1这其实是 Java 在输出时做了格式化处理并不代表存储值是精确的 0.1。一旦参与运算误差就会暴露出来。3. Java 中浮点运算的隐藏细节3.1 二进制浮点数的常用转换方式要把一个十进制小数转换成二进制可以按“乘 2 取整顺序排列”的方法进行。下面用 Java 代码演示小数部分的转换过程public class BinaryConvertDemo { public static void main(String[] args) { double decimal 0.1; StringBuilder sb new StringBuilder(0.); double temp decimal; for (int i 0; i 30; i) { temp * 2; if (temp 1.0) { sb.append(1); temp - 1.0; } else { sb.append(0); } } System.out.println(decimal 的二进制近似表示: sb.toString()); System.out.println(decimal 的 double 实际值: new BigDecimal(decimal)); } }输出结果0.1 的二进制近似表示: 0.000110011001100110011001100 0.1 的 double 实际值: 0.1000000000000000055511151231257827021181583404541015625第二行输出可以直接看到0.1在double中存储的真实值它是一个非常接近 0.1 但又不完全等于 0.1 的二进制近似数。3.2 为什么BigDecimal.valueOf(0.1)输出是精确的有人会问既然0.1的 double 表示本身就不精确那为什么BigDecimal.valueOf(0.1).toString()输出的却是0.1原因是BigDecimal.valueOf(double)内部调用的是Double.toString(double)方法该方法会按照double的十进制最短表示规则进行转换。Double.toString(0.1)返回的字符串就是0.1BigDecimal拿到这个字符串之后再按照十进制字符串解析。此时BigDecimal表示的是“十进制下的 0.1”而不是 double 底层存储的那个近似二进制数。这也就引出了下面的关键点BigDecimal并不是在“修正” double 的误差它只是完全不使用二进制浮点数存储而是用十进制的大整数 小数位标记来精确表达一个十进制数。4. BigDecimal 的解决原理与核心 API4.1 BigDecimal 内部结构BigDecimal在 Java 中的设计核心是用一个BigInteger任意精度整数来保存数值的未缩放值再使用一个int类型的scale来表示小数位数。例如BigDecimal bd new BigDecimal(123.45);在内部结构上未缩放值unscaledValue是12345scale是2表示小数点向左移动两位即123.45。这种设计的意义在于十进制小数的每一位都可以被精确存储不会出现二进制近似问题。BigDecimal的所有运算都是基于十进制整数或十进制数组进行的所以可以看到确定性的结果。4.2 BigDecimal 的创建方式对比创建BigDecimal时最容易犯的错误是使用new BigDecimal(double)。请看对比public class BigDecimalCreateDemo { public static void main(String[] args) { // 错误方式直接传入 double BigDecimal fromDouble new BigDecimal(0.1); System.out.println(new BigDecimal(0.1) fromDouble); // 推荐方式传入字符串 BigDecimal fromString new BigDecimal(0.1); System.out.println(new BigDecimal(\0.1\) fromString); // 推荐方式使用 valueOf BigDecimal valueOf BigDecimal.valueOf(0.1); System.out.println(BigDecimal.valueOf(0.1) valueOf); } }输出结果new BigDecimal(0.1)0.1000000000000000055511151231257827021181583404541015625 new BigDecimal(0.1)0.1 BigDecimal.valueOf(0.1)0.1new BigDecimal(0.1)会把double的二进制精确值完整地转换为BigDecimal所以能看到一长串数字这实际上如实反映了double存储的近似状态。这种方式在日常业务中基本不会使用。正确做法有两种new BigDecimal(0.1)直接以十进制字符串解析精确表达 0.1。BigDecimal.valueOf(0.1)先通过Double.toString得到十进制字符串再解析也是精确的。4.3 BigDecimal 的四则运算BigDecimal提供了加减乘除方法分别对应add、subtract、multiply、divide。下面给出完整示例import java.math.BigDecimal; public class BigDecimalArithmeticDemo { public static void main(String[] args) { BigDecimal num1 new BigDecimal(10.25); BigDecimal num2 new BigDecimal(3.15); // 加法 BigDecimal sum num1.add(num2); System.out.println(加法 sum); // 减法 BigDecimal difference num1.subtract(num2); System.out.println(减法 difference); // 乘法 BigDecimal product num1.multiply(num2); System.out.println(乘法 product); // 除法必须指定精度和舍入模式 BigDecimal quotient num1.divide(num2, 4, java.math.RoundingMode.HALF_UP); System.out.println(除法保留4位小数 quotient); // 取余 BigDecimal remainder num1.remainder(num2); System.out.println(取余 remainder); } }输出结果加法13.40 减法7.10 乘法32.2875 除法保留4位小数3.2539 取余0.80注意add、subtract等方法不会修改原有对象而是返回新的BigDecimal对象。这个特性符合不可变对象设计模式但使用时如果忘记接收返回值结果会丢失。4.4 除法中的舍入模式除法在不能整除时会产生无限小数此时必须指定舍入模式否则会抛出ArithmeticException: Non-terminating decimal expansion。常见的舍入模式如下舍入模式含义示例4.56 保留 1 位小数RoundingMode.UP远离零方向舍入4.6RoundingMode.DOWN趋向零方向舍入4.5RoundingMode.CEILING向正无穷方向舍入4.6RoundingMode.FLOOR向负无穷方向舍入4.5RoundingMode.HALF_UP四舍五入五入4.6RoundingMode.HALF_DOWN四舍五入五不入4.6RoundingMode.HALF_EVEN银行家舍入五后非零入五后为零看奇偶4.6HALF_EVEN也被称为“银行家舍入法”目的是在大量计算中减少系统性偏差。不过国内很多业务系统明确要求“四舍五入”所以最常见的还是HALF_UP。具体使用哪个模式需要结合业务约定。比如财务报表、税费计算通常会有明确的会计准则要求电商平台的分账、优惠分摊则会根据自身算法确定舍入规则。5. BigDecimal 的比较与相等性5.1 equals 与 compareTo 的区别BigDecimal的equals方法不仅会比较数值大小还会比较scale小数位数。也就是说1.0和1.00在equals看来是不相等的但在compareTo看来是相等的。import java.math.BigDecimal; public class BigDecimalCompareDemo { public static void main(String[] args) { BigDecimal a new BigDecimal(1.0); BigDecimal b new BigDecimal(1.00); System.out.println(a.equals(b) a.equals(b)); System.out.println(a.compareTo(b) a.compareTo(b)); System.out.println(a.stripTrailingZeros().equals(b.stripTrailingZeros()) a.stripTrailingZeros().equals(b.stripTrailingZeros())); } }输出结果a.equals(b)false a.compareTo(b)0 a.stripTrailingZeros().equals(b.stripTrailingZeros())true这个细节在业务判断中非常重要。比如判断订单金额是否为 0如果使用equals可能会因为精度位数不同而判断失败使用compareTo就没有这个问题。推荐在生产代码中统一使用compareTo进行数值比较不要用equals。5.2 判断是否为 0正确写法public class ZeroCheckDemo { public static void main(String[] args) { BigDecimal zero BigDecimal.ZERO; BigDecimal amount1 new BigDecimal(0); BigDecimal amount2 new BigDecimal(0.00); System.out.println(amount1.compareTo(zero) 0 (amount1.compareTo(zero) 0)); System.out.println(amount2.compareTo(zero) 0 (amount2.compareTo(zero) 0)); } }输出结果amount1.compareTo(zero) 0true amount2.compareTo(zero) 0true5.3 最大最小值BigDecimal还提供了max和min方法底层用的是compareToBigDecimal a new BigDecimal(9.99); BigDecimal b new BigDecimal(10.01); System.out.println(max a.max(b)); System.out.println(min a.min(b));输出max10.01 min9.996. 完整实战金额计算工具类在实际项目中反复手写BigDecimal运算容易出错尤其是舍入模式容易写不一致。下面封装一个简单的重试工具类包含了创建、运算、比较等常用方法。注意这个工具类只做演示实际项目中可以按团队规范继续扩展。import java.math.BigDecimal; import java.math.RoundingMode; public class MoneyUtils { private static final int DEFAULT_SCALE 2; private static final RoundingMode DEFAULT_ROUNDING_MODE RoundingMode.HALF_UP; private MoneyUtils() { } /** * 从 String 创建 BigDecimal */ public static BigDecimal of(String value) { return new BigDecimal(value); } /** * 从 double 创建 BigDecimal优先推荐 valueOf */ public static BigDecimal of(double value) { return BigDecimal.valueOf(value); } /** * 加法 */ public static BigDecimal add(BigDecimal a, BigDecimal b) { return a.add(b); } /** * 减法 */ public static BigDecimal subtract(BigDecimal a, BigDecimal b) { return a.subtract(b); } /** * 乘法结果保留 2 位小数 */ public static BigDecimal multiply(BigDecimal a, BigDecimal b) { return a.multiply(b).setScale(DEFAULT_SCALE, DEFAULT_ROUNDING_MODE); } /** * 除法结果保留 2 位小数 */ public static BigDecimal divide(BigDecimal a, BigDecimal b) { return a.divide(b, DEFAULT_SCALE, DEFAULT_ROUNDING_MODE); } /** * 设置小数位数 */ public static BigDecimal scale(BigDecimal value, int scale, RoundingMode roundingMode) { return value.setScale(scale, roundingMode); } /** * 是否相等使用 compareTo 忽略小数位差异 */ public static boolean equals(BigDecimal a, BigDecimal b) { if (a null || b null) { return false; } return a.compareTo(b) 0; } /** * 金额校验不能为 null且必须大于等于 0 */ public static boolean isValidAmount(BigDecimal amount) { return amount ! null amount.compareTo(BigDecimal.ZERO) 0; } }调用示例package com.example.demo; import java.math.BigDecimal; import java.math.RoundingMode; public class MoneyUtilsDemo { public static void main(String[] args) { BigDecimal price MoneyUtils.of(29.90); BigDecimal count MoneyUtils.of(3); BigDecimal discount MoneyUtils.of(5.00); BigDecimal total MoneyUtils.multiply(price, count); BigDecimal finalAmount MoneyUtils.subtract(total, discount); System.out.println(原价总额 total); System.out.println(优惠后金额 finalAmount); System.out.println(是否合法金额 MoneyUtils.isValidAmount(finalAmount)); BigDecimal average MoneyUtils.divide(finalAmount, MoneyUtils.of(3)); System.out.println(平均分摊金额 average); BigDecimal rounded MoneyUtils.scale(new BigDecimal(12.345), 2, RoundingMode.HALF_UP); System.out.println(四舍五入结果 rounded); } }输出结果原价总额89.70 优惠后金额84.70 是否合法金额true 平均分摊金额28.23 四舍五入结果12.35需要注意multiply在工具类中直接做了setScale处理所以返回结果已经是 2 位小数。但真实的业务场景中乘法过程可能希望保存更高精度最后再统一做舍入。例如计算含税单价、分摊金额时先保留 4 到 6 位小数最终汇总后再保留 2 位小数这样误差更小。7. 常见问题与排查思路7.1 问题清单问题现象常见原因解决思路页面显示金额带一长串小数使用 double 直接计算改用 BigDecimal数据库使用 DECIMAL金额比较一直不相等使用 equals 比较scale 不一致改用 compareTo对账系统账不平浮点数运算误差积累统一使用 BigDecimal 并提供统一舍入策略除法报错Non-terminating decimal expansion没有指定舍入模式调用 divide 时传入 scale 和 RoundingModenew BigDecimal(0.1) 出现长串数字使用了 double 构造方法改用 BigDecimal.valueOf 或字符串构造JSON 序列化金额后多出 0.00 后缀序列化策略未配置使用 Jackson 的 JsonFormat 或自定义序列化器前端 JS 计算金额出错前后端精度不一致后端返回字符串前端使用 decimal.js 或统一以“分”为单位传递7.2 典型踩坑复盘某个订单系统在计算退款金额时遇到过一个线上问题。现象用户申请部分退款后接口返回的退款金额为10.000000000000002元前端展示异常且数据库存储出现多余小数位。排查过程检查接口代码发现使用double计算退款比例然后直接返回结果。使用double的运算结果作为BigDecimal.valueOf的参数还是避免不了中间误差。最终修改为退款比例、订单金额全部使用BigDecimal并且统一保留小数位数在最后一步换算成“分”进行整型传输。解决方案的核心点所有金额字段统一使用BigDecimal。所有金额计算都显式传入scale和RoundingMode。对外接口的金额统一以“分”为单位使用Long或Integer传输避免精度丢失。前端展示时再将“分”转换为“元”。8. 最佳实践与进阶建议8.1 金额字段的数据类型选择在 Java 代码中金额相关字段统一使用BigDecimal不要使用double、float或Long分单位值之外的替代方案Long表示分是可行方案但需要团队统一约定。在数据库中金额字段建议使用DECIMAL类型。例如CREATE TABLE t_order ( order_id BIGINT PRIMARY KEY, order_amount DECIMAL(18, 2) NOT NULL, discount_amount DECIMAL(18, 2) NOT NULL DEFAULT 0.00, pay_amount DECIMAL(18, 2) NOT NULL, create_time DATETIME NOT NULL );DECIMAL(18, 2)表示一共 18 位数字其中小数占 2 位。这样可以存储非常大的金额同时精确到分。8.2 Java 后端与前端交互很多精度问题不止出现在 Java 层还会出现在前后端交互过程中。JSON 序列化时如果直接把BigDecimal序列化为数字JavaScript 读取后可能丢失精度。推荐做法在 DTO 层把金额字段序列化为字符串。或者统一以“分”为单位使用整数类型传输。示例配置Jacksonimport com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; public class OrderDTO { private Long orderId; JsonSerialize(using ToStringSerializer.class) private BigDecimal orderAmount; // getter / setter 省略 }通过JsonSerialize(using ToStringSerializer.class)BigDecimal会以字符串形式输出到 JSON 中避免前端精度丢失。8.3 不要在循环中频繁创建 BigDecimal 常量BigDecimal.ZERO、BigDecimal.ONE、BigDecimal.TEN是官方提供的常量可以直接复用。对于0.01、0.05这类高频常量可以考虑定义为static final字段private static final BigDecimal HUNDRED new BigDecimal(100); private static final BigDecimal PERCENT_50 new BigDecimal(0.50);8.4 关于性能BigDecimal的性能比double慢尤其在大量数值计算的场景下。因此在科学计算、三维图形、统计分析等领域仍然主要使用double。但在金融、计费、订单等领域精度比性能更重要。取舍原则是涉及钱的字段和计算必须用BigDecimal其他场景按需选择。如果确实需要对大量金额数据进行性能优化可以尽量使用Long/Integer表示“分”只在展示和外部交互时转换为BigDecimal。分页批量处理避免一次性加载全量数据做计算。把复杂计算下推到数据库如 SUM 聚合但确保数据库字段为 DECIMAL 类型。8.5 统一舍入策略团队内部一定要统一约定舍入模式。比如交易金额计算HALF_UP四舍五入。税费计算HALF_UP并按国家税务规则调整。统计报表可以按业务要求选择HALF_UP或HALF_EVEN。最好在项目基础工具类中统一封装舍入方法避免每个业务人员各自调用不同的舍入模式导致最终金额不一致。8.6 测试用例在涉及金额的代码中建议编写专门的精度测试用例。例如import org.junit.jupiter.api.Test; import java.math.BigDecimal; import static org.junit.jupiter.api.Assertions.assertEquals; public class MoneyUtilsTest { Test void testAdd() { BigDecimal result MoneyUtils.add(new BigDecimal(0.1), new BigDecimal(0.2)); assertEquals(0, result.compareTo(new BigDecimal(0.3))); } Test void testSubtract() { BigDecimal result MoneyUtils.subtract(new BigDecimal(1.00), new BigDecimal(0.90)); assertEquals(0, result.compareTo(new BigDecimal(0.10))); } Test void testDivide() { BigDecimal result MoneyUtils.divide(new BigDecimal(10), new BigDecimal(3)); assertEquals(0, result.compareTo(new BigDecimal(3.33))); } }这些测试可以防止团队在维护过程中重新引入浮点计算错误。9. 面试中的高频追问这道题如果出现在面试中面试官通常还会继续追问以下问题9.1 BigDecimal 是线程安全的吗BigDecimal是不可变对象所有运算方法都返回新对象不修改原对象因此是线程安全的。多个线程同时读取同一个BigDecimal实例不会产生线程安全问题。9.2 为什么 BigDecimal 的 equals 方法不比较数值本身因为equals同时比较了值和精度。这在某些集合场景中会带来困扰比如使用HashSet或HashMap时1.0和1.00会被视为两个不同的键。所以在金额比较场景下优先使用compareTo。9.3 Long 表示分和 BigDecimal 表示元怎么选两种方案都常见数据库使用DECIMAL(18, 2)Java 使用BigDecimal更直观代码易读但需要处理精度和舍入。数据库使用BIGINT表示分Java 使用Long性能更好存储更紧凑但展示时需要进行转换。团队应选择一种标准并坚持使用不建议混用。9.4 浮点数误差在哪些场景下可以忽略如果只是普通展示比如温度、身高、体重、百分比误差在可接受范围内可以直接使用double。但一旦涉及金额、费率、库存数量、积分就必须使用精确计算方案。面试官更希望听到的是“我会先评估场景金额类一律 BigDecimal非金额类根据精度需求决定”。10. 总结回到最初的问题浮点误差产生原因是什么BigDecimal如何解决的一句话总结浮点数误差的根源是计算机使用二进制表示小数而很多十进制小数无法用有限二进制精确表示有限的存储位数进一步放大了偏差BigDecimal通过十进制字符串解析和任意精度整数存储从根本上绕开了二进制近似从而实现了十进制小数的精确计算。在实际开发中除了掌握BigDecimal的 API更重要的是建立一套统一的金额处理规范使用字符串创建、显式指定舍入模式、用compareTo比较、数据库选 DECIMAL、前后端交互避免精度丢失。这些细节串联起来才能从源头上防止浮点误差渗入业务系统。如果本文对你有帮助可以收藏备用。后续可以继续阅读 Java 源码中BigDecimal的实现细节、RoundingMode在财报中的实际应用或者 Jackson 自定义序列化器的完整写法。欢迎在评论区聊聊你在项目中遇到过的浮点精度坑。