0.1 + 0.2 为什么不等于 0.3?用 IEEE 754 浮点数转换器把 64 个比特拆开看
JavaScript 算出 0.30000000000000004 不是 bug。把 0.1 和 0.2 的双精度位表示逐位拆开,看符号、指数、尾数三段是怎么把误差累出来的,以及实际写代码时该怎么对付它。
0.1 + 0.2 为什么不等于 0.3?用 IEEE 754 浮点数转换器把 64 个比特拆开看
打开浏览器控制台,输入 0.1 + 0.2,回车,你会得到 0.30000000000000004。几乎每个写过 JavaScript、Python 或 Java 的人都撞过这堵墙,然后在搜索引擎里敲下同一个问题。网上大多数回答停在"浮点数有精度问题"就完了,但这个答案等于没答:到底是哪一个比特出了问题?这篇文章用 IEEE 754 浮点数转换器 把 0.1、0.2、0.3 三个数的位表示逐位摆出来,你会看到误差不是玄学,而是能精确指认到第 52 位尾数的确定性结果。
0.1 在二进制里本来就存不准
十进制的 0.1 写成二进制小数,是一个无限循环:
0.1 (十进制) = 0.000110011001100110011… (二进制,0011 无限循环)
道理和十进制里 1/3 = 0.3333… 一模一样:分母含有 3,十进制(基数 2×5)除不尽;0.1 = 1/10,分母含有 5,二进制(基数 2)同样除不尽。而 IEEE 754 双精度只给尾数留了 52 个比特,循环节必须在某一位被砍断并按规则舍入。所以 0.1 这个数从被写进内存的那一刻起,存的就不是 0.1,而是最接近它的那个可表示值:
0.1000000000000000055511151231257827021181583404541015625
注意它比 0.1 大了约 5.55 × 10⁻¹⁸。这个偏差在你做任何加法之前就已经存在了。
拆开 64 个比特:符号、指数、尾数
双精度浮点数共 64 位,按 IEEE 754-2019 标准切成三段:1 位符号 + 11 位指数 + 52 位尾数。尾数前还有一个隐含的前导 1,所以有效精度是 53 位,折算成十进制约 15.95 位有效数字(53 × log₁₀2,IEEE 754 标准文档里的标称值)。这也是为什么 JavaScript 的 Number.MAX_SAFE_INTEGER 恰好是 2⁵³ − 1 = 9,007,199,254,740,991,超过它连整数都开始丢精度。
把 0.1 输入转换器的双精度模式,得到的实际输出是:
输入: 0.1
十六进制: 0x3FB999999999999A
符号位: 0
指数位: 01111111011 (1019,偏移 1023 后 = 2⁻⁴)
尾数位: 1001100110011001100110011001100110011001100110011010
尾数那串 1001… 就是上面那个无限循环的 0011 节,在第 52 位处按"舍入到最近偶数"规则进位,末尾才会是 …1010 而不是 …1001。每一位误差都有出处。
0.1 + 0.2 的误差是怎么累出来的
我把三个数挨个输进 IEEE 754 浮点数转换器,把十六进制结果抄下来对比,这是整篇文章里最值得自己动手做一遍的实验:
0.1 → 0x3FB999999999999A (实际值比 0.1 偏大)
0.2 → 0x3FC999999999999A (实际值比 0.2 偏大)
0.1 + 0.2 → 0x3FD3333333333334
0.3 → 0x3FD3333333333333 (字面量 0.3 的最近可表示值)
看最后两行:相加结果和字面量 0.3 的位表示只差最低 1 个比特(…34 对 …33)。两个各自偏大的加数,和落在了 0.3 的两个相邻可表示值中间偏上的位置,舍入后停在了高的那一个。这"差一个 ULP"(unit in the last place)的距离,十进制打印出来就是 0.30000000000000004。0.1 + 0.2 === 0.3 返回 false,因为比较的是这两个十六进制位串,确实不相等。
想自己核对十六进制和二进制之间的换算,可以配合 进制转换器 把 0x3FD3333333333334 展开成 64 位二进制,再手工切出三段,切完你会发现和转换器给出的分段完全一致。
单精度 float 误差来得更早
双精度好歹有 15 位十进制有效数字垫底,单精度(float32)只有 24 位有效尾数,折合约 7.22 位十进制有效数字。同样把 0.1 输进转换器,切到单精度模式:
输入: 0.1
十六进制: 0x3DCCCCCD
实际存储值: 0.100000001490116119384765625
偏差从双精度的 10⁻¹⁸ 量级一下放大到 10⁻⁹ 量级。如果你在写 C/C++ 或 GLSL,把钱、坐标累加值这类数据放进 float,误差在几千次累加后就能爬到肉眼可见的位置。游戏引擎里"角色走远了开始抖动"的经典 bug,根源就是世界坐标用 float 存,离原点越远相邻可表示值之间的间隙越大。
实战:什么时候该担心,什么时候不用管
拆完比特,给几条我自己写业务代码时遵守的规则:
- 比较浮点数永远带容差。 不写
a === b,写Math.abs(a - b) < 1e-9(容差按你的量级选)。 - 钱用整数算。 以"分"为单位存整数,只在展示层除以 100。9,007 万亿分之内双精度整数运算是精确的,够覆盖任何真实账本。
- 打印 ≠ 存储。
console.log(0.1)显示 0.1,是因为打印算法选了能唯一还原该位串的最短十进制串,不代表内存里存的是 0.1。怀疑哪个字面量背锅时,把它丢进转换器看十六进制,一秒出真相。 - 位运算另起炉灶。 JavaScript 的
|、&、>>会先把数截断成 32 位整数再操作,和浮点位表示是两回事,验证移位逻辑时用 二进制计算器 单独核,别和浮点问题搅在一起。
下次再有人在 code review 里问"这里为什么不直接 === 比较",把 0x3FD3333333333334 和 0x3FD3333333333333 这两串十六进制甩给他,比贴十篇科普链接都管用。
Made by Toolora · Updated 2026-06-12