AI生成-深入理解 Java 浮点数:从 0.1 + 0.2 ≠ 0.3 说起

深入理解 Java 浮点数:从 0.1 + 0.2 ≠ 0.3 说起

快速了解

1.浮点数的表示
image-20260627104839578
十进制 0.15625 -> 二进制 0.00101 -> 规格化 1.01 × 2⁻³
2.浮点数的误差
浮点数不能精确表示的就会有误差,有误差的数进行运算,最后看误差是不是一样,一样的话就相等,误差不一样就不相等

前言

如果你写过 Java,大概率见过这段代码:

System.out.println(0.1 + 0.2 == 0.3);  // false  ← 嗯??
System.out.println(0.1 + 0.1 == 0.2);  // true   ← 这又是为什么?

同样是加法,为什么一个对、一个错?这篇文章将带你从 IEEE 754 标准底层,彻底搞懂浮点数的表示、运算与误差。


一、为什么 0.1 无法被精确存储?

1.1 十进制 → 二进制转换

整数转二进制大家都会,小数怎么办?乘 2 取整法

0.1 × 2 = 0.2 → 0
0.2 × 2 = 0.4 → 0
0.4 × 2 = 0.8 → 0
0.8 × 2 = 1.6 → 1
0.6 × 2 = 1.2 → 1
0.2 × 2 = 0.4 → 0   ← 回到 0.4,开始循环!
...

0.1₁₀ = 0.00011001100110011...₂  (0011 无限循环)

# 以 0.15625 为例完整演示
0.15625 × 2 = 0.3125  → 0
0.3125  × 2 = 0.625   → 0
0.625   × 2 = 1.25    → 1
0.25    × 2 = 0.5     → 0
0.5     × 2 = 1.0     → 1  (小数部分归零,结束)

0.15625₁₀ = 0.00101₂

0.1 在二进制中是无限循环小数,就像十进制中的 1/3 = 0.333...。计算机的存储空间是有限的,只能截断。

1.2 能精确表示的小数

什么样的十进制小数能被二进制精确表示?

分母只含质因数 2 的小数,二进制表示就是有限的。

0.5   = 1/2     → 0.1₂         ✅ 精确
0.25  = 1/4     → 0.01₂        ✅ 精确
0.125 = 1/8     → 0.001₂       ✅ 精确
0.75  = 3/4     → 0.11₂        ✅ 精确
0.375 = 3/8     → 0.011₂       ✅ 精确

0.1   = 1/10    → 分母有 5     ❌ 无限循环
0.2   = 1/5     → 分母有 5     ❌ 无限循环
0.01  = 1/100   → 分母有 5²    ❌ 无限循环

所以 0.5 + 0.25 == 0.75true,这些数在二进制中都是"干净"的。


二、IEEE 754 双精度浮点数的结构

Java 的 double 遵循 IEEE 754 标准,用 64 位存储一个数:

 1 位        11 位                 52 位
┌────┬─────────────────┬──────────────────────────────────────────┐
│ S  │       E         │                  M                       │
│符号│     指数         │               尾数                       │
└────┴─────────────────┴──────────────────────────────────────────┘
 63   62              52  51                                      0

实际值的计算公式:

image-20260627104839578

2.1 三个字段的含义

符号位 S(1 bit)0 正数,1 负数。

指数 E(11 bits,移码):实际指数 = E − 1023,范围 −1022 ~ +1023。

尾数 M(52 bits,原码):存储小数点后的部分。对于规格化数(规格化后都是1.M的形式),隐含前导 1,所以实际精度是 53 位二进制(≈ 15~17 位十进制有效数字)。

# 常用对比
# 十进制表示 = 二进制表示
0.5 = 1/2 = 2的-1次方 = 0.1
0.25 = 0.01
0.125 = 0.001
0.0625 = 0.0001
0.03125 = 0.00001

十进制 0.15625  = 0.125 + 0.03125

    │
    ▼
二进制 0.00101

    │
    ▼
规格化 1.01 × 2⁻³

    │
    ├── S = 0
    ├── E = -3 + 1023 = 1020 = 01111111100₂
    └── M = 01 + 50个0

    │
    ▼
┌─┬───────────┬────────────────────────────────────────────────────┐
│0│01111111100│0100000000000000000000000000000000000000000000000000│
└─┴───────────┴────────────────────────────────────────────────────┘
 63 62      52 51                                                  0

这就是 0.15625 在内存中的真实样子

十进制有效位精度

52 位尾数 ≈ 十进制多少位?

2⁻⁵² ≈ 2.22 × 10⁻¹⁶

所以 double 能保证 15~17 位十进制有效数字

System.out.println(1.0 / 3.0);
// 0.3333333333333333  ← 16 位
//                    ↑ 第 17 位开始就不准了

2.2 为什么指数用移码?

如果指数用补码,负数指数的二进制编码会大于正数指数:

补码:  -1 → 11111111   比 0 (00000000) 还大!

硬件比较两个浮点数大小,最简单的办法是把 64 位当作整数来比。移码让编码顺序 = 数值顺序,从 000...111... 单调递增,硬件整数比较器就能直接比浮点数大小

2.3 为什么尾数用原码?

  1. 符号位已经独立:S 位已经拆出去了,尾数不需要再编码符号。
  2. 对齐右移不能符号扩展:浮点加减时,指数对齐需要把尾数右移,右移补 0。如果用补码,算术右移会符号扩展补 1,破坏数值。
  3. 乘法简单:原码尾数就是无符号绝对值,直接送乘法器。

三、0.1 和 0.2 的真实存储值

3.1 0.1 的存储

精确 0.1 = 0.00011001100110011...₂(0011 无限循环)

规范化:1.1001100110011001100110011001100110011001100110011010 × 2⁻⁴
                                                                ↑ 第53位是1,向上舍入

由于尾数只有 52 位,第 53 位必须舍入。0.1 的第 53 位是 1,向上舍入后,实际存储值略大于精确 0.1:

实际值 ≈ 0.1000000000000000055511151231257827021181583404541015625

3.2 0.2 的存储

0.2 是 0.1 的二倍:

0.2 = 1.1001100110011001100110011001100110011001100110011010 × 2⁻³

注意:尾数与 0.1 完全一样,只是指数从 -4 变成了 -3!

实际值 ≈ 0.200000000000000011102230246251565404236316680908203125

3.3 0.3 的存储

0.3₁₀ = 0.01001100110011001100110011...₂(1001 无限循环)

规范化:1.0011001100110011001100110011001100110011001100110011 × 2⁻²

实际值 ≈ 0.299999999999999988897769753748434595763683319091796875

注意:0.3 的实际值略小于精确 0.3!


四、逐表达式精度分析

回到开头的代码:

double a = 0.1 + 0.2;
double b = 0.3;
System.out.println(a == b);  // false

double c = 0.1 + 0.1;
double d = 0.2;
System.out.println(c == d);  // true

double e = 0.1 * 2;
double f = 0.2;
System.out.println(e == f);  // true

double g = 0.3;
double h = 0.3;
System.out.println(g == h);  // true

double i = 0.1 * 0.1;
double j = 0.01;
System.out.println(i == j);  // false

double l = 0.5 + 0.25;
double m = 0.75;
System.out.println(l == m);  // true
# 因为 0.5、0.25、0.75 在二进制中都是精确表示的,所以没有误差
0.5  = 1/2     = 0.1₂        ← 1 位,精确
0.25 = 1/4     = 0.01₂       ← 2 位,精确
0.75 = 3/4     = 0.11₂       ← 2 位,精确
0.125 = 1/8    = 0.001₂      ← 3 位,精确
0.375 = 3/8    = 0.011₂      ← 3 位,精确
0.625 = 5/8    = 0.101₂      ← 3 位,精确

4.1 0.1 + 0.2 == 0.3 → false(经典案例)

0.1: 1.10011001...1010 × 2⁻⁴
0.2: 1.10011001...1010 × 2⁻³

对齐指数(统一为 2⁻³):
0.1 对齐后: 0.110011001...1010 × 2⁻³  ← 低位右移丢失信息
0.2:        1.100110011...1010 × 2⁻³

相加:
  0.110011001...1010 0
+ 1.100110011...1010
────────────────────────
 10.011001100...0111 0

规范化 → 1.0011001100...01110 × 2⁻²
                          ↑ 第53位是1,后面还有1 → 向上舍入
最终:1.0011001100...0100 × 2⁻²

结果约 0.30000000000000004

而 0.3 的存储值是约 0.29999999999999999

双重舍入 vs 单次舍入,产生了不同的结果,相差约 5.55 × 10⁻¹⁷。

4.2 0.1 + 0.1 == 0.2 → true(特殊优化路径)

0.1 + 0.1 等价于 0.1 × 2

在二进制层面,×2 只是指数 +1,尾数完全不变:
  1.10011001...1010 × 2⁻⁴
→ 1.10011001...1010 × 2⁻³

而 0.2 的存储值正是 1.10011001...1010 × 2⁻³
尾数和指数完全一致!

x + x(或 x × 2)不涉及任何新的舍入,因为尾数不变。所以结果精确等于 2x 的存储值。

4.3 0.1 * 2 == 0.2 → true

与上同理,×2 = 指数 +1,尾数不动,精确相等。

4.4 0.3 == 0.3 → true

同一个编译期字面量,同一个二进制表示,当然相等。

4.5 0.1 * 0.1 == 0.01 → false

0.1 的近似值平方 ≈ 0.01000000000000000208...
0.01 的近似值    ≈ 0.010000000000000000208...

乘法使两个近似值的误差叠加,与 0.01 的舍入值不同。


五、误差规律总结

运算 尾数变化 是否引入新舍入 相等保证
同一个字面量 无运算
x + x / x × 2ⁿ 尾数不变,只调指数
x + y(x ≠ y) 对齐 + 相加 + 规范化
x × y 尾数相乘 + 舍入
x × 2ⁿ(n 为整数) 尾数不变

核心规律:

  1. 乘以 2 的幂次×2, ×4, ×8...):尾数不变,误差同步缩放,一定相等。
  2. 不同数的加法:需要对齐指数,低位丢失,然后相加再舍入——双重误差来源。
  3. 分母只有质因数 2 的数:二进制有限,完全精确,不受影响。
  4. 每次运算最多引入 1 ULP(最低有效位)的舍入误差

六、正确比较浮点数的方式

6.1 误差范围比较

double a = 0.1 + 0.2;
double b = 0.3;
double epsilon = 1e-10;
System.out.println(Math.abs(a - b) < epsilon);  // true

6.2 使用 BigDecimal(精确计算)

BigDecimal x = new BigDecimal("0.1");
BigDecimal y = new BigDecimal("0.2");
BigDecimal z = new BigDecimal("0.3");
System.out.println(x.add(y).compareTo(z) == 0);  // true

⚠️ 注意:必须用字符串构造 new BigDecimal("0.1"),而不是 new BigDecimal(0.1)。后者会先把 0.1 转成 double 的近似值,失去了使用 BigDecimal 的意义。

6.3 查看 double 的真实值

System.out.println(new BigDecimal(0.1));
// 0.1000000000000000055511151231257827021181583404541015625

System.out.println(new BigDecimal(0.1 + 0.2));
// 0.3000000000000000444089209850062616169452667236328125

System.out.println(new BigDecimal(0.3));
// 0.299999999999999988897769753748434595763683319091796875

七、特殊值

IEEE 754 还定义了以下特殊值:

符号 S 指数 E 尾数 M
+0.0 0 全 0 全 0
-0.0 1 全 0 全 0
+∞ 0 全 1 全 0
-∞ 1 全 1 全 0
NaN 任意 全 1 非 0
System.out.println(1.0 / 0.0);     // Infinity
System.out.println(-1.0 / 0.0);    // -Infinity
System.out.println(0.0 / 0.0);     // NaN
System.out.println(Math.sqrt(-1)); // NaN

// NaN 不等于任何值,包括它自己
System.out.println(Double.NaN == Double.NaN);  // false
System.out.println(Double.isNaN(Double.NaN));   // true

八、常见面试追问

Q1:float 和 double 有什么区别?

float double
位数 32 bit 64 bit
符号 1 bit 1 bit
指数 8 bit 11 bit
尾数 23 bit 52 bit
精度 6~7 位十进制 15~17 位十进制

Q2:float f = 3.14 编译报错,为什么?

3.14 字面量默认是 double 类型,不能隐式转为 float。需要写成 float f = 3.14f

Q3:为什么循环累加 0.1 十次不等于 1.0?

double sum = 0;
for (int i = 0; i < 10; i++) {
    sum += 0.1;
}
System.out.println(sum == 1.0);  // false

每次加法都可能引入舍入误差,10 次累加后误差累积明显。


九、总结

  1. 浮点数精度问题的根源:二进制无法精确表示分母含 2 以外质因数的十进制小数,52 位尾数截断产生舍入误差。
  2. 指数用移码:保证编码顺序 = 数值顺序,硬件整数比较器可以直接用。
  3. 尾数用原码:对齐右移不符号扩展,乘法也简单。
  4. 乘以 2 的幂次是安全的(尾数不变),不同数的加减乘除则不一定。
  5. 永远不要用 == 直接比较浮点数运算结果,用误差范围或 BigDecimal

浮点数不是魔法,它只是用有限比特近似无限精度的数学现实。理解了 IEEE 754 的 64 位结构,你就不会对 0.1 + 0.2 ≠ 0.3 感到困惑了。

posted @ 2026-06-27 10:45  deyang  阅读(20)  评论(0)    收藏  举报