本文将从这几个方面带大家去理解JavaScript中的浮点数问题
1、介绍JS的Number
2、0.1 + 0.2 为什么不等于 0.3
3、最大安全数为什么是 2^53 - 1
JS中的Number到底是什么
在深入问题之前,我们先来了解下JS的Number在二进制中是怎么存储的
双精度浮点数
JS中的Number是以双精度浮点数的形式计算的,双精度浮点数总共有8个字节(byte),每字节有8比特(bit-位),即 8bit/byet,所以总共占位64位。
根据IEEE754的标准,双精度浮点数中的占位分为3个部分

这三个部分组成这样一个公式

第一部分:
Sign-符号位,长度是1,0是整数,1是负数
第二部分:
Exponent-指数位(阶码),长度是11,取值范围是[0,2047](也可以说是0 ~ 2^11-1),本身是无符号位,取值范围是[0,2047]
第三部分
Mantissa-尾数,长度是52
根据第二部分的描述,我们可以把公式优化成这样:
https://pic1.zhimg.com/80/v2-eb41bd8524c33b2920aa41af65fbb02c_1440w.jpg
看完这两个公式,可能有同学开始疑惑了???
为什么需要M+1 ?
公式是遵循科学计数法规范的,我们常见的科学计数法是这样的
1 2 3
| const a = 2021; => 2.21 * 10^3 => 2.21E3;
|
二进制中的科学计数法则是这样的
1 2 3 4 5
| const a = "101011000"; => 1.01011 * 2^8
|
为什么需要E-1023 ?
1 2 3 4 5
| const a = "101011000"; => 1.01011 * 2^8
|
所以E的实际取值范围分为负数[0,1022],正数[1024,2047]
举个例子
1 2 3 4 5 6 7 8 9 10
| const num = 8080;
const numDec = num.toString(2); => 1111110010000
=> 1.111110010000 * 2^12 E = 12 + 1023 => 10000001011 M = 111110010000{000...000补全到52位}
|

0.1 + 0.2 === 0.3 ?
大家一定经常看到 0.1 + 0.2 这样的送命题,也知道结果肯定不等于0.3
相似的问题还有
0.1 + 0.7 === 0.8 ?
0.2 + 0.4 === 0.6 ?
等等…
问题出现的原因
在我们了解完JS的Number存储机制后,我们分析一下0.1+0.2中出现的问题
先分析0.1
1 2 3 4 5 6 7 8 9 10
| const a = 0.1;
const aDec = a.toString(2) => 0.000110011001100110011001100110011001100110011001100110011001100{后面循环1100}
=> 1.1001100110011001100110011001100110011001100110011010 * 2^-4
=> 1.00000000000000005551115123126E-1 => 0.100000000000000005551115123126 > 0.1
|
同理 0.2 也会进行一样的操作
1 2 3 4 5 6 7 8 9 10
| const a = 0.2;
const aDec = a.toString(2) => 0.001100110011001100110011001100110011001100110011001101{后面循环0011}
=> 1.100110011001100110011001100110011001100110011001101 * 2^-3
=> 2.00000000000000011102230246252E-1 => 0.200000000000000011102230246252 > 0.2
|
0.1,0.2 两数相加
1 2 3 4 5
| 0.1 + 0.2 === 0.00011001100110011001100110011001100110011001100110011010 + 0.00110011001100110011001100110011001100110011001100110100
|
两个二进制数的相加过程
- 对阶
- 尾数运算
- 规格化
- 舍入
- 判断溢出
对阶是为了能让让两个数的尾数相加
举个栗子
1 2 3 4 5 6
| 3.5*10^3 + 8.1*10^2 ===
3.5*10^3 + 0.81*10^3 === (3.5 + 0.81)*10^3
|
回到 0.1 + 0.2 中,首先取两数阶码中较大的那个
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 0.1 => 0.00011001100110011001100110011001100110011001100110011010 => 2^-4 0.2 => 0.00110011001100110011001100110011001100110011001100110100 => 2^-3 ∆E = -3 - (-4) = 1 > 0
0.00011001100110011001100110011001100110011001100110011010 => 1.1001100110011001100110011001100110011001100110011010 * 2^-4
=> 1100110011001100110011001100110011001100110011001101(0)
01111111011 => 01111111100
0.1 => 0;01111111100;1100110011001100110011001100110011001100110011001101
|
至此,两数阶码相同了,下面开始尾数相加
1 2 3 4 5 6 7 8 9 10 11 12 13
| 0.1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1101 * 2^-3 + 1.1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010 * 2^-3 === => 10.0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0111 * 2^-3 ===
=> 1.00110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 011(1) *
=> 1.00110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 100 * 2^-2
=> 0.010011001100110011001100110011001100110011001100110100
|
二进制转化为十进制就不展开说了
1 2 3 4
| 0.010011001100110011001100110011001100110011001100110100 => 0.30000000000000004440892098500626
≈> 0.30000000000000004
|
到这里,大家熟悉的结果又出来了
以上就是 0.1 + 0.2 为什么不等于 0.3 的原因
为什么Number在加减乘除后就出现误差,单个就能正常显示
但这里引出了新的疑问,既然0.1和0.2都有精度缺失,那为什么0.1和0.2可以正确表示
1 2
| const a = 0.1; a === 0.1
|
Number.prototype.toPrecision
双精度浮点数的有效尾数是16位,在js中有效位数是17位
可以理解为JS引擎会默认保留最多17位的有效小数
1 2 3
| const a = 0.1; => (0.1).toPrecision(17)
|
如何避免计算时的精度误差
第一种,也是常用的方式
在计算过程中把小数都进位到整数后,再进行计算
1 2 3 4
| const a = 0.1; const b = 0.2; a + b => (a * 10 + b * 10) / 10
|
第二种
对计算结果做一个精度校准
1 2 3
| const a = 0.1; const b = 0.2; const c = parseFloat((0.1 + 0.2).toFixed(1))
|
对精度有很高的要求的场景,比如在算价的场景,建议使用BigInt相关库去操作
推荐一个 bignumberjs
最大安全数为什么是 2^53-1
什么是最大安全数?
最大安全数是指能在JS中不丢失精度的情况下,能表示的最大数字。
通过文档我们可以查到,JS中的最大安全数是2^53-1
MDN MAX_SAFE_INTEGER
1
| Number.MAX_SAFE_INTEGER === 2^53-1 === 9007199254740991
|
但是为什么说2^53-1是最大的安全数呢?2^53也能正确表示呀
1
| Number.MAX_SAFE_INTEGER + 1 === 2^53 === 9007199254740992
|
原因在于:从2^53开始,尾数超出了能表示的最大范围,超出的部分被舍去,也就出现了精度上的丢失。
我们可以试着先分析 2^53 - 1
1、分析2^53-1的二进制结构
1 2 3 4 5 6 7 8 9
| (2^53-1).toString(2)
=> 11111111111111111111111111111111111111111111111111111
=> 1.1111111111111111111111111111111111111111111111111111 * 2^52
=> M = 1111111111111111111111111111111111111111111111111111 => E = 52 + 1023 = 1075 => 10000110011
|

根据IEEE754标准,双精度浮点数中,尾数最多只有52位,当52位都为1时已经是能表示的最大数值了,并且精度未丢失。
我们试着给2^53 - 1加1,也就是Number.MAX_SAFE_INTEGER + 1
1 2 3 4 5 6 7 8 9 10 11 12 13
| Number.MAX_SAFE_INTEGER + 1
=> 1.1111111111111111111111111111111111111111111111111111 * 2^52 + 0.0000000000000000000000000000000000000000000000000001 * 2^52 => 10.0000000000000000000000000000000000000000000000000000 * 2^52
=> 1.0000000000000000000000000000000000000000000000000000(0) * 2^53
=> 1.0000000000000000000000000000000000000000000000000000 * 2^53
|

2^53是可以正确表示的,但是从这里开始已经出现了精度丢失,只是因为丢失的是0,所以对结果没有影响,也就不影响展示了
但是丢失的精度会对后续的计算造成很大的影响
我们在用同样的方式算一遍 Number.MAX_SAFE_INTEGER + 2
1 2 3 4 5 6 7 8 9 10 11 12 13
| Number.MAX_SAFE_INTEGER + 2
=> 1.1111111111111111111111111111111111111111111111111111 * 2^52 + 0.0000000000000000000000000000000000000000000000000010 * 2^52 => 10.0000000000000000000000000000000000000000000000000001 * 2^52
=> 1.0000000000000000000000000000000000000000000000000000(1) * 2^53
=> 1.0000000000000000000000000000000000000000000000000000 * 2^53
|

注意Number.MAX_SAFE_INTEGER + 2的结果,跟Number.MAX_SAFE_INTEGER + 1是一样的,原因就在于多出的第53位精度丢失了,因为丢的是1,所以影响了最后的结果
1
| Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2
|
JavaScript 浮点数陷阱及解法
浮点数的二进制表示
JavaScript 浮点数运算的精度问题
二进制浮点数的加减法运算