0.1 + 0.2 != 0.3——这可能是编程世界中最令人困惑的"Bug"之一。但它不是 Bug,而是 IEEE 754 浮点数标准的必然结果。本文将从二进制表示的底层原理出发,深入剖析浮点数精度丢失的根本原因,并给出 Java 中精确计算的完整解决方案。


Part 1: IEEE 754 标准详解

历史背景

在 IEEE 754 标准(1985 年首版,2008 年修订为 IEEE 754-2008)出现之前,不同的计算机厂商使用不同的浮点数格式,导致同一个程序在不同机器上运行结果不同。IEEE 754 统一了浮点数的表示和运算规则,成为几乎所有现代处理器和编程语言的基础。

浮点数的三个组成部分

IEEE 754 浮点数由三部分组成:

1
2
3
4
5
(-1)^S × 1.M × 2^(E - bias)

S: 符号位(Sign) —— 0 正,1
E: 指数位(Exponent) —— 偏移后的指数
M: 尾数位(Mantissa) —— 小数部分(隐含前导 1

float 与 double 的内存布局

类型 总位数 符号位 指数位 尾数位 偏移量
float 32 1 8 23 127
double 64 1 11 52 1023
1
2
3
4
5
6
7
8
9
10
11
float (32 位):
┌───┬──────────┬───────────────────────────┐
│ S │ EEEEEEEE │ MMMMMMMMMMMMMMMMMMMMMMM │
1 8 23
└───┴──────────┴───────────────────────────┘

double (64 位):
┌───┬─────────────┬────────────────────────────────────────────────────────┐
│ S │ EEEEEEEEEEE │ MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM │
1 11 52
└───┴─────────────┴────────────────────────────────────────────────────────┘

偏移指数(Biased Exponent)

指数位使用偏移编码而非补码:实际指数 = 存储值 - 偏移量。

为什么不用补码?因为偏移编码使得浮点数可以直接用无符号整数比较来比较大小(先比指数,再比尾数),简化了硬件设计。

  • float 偏移量 = 127,指数范围:-126 到 +127(0 和 255 保留给特殊值)
  • double 偏移量 = 1023,指数范围:-1022 到 +1023

隐含的前导 1

对于规格化数(指数不全为 0 也不全为 1),尾数总是 1.xxxxx 的形式。由于前导 1 是固定的,IEEE 754 不存储它,从而多获得 1 位精度。

例如,尾数位存储的是 10110...,实际尾数是 1.10110...

完整示例:0.1 在 double 中的表示

步骤 1:将 0.1 转换为二进制

1
2
3
4
5
6
7
8
9
10
11
12
0.1 × 2 = 0.20
0.2 × 2 = 0.40
0.4 × 2 = 0.80
0.8 × 2 = 1.61
0.6 × 2 = 1.21
0.2 × 2 = 0.40 ← 开始循环
0.4 × 2 = 0.80
0.8 × 2 = 1.61
0.6 × 2 = 1.21
...

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

步骤 2:规格化

1
0.0001100110011... = 1.100110011... × 2^(-4)

步骤 3:确定各部分

  • 符号位 S = 0(正数)
  • 指数:-4 + 1023 = 1019 → 二进制 01111111011
  • 尾数:1001100110011001100110011001100110011001100110011010(52 位,最后一位因舍入而进位)

步骤 4:实际存储的值

由于尾数被截断为 52 位,存储的值并不精确等于 0.1,而是:

1
0.1000000000000000055511151231257827021181583404541015625

这就是精度丢失的根源。

特殊值

指数 尾数 含义
全 0 全 0 ±0(符号位决定正负)
全 0 非 0 非规格化数(Denormalized)
全 1 全 0 ±∞(Infinity)
全 1 非 0 NaN(Not a Number)

IEEE 754 舍入模式

IEEE 754 标准定义了四种舍入模式,用于处理无法精确表示的浮点数:

舍入模式 含义 2.5 → ? -2.5 → ? Java 对应
向正无穷舍入 (Round to +∞) 向上舍入,向正无穷方向 3 -2 BigDecimal.ROUND_CEILING
向负无穷舍入 (Round to -∞) 向下舍入,向负无穷方向 2 -3 BigDecimal.ROUND_FLOOR
向零舍入 (Round toward 0) 截断小数部分 2 2 BigDecimal.ROUND_DOWN
最近舍入 (Round to nearest) 四舍五入(默认) 2 或 3 -2 或 -3 BigDecimal.ROUND_HALF_UP

最近舍入模式的详细规则

当需要舍入的值恰好在两个可表示值的中间时:

  • Round half up:远离零方向舍入(传统的四舍五入)
    • 2.5 → 3,-2.5 → -3
  • Round half down:向零方向舍入
    • 2.5 → 2,-2.5 → -2
  • Round half even(银行家舍入,Java 默认):舍入到最近的偶数
    • 2.5 → 2,3.5 → 4(避免系统性误差)
  • Round half odd:舍入到最近的奇数(较少使用)

Java 的 doublefloat 使用 round to nearest, ties to even(银行家舍入),这是 IEEE 754 的默认模式。该模式在统计上无偏,避免了舍入误差的累积。

1
2
3
4
5
6
7
// Java 默认使用银行家舍入
System.out.println(Math.round(2.5)); // 2(舍入到偶数)
System.out.println(Math.round(3.5)); // 4(舍入到偶数)

// BigDecimal 可以显式指定舍入模式
new BigDecimal("2.5").setScale(0, RoundingMode.HALF_UP); // 3
new BigDecimal("2.5").setScale(0, RoundingMode.HALF_EVEN); // 2

+0 和 -0

1
2
3
4
5
double positiveZero = +0.0;
double negativeZero = -0.0;
System.out.println(positiveZero == negativeZero); // true
System.out.println(1.0 / positiveZero); // Infinity
System.out.println(1.0 / negativeZero); // -Infinity

NaN 的特殊性质

1
2
3
4
double nan = Double.NaN;
System.out.println(nan == nan); // false!NaN 不等于任何值,包括自己
System.out.println(nan != nan); // true
System.out.println(Double.isNaN(nan)); // true(正确的判断方式)

非规格化数:当指数全为 0 时,隐含的前导位变为 0(而非 1),用于表示非常接近 0 的极小数。这避免了 0 和最小规格化数之间的"间隙",实现了渐进下溢(Gradual Underflow)


Part 2: 精度丢失的根本原因

十进制小数转二进制的无限循环

只有分母是 2 的幂的分数才能在二进制中精确表示:

十进制 二进制 精确?
0.5 = 1/2 0.1 [正确]
0.25 = 1/4 0.01 [正确]
0.125 = 1/8 0.001 [正确]
0.1 = 1/10 0.000110011… [错误] 无限循环
0.2 = 1/5 0.001100110… [错误] 无限循环
0.3 = 3/10 0.010011001… [错误] 无限循环

有效位数的限制

类型 尾数位 有效十进制位数 说明
float 23 ~7 位 2^23 = 8,388,608(7 位数字)
double 52 ~15-16 位 2^52 ≈ 4.5 × 10^15(15-16 位数字)

超过有效位数的数字会被截断或舍入。

大数吃小数问题

1
2
3
double large = 1e18;
double small = 1.0;
System.out.println(large + small == large); // true!

原因:1e18 的有效位数已经占满了 double 的 52 位尾数。加上 1.0 时,1.0 相对于 1e18 太小,在对齐指数(右移尾数)时被完全丢弃。

当数值超过浮点数的有效位数范围时,较小的数值在运算过程中会被完全丢弃,导致运算结果与较大数值相等。

累积误差

1
2
3
4
5
double sum = 0.0;
for (int i = 0; i < 1000; i++) {
sum += 0.1;
}
System.out.println(sum); // 99.9999999999986(而非 100.0)

每次加 0.1 都会引入一个微小的误差,1000 次累积后误差变得明显。

ULP:精度的量化

ULP(Unit in the Last Place) 是两个相邻浮点数之间的最小间距:

1
2
3
4
System.out.println(Math.ulp(1.0));      // 2.220446049250313E-16
System.out.println(Math.ulp(1000.0)); // 1.1368683772161603E-13
System.out.println(Math.ulp(1e15)); // 0.125
System.out.println(Math.ulp(1e18)); // 128.0

注意:ULP 随着数值增大而增大。当 ULP 大于 1 时(如 1e18 附近),连整数都无法精确表示了。


Part 3: 浮点数比较的正确姿势

避免使用 == 进行浮点数比较

1
2
3
4
5
double a = 0.1 + 0.2;
double b = 0.3;
System.out.println(a == b); // false!
System.out.println(a); // 0.30000000000000004
System.out.println(b); // 0.3

epsilon 比较法

绝对误差比较

1
2
3
4
5
6
public static boolean nearlyEqual(double a, double b, double epsilon) {
return Math.abs(a - b) < epsilon;
}

// 使用
nearlyEqual(0.1 + 0.2, 0.3, 1e-10); // true

适用于数值范围已知且较小的场景。

相对误差比较(更通用):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static boolean relativelyEqual(double a, double b, double epsilon) {
double absA = Math.abs(a);
double absB = Math.abs(b);
double diff = Math.abs(a - b);

if (a == b) {
return true; // 处理 ±0 和完全相等的情况
}

if (a == 0 || b == 0 || diff < Double.MIN_NORMAL) {
// 接近零时使用绝对误差
return diff < epsilon * Double.MIN_NORMAL;
}

// 使用相对误差
return diff / Math.min(absA + absB, Double.MAX_VALUE) < epsilon;
}

Java 内置比较方法

1
2
3
4
5
6
7
8
// Double.compare() 处理了 NaN 和 -0.0 的特殊情况
int result = Double.compare(0.1 + 0.2, 0.3);
// result > 0,因为 0.30000000000000004 > 0.3

// Double.doubleToLongBits() 位级比较
long bits1 = Double.doubleToLongBits(+0.0); // 0
long bits2 = Double.doubleToLongBits(-0.0); // -9223372036854775808
// bits1 != bits2,虽然 +0.0 == -0.0

Math vs StrictMath

Java 提供了两个数学计算类:MathStrictMath,它们在精度和性能上存在显著差异:

特性 Math StrictMath
精度保证 平台相关 跨平台一致
实现方式 调用本地系统数学库 使用 fdlibm(Freely Distributable LIBM)算法
性能 较快(可能使用硬件加速) 较慢(纯软件实现)
一致性 不同平台结果可能略有差异 所有平台结果完全一致
使用场景 通用计算、科学计算 金融计算、跨平台一致性要求

关键区别

  • Math:允许 JVM 使用平台特定的优化实现(如 x86 的 FPU 指令),不同平台可能产生略微不同的结果,但性能更好。
  • StrictMath:严格遵循 IEEE 754 标准,使用 fdlibm 算法库,确保所有平台上的计算结果完全一致,但性能较差。
1
2
3
4
5
6
7
// 在大多数平台上,以下两个方法返回相同结果
double mathResult = Math.log(10.0);
double strictResult = StrictMath.log(10.0);

// 但在某些边界情况下,可能存在微小差异
System.out.println(Math.sin(Math.PI)); // 可能接近 0
System.out.println(StrictMath.sin(StrictMath.PI)); // 严格按 IEEE 754 计算

选择建议

  • 金融系统、需要跨平台一致性的结果:使用 StrictMath
  • 科学计算、图形渲染、机器学习:使用 Math(性能优先)

Kahan 求和算法

Kahan 求和是减少浮点数累积误差的经典算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static double kahanSum(double[] values) {
double sum = 0.0;
double compensation = 0.0; // 补偿变量,记录丢失的低位

for (double value : values) {
double adjustedValue = value - compensation;
double tempSum = sum + adjustedValue;
compensation = (tempSum - sum) - adjustedValue; // 计算丢失的部分
sum = tempSum;
}

return sum;
}

// 对比普通求和
double[] values = new double[1000];
Arrays.fill(values, 0.1);

double naiveSum = 0;
for (double v : values) naiveSum += v;
System.out.println("朴素求和: " + naiveSum); // 99.9999999999986

System.out.println("Kahan求和: " + kahanSum(values)); // 100.00000000000001(更精确)

Kahan 求和的核心思想:用一个补偿变量 compensation 记录每次加法中丢失的低位部分,在下一次加法中补偿回来。


Part 4: BigDecimal——精确计算的救星

内部实现

BigDecimal 的值 = unscaledValue × 10^(-scale)

1
2
3
4
BigDecimal bd = new BigDecimal("123.456");
// unscaledValue = 123456
// scale = 3
// 值 = 123456 × 10^(-3) = 123.456

unscaledValue 是一个 BigInteger(任意精度整数),因此 BigDecimal 可以表示任意精度的十进制数。

创建 BigDecimal 的正确方式

1
2
3
4
5
6
7
8
9
10
11
12
13
// [正确] 推荐:从字符串创建
BigDecimal bd1 = new BigDecimal("0.1");
System.out.println(bd1); // 0.1(精确)

// [正确] 推荐:使用 valueOf(内部先转 String)
BigDecimal bd2 = BigDecimal.valueOf(0.1);
System.out.println(bd2); // 0.1(精确)

// [错误] 陷阱:从 double 创建
BigDecimal bd3 = new BigDecimal(0.1);
System.out.println(bd3);
// 0.1000000000000000055511151231257827021181583404541015625
// 保留了 double 的精度误差!

new BigDecimal(0.1) 存在问题的原因

0.1 在 Java 源码中是一个 double 字面量,编译时已经被转换为 IEEE 754 的近似值。new BigDecimal(double) 会精确地保留这个近似值,而非预期的 0.1

RoundingMode 详解

BigDecimal 的除法和舍入操作需要指定 RoundingMode

RoundingMode 含义 示例(2.5 → ?) 示例(-2.5 → ?)
UP 远离零方向舍入 3 -3
DOWN 向零方向舍入(截断) 2 -2
CEILING 向正无穷方向舍入 3 -2
FLOOR 向负无穷方向舍入 2 -3
HALF_UP 四舍五入(中国传统) 3 -3
HALF_DOWN 五舍六入 2 -2
HALF_EVEN 银行家舍入(IEEE 754 默认) 2 -2
UNNECESSARY 断言精确,否则抛异常 抛异常 抛异常

银行家舍入(HALF_EVEN) 的特点:当恰好在两个值的中间时,舍入到最近的偶数。该模式在统计上无偏,避免了 HALF_UP 导致的系统性偏高。

1
2
3
4
// 银行家舍入示例
new BigDecimal("2.5").setScale(0, RoundingMode.HALF_EVEN); // 2(舍入到偶数)
new BigDecimal("3.5").setScale(0, RoundingMode.HALF_EVEN); // 4(舍入到偶数)
new BigDecimal("2.15").setScale(1, RoundingMode.HALF_EVEN); // 2.2

MathContext:精度上下文

MathContext 封装了精度和舍入模式,用于控制 BigDecimal 运算的精度范围。相比单独使用 setScale()MathContext 提供了更灵活的精度控制方式。

MathContext 的组成

  • precision:有效数字位数(非小数位数)
  • roundingMode:舍入模式

预定义的 MathContext

MathContext 精度 舍入模式 使用场景
DECIMAL128 34 位 HALF_EVEN IEEE 754 128 位十进制浮点数
DECIMAL64 16 位 HALF_EVEN IEEE 754 64 位十进制浮点数
DECIMAL32 7 位 HALF_EVEN IEEE 754 32 位十进制浮点数
UNLIMITED 无限制 UNNECESSARY 不限制精度(精确计算)

使用场景示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 场景 1:科学计算,限制有效数字位数
MathContext mc = new MathContext(10, RoundingMode.HALF_EVEN);
BigDecimal a = new BigDecimal("12345.67890123456789");
BigDecimal b = new BigDecimal("9876.54321098765432");
BigDecimal result = a.multiply(b, mc);
// 结果保留 10 位有效数字,自动舍入

// 场景 2:金融计算,使用 DECIMAL64 模拟 IEEE 754 十进制浮点数
MathContext ieee754 = MathContext.DECIMAL64;
BigDecimal price = new BigDecimal("19.99", ieee754);
BigDecimal quantity = new BigDecimal("3", ieee754);
BigDecimal total = price.multiply(quantity, ieee754);
// 结果保留 16 位有效数字

// 场景 3:链式运算,统一精度控制
MathContext precision7 = MathContext.DECIMAL32;
BigDecimal value = new BigDecimal("1.23456789")
.multiply(new BigDecimal("9.87654321"), precision7)
.divide(new BigDecimal("2.5"), precision7)
.add(new BigDecimal("100"), precision7);
// 每一步运算都应用相同的精度规则

// 场景 4:自定义精度
MathContext custom = new MathContext(5, RoundingMode.UP);
BigDecimal tax = new BigDecimal("0.0875");
BigDecimal amount = new BigDecimal("1234.56");
BigDecimal taxAmount = amount.multiply(tax, custom);
// 向上舍入到 5 位有效数字

setScale() vs MathContext

  • setScale(n, mode):指定小数点后的位数,适用于显示格式化
  • MathContext(precision, mode):指定有效数字位数,适用于科学计算和精度控制
1
2
3
4
5
// setScale 控制小数位数
new BigDecimal("123.456").setScale(2, RoundingMode.HALF_UP); // 123.46

// MathContext 控制有效数字位数
new BigDecimal("123.456", new MathContext(4, RoundingMode.HALF_UP)); // 123.5

BigDecimal 的性能开销

BigDecimal 的运算比 double 慢约 100-1000 倍

操作 double BigDecimal 倍数
加法 ~1ns ~100ns ~100x
乘法 ~1ns ~200ns ~200x
除法 ~5ns ~500ns ~100x

因此,BigDecimal 应该只用在需要精确计算的场景(如金融),而非所有浮点运算。

equals() vs compareTo()

1
2
3
4
5
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");

System.out.println(a.equals(b)); // false!scale 不同(1 vs 2)
System.out.println(a.compareTo(b)); // 0(数值相等)

equals() 比较 value 和 scale,而 compareTo() 只比较数值。在业务逻辑中,几乎总是应该使用 compareTo()

注意事项:HashSet/HashMap 中的 BigDecimal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Set<BigDecimal> set = new HashSet<>();
set.add(new BigDecimal("1.0"));
set.add(new BigDecimal("1.00"));
System.out.println(set.size()); // 2!因为 equals() 返回 false

// 解决方案:使用 TreeSet(基于 compareTo)
Set<BigDecimal> treeSet = new TreeSet<>();
treeSet.add(new BigDecimal("1.0"));
treeSet.add(new BigDecimal("1.00"));
System.out.println(treeSet.size()); // 1

// 或者使用 stripTrailingZeros()
set.add(new BigDecimal("1.0").stripTrailingZeros());
set.add(new BigDecimal("1.00").stripTrailingZeros());

Part 5: 金融计算的最佳实践

金融系统必须使用精确计算的原因

金融系统中,即使是微小的精度误差也可能导致:

  • 账目不平(借贷不等)
  • 累积误差导致巨额差异
  • 合规审计失败
  • 客户投诉

用"分"代替"元"的整数方案

将金额乘以 100(或更高精度),用 long 存储:

1
2
3
4
5
6
7
8
// 用 long 存储"分"
long priceInCents = 1999; // 19.99 元

// 加法
long total = priceInCents * quantity;

// 转换为元(仅在显示时)
String display = String.format("%.2f", priceInCents / 100.0);

优势:

  • 性能极高(整数运算)
  • 无精度问题
  • 存储空间小

劣势:

  • 需要统一约定精度(分?厘?)
  • 乘除法可能溢出
  • 不同币种精度不同(日元无小数,第纳尔有三位小数)

货币类型的设计模式(Money Pattern)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public final class Money implements Comparable<Money> {
private final BigDecimal amount;
private final Currency currency;

private Money(BigDecimal amount, Currency currency) {
this.amount = amount.setScale(
currency.getDefaultFractionDigits(),
RoundingMode.HALF_EVEN
);
this.currency = currency;
}

public static Money of(String amount, String currencyCode) {
return new Money(
new BigDecimal(amount),
Currency.getInstance(currencyCode)
);
}

public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException(
"Cannot add different currencies: " +
this.currency + " and " + other.currency
);
}
return new Money(this.amount.add(other.amount), this.currency);
}

public Money multiply(int quantity) {
return new Money(
this.amount.multiply(BigDecimal.valueOf(quantity)),
this.currency
);
}

@Override
public int compareTo(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Cannot compare different currencies");
}
return this.amount.compareTo(other.amount);
}

@Override
public String toString() {
return currency.getSymbol() + " " + amount.toPlainString();
}
}

数据库中的 DECIMAL 类型

1
2
3
4
5
6
-- MySQL
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
amount DECIMAL(19, 4) NOT NULL, -- 19 位总精度,4 位小数
currency CHAR(3) NOT NULL
);

DECIMAL(19, 4) 在 Java 中映射为 BigDecimal,JDBC 驱动会自动处理转换。

DECIMAL 类型的内部存储格式

不同数据库对 DECIMAL 类型的内部存储实现存在差异:

MySQL DECIMAL 存储格式

MySQL 使用紧凑的二进制格式存储 DECIMAL 值:

  • 存储结构整数部分 + 小数部分
  • 符号位:使用最高位表示正负(0 为正,1 为负)
  • 数值表示:使用二进制编码的十进制(BCD)或压缩格式
  • 存储空间⌈(digits + 1) / 2⌉ 字节
1
2
3
-- DECIMAL(5, 2) 存储 123.45
-- 存储空间:⌈(5 + 1) / 2⌉ = 3 字节
-- 实际存储:0x01 0x23 0x45(正数,12345)

PostgreSQL NUMERIC 存储格式

PostgreSQL 使用变长格式存储 NUMERIC/DECIMAL:

  • 存储结构头部 + 数组 + 权重 + 符号 + 比例因子
  • 数组:每 4 字节存储 4 位十进制数字(base 10000)
  • 存储空间4 + 4 × ⌈digits / 4⌉ 字节(头部 + 数组)
  • 特点:支持任意精度,空间效率随位数线性增长

Oracle NUMBER 存储格式

Oracle 使用可变长度的二进制格式:

  • 正数:第 1 字节 0x80 + 指数,后续字节存储尾数
  • 负数:使用 100 补码表示
  • 存储空间:1-22 字节(取决于数值大小)
  • 特点:高效存储大数值,支持指数表示

SQL Server DECIMAL 存储格式

SQL Server 使用定长格式存储 DECIMAL:

  • 存储结构符号 + 整数部分 + 小数部分
  • 存储空间
    • 1-9 位:5 字节
    • 10-19 位:9 字节
    • 20-28 位:13 字节
    • 29-38 位:17 字节
  • 特点:定长存储,查询性能稳定

DECIMAL vs DOUBLE 性能对比

数据库 DECIMAL DOUBLE 性能差异
MySQL 精确计算 近似计算 DECIMAL 慢 50-100%
PostgreSQL 任意精度 IEEE 754 NUMERIC 慢 10-50%
Oracle 精确计算 近似计算 NUMBER 慢 30-80%
SQL Server 精确计算 近似计算 DECIMAL 慢 20-60%

最佳实践

  1. 金融金额:始终使用 DECIMAL/NUMERIC
  2. 科学计算:使用 DOUBLE/FLOAT
  3. 索引选择:DECIMAL 索引比 DOUBLE 慢
  4. 存储空间:DECIMAL 比 DOUBLE 占用更多空间
  5. 跨数据库:注意不同数据库的精度限制

跨系统传输金额

方案 格式 优势 劣势
字符串 "19.99" 无精度丢失 需要解析
整数(分) 1999 性能好,无歧义 需要约定精度
BigDecimal 序列化 JSON 字符串 精确 体积大

推荐:使用字符串传输,接收方通过 new BigDecimal(string) 解析。


Part 6: 其他语言的处理方式

Python

1
2
3
4
5
6
7
8
9
from decimal import Decimal, ROUND_HALF_EVEN

# 正确方式:从字符串创建
price = Decimal('0.1') + Decimal('0.2')
print(price) # 0.3(精确)

# 错误方式:从 float 创建
price = Decimal(0.1)
print(price) # 0.1000000000000000055511151231257827021181583404541015625

JavaScript

1
2
3
4
5
6
7
// JavaScript 的 Number 全是 IEEE 754 double
0.1 + 0.2 // 0.30000000000000004

// BigInt(ES2020)只支持整数
const bigInt = 9007199254740993n; // 超过 Number.MAX_SAFE_INTEGER

// 第三方库:decimal.js、bignumber.js

Go

1
2
3
4
5
6
7
8
9
10
11
import "math/big"

// big.Float:任意精度浮点数
a := new(big.Float).SetPrec(128).SetString("0.1")
b := new(big.Float).SetPrec(128).SetString("0.2")
sum := new(big.Float).Add(a, b)
fmt.Println(sum) // 0.3

// big.Rat:有理数(分数表示)
r := new(big.Rat).SetString("1/3")
fmt.Println(r.FloatString(10)) // 0.3333333333

Rust

1
2
3
4
5
6
7
use rust_decimal::Decimal;
use std::str::FromStr;

let a = Decimal::from_str("0.1").unwrap();
let b = Decimal::from_str("0.2").unwrap();
let sum = a + b;
assert_eq!(sum, Decimal::from_str("0.3").unwrap()); // 精确相等

Part 7: 面试高频问题

Q1: 为什么 0.1 + 0.2 != 0.3?

:0.1 和 0.2 在 IEEE 754 二进制浮点数中无法精确表示(类似 1/3 在十进制中无法精确表示)。它们的二进制表示是无限循环小数,被截断为 52 位尾数后产生了微小的误差。两个近似值相加,误差累积,导致结果不等于 0.3 的近似值。

Q2: float 和 double 的精度分别是多少?

:float 有 23 位尾数,约 7 位有效十进制数字;double 有 52 位尾数,约 15-16 位有效十进制数字。

Q3: 什么时候用 BigDecimal?

:需要精确的十进制计算时,特别是金融、会计、税务等涉及金额的场景。日常的科学计算、图形渲染、机器学习等场景使用 double 即可。

Q4: BigDecimal 的 equals() 和 compareTo() 有什么区别?

equals() 比较 value 和 scale(1.01.00 不相等),compareTo() 只比较数值(1.01.00 相等)。业务比较应使用 compareTo()

Q5: new BigDecimal(0.1) 和 BigDecimal.valueOf(0.1) 的区别?

new BigDecimal(0.1) 精确保留 double 0.1 的近似值(约 0.1000000000000000055…),而 BigDecimal.valueOf(0.1) 内部先调用 Double.toString(0.1) 得到字符串 "0.1",再构造 BigDecimal,结果是精确的 0.1。

参考资料