主题
界面
主题
《变量与数据类型》 提到过"金额不能用 double",本节回答三个问题:误差从哪来、正确做法是什么、有哪些坑。
这是"看起来会、一写就错"的典型领域。对账差一分钱、优惠券金额算出 0.0000000001、比较两个金额永远不相等——这些都源自同一个根因。
完成本节后,你应该能够:
0.1 + 0.2 != 0.3 的根本原因(二进制表示);BigDecimal 正确表示与计算金额;new BigDecimal(double) 为什么是错的,正确写法是什么;equals 与 compareTo,并知道该用哪个;long 存分与 BigDecimal 之间做出合适选择。先看三个真实数字:
0.1 + 0.2 → 0.30000000000000004
1.0 - 0.9 → 0.09999999999999998
new BigDecimal(0.1) → 0.1000000000000000055511151231257827021181583404541015625第三行最能说明问题:你以为的 0.1,在 double 里根本就不是 0.1。
再看业务后果:
double total = 19.90 * 3; // 59.699999999999996
if (total == 59.7) { } // false对账时"差一分钱",往往就是从这里开始的。而金额对不上,在业务系统里是严重问题。
double 用 64 位二进制表示数字,遵循 IEEE 754 标准。问题在于:二进制只能精确表示"分母是 2 的幂"的分数。
1/2 = 0.5 → 可以精确表示(2 的幂)
1/4 = 0.25 → 可以精确表示
1/10 = 0.1 → 无法精确表示(分母含 5,二进制里是无限循环小数)
0.1 的二进制:0.0001100110011001100110011001100110011...(无限循环)double 只能存 52 位有效数字,多出来的部分被截断。这就是误差的来源。
关键结论:这不是 Java 的 bug,而是所有使用 IEEE 754 浮点的语言共有的特性。
// 正确:字符串构造
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.1000000000000000055511151231257827021181583404541015625BigDecimal.valueOf(double) 内部用的是 Double.toString(double),会得到"最短的可精确还原该 double 的十进制串"——所以 valueOf(0.1) 得到的是 0.1,适合"已经拿着一个 double、需要转成 BigDecimal"的场景。
但如果能从源头就避免 double,就不要引入它。
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) // 除 ← 危险:除不尽会抛异常除法必须指定精度与舍入模式:
// 抛异常: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 要求精确结果,于是只能报错。你必须告诉它"保留几位、怎么舍入"。
| 模式 | 含义 | 1.25 → 1 位 | 1.35 → 1 位 |
|---|---|---|---|
HALF_UP | 四舍五入(最常用) | 1.3 | 1.4 |
HALF_EVEN | 银行家舍入:向偶数舍入 | 1.2 | 1.4 |
DOWN | 直接截断 | 1.2 | 1.3 |
UP | 远离零向上 | 1.3 | 1.4 |
为什么有银行家舍入:大量数据做四舍五入时,.5 一律进位会造成统计偏差(结果系统性偏大)。HALF_EVEN 让 .5 交替向上向下,长期看误差相互抵消。
金额场景怎么选
HALF_UP(符合日常认知与会计习惯);HALF_EVEN;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 的 key | equals 会区分 1.0 与 1.00,通常不是你想要的 |
| 排序 | compareTo |
两种方案都对,看场景:
| 方案 | 优点 | 缺点 | 适合 |
|---|---|---|---|
long 存分 | 快、省内存、天然精确 | 只能表示固定两位小数;除不尽时要自己定规则 | 金额明确为两位小数的业务(人民币) |
BigDecimal | 表达力强,可动态精度、可表示小数位不定的数据 | 慢、占内存、API 略繁 | 费率、汇率、需要高精度科学计算的场景 |
实践中的常见组合:存储用 long 分,计算与展示用 BigDecimal——两者在边界上转换(BigDecimal.valueOf(cents).movePointLeft(2))。
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); // falseBigDecimal 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// 反例一:用 double 构造 BigDecimal
new BigDecimal(0.1); // 带着一整串误差
// 反例二:除法不指定舍入
total.divide(new BigDecimal("7")); // ArithmeticException
// 反例三:用 equals 比较金额
if (amount.equals(new BigDecimal("1.00"))) { } // amount 是 1.0 时返回 false
// 常见业务事故:把"不等于"当成"金额不同",导致重复告警或漏判文件:labs/common-classes/src/main/java/cn/kb/learn/common/BigDecimalLesson.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));
}
}运行命令:
cd labs
./mvnw -pl common-classes compile
java -cp common-classes/target/classes cn.kb.learn.common.BigDecimalLesson预期输出:
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.2 | HALF_UP 与 HALF_EVEN 的差别 |
最后一行的含义:BigDecimal.valueOf(rawValue) 只能"忠实转换已有的 double",误差在 0.1 + 0.2 那一刻就已经产生了——valueOf 救不了它。这也是为什么"金额从源头就不要用 double"。
new BigDecimal(0.1) 处断点,把鼠标悬停在 0.1 上看它的实际值(IDE 会显示更多位数);total.divide(new BigDecimal("7")) 处断点,故意让它抛异常,观察异常消息里"Non-terminating decimal expansion"的完整含义;one.equals(oneWithScale) 处分别按 F7 进入 equals 与 compareTo:equals 会比较 scale 字段,compareTo 只比较数值;1.25 改成 2.25(整数部分为偶数),观察 HALF_EVEN 的结果变成 2.2——这才是银行家舍入的完整规则(向最近的偶数)。double 做金额计算 double total = 19.90 * 3; // 59.699999999999996修复:金额从源头就用 BigDecimal(字符串构造)或 long 存分。
divide 不指定舍入模式 total.divide(new BigDecimal("7")); // ArithmeticException修复:divide(divisor, scale, roundingMode)。
equals 比较金额 if (a.equals(b)) { } // 1.0 与 1.00 会判为不等修复:if (a.compareTo(b) == 0) { }。
BigDecimal 与 double 混用 BigDecimal result = price.multiply(new BigDecimal(0.85)); // 又引入了 double 误差修复:全程 BigDecimal,常量也用字符串:new BigDecimal("0.85")。
BigDecimal 不可变 total.add(new BigDecimal("10")); // 返回值被丢弃,total 没变修复:total = total.add(...)——和 String 一样,所有运算都返回新对象(《String 与不可变性》)。
目标:亲手体验误差累积,并写一个正确的金额工具类。
步骤:
new BigDecimal(0.1) 那一长串);double 把 0.1 累加 10 次,打印结果;再用 BigDecimal 做一遍,对比;BigDecimal add(BigDecimal a, BigDecimal b):返回两数之和并保留 2 位(HALF_UP);BigDecimal split(BigDecimal total, int people):把金额均分给 N 人并返回每人金额,说明余数怎么处理(提示:先算每人金额,余数归第一个或者单独记录);验收方式:能解释第 2 步两种方式的结果差异,以及第 4 步余数的处理方案。
扩展任务:写一个 Money 类(long 存分 + 币种),提供 add、multiply(rate: BigDecimal)、format() 三个方法,体会"存储用 long、计算用 BigDecimal"的组合方式。
基础题
0.1 + 0.2 == 0.3 在 Java 中为 true。(对 / 错)BigDecimal 应该用 ______ 构造,除法必须指定 ______ 与 ______。1.0 与 1.00 的 equals 返回 false,而 compareTo 返回 0?代码阅读题
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);编程题
BigDecimal compoundInterest(BigDecimal principal, BigDecimal annualRate, int years):计算复利终值(principal × (1 + rate)^years),要求结果保留 2 位小数、四舍五入。注意 BigDecimal 没有幂运算,需要循环相乘。综合任务
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 份(按比例 + 四舍五入),最后一份用"总额 - 已分配"倒推——这样能保证总和严格相等。
0.1 与 0.2 在二进制中都是无限循环小数,相加后的结果有误差,所以不等于 0.3。new BigDecimal("0.1") 或 BigDecimal.valueOf)、精度(scale)、舍入模式(RoundingMode)。equals 同时比较数值与精度,所以 1.0 不等于 1.00;而 compareTo 只比较数值,认为它们相等。2 / 3HALF_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——这才是完整规则。
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));
}
}预期输出:
11087.18package 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"))));
}
}预期输出:
[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,账目就平不了。
这是一个真实的业务问题:优惠券按比例分摊到多个商品、退款按比例分摊到多笔支付、分账给多个商户……处理不当就会出现"分摊后总额对不上"的事故。核心原则是:先算可算的部分,最后一份用减法倒推,保证总额守恒。
double 误差的成因,并知道它无法完全消除;valueOf 构造 BigDecimal,不在中途引入 double;compareTo 而不是 equals;long 存分与 BigDecimal 各自的适用场景,并按场景选择。S1 的常用类模块到这里完整了:String、equals/hashCode、日期时间、BigDecimal。
《List、Set、Map》 进入集合模块——这是后端开发用得最多的数据结构,也是 《equals、hashCode 与对象比较》 里 hashCode 契约最重要的应用场景。
暂无