Skip to content

浮点精度与 BigDecimal ​

S1-014建议学入门约 45 分钟JDK 21编程语言基础 · 常用类

1. 本节定位 ​

《变量与数据类型》 提到过"金额不能用 double",本节回答三个问题:误差从哪来、正确做法是什么、有哪些坑。

这是"看起来会、一写就错"的典型领域。对账差一分钱、优惠券金额算出 0.0000000001、比较两个金额永远不相等——这些都源自同一个根因。

2. 学习目标 ​

完成本节后,你应该能够:

  • 解释 0.1 + 0.2 != 0.3 的根本原因(二进制表示);
  • 用 BigDecimal 正确表示与计算金额;
  • 说出 new BigDecimal(double) 为什么是错的,正确写法是什么;
  • 处理除法:为什么必须指定精度与舍入模式;
  • 区分 equals 与 compareTo,并知道该用哪个;
  • 在 long 存分与 BigDecimal 之间做出合适选择。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先看三个真实数字:

java
0.1 + 0.2              →  0.30000000000000004
1.0 - 0.9              →  0.09999999999999998
new BigDecimal(0.1)    →  0.1000000000000000055511151231257827021181583404541015625

第三行最能说明问题:你以为的 0.1,在 double 里根本就不是 0.1。

再看业务后果:

java
double total = 19.90 * 3;          // 59.699999999999996
if (total == 59.7) { }             // false

对账时"差一分钱",往往就是从这里开始的。而金额对不上,在业务系统里是严重问题。

5. 核心概念 ​

5.1 误差从哪来 ​

double 用 64 位二进制表示数字,遵循 IEEE 754 标准。问题在于:二进制只能精确表示"分母是 2 的幂"的分数。

text
1/2  = 0.5      → 可以精确表示(2 的幂)
1/4  = 0.25     → 可以精确表示
1/10 = 0.1      → 无法精确表示(分母含 5,二进制里是无限循环小数)

0.1 的二进制:0.0001100110011001100110011001100110011...(无限循环)

double 只能存 52 位有效数字,多出来的部分被截断。这就是误差的来源。

关键结论:这不是 Java 的 bug,而是所有使用 IEEE 754 浮点的语言共有的特性。

5.2 BigDecimal 的正确用法 ​

java
// 正确:字符串构造
BigDecimal a = new BigDecimal("0.1");
BigDecimal b = new BigDecimal("0.2");
System.out.println(a.add(b));            // 0.3

// 正确:从 double 转换时用 valueOf
BigDecimal fromDouble = BigDecimal.valueOf(0.1);

// 错误:直接用 double 构造,误差被原样带进来
BigDecimal wrong = new BigDecimal(0.1);  // 0.1000000000000000055511151231257827021181583404541015625

BigDecimal.valueOf(double) 内部用的是 Double.toString(double),会得到"最短的可精确还原该 double 的十进制串"——所以 valueOf(0.1) 得到的是 0.1,适合"已经拿着一个 double、需要转成 BigDecimal"的场景。

但如果能从源头就避免 double,就不要引入它。

5.3 四则运算与除法 ​

java
BigDecimal price = new BigDecimal("19.90");
BigDecimal quantity = new BigDecimal("3");

price.add(other)         // 加
price.subtract(other)    // 减
price.multiply(quantity) // 乘 → 59.70
price.divide(other)      // 除 ← 危险:除不尽会抛异常

除法必须指定精度与舍入模式:

java
// 抛异常:Non-terminating decimal expansion; no exact representable decimal result.
total.divide(new BigDecimal("7"));

// 正确:指定保留 2 位小数与舍入方式
total.divide(new BigDecimal("7"), 2, RoundingMode.HALF_UP);

原因:1 / 3 是无限循环小数,BigDecimal 要求精确结果,于是只能报错。你必须告诉它"保留几位、怎么舍入"。

5.4 舍入模式 ​

模式含义1.25 → 1 位1.35 → 1 位
HALF_UP四舍五入(最常用)1.31.4
HALF_EVEN银行家舍入:向偶数舍入1.21.4
DOWN直接截断1.21.3
UP远离零向上1.31.4

为什么有银行家舍入:大量数据做四舍五入时,.5 一律进位会造成统计偏差(结果系统性偏大)。HALF_EVEN 让 .5 交替向上向下,长期看误差相互抵消。

金额场景怎么选

  • 单笔计算:HALF_UP(符合日常认知与会计习惯);
  • 大批量统计:可以考虑 HALF_EVEN;
  • 核心原则:全系统统一,并在文档里写明——不统一才是最大的坑。

5.5 equals vs compareTo(重大陷阱) ​

java
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");

a.equals(b);          // false —— 因为精度(scale)不同!
a.compareTo(b);       // 0    —— 数值相等

BigDecimal.equals 同时比较"数值"与"精度",而 compareTo 只比较数值。

实用结论:

场景用什么
比较两个金额是否相等compareTo(或 compareTo(...) == 0)
作为 HashMap 的 keyequals 会区分 1.0 与 1.00,通常不是你想要的
排序compareTo

5.6 long 存分 vs BigDecimal ​

两种方案都对,看场景:

方案优点缺点适合
long 存分快、省内存、天然精确只能表示固定两位小数;除不尽时要自己定规则金额明确为两位小数的业务(人民币)
BigDecimal表达力强,可动态精度、可表示小数位不定的数据慢、占内存、API 略繁费率、汇率、需要高精度科学计算的场景

实践中的常见组合:存储用 long 分,计算与展示用 BigDecimal——两者在边界上转换(BigDecimal.valueOf(cents).movePointLeft(2))。

6. 递进示例 ​

6.1 最小示例:误差的直观感受 ​

java
System.out.println(0.1 + 0.2);              // 0.30000000000000004
System.out.println(1.0 - 0.9);              // 0.09999999999999998
System.out.println(0.1 + 0.2 == 0.3);       // false

6.2 业务示例:金额计算 ​

java
BigDecimal price = new BigDecimal("19.90");
BigDecimal quantity = new BigDecimal("3");
BigDecimal total = price.multiply(quantity);                    // 59.70
BigDecimal perPerson = total.divide(new BigDecimal("3"), 2, RoundingMode.HALF_UP);   // 19.90

6.3 反例:三个必须避免的写法 ​

java
// 反例一:用 double 构造 BigDecimal
new BigDecimal(0.1);                          // 带着一整串误差

// 反例二:除法不指定舍入
total.divide(new BigDecimal("7"));             // ArithmeticException

// 反例三:用 equals 比较金额
if (amount.equals(new BigDecimal("1.00"))) { } // amount 是 1.0 时返回 false

// 常见业务事故:把"不等于"当成"金额不同",导致重复告警或漏判

7. 完整可运行代码 ​

文件:labs/common-classes/src/main/java/cn/kb/learn/common/BigDecimalLesson.java

java
package cn.kb.learn.common;

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

public class BigDecimalLesson {
    public static void main(String[] args) {
        // 1. double 的精度问题
        System.out.println("0.1 + 0.2 = " + (0.1 + 0.2));
        System.out.println("1.0 - 0.9 = " + (1.0 - 0.9));
        System.out.println("0.1 + 0.2 == 0.3: " + (0.1 + 0.2 == 0.3));

        // 2. BigDecimal 用字符串构造才是精确的
        BigDecimal a = new BigDecimal("0.1");
        BigDecimal b = new BigDecimal("0.2");
        System.out.println("BigDecimal 相加: " + a.add(b));

        // 3. 反例:用 double 构造会带着误差进来
        System.out.println("new BigDecimal(0.1) = " + new BigDecimal(0.1));

        // 4. 金额计算
        BigDecimal price = new BigDecimal("19.90");
        BigDecimal quantity = new BigDecimal("3");
        BigDecimal total = price.multiply(quantity);
        System.out.println("总价: " + total);
        System.out.println("三等分(保留 2 位,四舍五入): " + total.divide(new BigDecimal("3"), 2, RoundingMode.HALF_UP));

        // 5. 除不尽必须指定舍入模式
        try {
            total.divide(new BigDecimal("7"));
        } catch (ArithmeticException exception) {
            System.out.println("预期内的失败: " + exception.getMessage());
        }

        // 6. equals 与 compareTo 的区别
        BigDecimal one = new BigDecimal("1.0");
        BigDecimal oneWithScale = new BigDecimal("1.00");
        System.out.println("equals: " + one.equals(oneWithScale));
        System.out.println("compareTo == 0: " + (one.compareTo(oneWithScale) == 0));

        // 7. 两种舍入方式
        System.out.println("1.25 保留一位(四舍五入): " + new BigDecimal("1.25").setScale(1, RoundingMode.HALF_UP));
        System.out.println("1.25 保留一位(银行家舍入): " + new BigDecimal("1.25").setScale(1, RoundingMode.HALF_EVEN));
        System.out.println("1.35 保留一位(银行家舍入): " + new BigDecimal("1.35").setScale(1, RoundingMode.HALF_EVEN));

        // 8. 从 double 转换的正确写法
        double rawValue = 0.1 + 0.2;
        System.out.println("用 valueOf 转换 double: " + BigDecimal.valueOf(rawValue));
    }
}

运行命令:

powershell
cd labs
./mvnw -pl common-classes compile
java -cp common-classes/target/classes cn.kb.learn.common.BigDecimalLesson

预期输出:

text
0.1 + 0.2 = 0.30000000000000004
1.0 - 0.9 = 0.09999999999999998
0.1 + 0.2 == 0.3: false
BigDecimal 相加: 0.3
new BigDecimal(0.1) = 0.1000000000000000055511151231257827021181583404541015625
总价: 59.70
三等分(保留 2 位,四舍五入): 19.90
预期内的失败: Non-terminating decimal expansion; no exact representable decimal result.
equals: false
compareTo == 0: true
1.25 保留一位(四舍五入): 1.3
1.25 保留一位(银行家舍入): 1.2
1.35 保留一位(银行家舍入): 1.4
用 valueOf 转换 double: 0.30000000000000004

三个最值得停留的输出:

输出含义
new BigDecimal(0.1) = 0.1000000000000000055...double 里的 0.1 根本不是 0.1
equals: false / compareTo == 0: true比较金额必须用 compareTo
1.25 两种舍入结果 1.3 / 1.2HALF_UP 与 HALF_EVEN 的差别

最后一行的含义:BigDecimal.valueOf(rawValue) 只能"忠实转换已有的 double",误差在 0.1 + 0.2 那一刻就已经产生了——valueOf 救不了它。这也是为什么"金额从源头就不要用 double"。

8. Debug 观察点 ​

  1. 在 new BigDecimal(0.1) 处断点,把鼠标悬停在 0.1 上看它的实际值(IDE 会显示更多位数);
  2. 在 total.divide(new BigDecimal("7")) 处断点,故意让它抛异常,观察异常消息里"Non-terminating decimal expansion"的完整含义;
  3. 在 one.equals(oneWithScale) 处分别按 F7 进入 equals 与 compareTo:equals 会比较 scale 字段,compareTo 只比较数值;
  4. 把 1.25 改成 2.25(整数部分为偶数),观察 HALF_EVEN 的结果变成 2.2——这才是银行家舍入的完整规则(向最近的偶数)。

9. 常见错误 ​

错误 1:用 double 做金额计算 ​

java
double total = 19.90 * 3;              // 59.699999999999996

修复:金额从源头就用 BigDecimal(字符串构造)或 long 存分。

错误 2:divide 不指定舍入模式 ​

java
total.divide(new BigDecimal("7"));      // ArithmeticException

修复:divide(divisor, scale, roundingMode)。

错误 3:用 equals 比较金额 ​

java
if (a.equals(b)) { }                    // 1.0 与 1.00 会判为不等

修复:if (a.compareTo(b) == 0) { }。

错误 4:BigDecimal 与 double 混用 ​

java
BigDecimal result = price.multiply(new BigDecimal(0.85));   // 又引入了 double 误差

修复:全程 BigDecimal,常量也用字符串:new BigDecimal("0.85")。

错误 5:忘记 BigDecimal 不可变 ​

java
total.add(new BigDecimal("10"));        // 返回值被丢弃,total 没变

修复:total = total.add(...)——和 String 一样,所有运算都返回新对象(《String 与不可变性》)。

10. 动手实验 ​

目标:亲手体验误差累积,并写一个正确的金额工具类。

步骤:

  1. 运行第 7 节代码,核对输出(重点看 new BigDecimal(0.1) 那一长串);
  2. 写一段代码:用 double 把 0.1 累加 10 次,打印结果;再用 BigDecimal 做一遍,对比;
  3. 写方法 BigDecimal add(BigDecimal a, BigDecimal b):返回两数之和并保留 2 位(HALF_UP);
  4. 写方法 BigDecimal split(BigDecimal total, int people):把金额均分给 N 人并返回每人金额,说明余数怎么处理(提示:先算每人金额,余数归第一个或者单独记录);
  5. 写出你的"金额计算规范"三条:用什么类型存、怎么比较、舍入模式选哪个。

验收方式:能解释第 2 步两种方式的结果差异,以及第 4 步余数的处理方案。

扩展任务:写一个 Money 类(long 存分 + 币种),提供 add、multiply(rate: BigDecimal)、format() 三个方法,体会"存储用 long、计算用 BigDecimal"的组合方式。

11. 练习题 ​

基础题

  1. 判断:0.1 + 0.2 == 0.3 在 Java 中为 true。(对 / 错)
  2. 填空:创建 BigDecimal 应该用 ______ 构造,除法必须指定 ______ 与 ______。
  3. 简答:为什么 1.0 与 1.00 的 equals 返回 false,而 compareTo 返回 0?

代码阅读题

java
BigDecimal x = new BigDecimal("2.5");
BigDecimal y = x.setScale(0, RoundingMode.HALF_EVEN);
BigDecimal z = x.setScale(0, RoundingMode.HALF_UP);
System.out.println(y + " / " + z);
  1. 输出是什么?为什么两者不同?

编程题

  1. 写方法 BigDecimal compoundInterest(BigDecimal principal, BigDecimal annualRate, int years):计算复利终值(principal × (1 + rate)^years),要求结果保留 2 位小数、四舍五入。注意 BigDecimal 没有幂运算,需要循环相乘。

综合任务

  1. 写一个"分摊"工具:
    • 方法 List<BigDecimal> allocate(BigDecimal total, List<BigDecimal> ratios):按比例分摊金额;
    • 要求:分摊结果之和必须严格等于总额(不能因为舍入而多一分或少一分);
    • 处理策略:先按比例计算并四舍五入,把"差额"补给最大的那一份(或第一个);
    • 用 100.00 分摊给比例 [1, 1, 1] 验证你的实现。
分级提示

提示 1(方向):第 4 题的关键是 HALF_EVEN 在 .5 时向"最近的偶数"舍入。

提示 2(关键点):第 5 题的复利用循环 BigDecimal result = principal; 然后 years 次 result = result.multiply(BigDecimal.ONE.add(annualRate))。

提示 3(接近答案):第 6 题先算出前 N-1 份(按比例 + 四舍五入),最后一份用"总额 - 已分配"倒推——这样能保证总和严格相等。

12. 参考答案 ​

基础题
  1. 错。0.1 与 0.2 在二进制中都是无限循环小数,相加后的结果有误差,所以不等于 0.3。
  2. 字符串(new BigDecimal("0.1") 或 BigDecimal.valueOf)、精度(scale)、舍入模式(RoundingMode)。
  3. 因为两者的精度不同:equals 同时比较数值与精度,所以 1.0 不等于 1.00;而 compareTo 只比较数值,认为它们相等。
代码阅读题(第 4 题)
text
2 / 3
  • HALF_EVEN:2.5 的 .5 要向"最近的偶数"舍入,2 与 3 中偶数是 2,所以得到 2;
  • HALF_UP:.5 一律进位,得到 3。

注意:HALF_EVEN 不是"总是向下",而是"向偶数"。1.5 用 HALF_EVEN 会得到 2,2.5 得到 2,3.5 得到 4——这才是完整规则。

编程题(第 5 题)
java
package cn.kb.learn.common;

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

public class InterestCalculator {
    static BigDecimal compoundInterest(BigDecimal principal, BigDecimal annualRate, int years) {
        if (years < 0) {
            throw new IllegalArgumentException("年限不能为负");
        }
        BigDecimal factor = BigDecimal.ONE.add(annualRate);
        BigDecimal result = principal;
        for (int i = 0; i < years; i++) {
            result = result.multiply(factor);
        }
        return result.setScale(2, RoundingMode.HALF_UP);
    }

    public static void main(String[] args) {
        System.out.println(compoundInterest(new BigDecimal("10000.00"), new BigDecimal("0.035"), 3));
    }
}

预期输出:

text
11087.18
综合任务(第 6 题)
java
package cn.kb.learn.common;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;

public class AmountAllocator {
    static List<BigDecimal> allocate(BigDecimal total, List<BigDecimal> ratios) {
        if (ratios == null || ratios.isEmpty()) {
            throw new IllegalArgumentException("比例不能为空");
        }
        BigDecimal ratioSum = ratios.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
        if (ratioSum.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("比例之和必须大于 0");
        }

        List<BigDecimal> result = new ArrayList<>();
        BigDecimal allocated = BigDecimal.ZERO;

        // 前 N-1 份:按比例四舍五入
        for (int i = 0; i < ratios.size() - 1; i++) {
            BigDecimal share = total.multiply(ratios.get(i))
                    .divide(ratioSum, 2, RoundingMode.HALF_UP);
            result.add(share);
            allocated = allocated.add(share);
        }

        // 最后一份:用总额倒推,保证总和严格相等
        result.add(total.subtract(allocated));
        return result;
    }

    public static void main(String[] args) {
        System.out.println(allocate(new BigDecimal("100.00"),
                List.of(new BigDecimal("1"), new BigDecimal("1"), new BigDecimal("1"))));
        System.out.println(allocate(new BigDecimal("100.00"),
                List.of(new BigDecimal("1"), new BigDecimal("1"), new BigDecimal("1"), new BigDecimal("1"))));
    }
}

预期输出:

text
[33.33, 33.33, 33.34]
[25.00, 25.00, 25.00, 25.00]

第一行的 33.33 + 33.33 + 33.34 = 100.00——这就是"倒推最后一份"的价值:如果三份都算成 33.33,总和只有 99.99,账目就平不了。

这是一个真实的业务问题:优惠券按比例分摊到多个商品、退款按比例分摊到多笔支付、分账给多个商户……处理不当就会出现"分摊后总额对不上"的事故。核心原则是:先算可算的部分,最后一份用减法倒推,保证总额守恒。

13. 掌握标准 ​

  • [ ] 能解释 double 误差的成因,并知道它无法完全消除;
  • [ ] 会用字符串或 valueOf 构造 BigDecimal,不在中途引入 double;
  • [ ] 除法一定会指定精度与舍入模式;
  • [ ] 比较金额用 compareTo 而不是 equals;
  • [ ] 知道 long 存分与 BigDecimal 各自的适用场景,并按场景选择。

14. 扩展资源 ​

15. 下一步学习 ​

S1 的常用类模块到这里完整了:String、equals/hashCode、日期时间、BigDecimal。

《List、Set、Map》 进入集合模块——这是后端开发用得最多的数据结构,也是 《equals、hashCode 与对象比较》 里 hashCode 契约最重要的应用场景。

后续知识

暂无

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引