深究浏览器长整型数值精度丢失问题

alert(28443422041709109)会输出什么?

背景

上一篇博文里我记录了一个诡异的前后端数据不一致的问题,最终定位为前端js精度丢失。但只说了原因及结论并没有深入研究这个问题。
这一篇博文准备在此基础上,深入探寻一番,彻底弄清楚这个问题发生的本质。

引子

让我们先来看几个小问题热热身:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
## js环境下运行,输出结果是什么?
1. console.log(1 === 1.0);

2. console.log(0.1 + 0.2 === 0.3);

3. console.log(9007199254740992);

4. console.log(9007199254740992 + 1);

5. console.log(9007199254740992 + 2);

6. console.log(9007199254740992 + 3);

7. console.log(9007199254740992 + 4);
... ...

浏览器环境下运行下看看是不是有点颠覆了三观?

JS数值的基础知识

在JS中,所有数值都是以64位浮点数形式保存的,所以1和1.0是相同的,是同一个数值。
浮点数不是精确的数值,也正因为此,0.1+0.2才不等于0.3

根据浮点数表示的标准:

1
2
3
第1位:符号位,0表示正数,1表示负数
第2-12位:指数部分
第13-64位:尾数部分(即有效数字)

符号位决定了正负,指数决定了数值大小,尾数决定了精度。

精度范围

其中有效数字第一位默认总是1,不保存在64位浮点数之中,也就是说有效数字总是1.xxx..的形式,最长可能为52位。加上默认的1,js中提供的有效数字最长为53个二进制位。
JS中数值浮点数表示形式公式为:

1
(-1)^符号位 * 1.xxx... * 2^指数位

因此精度最多只能表示到53个二进制位,即-(2^53 - 1) ~ 2^53。超过该范围的数值不能被精确表示。

数值范围

指数部分11位,最大值为2047(2^11 - 1),则数值范围为2^-1023 ~ 2^1024,超过该范围则无法表示。

分析问题

了解了JS中数值表示的方法后,我们回过头来分析具体的问题。
引言中alert(28443422041709109)为什么会输出28443422041709108呢?
转换为2进制为1100101000011010010010001000010111111100001101000110100,这串二进制数字我们使用进制转换工具转换一下可以得到28443422041709108,也就是说28443422041709108可以精确表示,而28443422041709109就不能精确表示了。为什么会这样,我们可以看一个更直观的例子:

让我们看一下引子中的几个问题:
9007199254740992是有效精度范围内最大的数,即2^53,二进制表示为100...0,1后面跟53个0。
9007199254740992 + 1则表示为100...01,使用浮点数表示法表示时,最后一位的1将由于超出位数被舍去,因此9007199254740992 + 19007199254740992的表示一致,因此也就相等了。
9007199254740992 + 2则表示为100..10,使用浮点数表示法表示时,1被保留下来,因此9007199254740992 + 2反而是可以精确表示的。

同样地,28443422041709108可以被精确地表示,而28443422041709109的最后两位被舍为了0,因此他们的二进制表示是一样的,也就造成了诡异的28443422041709108 === 28443422041709109问题。

结论&解决方案

当数值使用浮点数表示法表示精度位数超过53位时,就会存在精度丢失。大整数能够精确表示的上限是9007199254740992,超过则可能存在精度丢失。

JAVA的Long型整数超过了JS可以精确表示的大数范围,所以后端在涉及到和前端交互大数值时,建议使用String类型替换Long,否则可能会由于精度丢失导致产生奇怪的问题。

有时候项目中使用的类库中定义了Long型的大整数,由于封装特性我们没法直接修改,在跟前端交互时,吐出的数据一定要转为String返回给浏览器。目前只有交易订单号超过了位数,在处理订单号时一定要注意返回给浏览器时要将Long型转为String。