为什么不能用浮点数表示金额?

#Java#面试八股

为什么不能用浮点数表示金额?

结论:float/double 采用 IEEE 754 二进制浮点(binary floating point),只能精确表示"分母是 2 的幂"的分数;像 0.1、0.2、4.35 这类十进制小数在二进制里是无限循环小数,存进 64 位尾数时必然被舍入。于是加减会累积误差、比较会失效、强转可能少一分钱。金额必须用整数最小单位(long 存分/厘)或 BigDecimal(十进制定点 + 显式舍入)。

1. 误差从哪来

double(64 位)= [1 位符号][11 位阶码][52 位尾数]

0.1₁₀ = 0.0001100110011001100...₂      ← 0011 无限循环
      = 1.1001100110011001100110011001100110011001100110011001 10011...₂ × 2⁻⁴
        └────────── 52 位尾数只保留到这里 ──────────┘ ↑ 后面被舍入

存下来的实际值:0.1000000000000000055511151231257827...

float 只有 24 位有效位(约 7 位十进制),double 有 53 位(约 15~17 位十进制)。位数再多也只是"更接近",只要分母含因子 5,二进制就无法精确表示。

反过来,0.5、0.25、0.125 这些分母是 2 的幂的小数可以精确表示,这也是为什么"用 0.5 做测试一切正常"会给人错觉。

2. 代码:误差如何变成事故

public class FloatMoneyDemo {
    public static void main(String[] args) {
        System.out.println(0.1 + 0.2);           // 0.30000000000000004
        System.out.println(0.1 + 0.2 == 0.3);    // false
        System.out.println(1.0 - 0.9);           // 0.09999999999999998
        System.out.println(4.35 * 100);          // 434.99999999999994
        System.out.println((int) (4.35 * 100));  // 434,强转直接截断,少一分钱

        double sum = 0;
        for (int i = 0; i < 10; i++) {
            sum += 0.1;
        }
        System.out.println(sum);                 // 0.9999999999999999,不等于 1.0

        // 用 BigDecimal 也没用,构造方式错了照样带上误差
        System.out.println(new BigDecimal(0.1));       // 0.1000000000000000055511151231257827...
        System.out.println(new BigDecimal("0.1"));     // 0.1
        System.out.println(BigDecimal.valueOf(0.1));   // 0.1,内部先 Double.toString
    }
}

(int) (4.35 * 100) 是线上最常见的一分钱问题:费率计算后强转取整,看起来"四舍五入",实际是"截断",用户少付或多付一分。金额链路上任何一次 double 参与,误差都会被后续累加放大。

3. 精度对照

类型精度表示 0.1适合金额
float24 位尾数,约 7 位十进制不精确否
double53 位尾数,约 15~17 位十进制不精确否
long(分)整数精确到 9.2×10¹⁸ 分需换算是(推荐)
BigDecimal任意精度十进制精确(字符串构造)是(推荐)
数据库 DECIMAL(18,2)定点十进制精确是(存储层)

4. 常见追问

追问回答要点
为什么 double 能表示 0.5 却不能表示 0.1?0.5 = 2⁻¹,分母是 2 的幂;0.1 = 1/10,分母含因子 5,二进制无限循环
用 double 然后 Math.round 行不行?只能"看起来对",边界值仍会错;Math.round(4.35 * 100) 恰好得 435 属于运气
金额比较怎么写?BigDecimal.compareTo(...) == 0,或统一用 long 分比较
float 和 double 的区别?尾数 24 vs 53 位,float 约 7 位有效数字,double 约 15~17 位
数据库字段怎么选?DECIMAL(18,2),不要用 FLOAT/DOUBLE;ORM 映射到 BigDecimal
JSON 传输金额要注意什么?用字符串传,避免前端 JS 的 Number(同为 IEEE 754 double)再次丢精度;大整数同样要传字符串
为什么 System.out.println(0.1) 打印的是 0.1?Double.toString 输出的是"能唯一还原该 double 的最短十进制",是一种美化后的表示,不代表内部精确等于 0.1
已经用 double 存了历史数据怎么办?误差已经写进数据,只能按业务规则做对账与修正,不能"事后转 BigDecimal"消除

还有两个容易被追问的延伸:

  1. BigDecimal 的除法必须指定精度和舍入模式,否则 1/3 会抛 ArithmeticException: Non-terminating decimal expansion;
  2. 统计场景(如对账)如果必须用小数,至少统一 scale 并在最后一步舍入,不要在中间步骤反复 setScale;
  3. float 的有效位数更少(约 7 位十进制),在金额链路上连"看起来对"都做不到,比 double 更危险;
  4. BigDecimal 的 scale 要与数据库列对齐:DECIMAL(18,2) 读回是 scale=2,写入时 scale 更大可能被静默截断或触发舍入。

小结

浮点数的问题是"二进制无法精确表示十进制小数",不是"精度不够高"。金额用 long 存最小单位,或用 BigDecimal 且用字符串构造、显式指定舍入,传输用字符串,比较用 compareTo。

笔记加载中…