JavaScript 数值存储、精度与安全详解
在浮点数的世界里
精确」是偶然 , 「 近似」才是常态。 , 「
毕竟,计算机和程序员的使命便是用离散的数描绘这多彩的世界。
注:本文只讨论 JavaScript 中的 number
类型,不考虑 bigint
。
在 JavaScript 里,所有 number
类型的数字都是 IEEE 754 标准的双精度浮点数(即大家熟悉的 double
型1
与 1.0
是等价的。也就是说,JS 中根本没有整型的概念,所有数字都是浮点数
因此,要想弄清楚 JS 里的 number
类型,就要先搞懂 IEEE 754 标准对双精度浮点的定义。
存储格式
「浮点」的「浮」意味着小数点的位置是「浮动」的,并不是固定存储多少位小数。实际上存储采用的是二进制下的科学计数法。双精度使用 64 个二进制位(8 个字节)来表示科学计数法的 3 个部分:
1 位用于表示符号 sign(用
表示) 0 表示正数,1 表示负数
11 位用于表示指数 exponent(也称阶码,用
表示) 指数以 2 为底,并且实际存储的是加上 1023 后的值(1023 称为「偏置
这样就可以表示负数指数了」 ) , 52 位用于表示尾数 mantissa(用
表示,fraction 的首字母) 科学计数法开头的那个数字的小数部分。
NOTE
Float 类型是 1 位符号位、8 位阶码和 23 位尾数,共计 32 位。
由于阶码是 11 个二进制位,在十进制下对应取值为
- 规格数 normal number:
且 - 非规格数 subnormal number:
,即指数位全为 0 - 特殊数 non-number:
,即指数位全为 1
下面我们一个个来看。
规格数
规格数用于表示最常见的数值。规格数是可以用「标准」的科学计数法来表示的数。规格数的阶码取值范围是
现在考虑科学计数法前面的那个系数。这里有一个小心思,系数的第一位上一定是非零的,在二进制下就一定是「1.xxx
所以最后实际数值的计算公式为:
规格数存储举例
存储数字
最终表示:0 10000001000 0100001000000000000000000000000000000000000000000000
存储数字
最终表示:1 10000000110 1110011111000000000000000000000000000000000000000000
存储数字
最终表示:0 10001101011 1100100110001010000100010011010001111101111110100001
52 个二进制位存储的数字范围是
所以算上指数,规格数能表示的范围(绝对值)就应该是
这里的最大值就是 double
型能表示的最大数字了,但是这里还有点问题:
- 其实如果把开头的 1 拿掉,其实还可以表示更小的数
- 如果开头的 1 一直在,就不能表示 0!
这两个问题由非规格数解决。
非规格数
非规格数考虑比规格数更靠近 0 的情况。我们希望非规格数的最大值和规格数的最小值能够平稳地衔接起来,防止出现「数值悬崖
0 00000000001 0000000000000000000000000000000000000000000000000000
-> 0 00000000000 1111111111111111111111111111111111111111111111111111
尾数位依然只保存小数点后的尾数,但是此时规定小数点前的数字为 0 而非 1。那这样规格数的最大值就会是
所以非规格数可以看作是一种「畸形」的科学计数法,在指数无法继续变小时让系数小于 1 来达到目标,产生类似
尾数位表示的范围依然为
算上指数
这便是非规格数所表示的数字范围。注意到:
非规格数相比规格数,可以表示更靠近 0 的数。并且这里非规格数的最大值和前面规格数的最小值(
)平稳地衔接了起来,这被称为「逐渐溢出 gradual underflow」 ( ) 。 0 可以表示了。
规格数的两个问题都解决了。但是要注意,虽然表达出了更靠近 0 的数,但这是以精度为代价的 —— 非规格数不再保证 53 位有效数字的精度。
此外,我们还关心能表示的最小正数。令尾数
这就是非规格数中除了 0 之外的最小数字。
所以,double
类型所能表示的数在数轴上大概是(图未按比例
特殊数
特殊数分成两种:无穷和 NaN
。规定:
- 当指数位全为 1 且尾数位全为 0 时,为无穷。符号位可正可负,分别对应
+Infinity
和-Infinity
。 - 当指数位全为 1 且尾数为不全为 0 时,为
NaN
。
溢出
- 正向溢出:如果一个数的绝对值大于等于
,则发生正向溢出变成 Infinity
。 - 负向溢出:如果一个数的绝对值小于等于
,则发生负向溢出变成 0
。
正向和负向溢出不会改变符号位。
在 JS 中,溢出的临界点可以通过调用 Number 对象的两个静态属性 Number.MAX_VALUE
和 Number.MIN_VALUE
得到。
Number.MAX_VALUE; // 1.7976931348623157e+308
Number.MIN_VALUE; // 5e-324
舍入
IEEE 754 的默认舍入模式称作「向最邻近值舍入
假设有十进制小数
但是
答案是:会
听起来「向偶数舍入」是个相当随意的选择 —— 为什么是偶数而不是奇数呢?统计意义上看偶数和奇数出现的概率相等,所以向偶数舍入可以随机地选取舍入方向,避免了系统性的偏差。此外这样的操作在底层门电路中很好实现。
回到二进制,假设对于某个二进制小数,小数点后
- 对于非中间值的数,即
,或者 且第 位或以后有为 1
的位( 例如) , 时的 等,就按最临近取整即可。 - 对于中间值的数,即
且 ,向偶数取整。在这里我们推广奇偶的概念:将最低有效位为 0
的称为偶数,为1
的称为奇数。此时假设,对于二进制小数 ,向下取整变成「偶数」 ,向上取整变成「奇数」 ,因此选择向下取整变为 。
当 时的舍入处理举例
舍入前 | 舍入后 | 状态 |
---|---|---|
向下 | ||
向下 | ||
向偶数 (上) | ||
向上 | ||
向上 | ||
… | … | … |
向下 | ||
向偶数 (下) | ||
向上 |
尝试一下
二进制数 1.010100
(小数点后 6 位)保留 3 位小数,结果是?
【答案】向偶数舍入,下取整为 1.010
查看数字存储
上面说了这么多,那有没有可以直接看到 JS 中某个数字是怎么存储的方法?有的兄弟,有的。可以借助 JS 中的 Buffer 来处理:
function getDoubleBinary(num) {
const buffer = new ArrayBuffer(8); // 8 字节存储 64 位
new DataView(buffer).setFloat64(0, num); // 以双精度格式写入
const bytes = new Uint8Array(buffer);
let binary = "";
for (let byte of bytes) binary += byte.toString(2).padStart(8, "0");
const sign = binary[0];
const exponent = binary.substr(1, 11);
const mantissa = binary.substr(12);
return { sign, exponent, mantissa };
}
这样我们就可以看到:
getDoubleBinary(3.8);
// sign: "0"
// exponent: "10000000000"
// mantissa: "1110011001100110011001100110011001100110011001100110"
getDoubleBinary(5e-324);
// sign: "0"
// exponent: "00000000000"
// mantissa: "0000000000000000000000000000000000000000000000000001"
getDoubleBinary(-Infinity);
// sign: "0"
// exponent: "11111111111"
// mantissa: "0000000000000000000000000000000000000000000000000000"
NOTE
有意思的是,ECMAScript 标准的 6.1.6.1 The Number Type 一节中提到:
The bit pattern that might be observed in an ArrayBuffer (see 25.1) or a SharedArrayBuffer (see 25.2) after a Number value has been stored into it is not necessarily the same as the internal representation of that Number value used by the ECMAScript implementation.
即,标准不要求在 ArrayBuffer 中输出的位模式与引擎实现中的存储模式完全相同。但这其实不太重要,毕竟引擎的外在的行为和输出都遵守 IEEE 754 64 位双精度浮点标准,那我们也没必要关心引擎内部具体是什么。
你也可以使用可视化工具 VisualNumeric64 或者 IEEE-754 Floating Point Converter 来查看数字的存储方式。
为什么不用 .toString(2)
观察
Number
对象重写了 Object
对象的 toString
方法,允许接受一个正整数作为基数,输出在该数制下的数字字符串。
(0.1).toString(2);
// "0.0001100110011001100110011001100110011001100110011001101"
这里先不提无法观察指数位的问题,先关注精度问题。如果你数一下这里的有效数字位数就会发现,1100...1101
这里总共只有 52 位。而根据上面的介绍,这里应该有「开头的 1」加上后面 52 位共计 53 位。少了一位去哪了?
getDoubleBinary(0.1);
// sign: "0"
// exponent: "01111111011"
// mantissa: "1001100110011001100110011001100110011001100110011010"
通过刚才的 getDoubleBinary
函数输出的是 52 位尾数位。注意到尾数位的最后一位是 0
,结尾是 11010
。也就是说,toString(2)
在输出的时候将末尾的 0
略去了。这不利于我们观察精度。
toString
方法这样处理是合理的,毕竟没有人想在调用 (0.5).toString(2)
时看到 0.100000000000000...
的输出。有关 toString
是如何定义的,可参阅标准文档:ECMA262 6.1.6.1.20 Number::toString。
存储格式带来的细节问题
+0
和 -0
我们刚才讨论的时候似乎都没有符号位什么事。从另外一个角度说就是,对于任意一个值,改变其符号位就能得到与之对应的相反数,0 也不例外。
getDoubleBinary(0);
// sign: "0"
// exponent: "00000000000"
// mantissa: "0000000000000000000000000000000000000000000000000000"
getDoubleBinary(-0);
// sign: "1"
// exponent: "00000000000"
// mantissa: "0000000000000000000000000000000000000000000000000000"
它们基本上是等价的,但在部分特殊情况下会有不同的行为。
// 二者通常等价:
0 === -0; // true
(0).toString(); // "0"
(-0).toString(); // "0"
// 不同的行为:
1 / 0; // Infinity
1 / -0; // -Infinity
Object.is(0, -0); // false
NaN
的符号位和尾数位
NaN
的符号为和尾数位没有明确规定,不同的实现也可能会有不同的处理方式。这里甚至你无法依赖上面的 getDoubleBinary
函数 —— 因为前面提过,标准不要求在 ArrayBuffer 中输出的位模式与引擎实现中的存储模式完全相同。
这里还涉及到 qNaN
(Quiet NaN)和 sNaN
(Signal NaN)的区别。部分平台会在尾数位中存储额外的信息。但作为前端程序员,我们不需要关心,也接触不到这些细节。
如果你非要关心,JS 提供了按位操作数字内存的能力,可以参考:MDN:明显不同的 NaN 值。
精度问题
整数安全
JS 中取得最大安全整数就是当尾数位的每个位都用上,并且最后一位恰落在个位。尾数总共 52 位,加上第一位的 1,一共 53 位,所以最大安全整数是 Number.MAX_SAFE_INTEGER
和 Number.MIN_SAFE_INTEGER
得到,大概是
Number.MAX_SAFE_INTEGER; // 9007199254740991
Number.MIN_SAFE_INTEGER; // -9007199254740991
比它大的整数不是不能存储和表示,而是会出现一些问题:
2 ** 53 + 0; // 9007199254740992
2 ** 53 + 1; // 9007199254740992
2 ** 53 + 2; // 9007199254740994
2 ** 53 + 3; // 9007199254740996
2 ** 53 + 4; // 9007199254740996
大整数的问题在 ES2020 中得到了解决。ES2020 引入了 bigint
大整数类型,提供原生的高精度整数支持。在存储时,其本质是多个 32 位整数。你可以在 MDN:BigInt 查看更多关于 bigint
的信息。这里也总结一个简单的表格以供参考:
特性 | number | bigint |
---|---|---|
小数 | 允许 | 不允许 |
精度 | 53 位 | 任意精度 |
运算符支持 | 全部 | 除 >>> 外 |
类型转换 | 隐式转换 | 必须显式转换 |
小数精度问题
0.1 + 0.2 != 0.3
应该算是老梗了:
0.1 + 0.2 === 0.3; // false
0.2 + 0.7 === 0.9; // false
0.125 + 0.25 == 0.375; // true
其本质原因其实很好理解:十进制下的有限小数在二进制下未必是有限小数。
对于
在二进制就是:
很显然 0.1
和 0.2
是不符合这个表达式的。可以看到:
(0.1).toString(2);
// sign: "0"
// exponent: "01111111011"
// mantissa: "1001100110011001100110011001100110011001100110011010"
(0.2).toString(2);
// sign: "0"
// exponent: "01111111100"
// mantissa: "1001100110011001100110011001100110011001100110011010"
0.1
和 0.2
在存储时转换为二进制,而二进制下的 0.1
和 0.2
是无限循环小数,存储的是取整过的近似值。进行运算时:
(0.1 + 0.2).toString(2);
// sign: "0"
// exponent: "01111111101"
// mantissa: "0011001100110011001100110011001100110011001100110100"
(0.3).toString(2);
// sign: "0"
// exponent: "01111111101"
// mantissa: "0011001100110011001100110011001100110011001100110011"
可以看到由于 0.1
和 0.2
在存储时都向上取整了,0.1 + 0.2
的结果也比 0.3
(的近似值)大了一点点。
但如果是满足上面那个求和式的小数(即 2 的幂的倍数之和0.125
和 0.25
,它们的和 0.375
是可以精确表示的。
0.125 + 0.25 === 0.375; // true
小数的精度问题是一个很大的话题,有很多库和算法都是为了解决这个问题而生的。在实践中最简单粗暴的方式就是避免小数运算,转而使用整数运算。例如对于金额,应当存储和处理货币最小单位(例如「分」而非「元
位运算时的整数安全
在 JavaScript 中,位运算符 &
、|
、^
、~
、<<
、>>
、>>>
只能操作 32 位有符号整数。当操作的数不是 32 位整数时,JavaScript 引擎会先将其转换为 32 位整数,然后再进行位运算。
32 位有符号整数的安全范围是
本目录下的 位运算 中有更多关于位运算的内容。
延伸阅读
以上说的这些都是标准中规定的、引擎的外在表现。事实上,我们在 JS 代码中大量存储和使用整数,如果这些数字真的在底层都使用双精度浮点的格式存储,肯定是不划算的。对此,所有 JS 引擎都有一个特殊的整数表示方式,例如 V8 有所谓的 Smis
小整数。
如果有兴趣,可以阅读:
参考文献
- WangDoc 网道:JavaScript 数值
- 知乎 @等夏天再见啦:IEEE754 规范:四,非规格数,±infinity, NaN
- Wikipedia: 双精度浮点
- Wikipedia: NaN
- ECMA 262 标准
此外,本文的完成也离不开 DeekSeek R1 模型,没有跟它的「争吵」就不会有这篇文章。