在 JavaScript 或任何基于 IEEE 754 标准的编程语言中,几乎每一个开发者都在初学时被这一行代码震惊过:

console.log(0.1 + 0.2);
// 输出:0.30000000000000004
console.log(0.1 + 0.2 === 0.3);
// 输出:false

对于不了解底层计算机体系的人来说,两个只有一位小数的简单数字相加,计算机居然算不对,简直不可思议。

很多初级工程师在遇到类似问题时,往往通过 toFixed(2) 或打补丁的方式应付;但在涉及金融计息、大数计数或科学计算时,如果不彻底弄清浮点数的物理限制,微小的精度漂移在迭代累加后就会演变成严重的财务差错。

本站的 进制转换器分数化简器科学计算器 均针对高精度做了专门防护。这篇文章带你彻底穿透 IEEE 754 规范的内部世界。


1. 根源:十进制有限小数,在二进制下是无限循环小数

我们之所以觉得 0.1 和 0.2 是极度整洁的有限小数,完全是因为我们习惯了十进制(逢十进一)

在十进制中,一个最简分数能不能化为有限小数,取决于它的分母在质因数分解后是否只包含 10 的质因子(即 2 和 5)

  • 1/2=0.51/2 = 0.5(分母含 2,有限);
  • 1/5=0.21/5 = 0.2(分母含 5,有限);
  • 1/3=0.33331/3 = 0.3333\dots(分母含 3,十进制无限循环小数)。

二进制下的十进制小数

但是,现代计算机的硬件电路是基于二进制(逢二进一)构建的。 在二进制中,一个最简分数要能表示为有限小数,其分母的质因子只能包含 2

我们尝试把十进制的 0.10.1 转换为二进制(采用“乘 2 取整法”):

0.1×2=0.2取整数部分 00.2×2=0.4取整数部分 00.4×2=0.8取整数部分 00.8×2=1.6取整数部分 10.6×2=1.2取整数部分 10.2×2=0.4取整数部分 0(开始陷入 0011 循环!)\begin{aligned} 0.1 \times 2 &= 0.2 \quad \to \text{取整数部分 } 0 \\ 0.2 \times 2 &= 0.4 \quad \to \text{取整数部分 } 0 \\ 0.4 \times 2 &= 0.8 \quad \to \text{取整数部分 } 0 \\ 0.8 \times 2 &= 1.6 \quad \to \text{取整数部分 } 1 \\ 0.6 \times 2 &= 1.2 \quad \to \text{取整数部分 } 1 \\ 0.2 \times 2 &= 0.4 \quad \to \text{取整数部分 } 0 \quad \text{(开始陷入 0011 循环!)} \end{aligned}

展开成二进制形式:

0.110=0.00011001100110011001120.1_{10} = 0.000110011001100110011\dots_2

在十进制里看似简简单单的 0.1,在二进制的世界里就如同十进制下的 1/31/3 一样,是一个永无止境的无限循环小数!

同理,十进制的 0.20.2 转换为二进制也是无限循环:

0.210=0.0011001100110011001120.2_{10} = 0.00110011001100110011\dots_2

计算机的寄存器和内存长度是有限的,它无法存储无限长度的二进制位,必须在某一处硬生生将其截断并舍入


2. IEEE 754 双精度浮点数的内存布局

JavaScript 中所有的标准数字(number 类型)均遵循 IEEE 754 双精度 64 位(Double Precision Binary Floating-Point) 格式存储。

在物理内存中,每一个 64 位的浮点数被划分为三个严格的字段:

  1 bit        11 bits                     52 bits
[ 符号位 S ] [ 阶码/指数 E ] [               尾数/有效数 M               ]
   (0/1)    (Bias = 1023)    (隐藏前导 1,实际有效精度为 53 位)

其表示的数值公式为:

V=(1)S×(1.M)×2E1023V = (-1)^S \times (1.M) \times 2^{E - 1023}
  • 符号位(Sign, 1 bit):0 代表正数,1 代表负数;
  • 阶码(Exponent, 11 bits):取值范围 0~2047,引入 1023 的固定偏移量(Bias),实际指数范围为 1022+1023-1022 \sim +1023
  • 尾数(Fraction / Mantissa, 52 bits):规格化表示中,最高位必然是 1,因此该 1 默认被省去(不占内存),实际有效数字达到了 53 位

为什么安全整数上限是 2⁵³ - 1?

尾数有 52 位,加上隐含的前导 1,总共可以精确表达 53 位二进制有效数字。 因此,当整数大小在 [(2531),2531][- (2^{53} - 1), 2^{53} - 1] 范围内时,整数中的每一位都可以严丝合缝地塞进 53 位尾数中,绝无任何精度损失。 这就是 JavaScript 著名常量 Number.MAX_SAFE_INTEGER = 9007199254740991(即 25312^{53}-1)的根本来源。


3. 0.1 + 0.2 的截断与向偶数舍入

当将 0.10.1 存入 64 位浮点数时,无限循环的尾数只能保留 53 位有效位。

IEEE 754 默认采用**向偶数舍入(Round to nearest, ties to even)**规则:

  • 0.10.1 在第 53 位后被截断舍入,实际存入的值为: 0.1000000000000000055511151231257827021181583404541015625
  • 0.20.2 截断舍入后存入的值为: 0.20000000000000000277555756156289135105907917022705078125

将这两个带有些许微小上浮误差的二进制数在 CPU 的 ALU 中做加法运算后,结果为: 0.3000000000000000444089209850062616169452667236328125

当 JavaScript 引擎尝试将这个加法结果转换回人类可读的十进制字符串时,保留到有效精度极限,末尾多出来的极小偏差便暴露无遗,呈现出 0.30000000000000004


4. 生产环境的避坑准则

在工业级系统与 Web 前端工程中,处理精度问题有三条不可动摇的最佳实践:

  1. 金融货币系统:“化整为零”,一律用整型分(微)存储: 在任何电商交易、账单或支付系统中,数据库和前后端通信绝不要以浮点数 $12.34 传递,而应以整数分 1234(或厘/微)存储传递。由于整数在 9×10159 \times 10^{15}(9000 万亿元)以内是绝对精确的,加减乘除绝不会出现任何零头漂移,仅在最外层渲染 UI 时除以 100;
  2. 大整数运算升级为原生 BigInt: 对于雪花算法 ID(Snowflake ID)、64 位数据库主键或大数因数分解,原生 number 超过 2532^{53} 会发生低位静默归零(例如 9007199254740992 + 1 === 9007199254740992)。必须使用 ES2020 原生的 BigInt(如 1234567890123456789n);
  3. 高精度科学与财务计算:使用不可变 Decimal 库: 涉及复利贴现、连续除法或科学计算时,引入 decimal.jsbignumber.js,基于十进制字符串进行无损的高精度四则运算与按需舍入。