JavaScript 深入理解浮点数精度问题

301 阅读12分钟

背景

在开发过程中,可能会遇到下图展示的浮点算术得出的结果与预期不一致问题。如果不了解浮点数在计算机中是如何被处理的,遇到如下类似的问题就会让我们感到困惑。

但是浮点数精度问题并不是 JavaScript 语言特有的,归根结底来说是编程语言根据 IEEE 754 规范来实现浮点数而引入的,但是也不能说是规范的问题。因为在我们学习数学的时候也会遇到 1/3 = 1.666666... 永远也除不完的问题,这时候如果让我们保留两位小数的话,我们就会改写成 1/3 = 1.67

计算机处理类似的数跟我们日常使用的数学并没有什么不同,计算机使用的是二进制来存储数据,展示给我们看的一般是十进制,这时候需要经过二进制到十进制的转换,这一过程可能有永远也除不尽的情况,所以就导致了我们看到的十进制并不完全等于计算机内部存储的数据。

image.png

接下来我们就从二进制浮点数表示、存储到算术运算来一步步深入理解为什么浮点数进行计算后得出的数不是我们所期望的,但是浮点字面常量却展示很正常等情况。

二进制表示浮点数

十进制转化为二进制方法

  • 整数部分:把十进制转换成二进制一直分解至商数为0。读余数从下读到上,即是二进制的整数部分数字;
  • 小数部分:用其乘2,取其整数部分的结果,再用计算后的小数部分依此重复计算,算到小数部分全为0位置,之后读所有计算后整数部分的数字,从上读到下。

8.8(10)8.8_{(10)} 转为二进制:

  1. 整数部分:
8 / 2 = 40(余数)
4 / 2 = 20
2 / 2 = 10
1 / 2 = 01
  1. 小数部分:
0.8 * 2 = 1.61(取整)
0.6 * 2 = 1.21
0.2 * 2 = 0.40
0.4 * 2 = 0.80
0.8 * 2 = 1.61
// ...(一直循环)

所以 8.8(10)=1000.110011001100...(2)8.8_{(10)} = 1000.110011001100..._{(2)}

将小数部分 0.8(10)0.8_{(10)} 转为二进制数会导致永远除不尽的问题,跟之前说的十进制除法 1/3=0.666666... 是一样的。

既然除不尽,那么计算机存储这一串二进制总会有能存储多少位的限制,那么就会需要进行四舍五入,这么来看计算机在存储像 0.80.8 这样的浮点数时,存储的值本来精度就丢失了,精度丢失的浮点数再进行算术运算就会产生与预期不符的情况。

我们可以使用 toPrecision 方法打印出 Number 类型无法显示16位后的数值。在计算机中存储的 0.80.8 实际上是 0.800000000000000044408921...0.800000000000000044408921... 这样的数,但是我们使用数值类型时,只看到 0.80.8 是因为 IEEE 754 规范中的64位浮点数的有效位转化为十进制时最多只能展示16位有效位,0.80000000000000000.8000000000000000 这个是 JS 能完全展示的十进制有效位数,后面的0会被省略,最终我们就只看到 0.80.8 而已。后面会详细解释为什么是最多只能展示16位精度。

image.png

浮点数比较大或比较小时,我们常常使用科学记数法来表示,如果用一般的方法,将一个数的所有位数都写出来,会很难直接确知它的大小,还会浪费很多空间。

1000.110011001100...(2)1000.110011001100..._{(2)} 使用科学记数法来表示:

1000.110011001100...

IEEE 754 64位二进制浮点数的存储

在最新的 ECMAScript 2025 规范中,Number 类型的值对应双精度64位二进制形式的 IEEE 754-2019的值,也就是说 JavaScript 中数值是根据 IEEE 754-2019 规范来实现的,只要我们理解了 IEEE 754-2019 规范中对于这些数值是如何定义的,也就理解了在 JavaScript 中浮点数是如何存储的,为什么会导致浮点精度问题。

注:以下讲解以正数为例,可以通过改变符号位得到对应的负数。

浮点数有规约化和非规约化两种形式的数值:

  • 规约化浮点数(normal numbers):  一个非零、大于或等于 210222^{−1022}的数。
  • 非规约化浮点数(subnormal numbers):  是一个非零、比规约化的最小浮点数还小的数,用于填补浮点运算中零附近的下溢间隙。

image.png

符号(Sign)

符号占1个比特位,用来表示正负号。

  • 0 代表数值为正;
  • 1 代表数值为负。

指数(Exponent)

指数占11个比特位,用来表示次方数。它是一个无符号整数,范围从 002047(2111)2047(2^{11}-1)

指数有两个特殊情况:

  • e=00000000000(全0) 被用来表示 ±0±0 和非规约化数值;
  • e=11111111111(全1) 被用来表示 ±±∞ 和 NaNs。

尾数(Mantissa)

尾数部分占52个比特位,用来表示数值的精度。但实际上有53位有效位,包含一位隐藏的前导有效位。

  • 规约化浮点数(normal numbers)有一个隐式的前导有效位 1
  • 非规约化浮点数(subnormal numbers)有一个隐式的前导有效位 0

根据上述这三个部分,规约化浮点数可以被描述为: v=(1)s2e1.fractionv=(-1)^s * 2^e * 1.fraction

  • ss0011
  • 1e20461 ≤ e ≤ 2046(除去特殊情况)
  • 0fraction(1252)0 ≤ fraction ≤ (1 - 2^{-52})

非规约化浮点数可以被描述为:v=(1)s2e0.fractionv=(-1)^s * 2^e * 0.fraction

  • ss0011
  • e=1022e=-1022(前面有说过非规约化浮点数比规约化浮点数还要小,后面会说到规划化浮点数的偏移指数最小为 1022-1022
  • 252fraction(1252)2^{-52} ≤ fraction ≤ (1 - 2^{-52})(因为指数位全为0,所以尾数部分第52位数字必须为1,否则就与 +0+0 的编码冲突了)

根据规约化浮点数的描述,表示的最小正数为 (1)0201.0=1(-1)^0 * 2^0 * 1.0 = 1,那 (0,1) 区间的数值我们就无法表示了。由于尾数部分无法再小了,这时候需要从指数部分入手。

指数需要使用使用偏移表示法来实现偏移指数(biased exponent),使我们可以正确的表示一个合理的小数区间。

我们需要来模拟一个有符号数,不但范围内包含正数,也需要能包含负数。但是指数又只能表示整数,这时候就需要一个偏离的值来达到这个目的。为了使正数和负数区间的数数量一致,1023 ➜ (0 + 2047) / 2 作为偏移标准。

规约化浮点数可以重新描述为: v=(1)s2e10231.fractionv=(-1)^s * 2^{e-1023} * 1.fraction

定义 E=ebiasE=e-bias,所以 1022E1023-1022 ≤ E ≤ 1023。(别忘记了全0的指数被保留了,所以这里ee最小值为11;全1也是一样的。)

非规约化浮点数可以重新描述为: v=(1)s210220.fractionv=(-1)^s * 2^{-1022} * 0.fraction

浮点数编码

根据前面所讲的浮点数存储,我们可以得到如下表:

对应数值二进制编码结果
NormalminNormal_{min}(有一个隐式的前导1)0 00000000001 00000000000000000000000000000000000000000000000000002102212.2250738585072014×103082^{-1022} * 1 ≈ 2.2250738585072014 × 10^{−308}
NormalmaxNormal_{max}(有一个隐式的前导1)0 11111111110 111111111111111111111111111111111111111111111111111121023(1+(1252))=2^{1023} * (1 + (1 - 2^{-52}))= Number.MAX_VALUE
SubnormalminSubnormal_{min}(有一个隐式的前导0)0 00000000000 000000000000000000000000000000000000000000000000000121022252=210742^{-1022} * 2^{-52} = 2^{-1074} = Number.MIN_VALUE
SubnormalmaxSubnormal_{max}(有一个隐式的前导0)0 00000000000 111111111111111111111111111111111111111111111111111121022(1252)=2.2250738585072009×103082^{-1022} * (1 - 2^{-52}) = 2.2250738585072009 × 10^{−308}
+0+00 00000000000 0000000000000000000000000000000000000000000000000000-
0-01 00000000000 0000000000000000000000000000000000000000000000000000-
++∞0 11111111111 0000000000000000000000000000000000000000000000000000-
-∞1 11111111111 0000000000000000000000000000000000000000000000000000-
sNaNsNaN0 11111111111 0000000000000000000000000000000000000000000000000001-
qNaNqNaN0 11111111111 1000000000000000000000000000000000000000000000000001-

到这里基本已经掌握浮点数的存储格式了。我们可以对之前计算的十进制数 8.88.8,最后得到的二进制数科学记数法表示为 +1.000110011001100...23+1.000110011001100... * 2^3 进行双精度64位浮点数编码:

v=(1)0210261023(1+0.0001100110011001100110011001100110011001100110011001100...(2))v = (-1)^0 * 2^{1026 - 1023} * (1 + 0.0001100110011001100110011001100110011001100110011001100..._{(2)})

这个数的小数点会无限循环,但是计算机肯定无法给你所有空间来存储这个数,这时候我们需要通过取有效位数内的数字并进行四舍五入来得到我们的尾数部,尾数部分根据之前说的只能取52位,我们取53位出来看看:

0001100110011001100110011001100110011001100110011001100011001100110011001100110011001100110011001100110011

这时最后一位是 11,那么我们需要进位,如果是0就直接忽略,得到:

v=(1)0210261023(1+0.0001100110011001100110011001100110011001100110011010(2))v = (-1)^0 * 2^{1026 - 1023} * (1 + 0.0001100110011001100110011001100110011001100110011010_{(2)})

使用64位二进制进行编码得到:0 10000000010 0001100110011001100110011001100110011001100110011010,这就是计算机存储 8.88.8 这个数的形式,因为小数部分有精度问题,所以整个数存储到内存时就已经有问题了。

二进制浮点数转十进制

既然在内存中精度就有问题,那么我们将内存中存储的数据取出来,转回十进制看看是个什么数字。

v=(1)0210261023(1+0.0001100110011001100110011001100110011001100110011010(2))v = (-1)^0 * 2^{1026 - 1023} * (1 + 0.0001100110011001100110011001100110011001100110011010_{(2)})

简化得到:

v=8(1+0.0001100110011001100110011001100110011001100110011010(2))v = 8 * (1 + 0.0001100110011001100110011001100110011001100110011010_{(2)})

0.0001100110011001100110011001100110011001100110011010(2)0.0001100110011001100110011001100110011001100110011010_{(2)} 转为十进制数:

  • 021=00 * 2^{-1} = 0 (从小数点右边第1位开始算起,取结果求和)
  • 022=00 * 2^{-2} = 0
  • 023=00 * 2^{-3} = 0
  • 024=0.06250 * 2^{-4} = 0.0625
  • ...

结果为 0.1000000000000000890.100000000000000089,根据公式得出:

  • v=8(1+0.100000000000000089)v = 8 * (1 + 0.100000000000000089)
  • v=81.100000000000000089v = 8 * 1.100000000000000089

计算得出结果为 8.8000000000000007118.800000000000000711,与 8.88.8 不一致了,这就是我们一直讨论的精度问题。

在 Chrome 控制台运行看看显示结果:

image.png

但是,为什么我们看到的还是 8.8 呢?

十进制数最大有效位

由于64位的双精度浮点数的有效位是53位,所以在十进制数表示中有效位是16位log1025315.955log_{10}2^{53}≈15.955)。

之前 8.88.8 二进制反推到十进制后得出的十进制结果 8.800000000000000711,发现需要19个有效位才能展示完这串数字,但是64位浮点数最多只能展示16位十进制,那么就会变成 8.800000000000000,在 JavaScript 中会忽略掉多余的 00,最终变为 8.8,这也就是为什么我们看到的和实际的计算出来的不一定一样的原因。计算机用二进制存储所有的数据,当输出显示在屏幕上时使用十进制数展示一些浮点数会有精度问题。例如像 0.5 这样的浮点数可以完全表示,除得尽的数就不会有精度问题。

浮点数算术运算

前面我们已经掌握了浮点数的精度问题是如何产生的了。现在我们来解决为什么 8.8 - 0.1 ≠ 8.7?

8.8 = 0 10000000010 0001100110011001100110011001100110011001100110011010
0.1 = 0 01111111011 1001100110011001100110011001100110011001100110011010
-----------------------------------------
8.8 = 0 10000000010 0001100110011001100110011001100110011001100110011010
0.1 = 0 10000000010 0000001100110011001100110011001100110011001100110011 (转为与8.8偏移指数一致)
-----------------------------------------
    = 0 10000000010 0001011001100110011001100110011001100110011001100111

计算得出的二进制结果为:

0 10000000010 0001011001100110011001100110011001100110011001100111

使用公式转为十进制:

8.80.1=(1)0210261023(1+0.0001011001100110011001100110011001100110011001100111(2))8.8 - 0.1 = (-1)^{0} * 2^{1026-1023} * (1 + 0.0001011001100110011001100110011001100110011001100111_{(2)})

=8(1+0.08750000000000013)= 8 * (1 + 0.08750000000000013)

=8.700000000000001= 8.700000000000001

在 Chrome 控制台上验证一下我们的结果是一致的:

image.png

当浮点数存储在内存中已经有精度问题时,根据有精度问题的浮点数来进行算术运算,得出的结果不一定与我们预期的一致。所以,使用精度运算时我们需要注意,尤其是小数点判断相等、算术运算等用法时。

参考

  1. Double-precision floating-point format
  2. IEEE Standard for Floating-Point Arithmetic
  3. IEEE 754
  4. Subnormal number
  5. JavaScript 浮点数陷阱及解法
  6. JavaScript 深入之浮点数精度
  7. 二进制转换