ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Java浮点数精度问题详解:从0.1+0.2到BigDecimal工程实践

2026/8/27 5:59:02 拓冰建站 浏览量
Java浮点数精度问题详解:从0.1+0.2到BigDecimal工程实践 第一次在终端打出0.1 0.2结果却是0.30000000000000004时很多人第一反应是Java 是不是有 Bug。这不是 Java 的问题也不是 JDK 的问题而是从第一代支持浮点运算的计算机开始就存在的二进制表示限制。最近在 Java 面试题里这个问题被反复拿出来当过滤器因为它确实能考察一个人对底层表示、数值计算和工程取舍的综合理解。这道题表面是问浮点误差实际上有三个层次第一层是误差为什么产生第二层是BigDecimal 为什么能解决第三层是项目里真正落地时还有哪些坑。如果你只背住了结论碰到面试官追问new BigDecimal(0.1) 和 new BigDecimal(0.1) 有什么区别就会露馅。本文会带着代码把这三层全部走一遍读完你可以直接照着写一个能用于生产金额计算的工具类。1. 一个让新手项目翻车的小问题先看一个典型的电商结算场景商品单价 39.9 元数量是 3用double计算会得到119.69999999999999。如果这段代码出现在订单金额、退款金额、账户余额这些关键路径上最终落到数据库里的就是一分钱、几厘钱的偏差。单笔订单看起来无所谓但一天几十万笔订单累计下来对账就会对不上。再看另一个常见翻车点循环累加。写一个for循环从 0 开始连续加 100 次0.1你期待得到10.0实际得到的是9.99999999999998。这类问题在金额报表、库存扣减、统计汇总中非常隐蔽因为它不是直接报错而是悄悄算错。排查的时候你甚至不会怀疑到数据类型上只会觉得逻辑哪里少处理了一个边界条件。还有一个高频错误是用判断浮点数。很多新手会写if (price 0.1)结果这个条件永远为 false。只要数据经过了浮点运算精确相等几乎就是不可能事件。所以判断很明确只要还在用float或double直接做十进制小数的连续运算误差几乎必然出现只是早晚和大小的问题。今天要讲的 BigDecimal本质上是把数值计算从二进制近似域拉回到十进制定点域从而让业务层可以精确控制每一位小数。2. 浮点误差到底是怎么产生的2.1 十进制的有限小数不一定是二进制的有限小数计算机内部只能用 0 和 1 表示数字。整数部分还好二进制转换通常能精确完成但小数部分采用的是乘 2 取整规则一个十进制小数能不能被二进制精确表示完全看它能不能写成有限个 2 的负幂之和。举个例子0.5 在二进制里是0.10.25 是0.010.75 是0.11这些都能精确表示。但 0.1 呢它无法写成有限个1/2、1/4、1/8的有限组合只能写成无限循环小数。这里就是误差的根源一个看起来再普通不过的十进制小数在二进制世界里可能根本没有有限精确表示。2.2 手算一遍0.1 的二进制到底是什么把 0.1 转换成二进制用乘 2 取整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.4 * 2 0.8 整数位 0 0.8 * 2 1.6 整数位 1余 0.6 0.6 * 2 1.2 整数位 1余 0.2可以看到从第 4 步开始就一直循环二进制结果是0.0001100110011001100110011...循环节是0011。也就是说0.1 在二进制里是无限循环小数和十进制里 1/3 0.333... 是同一个性质。任何有限位的存储结构都装不下它只能截断或舍入。2.3 IEEE 754 存储精度的限制Java 的float和double都遵循 IEEE 754 标准类型总位数符号位指数位尾数位有效十进制位数float32 位1 位8 位23 位约 7 位double64 位1 位11 位52 位约 15 到 16 位尾数位就是用来存放小数精度的地方。0.1 的二进制是无限循环的但 double 只给尾数留了 52 位实际有效精度是 53 位因为还有一个隐含的前导 1所以必须舍入到最接近的二进制数。于是 double 里存的并不是真正的 0.1而是一个非常接近 0.1 的近似值。这个近似值换算回十进制大约是0.1000000000000000055511151231257827021181583404541015625这就是为什么new BigDecimal(0.1)会打印出一长串数的底层原因。后面讲到 BigDecimal 构造器时这个数字还会再次出现。2.4 表示误差如何变成运算误差理解到这里0.1 0.2 不等于 0.3 的真相就清楚了参与运算的本来就不是 0.1 和 0.2而是两个最接近它们的二进制近似值。近似值相加得到的结果还是近似值最终换算回十进制就变成了0.30000000000000004。这里要注意一个概念表示误差和运算误差是两层。表示误差在存储那一刻就产生了运算误差则是后续加减乘除过程中误差的传播和累积。单次运算的误差可能很小但经过循环累加、乘除、指数运算误差会被放大。这也是面试时能体现深度的关键区分。3. 用代码感受浮点误差3.1 最直接的演示新建一个文件FloatPrecisionDemo.java// 文件路径FloatPrecisionDemo.java public class FloatPrecisionDemo { public static void main(String[] args) { System.out.println(0.1 0.2 (0.1 0.2)); System.out.println(1.0 - 0.9 (1.0 - 0.9)); System.out.println(0.1 0.2 0.3 ? (0.1 0.2 0.3)); System.out.println(0.1f 0.2f 0.3f ? (0.1f 0.2f 0.3f)); System.out.println(0.3f 0.3d ? (0.3f 0.3d)); } }编译运行javac FloatPrecisionDemo.java java FloatPrecisionDemo输出0.1 0.2 0.30000000000000004 1.0 - 0.9 0.09999999999999998 0.1 0.2 0.3 ? false 0.1f 0.2f 0.3f ? false 0.3f 0.3d ? false注意最后一个例子0.3f是 float 类型0.3d是 double 类型两者在二进制下已经被舍入成了不同的近似值所以结果也是 false。这个小例子适合在面试时快速铺开先用现象说明浮点运算不可靠再解释 IEEE 754。3.2 误差潜伏的更隐蔽场景如果只是打印结果很多人会觉得误差无非是0.1 加 0.2 多出几个 9而已。真正的风险在循环累加和条件判断里// 文件路径FloatAccumulateDemo.java public class FloatAccumulateDemo { public static void main(String[] args) { double sum 0.0; for (int i 0; i 100; i) { sum 0.1; } System.out.println(累加 100 次 0.1 的结果 sum); System.out.println(是否等于 10.0 (sum 10.0)); } }输出累加 100 次 0.1 的结果9.99999999999998 是否等于 10.0false这就是误差累积后的真实影响。每一次加法都有微小的表示误差误差不会互相抵消而是持续累积。最终你得到的不是用户期待的整数而是一个心理上接近于整数但技术上不等于整数的数字。4. BigDecimal 的设计思路从二进制近似回到十进制定点BigDecimal 为什么能解决这个问题它没有用什么特殊魔法而是换了一种完全不同的存储和运算方式。先看它的核心组成一个 BigDecimal 无缩放整数BigInteger/intCompact 标度scale例如数值123.45BigDecimal 内部存的是整数12345和标度2含义是把这个整数的小数点向左移动 2 位。加法和减法时先统一两个数的标度再做整数运算乘法时整数相乘、标度相加除法时可以显式指定结果保留多少位小数以及舍入策略。这个设计的关键在于整个计算过程始终停留在十进制整数域不经过任何二进制转换。所以它不会因为 0.1 的二进制是无限循环而丢精度。它让计算机按照人类做十进制竖式运算的方式计算代价是速度变慢、代码变啰嗦。维度doubleBigDecimal小数表示二进制近似十进制定点0.1 的存储0.1000000000000000055...精确的 0.1运算速度快慢一个数量级精度控制固定尾数位通过 scale 和 RoundingMode 灵活控制适用场景科学计算、图形渲染、坐标计算金额、利率、统计、精确比较需要强调一点BigDecimal 解决的是十进制精确运算问题不是让所有数学运算都精确。比如1 ÷ 3在十进制里本身就是无限小数这时仍然必须指定舍入规则否则会抛异常。这一点放到第 6 章细讲。5. 构造 BigDecimal 的三种方式陷阱全在这里很多人在使用 BigDecimal 时已经避开了double运算却在构造器上栽了跟头。这是面试中最高频的追问点也是实际项目最隐蔽的坑。5.1 new BigDecimal(double) 为什么不对直接看例子// 文件路径BigDecimalBuilderDemo.java import java.math.BigDecimal; public class BigDecimalBuilderDemo { public static void main(String[] args) { BigDecimal fromDouble new BigDecimal(0.1); BigDecimal fromString new BigDecimal(0.1); BigDecimal fromValueOf BigDecimal.valueOf(0.1); System.out.println(new BigDecimal(0.1) fromDouble); System.out.println(new BigDecimal(\0.1\) fromString); System.out.println(BigDecimal.valueOf(0.1) fromValueOf); } }输出new BigDecimal(0.1) 0.1000000000000000055511151231257827021181583404541015625 new BigDecimal(0.1) 0.1 BigDecimal.valueOf(0.1) 0.1new BigDecimal(0.1)的结果那一长串就是前面提到的 double 实际存储的近似值。double本身已经是近似值了这个构造器做的事情只是把近似值完整展开成十进制字符串。你本来想精确表示 0.1传入的却是近似值自然得不到理想结果。new BigDecimal(0.1)则是直接解析字符串它在创建的瞬间就是精确的 0.1不涉及任何浮点转换所以结果正确。BigDecimal.valueOf(0.1)内部实际上通过Double.toString(0.1)拿到了能还原这个 double 的最短可读字符串然后按字符串方式构造所以结果同样是精确的 0.1。5.2 三种构造方式对比构造方式结果是否推荐new BigDecimal(0.1)0.1000000000000000055...禁止用于业务计算new BigDecimal(0.1)0.1推荐BigDecimal.valueOf(0.1)0.1推荐最能表达意图5.3 实际项目中的铁律业务代码里统一遵守两条规则优先使用BigDecimal.valueOf(...)因为大部分情况下你拿到的目标值可能来自前端传参、数据库读取或double变量直接valueOf能保证安全。如果目标是常量字符串直接用字符串构造器new BigDecimal(0.1)。永远不要写new BigDecimal(0.1)这样的代码代码评审见到一次就应该打回一次。从数据库读取DECIMAL类型时JDBC 驱动返回的通常已经是BigDecimal对象不需要再经过double中转如果遇到某些框架返回String也只用字符串构造器就能安全还原。6. BigDecimal 加减乘除完整实现与验证6.1 一个可复制的工具类下面这个工具类适合直接放进项目的基础设施模块统一金额计算的写法// 文件路径com/example/money/BigDecimalUtil.java import java.math.BigDecimal; import java.math.RoundingMode; public class BigDecimalUtil { private BigDecimalUtil() { // 工具类不允许实例化 } public static BigDecimal add(String v1, String v2) { return new BigDecimal(v1).add(new BigDecimal(v2)); } public static BigDecimal subtract(String v1, String v2) { return new BigDecimal(v1).subtract(new BigDecimal(v2)); } public static BigDecimal multiply(String v1, String v2) { return new BigDecimal(v1).multiply(new BigDecimal(v2)); } public static BigDecimal divide(String v1, String v2, int scale, RoundingMode mode) { return new BigDecimal(v1).divide(new BigDecimal(v2), scale, mode); } public static String toPlain(BigDecimal value) { if (value null) { return 0; } // 用 stripTrailingZeros toPlainString避免科学计数法输出 return value.stripTrailingZeros().toPlainString(); } }调用示例// 文件路径BigDecimalDemo.java import java.math.RoundingMode; public class BigDecimalDemo { public static void main(String[] args) { BigDecimal unitPrice BigDecimalUtil.add(39.9, 0); BigDecimal count new BigDecimal(3); BigDecimal total BigDecimalUtil.multiply(unitPrice.toString(), count.toString()); System.out.println(单价 unitPrice.toPlainString()); System.out.log(总价 total.toPlainString()); System.out.println(除以 3 保留 2 位 BigDecimalUtil.divide(total.toString(), 3, 2, RoundingMode.HALF_UP)); } }运行结果单价39.9 总价119.7 除以 3 保留 2 位39.90这里把单价、数量统一转成字符串再交给工具类就是为了绕开double构造器陷阱。真正在项目里数量如果是 int 类型直接new BigDecimal(count)对 int 是安全的因为整数不存在二进制小数误差。但为了统一风格我倾向于都走字符串。6.2 除法为什么必须指定 scale 和 RoundingModeBigDecimal 里最容易踩的坑是除法new BigDecimal(1).divide(new BigDecimal(3));这段代码会直接抛出ArithmeticException错误信息是Non-terminating decimal expansion; no exact representable decimal result.因为 1 ÷ 3 在十进制下是无限小数BigDecimal 不知道该保留多少位也不知道怎么舍入只能拒绝执行。正确的写法必须同时指定结果的小数位数和舍入模式new BigDecimal(1).divide(new BigDecimal(3), 10, RoundingMode.HALF_UP);常见舍入模式模式行为适用场景HALF_UP四舍五入5 进最常见的业务金额场景HALF_DOWN5 不进位部分统计口径HALF_EVEN银行家舍入5 看前一位金融利率、国际结算常见UP远离零方向舍入手续费向上取整DOWN向零方向舍入促销折扣向下取整面试时如果能说出HALF_EVEN 可以避免大量 0.5 舍入带来的系统性偏差会是一个很有分量的加分项因为多数人只知道四舍五入。6.3 运行验证方法写入代码后直接javac BigDecimalDemo.java java BigDecimalDemo如果是在 Maven 工程里把BigDecimalUtil.java放入src/main/java/com/example/money/目录然后在测试类里写断言即可。验证成功有一个硬性标准每次输出都与手算的十进制结果完全一致不允许出现 0.30000000000000004 这种尾数。7. BigDecimal 的比较、去零和哈希问题7.1 equals 与 compareTo 的区别BigDecimal 有equals和compareTo两套比较逻辑这又是一个经典陷阱。先看代码// 文件路径BigDecimalCompareDemo.java 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(equals 比较 a.equals(b)); System.out.println(compareTo 比较 a.compareTo(b)); System.out.println(hashCode 是否相同 (a.hashCode() b.hashCode())); } }输出equals 比较false compareTo 比较0 hashCode 是否相同false原因在于equals不仅比较数值还要比较 scale。1.0的 scale 是 11.00的 scale 是 2二者在 equals 语义下不相等。compareTo只比较数值大小所以结果 0 表示相等。这个差异带来的实际影响很直接把 BigDecimal 放进HashSet或作为HashMap的 key 时1.0和1.00会变成两个不同的 key取值时可能拿到 null。用TreeSet、TreeMap等基于compareTo的容器时它们会被视为同一个元素。所以项目里判断金额是否相等应统一使用compareTo不能直接用equals。阿里巴巴 Java 开发手册里也明确过BigDecimal 等值比较应使用compareTo()这一点在面试中经常作为是否读过规范的考察点。7.2 stripTrailingZeros 和科学计数法输出的坑很多同学在格式化金额时喜欢调用stripTrailingZeros()去掉末尾多余的 0这个思路没错但输出会踩坑BigDecimal value new BigDecimal(100.00); System.out.println(value.stripTrailingZeros()); System.out.println(value.stripTrailingZeros().toPlainString());在某些 JDK 版本中第一行输出可能是1E2这是因为去零后 BigDecimal 变成了以科学计数法表示的数字。toPlainString()才是适合展示给用户的普通十进制字符串100。所以工具类里的toPlain()方法没有直接用toString()而是先stripTrailingZeros()再toPlainString()两个调用缺一不可。这也是生产环境中很常见的线上 bug金额在日志里显示成1E2排查的人看半天不知道是多少钱。8. 项目落地数据库类型、金额设计与最佳实践8.1 数据库字段选型如果 Java 代码里用了 BigDecimal数据库字段却还是FLOAT或DOUBLE那么精确性在 ORM 层已经丢失了一半。金额、利率、费率、积分等对精度敏感的字段在关系型数据库里应当使用DECIMAL。建表示例CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL COMMENT 订单号, total_amount DECIMAL(19, 2) NOT NULL DEFAULT 0.00 COMMENT 订单总金额, refund_amount DECIMAL(19, 2) NOT NULL DEFAULT 0.00 COMMENT 已退款金额, created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_order_no (order_no) ) COMMENT 订单表;DECIMAL(19, 2)表示最多 19 位数字其中小数部分占 2 位整数部分 17 位。这个精度对绝大多数业务足够了。如果业务涉及更细的费率比如 0.0001 的精度可以设计成DECIMAL(20, 4)但不要让同一张表里金额字段的精度不一致否则后续计算还需要统一 scale。8.2 金额服务的最佳实践清单金额字段统一使用BigDecimal禁止double。构造时统一使用BigDecimal.valueOf(...)或字符串构造器禁止new BigDecimal(double)。除法必须显式传scale和RoundingMode。金额比较统一用compareTo不能使用equals。打印和返回前端时统一toPlainString()。数据库金额列使用DECIMAL不要用FLOAT/DOUBLE。对外接口如果传金额建议用字符串传输避免 JSON 反序列化时变成 double。高频循环里的累计计算要避免 BigDecimal可以先聚合整数 count或先确保次数可控再运算。重要的金额计算必须加单测并且用已知结果断言而不是只测不抛异常。涉及多币种时要额外考虑币种精度字段例如人民币 2 位、日元 0 位不能统一写死 scale。9. 常见问题排查表问题现象可能原因排查方式解决方案0.1 0.2 打印为 0.30000000000000004直接使用 double 运算查看代码运算链路是否用了基本类型改用 BigDecimal 运算new BigDecimal(0.1) 出现一长串小数使用了 double 参构造器搜索new BigDecimal(后接数字字面量改为BigDecimal.valueOf(0.1)或字符串构造除法抛 Non-terminating decimal expansiondivide 未指定 scale 和 RoundingMode查看报错堆栈定位除法调用点显式传入 scale 和 RoundingMode两个 BigDecimal equals 为 false 但值相同两个数 scale 不同打印两个数的 scale()使用 compareTo 比较金额字段在日志中显示为 1E2stripTrailingZeros 后调用了 toString检查格式化输出代码改用 toPlainString()数据库金额对账不平数据库字段用了 FLOAT/DOUBLE查看表结构字段类型迁移为 DECIMAL并处理存量数据10. 面试复盘怎么把这个题答出亮点这道题进入面试环节时理想的回答链路是五层渐进第一层说现象0.1 0.2不等于 0.3因为 0.1 和 0.2 在二进制下是无限循环小数存储时被舍入成近似值。第二层说原理float 是 32 位、double 是 64 位尾数分别只有 23 位和 52 位存不下无限小数必然存在表示误差运算时误差继续传播。第三层说方案BigDecimal 用无缩放整数 标度表示十进制数整个运算停留在十进制整数域避免二进制转换因此能精确控制结果。第四层说细节new BigDecimal(0.1)和new BigDecimal(0.1)结果不同因为前者接收的 double 参数本身就是近似值除法时必须指定舍入模式比较金额要用compareTo而不是equals。第五层说工程数据库字段选 DECIMAL对外接口金额用字符串金额计算要封装统一工具类高频计算要考虑性能。能讲到第四层的人已经超过多数候选人能讲到第五层的人面试官基本可以确认他参与过真实的资金类系统开发。这也是我认为这道题不该被当成八股背诵题的原因它更像一次快速的能力画像。最后给你一个建议把这篇文章当作一个动手清单会更有效。你完全可以打开终端依次跑一遍第 3 章的浮点演示、第 5 章的构造器对比和第 6 章的工具类。亲眼看到0.30000000000000004变成精确的0.3比反复背结论更能建立起对数值表示的直觉。下次再有人问浮点误差产生原因是什么BigDecimal 如何解决的你不需要把整篇文章背出来只需要记住一句话误差来自二进制无法精确表示十进制小数BigDecimal 通过把计算拉回十进制整数域来规避这个问题而最终是否可靠取决于你是否避开了所有构造器和比较的陷阱。这句话能讲清楚回答就已经立住了。