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

一则或许对你有用的小广告

欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

面试考察点

  1. 基础理解深度:面试官不仅仅是想知道 "不能用",更是想考察你是否理解浮点数在计算机中的存储原理(IEEE 754 标准),以及为什么会产生精度丢失。

  2. 实战经验:金额计算是金融系统的核心场景,考察你是否在生产环境中踩过坑,是否知道正确的解决方案。

  3. 知识广度:从浮点数精度问题延伸到 BigDecimal 的使用、数据库字段选择(DECIMAL vs DOUBLE)、分布式场景下的金额处理等。

核心答案

浮点数(float/double)不能用于金额计算,核心原因是 IEEE 754 标准的二进制浮点数无法精确表示某些十进制小数,会导致精度丢失和累计误差。

数据类型存储方式精度问题金额场景
float32 位 IEEE 754约 7 位有效数字❌ 禁止使用
double64 位 IEEE 754约 15 位有效数字❌ 禁止使用
BigDecimal十进制整数 + 标度任意精度推荐
long(分)64 位整数精确✅ 推荐

深度解析

一、精度丢失的本质原因

计算机使用二进制存储数据,而人类习惯使用十进制表示金额。某些十进制小数(如 0.1)在二进制中是无限循环小数,无法精确表示。

上图展示了十进制小数转换为二进制时的无限循环问题。关键点在于:

  • 无法精确表示:十进制的 0.10.20.3 等常见小数,在二进制中都是无限循环的,计算机只能截断存储。

  • 截断即误差double 类型只有 52 位尾数,超出部分被截断,导致存储的值与真实值存在微小差异。

  • 误差会放大:单次计算误差可能很小(0.0000000001 级别),但在大量累加乘除运算后,误差会显著放大。

二、经典翻车案例

来看一个让无数程序员"怀疑人生"的例子:

public class FloatMoneyDemo {
    public static void main(String[] args) {
        // 案例 1:0.1 + 0.2 不等于 0.3
        double a = 0.1;
        double b = 0.2;
        System.out.println(a + b);  // 输出: 0.30000000000000004
        System.out.println(a + b == 0.3);  // 输出: false

        // 案例 2:累加误差更恐怖
        double total = 0.0;
        for (int i = 0; i < 10; i++) {
            total += 0.1;
        }
        System.out.println(total);  // 输出: 0.9999999999999999(不是 1.0!)

        // 案例 3:金额计算翻车
        double price = 19.9;
        int count = 3;
        System.out.println(price * count);  // 输出: 59.699999999999996
    }
}

想象一下,如果这是银行系统:用户转账 0.1 + 0.2 元,系统记录的却是 0.30000000000000004 元,日终对账时差了 0.00000000000000004 元,数百万笔交易累计下来...财务部会疯的。

三、IEEE 754 存储结构

double 为例,64 位存储结构如下:

上图展示了 double 类型的存储结构。核心要点:

  • 52 位尾数限制:只能精确表示约 15-16 位十进制数字,超出部分必然截断。

  • 二进制小数特点:能精确表示的十进制小数只有 0.50.250.125 等(分母为 2 的幂次),其他都是近似值。

  • 规格化存储:隐含的 1.M 中的 1 不存储,实际精度是 53 位。

四、正确姿势:BigDecimal

import java.math.BigDecimal;
import java.math.RoundingMode;

public class BigDecimalMoneyDemo {
    public static void main(String[] args) {
        // ⚠️ 错误:用 double 构造 BigDecimal,精度已经丢失
        BigDecimal wrong = new BigDecimal(0.1);
        System.out.println(wrong);  // 输出: 0.1000000000000000055511151231257827021181583404541015625

        // ✅ 正确:用字符串构造 BigDecimal
        BigDecimal a = new BigDecimal("0.1");
        BigDecimal b = new BigDecimal("0.2");
        BigDecimal result = a.add(b);
        System.out.println(result);  // 输出: 0.3
        System.out.println(result.compareTo(new BigDecimal("0.3")) == 0);  // 输出: true

        // ✅ 金额计算:指定舍入模式
        BigDecimal price = new BigDecimal("19.9");
        BigDecimal count = new BigDecimal("3");
        BigDecimal total = price.multiply(count).setScale(2, RoundingMode.HALF_UP);
        System.out.println(total);  // 输出: 59.70

        // ✅ 除法必须指定精度和舍入模式
        BigDecimal amount = new BigDecimal("10");
        BigDecimal divisor = new BigDecimal("3");
        BigDecimal quotient = amount.divide(divisor, 2, RoundingMode.HALF_UP);
        System.out.println(quotient);  // 输出: 3.33
    }
}

BigDecimal 使用要点

要点说明
构造方式必须使用字符串构造 new BigDecimal("0.1"),禁止使用 new BigDecimal(0.1)
比较方式使用 compareTo() 而非 equals()equals 会比较精度)
除法运算必须指定精度和舍入模式,否则可能抛出 ArithmeticException
舍入模式金额通常使用 RoundingMode.HALF_UP(四舍五入)

五、另一种方案:整数存储(分)

public class LongMoneyDemo {
    public static void main(String[] args) {
        // 金额以"分"为单位,使用 long 存储
        long priceInCents = 1990;  // 19.90 元 = 1990 分
        int count = 3;

        long totalInCents = priceInCents * count;  // 5970 分
        double totalInYuan = totalInCents / 100.0;  // 59.70 元(仅用于展示)

        System.out.println("总价(分): " + totalInCents);      // 输出: 5970
        System.out.println("总价(元): " + totalInYuan);       // 输出: 59.7
    }
}

整数存储方案优势

  • 性能更高:整数运算比 BigDecimal 快得多
  • 内存更省long 占 8 字节,BigDecimal 对象开销大
  • 完全精确:整数运算不会丢失精度

适用场景:对性能要求高、金额精度固定(精确到分)、不需要复杂运算的场景。

面试高频追问

  1. BigDecimalequals()compareTo() 有什么区别?

    equals() 会比较精度(scale),new BigDecimal("1.0").equals(new BigDecimal("1.00")) 返回 false;而 compareTo() 只比较数值大小,返回 0。金额比较必须用 compareTo()

  2. 数据库中金额字段应该用什么类型?

    MySQL 使用 DECIMAL(19,4)BIGINT(存分),禁止使用 FLOAT/DOUBLEDECIMAL 在数据库层面保证精度,BIGINT 性能更好。

  3. 分布式场景下金额计算有什么注意事项?

    多服务并发扣款时,要考虑分布式锁、数据库事务、幂等性,确保金额一致性。可参考 TCC、Saga 等分布式事务方案。

常见面试变体

  • 变体一:"0.1 + 0.2 != 0.3 是什么原因?如何解决?"
  • 变体二:"BigDecimal 构造时为什么要用字符串?"
  • 变体三:"金融系统中金额字段如何设计?"

记忆口诀

浮点存金额,精度必丢失; BigD 用字符串,除法指定舍; 或者用长整,单位换成分。

总结

浮点数采用 IEEE 754 二进制存储,无法精确表示某些十进制小数,金额计算必须使用 BigDecimal(字符串构造)或将金额转为整数(分)存储。数据库中金额字段使用 DECIMALBIGINT禁止使用 FLOAT/DOUBLE