IEEE 754 与 Number 精度:0.1+0.2 的底层真相
0.1 + 0.2 在 JavaScript 中等于 0.30000000000000004,但 0.1 + 0.7 恰好等于 0.8。同样是浮点运算,为什么结果截然不同?答案藏在 IEEE 754 标准的每一个比特位里。
🔹 Level 1 · 你需要知道的
JavaScript 只有一种数字类型
JavaScript 的所有数字——无论整数、小数、大数——都用 64 位 IEEE 754 双精度浮点数(double)表示。这是 ECMAScript 规范的强制规定,没有单独的 int 类型。
// 以下都是同一种类型:64位浮点数
typeof 42 // "number"
typeof 3.14 // "number"
typeof NaN // "number"
typeof Infinity // "number"
typeof -0 // "number"
typeof 1e300 // "number"
// 整数精度的安全范围
Number.MAX_SAFE_INTEGER // 9007199254740991(即 2^53 - 1)
Number.MIN_SAFE_INTEGER // -9007199254740991(即 -(2^53 - 1))
// 超出安全范围后,整数精度开始丢失
9007199254740991 + 1 // 9007199254740992(正确)
9007199254740991 + 2 // 9007199254740992(错误!应为 9007199254740993)
9007199254740992 + 1 // 9007199254740992(1 消失了!)
安全整数范围的判断
// 检查是否在安全整数范围内
Number.isSafeInteger(9007199254740991) // true
Number.isSafeInteger(9007199254740992) // false
// 实际场景:数据库 ID、时间戳等超大整数
// ❌ 危险:超过 2^53 的整数 ID 会丢失精度
const bigId = 9007199254740993;
console.log(bigId); // 9007199254740992(精度丢失!)
// ✅ 正确:使用 BigInt 处理超大整数
const safeBigId = 9007199254740993n;
console.log(safeBigId); // 9007199254740993n(精确)
// ✅ 或者用字符串存储
const idAsString = "9007199254740993";
浮点数比较的正确姿势
// ❌ 永远不要直接比较浮点数结果
0.1 + 0.2 === 0.3 // false!
// ✅ 方法1:使用 Number.EPSILON(约 2.22 × 10^-16)做误差范围比较
function almostEqual(a, b) {
return Math.abs(a - b) < Number.EPSILON;
}
almostEqual(0.1 + 0.2, 0.3) // true
// ✅ 方法2:精度更友好的误差范围(根据业务需求调整)
function nearlyEqual(a, b, epsilon = 1e-9) {
return Math.abs(a - b) < epsilon;
}
// ✅ 方法3:金融计算——整数分(最可靠)
// 将金额乘以 100 转为分,用整数运算
function addMoney(a, b) {
// a = 0.1 元,b = 0.2 元
const cents = Math.round(a * 100) + Math.round(b * 100);
return cents / 100; // 0.3 元
}
addMoney(0.1, 0.2) // 0.3
// ✅ 方法4:使用 toFixed 后转字符串(展示用,不用于计算)
(0.1 + 0.2).toFixed(1) // "0.3"(字符串!)
金钱计算的正确做法
// ❌ 错误:直接用浮点数做金融计算
const price = 1.1; // 1.1 元
const quantity = 3;
const total = price * quantity; // 3.3000000000000003!
// ✅ 正确做法1:全程使用分(整数)
class Money {
constructor(cents) {
this.cents = Math.round(cents); // 单位:分(整数)
}
add(other) {
return new Money(this.cents + other.cents);
}
multiply(n) {
return new Money(Math.round(this.cents * n));
}
toString() {
return (this.cents / 100).toFixed(2) + " 元";
}
}
const price2 = new Money(110); // 1.10 元 = 110 分
const total2 = price2.multiply(3);
console.log(total2.toString()); // "3.30 元"
// ✅ 正确做法2:使用 decimal.js 库(npm install decimal.js)
// import Decimal from 'decimal.js';
// new Decimal('0.1').plus('0.2').toString() // "0.3"(精确)
🔸 Level 2 · 它是怎么运行的
IEEE 754 双精度浮点数的64位结构
64位 double 内存布局:
63 62 52 51 0
┌───┬───────────────┬─────────────────────────────────────┐
│ S │ Exponent │ Mantissa │
│ 1 │ 11位 │ 52位 │
└───┴───────────────┴─────────────────────────────────────┘
↑ ↑ ↑
符号位 指数(偏移量1023) 尾数(有效数字)
S(Sign):0 = 正数,1 = 负数
Exponent(指数,11位):
存储值 = 实际指数 + 1023(偏移编码/bias)
存储值范围:0-2047
实际指数范围:-1022 到 +1023(0 和 2047 是特殊值)
Mantissa(尾数/分数,52位):
实际有效数字是 1.尾数(隐含前导1,除非指数为0)
等效于 53 位有效精度(1 + 52)
数值计算公式:
value = (-1)^S × 2^(E-1023) × 1.M
其中 E 是指数存储值,M 是尾数部分
0.1 的二进制表示与截断
0.1 的十进制转二进制过程:
0.1 × 2 = 0.2 → 取整 0,余 0.2
0.2 × 2 = 0.4 → 取整 0,余 0.4
0.4 × 2 = 0.8 → 取整 0,余 0.8
0.8 × 2 = 1.6 → 取整 1,余 0.6
0.6 × 2 = 1.2 → 取整 1,余 0.2 ← 循环开始!
0.2 × 2 = 0.4 → 取整 0,余 0.4
... (无限循环)
0.1 的二进制表示(无限循环小数):
0.0001100110011001100110011001100110011001100110011...
^^^^
这4位是 0.0625 = 1/16,然后开始循环 0011
存储在 double 中(53位有效位后截断):
0.1 实际存储值 ≈ 0.1000000000000000055511151231257827021181583404541015625
0.2 同理(实际存储值略大于 0.2):
0.2 实际存储值 ≈ 0.200000000000000011102230246251565404236316680908203125
0.1 + 0.2 的加法:两个"略大"的值相加
≈ 0.30000000000000004440892098500626...
显示为:0.30000000000000004
0.3 的直接存储值:
≈ 0.299999999999999988897769753748434595763683319091796875
注意:0.3 的存储值略小于 0.3!
所以 0.1 + 0.2(略大于0.3)!= 0.3(略小于0.3)
为什么 0.1 + 0.7 === 0.8
0.7 的二进制截断:
0.7 实际存储值略小于 0.7
0.1(略大)+ 0.7(略小)→ 截断误差相互抵消
得到的和恰好等于 0.8 的存储表示
这就是浮点运算的"幸运巧合":
不同数的截断误差方向不同,有时会相互抵消,有时会累积。
NaN、-0、±Infinity 的特殊位模式
特殊值的 IEEE 754 位模式:
值 S 指数(11位) 尾数(52位)
─────────────────────────────────────────────
+Infinity 0 11111111111 0...0(全0)
-Infinity 1 11111111111 0...0(全0)
NaN x 11111111111 非全0(任意非零)
+0 0 00000000000 0...0(全0)
-0 1 00000000000 0...0(全0)
NaN 的特殊性:
有 2^52 - 1 = 4503599627370495 种不同的 NaN 位模式
JavaScript 规范将它们统一视为一个 NaN 值
NaN !== NaN 是 IEEE 754 规范的要求,不是 JS 的 bug
-0 的特殊性:
-0 === +0 为 true(规范规定)
但 1 / -0 === -Infinity,1 / +0 === +Infinity
Object.is(-0, +0) 为 false(Object.is 区分 -0 和 +0)
🔺 Level 3 · 规范怎么定义的
6.1.6.1 The Number Type
规范原文(ECMA-262 第 6.1.6.1 节):
6.1.6.1 The Number Type
The Number type has exactly 18437736874454810627 (that is, 2^64 − 2^53 + 3) values, representing the double-precision 64-bit format IEEE 754-2019 values as specified in the IEEE Standard for Binary Floating-Point Arithmetic, except that the 9007199254740990 (that is, 2^53 − 2) distinct "Not-a-Number" values of the IEEE Standard are represented in ECMAScript as a single special NaN value. (Note that the NaN value is produced by the program expression NaN.) In some implementations, external code might be able to detect a difference between various Not-a-Number values, but such behaviour is implementation-defined; to ECMAScript code, all NaN values are indistinguishable from each other.
关键数字解读:
- 18437736874454810627 = 2^64 - 2^53 + 3
- 2^64 = 所有 64 位组合数量
- -2^53 = 减去 NaN 的 2^52 种位模式(规范统一视为1个)再减2(-0和+0各计一次)
- +3 = 加回 NaN(1个)+ +0(1个)+ -0(1个)
Math 函数的规范定义
规范对 Math.floor、Math.ceil、Math.round 的定义依赖于一个抽象概念:数学实值(mathematical value,简写 ℝ(x)):
21.3.2.16 Math.floor ( x )
This function returns the largest (closest to +∞) integral Number value that is not greater than x. If x is already an integral Number, the return value is x.
- If x is NaN, +∞𝔽, or -∞𝔽, return x.
- If x is +0𝔽 or -0𝔽, return x.
- If x < -0𝔽 and x > -1𝔽, return -0𝔽. ← 注意这里!
特殊情况:Math.floor(-0.5) 返回 -0,因为 -0.5 < -0 且 -0.5 > -1,满足第三条规则。
Math.floor(-0.5) // -0(不是 -1!)
Math.floor(-0.0) // -0
Math.floor(0.0) // 0
// 验证
Object.is(Math.floor(-0.5), -0) // true
Object.is(Math.floor(-0.5), 0) // false
Number.prototype.toFixed 的精度问题
规范中 toFixed 的实现基于十进制字符串表示,但它依然有精度陷阱:
// toFixed 的意外行为
(1.005).toFixed(2) // "1.00" 而不是 "1.01"!
// 原因:1.005 在 double 中实际是 1.00499999...
(1.255).toFixed(2) // "1.25"(同样的问题)
(1.355).toFixed(2) // "1.35"
// 更可靠的四舍五入
function round(num, digits) {
const factor = Math.pow(10, digits);
return Math.round(num * factor) / factor;
}
round(1.005, 2) // 1.01(但仍然依赖乘法精度)
// 最可靠的方法:用字符串+BigInt(适合金融场景)
function preciseRound(numStr, digits) {
// numStr: 字符串形式的数字,如 "1.005"
const [int, dec = ""] = numStr.split(".");
const padded = dec.padEnd(digits + 1, "0");
const lastDigit = parseInt(padded[digits]);
const bigVal = BigInt(int + padded.slice(0, digits)) + (lastDigit >= 5 ? 1n : 0n);
const result = bigVal.toString();
const intPart = result.slice(0, -digits) || "0";
const decPart = result.slice(-digits).padStart(digits, "0");
return `${intPart}.${decPart}`;
}
preciseRound("1.005", 2) // "1.01"(正确!)
💎 Level 4 · 边界与陷阱
陷阱1:0.1 + 0.2 !== 0.3 但 0.1 + 0.7 === 0.8
// 位级别的真相
// 在 JavaScript 中可以用以下方式查看浮点数的精确值:
function toExactDecimal(n) {
return n.toPrecision(21); // 21位有效数字足以显示所有精度差异
}
toExactDecimal(0.1) // "0.100000000000000005551"
toExactDecimal(0.2) // "0.200000000000000011102"
toExactDecimal(0.3) // "0.299999999999999988898"
toExactDecimal(0.1+0.2)// "0.300000000000000044409"
// 0.1 + 0.2(约 +4.4e-17 误差)> 0.3(约 -1.1e-17 误差)→ 不相等
toExactDecimal(0.7) // "0.699999999999999955591"
toExactDecimal(0.8) // "0.800000000000000044409"
toExactDecimal(0.1+0.7)// "0.800000000000000044409"
// 0.1 + 0.7 得到的位模式恰好等于 0.8 的位模式 → 相等!
陷阱2:精度丢失的整数运算
// 2^53 = 9007199254740992
const MAX_SAFE = Number.MAX_SAFE_INTEGER; // 9007199254740991
// 在安全范围内:精确
MAX_SAFE + 1 // 9007199254740992(正确)
MAX_SAFE + 2 // 9007199254740992(错误!应为9007199254740993)
// 为什么?2^53 以上,相邻可表示的 double 值间距为 2
// 即 9007199254740992 和 9007199254740994 是相邻的 double 值
// 9007199254740993 无法精确表示,被四舍五入到 9007199254740992
// 更大的数字,间距更大
2**53 // 9007199254740992
2**53 + 1 // 9007199254740992(1 消失)
2**54 // 18014398509481984
2**54 + 1 // 18014398509481984(间距为2,1消失)
2**54 + 2 // 18014398509481984(间距为2,2消失!)
2**54 + 4 // 18014398509481988(第一个可表示的差异)
// 实际后果
const userId = 9007199254740993; // 来自后端的大ID
console.log(userId === 9007199254740992); // true!ID 被改变了
陷阱3:-0 的检测
// -0 的存在
const negZero = -0;
const posZero = 0;
// 常规比较认为它们相等
negZero === posZero // true
negZero == posZero // true
// 但它们不是同一个值
1 / negZero // -Infinity
1 / posZero // +Infinity
// 检测 -0 的正确方式
Object.is(negZero, -0) // true
Object.is(posZero, -0) // false
Object.is(negZero, 0) // false
// 传统方式(利用除法)
function isNegativeZero(n) {
return n === 0 && 1/n === -Infinity;
}
isNegativeZero(-0) // true
isNegativeZero(0) // false
// -0 在什么时候出现?
Math.sign(-0) // -0(保持符号)
-0 * 2 // -0
-0 / 2 // -0
-0 + (-0) // -0
-0 + 0 // 0(正零!)
-1 * 0 // -0
Math.round(-0.4) // -0
// 实际业务中的 -0 陷阱
const speed = -0; // 来自某个物理计算
if (speed < 0) {
console.log("向左移动");
} else {
console.log("向右移动"); // ← 会走这个分支!因为 -0 < 0 是 false
}
// 正确做法:用 Object.is 检测
if (Object.is(speed, -0)) {
console.log("静止但有负方向");
}
陷阱4:NaN 的检测与传播
// NaN 的特殊性:唯一不等于自身的值
NaN === NaN // false(IEEE 754 规范要求)
NaN !== NaN // true
// 全局 isNaN vs Number.isNaN 的区别
isNaN(NaN) // true(正确)
isNaN("hello") // true!("hello" 先被转为 Number → NaN)
isNaN(undefined) // true!(undefined → NaN)
isNaN({}) // true!({} → NaN)
isNaN(null) // false(null → 0)
Number.isNaN(NaN) // true(正确)
Number.isNaN("hello") // false(不做类型转换!)
Number.isNaN(undefined) // false
Number.isNaN({}) // false
Number.isNaN(null) // false
// NaN 的传播
NaN + 1 // NaN
NaN * 0 // NaN(不是 0!)
NaN ** 0 // 1(例外!任何值的0次幂是1,包括NaN)
Math.max(NaN, 1)// NaN
[1, NaN, 3].reduce((a, b) => a + b) // NaN(一个NaN污染整个计算)
// 过滤 NaN
const nums = [1, NaN, 3, NaN, 5];
const clean = nums.filter(Number.isFinite); // [1, 3, 5]
// 注意:Number.isFinite 同时过滤 NaN 和 Infinity
陷阱5:金融计算的完整解决方案
// 实际的金融计算库(简化版)
class Decimal {
// 内部使用整数分(或更小单位)
constructor(value) {
if (typeof value === 'string') {
// 解析字符串,避免浮点误差
const parts = value.split('.');
const intPart = BigInt(parts[0]);
const decStr = (parts[1] || '').padEnd(10, '0').slice(0, 10);
this._value = intPart * 10000000000n + BigInt(decStr);
} else if (typeof value === 'number') {
// 浮点数转字符串再解析(损失已发生,但尽量减小影响)
this._value = BigInt(Math.round(value * 10000000000));
}
this._scale = 10000000000n; // 10^10,10位小数精度
}
add(other) {
const result = new Decimal(0);
result._value = this._value + other._value;
return result;
}
multiply(other) {
const result = new Decimal(0);
result._value = (this._value * other._value) / this._scale;
return result;
}
toString() {
const absVal = this._value < 0n ? -this._value : this._value;
const sign = this._value < 0n ? '-' : '';
const str = absVal.toString().padStart(11, '0');
const intPart = str.slice(0, -10) || '0';
const decPart = str.slice(-10).replace(/0+$/, '') || '0';
return `${sign}${intPart}.${decPart}`;
}
}
// 使用
const price = new Decimal('0.1');
const qty = new Decimal('3');
const tax = new Decimal('0.07');
const subtotal = price.multiply(qty);
const taxAmount = subtotal.multiply(tax);
console.log(subtotal.toString()); // "0.3"
console.log(taxAmount.toString()); // "0.021"
本章小结
-
JavaScript 所有数字都是 IEEE 754 64位双精度浮点数:53位有效精度,整数安全范围 ±(2^53-1) = ±9007199254740991。超出此范围的整数会静默丢失精度,没有错误提示。
-
0.1 + 0.2 !== 0.3的根本原因:0.1 和 0.2 在二进制中都是无限循环小数,存储时截断产生误差(均略大于真实值),两者相加的误差大于 0.3 直接存储的误差,方向不同导致结果不等于 0.3 的存储值。 -
-0存在且有意义:-0 === 0为 true,但1/-0 === -Infinity。需要用Object.is(x, -0)或1/x === -Infinity来区分 -0 和 0。物理模拟、方向计算等场景需要注意。 -
Number.isNaN优于全局isNaN:全局isNaN先做类型转换(isNaN("hello")为 true),Number.isNaN严格判断(只有真正的 NaN 返回 true)。NaN 通过算术运算传播,会"污染"整个计算链。 -
金融计算必须用整数或专用库:绝对不要直接用浮点数做金额计算。可选方案:整数分单位(
Math.round(amount * 100)全程用整数)、BigInt精确运算、或专业库decimal.js/big.js。(1.005).toFixed(2)返回"1.00"而非"1.01"是典型的精度陷阱。