
在很多 Java 面试中有一道经典问题让人既爱又恨System.out.println(0.1 0.2);这段代码输出什么如果回答“0.3”面试官会摇头如果回答“0.30000000000000004”面试官会追问一句“为什么”很多候选人倒在这一步。浮点误差问题表面上看只是一个偏门的语法细节实际上是把计算机底层表示、Java 语言规范和工程实践串在一条线上的绝佳题目。它不要求你背 API而是考察你能否从“现象”推到“原理”再从“原理”落到“工程方案”。本文要做的就是把这层窗户纸彻底捅破既讲清楚误差产生的底层原因也讲清楚 BigDecimal 的原理、用法和陷阱。读完这篇文章你不仅能回答这道题还能在真实项目里正确使用 BigDecimal。1. 这道面试题到底想考察什么很多面试官喜欢用0.1 0.2开头不是因为它本身有多难而是因为它可以像剥洋葱一样一层层追问。第一层追问是float和double为什么有误差这一层考察的是底层知识候选人需要知道浮点数在计算机中如何存储。第二层追问是怎么解决候选人如果说“用 BigDecimal”面试官会继续问“为什么 BigDecimal 可以解决”。这一层考察的是对 JDK 标准库设计的理解不只是背会一个类名。第三层追问是new BigDecimal(0.1)和new BigDecimal(0.1)有什么区别这一层已经进入实战细节了。如果候选人能说出前者把 double 的二进制值原封不动地转成了十进制而后者才是我们想要的“字符串解析”面试官基本可以判断这个人真的踩过坑。第四层追问是BigDecimal 和 double 相比有什么代价它一定比 double 好吗这一层考察候选人对技术选型的判断力。实际项目中BigDecimal 也不是万能的它牺牲了性能和存储在极高性能场景下有更适合的选择。所以这道题的完整价值不在于记住一个输出结果而在于建立一条从“浮点数表示”到“精度问题”再到“解决方案与代价”的完整链条。下面我们按这条链条逐步拆解。2. 误差产生的底层原因二进制为什么存不准 0.12.1 十进制与二进制的“转换差”先问一个看似无关的问题在十进制里1 / 3等于多少答案很直接0.3333……是一个无限循环小数。十进制下我们无法用有限位数精确表示三分之一只能保留一定位数比如0.33或0.3333这就产生了误差。计算机处理浮点数时遇到的是类似的情况只不过它用的是二进制。十进制的有限小数转成二进制后不一定是有限小数。0.1就是最典型的例子。十进制小数转二进制采用的规则是“乘 2 取整顺序排列”。我们把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 × 2 1.2 → 整数部分 1 0.2 × 2 0.4 → 整数部分 0 0.4 × 2 0.8 → 整数部分 0 0.8 × 2 1.6 → 整数部分 1 0.6 × 2 1.2 → 整数部分 1 ……可以清楚看到0.1的二进制表示是0.00011001100110011……其中0011会无限循环下去。换句话说二进制无法用有限位数精确表示0.1。这就是误差的根源不是 Java 的 bug而是二进制表示方式的天然限制。2.2 IEEE 754 浮点数标准Java 的float和double遵循 IEEE 754 标准。double占 64 位由三部分组成1 位符号位11 位指数位52 位尾数有效数字位。因为尾数只有 52 位遇到0.1这种二进制无限循环小数时只能截断或舍入到 52 位。这个操作本身就引入了“存储误差”。我们看到的0.1实际上是“最接近 0.1 的那个二进制浮点数”而不是数学意义上精确的0.1。可以做一个类比把1 / 3记成0.333333已经是一个近似值然后0.333333 0.333333 0.333333自然不可能等于精确的1。计算机里的0.1 0.2就是这个过程的二进制版本。2.3 float 的精度问题比 double 更明显float只有 23 位尾数有效十进制位数大约 7 位double有 52 位尾数有效十进制位数大约 15 到 16 位。这意味着float不仅小数容易出误差连大整数都可能丢失精度而且在远离 0 的大数上更明显。典型例子float f 16777216f; // 2^24 System.out.println(f); // 1.6777216E7 System.out.println(f 1); // 1.6777216E7仍然是同一个值16777216是2^24float的 23 位尾数加隐含位正好 24 位再往后的整数就无法精确表示了。所以16777216 1之后还是16777216这不是怪事而是 IEEE 754 的设计结果。这里有一个小结论float和double的误差不是“偶尔出现”而是“必然存在”只是大多数情况下差值极小被正常输出掩盖了。一旦涉及连续运算误差会被放大最终在某些场景下暴露出来。3. 亲眼看到误差Java 代码演示理论讲完我们看实际输出。下面这段代码是很多面试官会现场让候选人写的// 文件路径src/main/java/com/example/demo/FloatErrorDemo.java public class FloatErrorDemo { public static void main(String[] args) { double a 0.1; double b 0.2; System.out.println(0.1 0.2 (a b)); System.out.println(0.1 0.2 0.3 ? ((a b) 0.3)); System.out.println(1.0 - 0.9 (1.0 - 0.9)); System.out.println(0.1 * 3 (0.1 * 3)); System.out.println(1.0 / 3.0 (1.0 / 3.0)); float f 16777216f; System.out.println(16777216f 1 16777216f ? ((f 1) f)); } }运行结果0.1 0.2 0.30000000000000004 0.1 0.2 0.3 ? false 1.0 - 0.9 0.09999999999999998 0.1 * 3 0.30000000000000004 1.0 / 3.0 0.3333333333333333 16777216f 1 16777216f ? true这些结果不是随机出现的。0.1 0.2之所以得到0.30000000000000004是因为 0.1 和 0.2 在二进制下都是近似值两者相加的结果再转回十进制时落在了0.3附近的一个浮点数上于是多出了末尾的4。更直观的误差累积例子是循环累加// 文件路径src/main/java/com/example/demo/SumDemo.java public class SumDemo { public static void main(String[] args) { double sum 0.0; for (int i 0; i 1000; i) { sum 0.1; } System.out.println(double 累加 1000 次 0.1 sum); java.math.BigDecimal bdSum java.math.BigDecimal.ZERO; for (int i 0; i 1000; i) { bdSum bdSum.add(new java.math.BigDecimal(0.1)); } System.out.println(BigDecimal 累加 1000 次 0.1 bdSum); } }运行结果double 累加 1000 次 0.1 99.99999999999864 BigDecimal 累加 1000 次 0.1 100.0单次运算的误差看起来微不足道但放到循环里误差就开始累积最终让结果偏离正确值。这才是浮点误差在工程上真正可怕的地方它不是某一次算错而是在长期累积之后突然暴露。4. 金融系统中为什么不敢用 double如果只是科学计算误差通常可以接受因为计算结果本身只需要一定精度。但金融场景完全不同金额不允许出现“差不多”的情况。举一个最常见的电商订单场景商品单价19.90 元购买数量3 件运费5.00 元优惠券2.50 元。如果用double计算double price 19.90; int count 3; double shippingFee 5.00; double discount 2.50; double subtotal price * count; double total subtotal shippingFee - discount; System.out.println(应付金额: total);运行结果可能是应付金额: 62.20000000000001页面显示 62.20000000000001用户不会接受这种账单。如果做等值判断比如total 62.20结果永远是false那么依赖金额比较的优惠逻辑、支付对账逻辑都会出问题。更严重的场景是金融系统的每日累计。每天几百万笔交易每笔都差一点点累计到月末可能差出几块钱甚至更多。银行、支付、财务系统里这种误差是不可接受的。这不是说double一无是处。它会用科学计算、图形渲染、游戏物理等场景因为那些地方追求速度和一定的近似度而不是精确的十进制结果。技术选型的关键是分场景计算物理轨迹用double计算用户余额用 BigDecimal。5. BigDecimal 为什么能精确设计与核心原理5.1 不依赖二进制小数BigDecimal 和double的本质区别在于BigDecimal 不采用“二进制小数”表示数值而是把小数拆成“未缩放的值”加“小数位数”。它内部有两个核心字段unscaledValue一个BigInteger类型的整数保存去掉小数点后的数值scale一个int类型保存小数位数。比如123.45在 BigDecimal 内部就是unscaledValue 12345 scale 2含义是12345 × 10^(-2)也就是123.45。这样一来所有小数运算都可以先转成整数运算最后再根据scale调整小数点位置。整数在 BigInteger 里可以无限大因此不会出现二进制的精度丢失问题。用代码验证一下内部结构// 文件路径src/main/java/com/example/demo/BigDecimalStructureDemo.java import java.math.BigDecimal; public class BigDecimalStructureDemo { public static void main(String[] args) { BigDecimal d new BigDecimal(123.45); System.out.println(unscaledValue d.unscaledValue()); System.out.println(scale d.scale()); BigDecimal e new BigDecimal(0.1); System.out.println(0.1 unscaledValue e.unscaledValue()); System.out.println(0.1 scale e.scale()); } }运行结果unscaledValue 12345 scale 2 0.1 unscaledValue 1 0.1 scale 10.1在 BigDecimal 看来就是1这个整数配上scale 1。1 ÷ 10 0.1这个过程不涉及任何二进制近似。加法的逻辑也因此变得清晰两个 BigDecimal 相加时先统一scale再对unscaledValue做整数加法。整个过程只有整数运算自然不存在浮点误差。5.2 BigDecimal 的本质是用空间换精度BigDecimal 能做到精确不是因为它用了魔法而是因为它把“数值表示”的方式换了不再用固定长度的二进制位去近似一个小数而是用“无限长的整数 小数位标记”来精确表达十进制小数。代价也十分明显运算速度比double慢一个数量级以上内存占用比double大很多代码写起来更啰嗦。所以在不需要十进制精确语义的场景里没必要强行使用 BigDecimal。6. BigDecimal 完整实战代码6.1 构造方法第一个大坑BigDecimal 有多个构造方法其中最容易踩坑的是直接传入double// 文件路径src/main/java/com/example/demo/BigDecimalConstructorDemo.java import java.math.BigDecimal; public class BigDecimalConstructorDemo { public static void main(String[] args) { BigDecimal fromString new BigDecimal(0.1); BigDecimal fromDouble new BigDecimal(0.1); BigDecimal fromValueOf BigDecimal.valueOf(0.1); System.out.println(new BigDecimal(\0.1\) fromString); System.out.println(new BigDecimal(0.1) fromDouble); System.out.println(BigDecimal.valueOf(0.1) fromValueOf); } }运行结果new BigDecimal(0.1) 0.1 new BigDecimal(0.1) 0.1000000000000000055511151231257827021181583404541015625 BigDecimal.valueOf(0.1) 0.1原因很直接new BigDecimal(0.1)接收的是double类型而double本身已经把0.1变成了一个二进制近似值。BigDecimal 忠实地把这个近似值的十进制展开完整地记录了下来于是得到了一个特别长的数。而new BigDecimal(0.1)走的是字符串解析路径它直接按十进制语义处理字符串得到的才是我们主观意义上的0.1。所以核心结论是优先使用字符串构造或BigDecimal.valueOf()避免直接传double给构造方法。6.2 加减乘除与舍入// 文件路径src/main/java/com/example/demo/BigDecimalArithmeticDemo.java import java.math.BigDecimal; import java.math.RoundingMode; public class BigDecimalArithmeticDemo { public static void main(String[] args) { BigDecimal a new BigDecimal(10.50); BigDecimal b new BigDecimal(3.20); System.out.println(a b a.add(b)); System.out.println(a - b a.subtract(b)); System.out.println(a * b a.multiply(b)); // 除法必须指定精度和舍入模式否则可能抛异常 BigDecimal result a.divide(b, 4, RoundingMode.HALF_UP); System.out.println(a / b result); } }运行结果a b 13.70 a - b 7.30 a * b 33.600 a / b 3.2813注意a * b 33.600结果是三位小数因为10.50有两位小数3.20有两位小数乘法的scale是两者之和4 位。这里的尾部0不改变数值但在equals比较时需要注意后面会讲到。divide是 BigDecimal 里最容易出异常的方法。当除不尽时如果不指定精度和舍入模式会直接抛出ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result。正确做法是指定小数位数和舍入模式a.divide(b, 2, RoundingMode.HALF_UP)舍入模式常用HALF_UP也就是四舍五入。金融计算中具体用哪种模式要按业务规则决定。6.3 比较大小用 compareTo 而不是 equalsBigDecimal 的equals和compareTo行为不同这是一个高频面试点也是实际开发中容易踩的坑。// 文件路径src/main/java/com/example/demo/BigDecimalCompareDemo.java import java.math.BigDecimal; public class BigDecimalCompareDemo { public static void main(String[] args) { BigDecimal x new BigDecimal(1.0); BigDecimal y new BigDecimal(1.00); System.out.println(x.equals(y) x.equals(y)); System.out.println(x.compareTo(y) x.compareTo(y)); } }运行结果x.equals(y) false x.compareTo(y) 0equals要求数值和scale都相等所以1.0和1.00不相等。但compareTo只比较数值大小所以返回0表示相等。在金额比较、排序、去重时应该使用compareTo。使用HashSet或HashMap时BigDecimal 的hashCode也依赖scale因此1.0和1.00会被视为不同元素需要结合业务判断是否可接受。6.4 完整金额计算示例下面是一个更接近真实业务的完整示例包含加、减、乘、舍入和输出// 文件路径src/main/java/com/example/demo/MoneyCalculator.java import java.math.BigDecimal; import java.math.RoundingMode; public class MoneyCalculator { public static void main(String[] args) { BigDecimal price new BigDecimal(19.90); BigDecimal count new BigDecimal(3); BigDecimal shippingFee new BigDecimal(5.00); BigDecimal discount new BigDecimal(2.50); BigDecimal taxRate new BigDecimal(0.06); // 商品小计 BigDecimal subtotal price.multiply(count); // 加运费减优惠 BigDecimal beforeTax subtotal.add(shippingFee).subtract(discount); // 计算税费保留 2 位小数四舍五入 BigDecimal tax beforeTax.multiply(taxRate).setScale(2, RoundingMode.HALF_UP); // 最终应付 BigDecimal finalTotal beforeTax.add(tax); System.out.println(商品小计: subtotal); System.out.println(优惠后金额: beforeTax); System.out.println(税费: tax); System.out.println(应付总额: finalTotal); // 金额比较必须用 compareTo BigDecimal expected new BigDecimal(62.20); System.out.println(应付金额是否为 62.20 ? (finalTotal.compareTo(expected) 0)); } }运行结果商品小计: 59.70 优惠后金额: 62.20 税费: 3.73 应付总额: 65.93 应付金额是否为 62.20 ? true这个例子把前面提到的所有核心点都串起来了字符串构造、链式运算、setScale指定精度、RoundingMode.HALF_UP、compareTo比较。7. BigDecimal 的坑也是面试官爱追问的点7.1 new BigDecimal(0.1) 的奇怪结果这个问题前面已经演示过。new BigDecimal(0.1)得到的是那个极长的0.1000000000000000055511151231257827021181583404541015625。面试官追问时你要能解释“double 入参已经把 0.1 变成了二进制近似值BigDecimal 只是如实展开”。这不是 BigDecimal 的问题而是 double 的问题被 BigDecimal 完整暴露了。7.2 equals 与 compareTo 的差异前面也演示过。1.0和1.00用equals比较是false用compareTo比较是0。项目里如果突然发现金额明明相等Set 或 Map 却认为不相等通常就是这个问题。7.3 divide 不指定舍入模式抛异常new BigDecimal(1).divide(new BigDecimal(3))会直接抛出ArithmeticException因为1 ÷ 3无法得到有限位数的十进制结果。这是 BigDecimal 故意设计的保护机制不指定精确度就不允许进行可能丢失精度的运算。7.4 scale 不一致导致的数据问题BigDecimal(2.50)和BigDecimal(2.5)数值相等但scale不同。在与数据库交互时如果scale不一致可能导致序列化、toString输出的结果不符合前端展示预期甚至在某些 ORM 框架中影响字段映射。建议统一金额的scale比如统一为 2 位小数。7.5 性能问题BigDecimal 的运算涉及BigInteger对象分配和操作性能远低于原始类型double。如果在一秒需要执行几十万次的循环里使用 BigDecimal需要评估是否有必要。对于大多数业务接口单次金额运算的性能开销可以忽略但无节制的使用仍需要避免。8. 回答这个面试题的标准范式面试中遇到这道题可以参考下面的回答路径先从浮点原理切入计算机使用二进制表示浮点数0.1在二进制中是无限循环小数而 IEEE 754 的double尾数只有 52 位所以发生了舍入产生了存储误差。再给出代码现象0.1 0.2的输出并不是0.3而是0.30000000000000004根本原因就是两个近似值相加的结果再次近似误差被保留了下来。然后给出解决方案需要精确十进制运算时使用 BigDecimal。它的核心设计是用BigInteger保存未缩放整数用scale保存小数位让小数运算变为整数运算避免二进制浮点误差。再补充使用细节构造时应优先使用new BigDecimal(String)或BigDecimal.valueOf(double)不要直接new BigDecimal(double)比较大小应使用compareTo而不是equalsdivide必须指定精度和舍入模式。最后落到工程判断金额等需要精确十进制语义的场景使用 BigDecimal科学计算、性能敏感场景可以继续使用double数据库字段金额一般使用DECIMAL而不是浮点类型。这样回答既展示了底层知识又展示了工具使用经验还体现了技术选型的判断力。9. 总结与工程建议现在回到最开始的问题0.1 0.2为什么不等于0.3因为0.1和0.2在二进制里是无限循环小数IEEE 754 只能用近似值保存两个近似值相加的结果离真正的0.3差了一点点。BigDecimal 之所以能解决是因为它完全放弃了二进制小数表示改用“整数 小数位”的方式精确表达十进制数。工程上我对使用 BigDecimal 的建议可以总结为五条金额、税率、费率等一切需要十进制精确语义的数据一律使用 BigDecimal不要心存侥幸。构造 BigDecimal 时尽量使用字符串构造或BigDecimal.valueOf()。从外部接口接收金额时也先转成字符串再构造。金额运算时统一精度比如setScale(2, RoundingMode.HALF_UP)避免scale不一致引出的诡异问题。金额比较统一使用compareTo不要用equals。写入数据库时表字段类型建议使用DECIMAL而不是FLOAT或DOUBLE。每次divide都要显式指定精度和舍入模式把“四舍五入”这个行为从隐式变成显式让代码的意图更清晰。最后说一个容易被忽略的点如果有人告诉你 BigDecimal 一定比 double 好这句话不准确。BigDecimal 解决的是十进制的精确表示它在性能、内存和编码复杂度上是有代价的。真正的工程能力是知道什么时候需要精确什么时候可以近似然后选择正确的工具。这道面试题能成为经典不是因为它刁钻而是因为它用最简单的方式检验了一个开发者的底层积累和工程经验。建议把它收藏起来亲手跑一遍文中代码把输出记在心里下一次不管是面试还是代码审查你都能从容应对。