
0.1 加 0.2 为什么不等于 0.3我第一次见到这道题是在一次技术面试里。面试官没给任何代码直接抛过来一句如果让系统算 0.1 加 0.2结果会是什么为什么我凭着印象说出浮点数精度问题紧接着就被追问那 0.1 在内存里到底长什么样当场卡壳。后来我接手一个交易结算模块用 double 存金额做加法对账差了一分钱排查了大半天才找到元凶差点背锅。从那以后我才算把这个问题彻底理顺。这篇文章就是要把整件事讲透先从现象入手把各种语言里 0.1 加 0.2 的打印结果摆出来再讲二进制小数转换、IEEE 754 存储结构、舍入规则这些底层原理最后给出一套工程上可落地的解决方案和排查技巧。无论你是刚接触编程的新手还是写过几年业务代码的老手只要还在跟数字打交道这篇都能让你少走弯路。1. 复现现场0.1 0.2 到底打印出了什么1.1 主流的开发语言一个都没跑掉先在自己的机器上跑一遍最直白的代码。JavaScript 的控制台是重灾区console.log(0.1 0.2); // 0.30000000000000004 console.log(0.1 0.2 0.3); // falsePython 的表现一模一样print(0.1 0.2) # 0.30000000000000004 print(0.1 0.2 0.3) # FalseJava 里的 double 也不给你留面子System.out.println(0.1 0.2); // 0.30000000000000004C 语言稍微隐蔽一点。如果用 printf 的默认 6 位小数输出你会看到“0.3”但这只是打印格式在“美化”结果。把精度调高就露馅printf(%.17f\n, 0.1 0.2); // 0.30000000000000004这里要澄清一个很多人会误会的点这不是某个语言写得烂更不是系统随机出错。恰恰相反所有主流语言在底层都遵循同一套浮点数标准 IEEE 754所以同样的计算才会在 JavaScript、Python、Java、C、Go、Rust 里给出高度一致的结果。这个结果不是 bug而是统一标准下的确定性行为。1.2 等等有些小数是准的刚接触这个问题时最容易产生一种极端结论“凡小数都不准”。其实不是。下面这些数在二进制里是精确表示的十进制小数二进制表示是否精确0.50.1精确0.250.01精确0.1250.001精确0.750.11精确0.10.0001100110011001...不精确0.20.0011001100110011...不精确0.30.0100110011001100...不精确规律很明显分母只含 2 的幂的十进制小数在二进制世界里能一步到位写清楚。0.5 就是 2 的负一次方0.25 是 2 的负二次方0.125 是 2 的负三次方这些都是二进制小数的“整数块”。而 0.1、0.2、0.3 的分母分别是 10、10、10都含有因子 5没法化成有限位二进制小数。1.3 只要一碰相等比较坑就浮出来了加法打印出来多个几十亿分之一的小尾巴很多时候肉眼还能忍。真正让程序崩溃的是相等判断。很多代码里都会写类似这样的逻辑// 判断折扣后金额是否刚好等于某个阈值 const discount 0.7; const price 0.6; if (discount - price 0.1) { // 实际不会进来 } console.log(0.7 - 0.6); // 0.09999999999999998乍一看 0.7 减 0.6 应该等于 0.1可真实结果比 0.1 小了那么一丁点。相等判断直接失败于是可能走错分支、漏掉优惠、报错提示。这类问题最麻烦的地方就是它“大多数时候是对的偶尔差一丁点”你很难第一时间想到是浮点数在作怪。2. 二进制视角为什么 0.1 天生“写不完”2.1 十进制小数转二进制的通用算法要理解原因得先知道十进制小数是怎么变成二进制小数的。方法很简单不断把小数部分乘以 2取整数部分作为二进制位直到小数部分为 0。拿 0.1 手动走一遍0.1 × 2 0.2 → 整数位 0余 0.2 0.2 × 2 0.4 → 整数位 0余 0.4 0.4 × 2 0.8 → 整数位 0余 0.8 0.8 × 2 1.6 → 整数位 1余 0.6 0.6 × 2 1.2 → 整数位 1余 0.2 0.2 × 2 0.4 → 整数位 0余 0.4 0.4 × 2 0.8 → 整数位 0余 0.8 0.8 × 2 1.6 → 整数位 1余 0.6 ...从第 5 步开始你能看到余数 0.2、0.4、0.8、0.6 在循环出现对应的二进制位是 0011然后一直重复。所以 0.1 的二进制是0.0001100110011001100110011...后面跟着无穷无尽的“1100”循环。1/10 在二进制里根本不是一个有限小数这跟你永远用十进制写不完 1/3 是一样的道理。2.2 一个更熟悉的直觉1/3十进制里我们很清楚 1 除以 3 等于 0.3333...小数位无限循环永远写不完。这是因为 3 不是 10 的质因子。同理二进制里“写不完”的数字更多。二进制的位权是 2 的幂只有分母能够分解成若干个 2 相乘的数才能写成有限二进制小数。1/10 的分母是 2 × 5里面有个 5二进制无能为力。1/5、1/6、1/7、1/9 这些统统写不干净。所以“0.1 加 0.2 不等于 0.3”的第一层原因就是0.1、0.2、0.3 这三个数从进入二进制那一刻起就没有一个是“本人”。你算的从来就不是真正的 0.1 加 0.2。2.3 有限的位长决定了必然截断就算二进制小数是无限循环的计算机的内存也不是无限的。double 类型里的尾数部分只有 52 个有效二进制位算上规格化时的隐藏位总共 53 位左右。放不下后面的循环位只能截断加舍入。这就好比你用一个只能写 10 位数字的表格去记 1/3只能写成 0.3333333333后面几位丢了自然会比真正的 1/3 小那么一丁点。0.1 在二进制里无限循环存储时被迫变成某个非常接近它的有限二进制数——模式是“截断”而 IEEE 754 用的是更聪明一点的“就近舍入”。于是每个数都带着一丁点误差进入内存误差在计算时一次次叠加、放大最终在打印时暴露成 0.30000000000000004。3. 打开内存IEEE 754 里 0.1 的真实长相3.1 符号位、指数位、尾数位三块板拼一个数理解了无限循环这层原因下一步就是看计算机到底把这串循环存成了什么。IEEE 754 浮点数用类似科学计数法的方式存储一个 double 由三部分组成符号位1 位表示正负。指数位11 位存 2 的多少次幂但要加一个偏移量避免负数double 的偏移是 1023。尾数位52 位存有效数字。规格化状态下最高位默认是 1实际获得 53 位精度。单精度 float 和三部分的对应关系略有差异常规使用中 double 更常见两张表都放出来方便对照类型总位数符号位指数位尾数位指数偏移十进制有效精度float321823127约 7 位double64111521023约 15~16 位这种结构有点像十进制里写 1.996 × 10³只是底数换成 2尾数位只能存固定的位数。超过位数的部分必须舍掉。3.2 0.1 和 0.2 的存储值到底是哪个数Java 有个冷门方法能直接看一个 double 内部长什么样Double.toHexString(0.1)返回0x1.999999999999ap-4。意思就是 0.1 这个 double 实际存储的是一个规格化二进制数指数部分表示 2 的负四次方尾数部分是 0x1.999999999999a。把它完整展开成十进制0.1 真正存进内存的值是0.1000000000000000055511151231257827021181583404541015625可以看到它比真正的 0.1 稍微大了一点点。0.2 的存储值为0.200000000000000011102230246251565404236316680908203125也比真正的 0.2 大了一点点。两个数都“虚高”这是它们各自的尾数经过 53 位取舍后的结果。平时你打印 0.1 之所以看到的是“0.1”是因为很多语言在输出时会把存储值再转回十进制并且选择最短的、能定位回同一个存储值的字符串。真正的存储值并不是你以为的那个数。3.3 加法不是“真的算错”而是一次新的舍入现在把两个存储值相加0.100000000000000005551115123125782702... 0.200000000000000011102230246251565404... 0.300000000000000016653345369377348106...这个“真和”本身又落到了两个可表示的 double 之间的正中间。按照 IEEE 754 的默认舍入规则——就近舍入、遇到正好中间时取尾数为偶数——加法的最终结果变成了0.3000000000000000444089209850062616169452667236328125也就是打印出来的 0.30000000000000004。而 0.3 自己存储的是什么是0.299999999999999988897769753748434595763683319091796875一个比 0.3 小一点一个比 0.3 大一点两边天然错开。所以你拿0.1 0.2的结果去和0.3比较实际比较的是两串位数极多的二进制数它们根本就不是同一个数返回 false 是必然的。3.4 为什么说这是特性而不是 bug很多人会给浮点数精度问题贴上“垃圾”的标签。但在计算机体系里用固定位宽表示任意范围内的小数本质上就不可能完全精确这是一道数学题有限的内存装不下无限的信息。IEEE 754 用 64 位换取大约 15 到 16 位十进制有效精度把绝大部分应用场景覆盖住这是成本和收益之间的权衡。而且它有一个特别重要的优点确定性。同一个 double 在全世界任何一台机器上做同样的运算结果都完全一致。这比“结果精准但每次都不一样”要可靠得多。所以正确态度不是骂它而是认清它的边界在需要精确的场景里绕过它。4. 误差的放大效应从一次加法到整套系统4.1 循环累加误差从不缺席还会累积单次加法只有大约 5.5e-17 级别的误差看起来无关痛痒。但误差的可怕之处在于它会累积。跑一个最经典的循环total 0.0 for _ in range(10): total 0.1 print(total) # 0.9999999999999999正常人期待的是 1.0结果却是 0.9999999999999999。每次累加都带着小的舍入误差十次下来误差叠加就像你每次量布都偏差一毫米量十次就偏了一厘米。如果循环一万次、十万次漂移会更加明显。这类问题在读仪表数据、做统计聚合、算累计收益时特别危险因为表面上看这是一串再加法的逻辑实际上每一步都在给最终结果“掺水分”。4.2 三个最容易中招的真实场景金额计算是重灾区。商品价格 19.9 元打个折扣加个税再分摊优惠最后对账差一分钱。用 double 算出来的 19.9 本身就不是真的 19.9多个步骤叠加之后显示时四舍五入可能把错误掩盖住但一旦遇到精确到分的对账、退款、批次汇总就会原形毕露。统计学计算同样危险。计算一组浮点数的均值、方差时如果样本值很大而差值很小会出现“灾难性抵消”。比如一组数值都在 100 亿附近你关心的是它们之间 0.01 级别的差异double 只保留 15 到 16 位有效数字100 亿本身就已经占掉了 11 位剩下的精度根本装不下你要观察的差异。图形学和几何判断则是另一个典型。判断一个点是否在某条线段上、两条直线是否相交核心都是浮点差值的比较。直接用等号判断程序大概率永远走不进那个分支必须用带容差的比较。排序和哈希也可能出问题。把浮点数当集合 key 或者排序字段时两个“看起来相等”的数可能因为存储值不同被当成不同元素进而引发重复插入、分组错乱、缓存命中不稳。这些场景最难排查因为它们不报错只是结果悄悄不对。4.3 前面回答“为什么”工程上更要问“怎么判断”当你真正负责一个业务系统时面试题里的“为什么”只是入门。更重要的是该怎么判断两个浮点数是否“相等”该怎么存储才能不丢精度好消息是这些都是有成熟套路的下一节我们直接落地上方案。5. 工程解决方案从根源到兜底5.1 方案一钱相关的场景先转成整数金融业务里最经典的原始方案就是“分记账”。不要把金额当成 19.9 元而是当成 1990 分。整数在 JavaScript 和 Python 的安全范围内是精确表示的加法、乘法统统用整数算最后展示时再除以 100 加个小数点。但在从“元”转换成“分”这一步也要警惕浮点乘法。比如const price 19.9; // 不建议直接 price * 100可能存在尾差 const cents Math.round(price * 100); // 用 round 拉回整数Math.round会把 1989.9999999999998 这类尾差拉回 1990。更稳妥的做法是直接在设计数据源时就按分存储从接口、数据库到前端全程穿整数根本不给浮点小数进场的机会。记账、支付、积分换算这些场景整数分是性价比最高的方案。5.2 方案二用十进制字符串精算的 Decimal有些场景不适合用整数比如需要计算复杂的利息、折扣比例、汇率换算或者数值本身带多位小数。这时候要引入“十进制浮点”类型本质是模拟十进制计算而不是用二进制浮点硬算。Python 里的 Decimal、Java 里的 BigDecimal 都属于这一类。Python 的用法要特别注意字符串入参from decimal import Decimal # 正确从字符串构造 a Decimal(0.1) b Decimal(0.2) print(a b) # 0.3 # 错误直接传 float 进去等于把二进制误差原样搬进 Decimal print(Decimal(0.1) Decimal(0.2)) # 0.3000000000000000166533453693773481063544750213623046875Java 的 BigDecimal 同理必须用字符串构造BigDecimal a new BigDecimal(0.1); BigDecimal b new BigDecimal(0.2); System.out.println(a.add(b)); // 0.3如果从某个 double 变量转 BigDecimal先Double.toString或String.valueOf一下等价于 Decimal 的字符串入参原则。数据库层面金额字段要用 DECIMAL(10,2) 而不是 DOUBLE这样查询和聚合结果才能贴合业务。JavaScript 在前端也有 decimal.js、bignumber.js 这类库用法与 Python Decimal 很接近。Decimal 的代价是性能和实现复杂度比原生 double 高不少但对金额、账目这类“绝不能错”的数据这点代价完全值得。5.3 方案三比较时不看相等看“足够接近”科学计算、物理模拟、图形学这些场景没办法把所有数都转成 Decimal这时候该学的是“容差比较”。最朴素的做法是绝对误差function nearlyEqual(a, b, epsilon 1e-9) { return Math.abs(a - b) epsilon; } console.log(nearlyEqual(0.1 0.2, 0.3)); // truePython 直接有现成接口import math math.isclose(0.1 0.2, 0.3, rel_tol1e-9) # True这里有个细节值得说。绝对误差比较适合数值量级差不多的场景。如果比较的都是 1 附近的小数1e-9 没问题但如果一边是 100 亿一边是 100 亿零 0.01直接比绝对误差就会误判不相等。这时候要用相对误差或者像math.isclose那样同时考虑相对容差和绝对容差。实际工程里我习惯先分析数值量级再选择合适的 epsilon宁可让容差稍微宽一点也不要肉眼看着相等却判 false。JavaScript 还提供了Number.EPSILON它表示 1 与下一个可表示 double 的间距约 2.22e-16。用它做尺度参照比拍脑袋给个 1e-9 更科学console.log(Number.EPSILON); // 2.220446049250313e-16 function nearlyEqual(a, b, eps Number.EPSILON * 4) { return Math.abs(a - b) eps; }还要记住一点容差比较只是“判断策略”不是“精度修复”。该用整数的场景还是得用整数容差方案只是给无法避免浮点数计算的场景兜底。5.4 方案四科学计算和高精度扩展如果浮点位数实在不够比如处理天文数字量级、椭圆积分、高次方程Python 可以用 mpmath 这种任意精度库想要多少位小数就设多少位。数值计算里还有 numpy 自带的比较约定很多官方 API 在设计时就带着容差参数比如判断矩阵是否相等的allclose。碰到这类场景优先查一下你正在用的数值库是不是已经提供了精度安全的函数别自己手搓比较逻辑。6. 常见问题与排查技巧实录6.1 为什么有时候 0.1f 0.2f 又等于 0.3f这个现象在 C/C 里最常见。因为单精度 float 只有 23 位尾数0.1f 和 0.2f 的存储值与 0.3f 的存储值在低精度舍入下碰巧在同一档可表示数值附近某些情况下相加后能“对齐”到 0.3f。但这只是低精度下误差互相抵消的巧合并不代表单精度更准确。换成 double 精确比较照样是 false。这类困惑提醒我们不要拿“打印结果看着对”来反推内部精度打印格式、精度档位都会骗人。6.2 toFixed 能修问题吗很多人会写(0.1 0.2).toFixed(1)得到0.3然后以为自己解决了问题。实际上 toFixed 只是把已经算错的 double 再四舍五入成字符串属于“展示端的美化”。它的返回值是字符串不能继续参与数值运算如果拿它去累加、去比较很容易引入新的错误。短期展示可以这么糊弄长期来说记账、统计、对账绝对不能依赖 toFixed。正确姿势是数据源头就用整数或 Decimal。如果只是想让一个计算结果收敛到想要的位数可以配合Math.roundMath.round((0.1 0.2) * 10) / 10; // 0.3注意这也只是把数值拉到你期望的刻度上并没有让原计算变精确一切还是要回到数据源的选型。6.3 大整数超过 2^53 也会悄悄变浮点数的坑不只在小数。JavaScript 的 double 精准表示安全整数上限是 2^53 减 1也就是 9007199254740991。超过之后整数之间也会发生“撞车”console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991 console.log(9007199254740992 9007199254740993); // true9007199254740993 存进 double 后会落到 9007199254740992 上。这在处理订单号、流水号、雪花 ID、身份证号时会出大问题——你以为代码里写的是 9007199254740993实际存储的是 9007199254740992。这类字段应当用字符串处理前端也要避免把超过安全范围的整数塞进 Number 里。6.4 排查速查表我把实际工作中最常见的几个症状整理成了一张速查表遇到问题可以先对着表格定位症状根因对策打印 0.10.2 出现 0.30000000000000004二进制无法精确表示 0.1、0.2存储值有尾差理解这是 double 正常表现展示时按需格式化金额对不上、差一分钱用 double 存金额多次运算尾差累积金额一律转整数分或用 Decimal相等判断总是 false比较两个存储值而非两个数学值用容差比较Math.abs(a-b) eps或math.isclose循环累加结果漂移误差逐次累积用整数累加或改为 Decimal 累加大整数 ID 变短超过 2^53 安全整数范围用字符串传递 ID避免进 Number 再做算术跨语言/跨库对不上账不同语言的 float/double 精度档位不同打印格式掩盖差异统一用 Decimal 类型和字符串传输排查这类问题时有个非常实用的小技巧先把问题数字各自转成十六进制或完整十进制打印出来很多“莫名其妙不相等”的谜团一看存储值就豁然开朗。Java 的Double.toHexString、Python 的float.hex()、JavaScript 的toPrecision(17)都是快速查看完整存储值的好帮手。我自己在踩了那次对账的坑之后养成了一个习惯凡是代码里出现浮点数先问一句“这个数将来要不要做相等比较、要不要累计、要不要对账”。只要答案是“要”就不让 double 当主角。面试时如果再被问到这个问题我一般按“进制转换、有限位数、舍入规则、误差累积、工程对策”五层递进回答既能展示原理理解又能体现工程意识。这套思路希望你也能用上。