在很多 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.1
2.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^24,float的 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 而不是 equals
BigDecimal 的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而不是equals;divide必须指定精度和舍入模式。
最后落到工程判断:金额等需要精确十进制语义的场景使用 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 解决的是十进制的精确表示,它在性能、内存和编码复杂度上是有代价的。真正的工程能力,是知道什么时候需要精确,什么时候可以近似,然后选择正确的工具。
这道面试题能成为经典,不是因为它刁钻,而是因为它用最简单的方式检验了一个开发者的底层积累和工程经验。建议把它收藏起来,亲手跑一遍文中代码,把输出记在心里,下一次不管是面试还是代码审查,你都能从容应对。