BigDecimal:0.1 + 0.2 为什么不等于 0.3?
一句话结论(30s)
0.1 + 0.2 != 0.3 是因为 IEEE 754 二进制浮点无法精确表示十进制的 0.1,就像十进制无法精确表示 1/3。关键设计是 BigDecimal 用 unscaledValue × 10^(-scale) 的十进制定点模型消除精度误差;代价是软件实现比硬件指令慢几个数量级,且 new BigDecimal(double) 会传入已丢失精度的值,必须用字符串构造或 valueOf。
核心原理(2min)
BigDecimal 内部用 intCompact(扩大后的整数)+ scale(小数点位数)+ precision(有效位数)存十进制,计算时对齐 scale 后做整数加减,无二进制截断;除法遇无限循环小数(如 1/3)必须显式指定精度与 RoundingMode,金额计算严禁用 double(0.01 累积误差在亿级流水后可达万元级)。
底层深入(5-10min)
Double 的精度陷阱
System.out.println(0.1 + 0.2); // 0.30000000000000004
System.out.println(0.1 + 0.2 == 0.3); // false
根源:IEEE 754 二进制浮点数无法精确表示十进制的 0.1。 就像十进制无法精确表示 1/3 = 0.3333…一样,二进制无法精确表示 1/10。
💭 思考:为什么 0.1 在二进制里是无限循环小数?因为 0.1 = 1/10,分母 10 = 2×5 含因子 5;而二进制只有分母是 2 的幂的数(0.5=1/2、0.25=1/4)才有有限表示。所以 0.5+0.25 精确、0.1+0.2 却多出个尾巴。
BigDecimal 的解决思路
BigDecimal 不使用二进制浮点数——它用十进制定点数模型:
BigDecimal = unscaledValue × 10^(-scale)
0.1 → unscaledValue = 1, scale = 1
0.01 → unscaledValue = 1, scale = 2
计算 0.1 + 0.2:
0.1 = 1 × 10^(-1)
0.2 = 2 × 10^(-1)
→ 对齐 scale → (1+2) × 10^(-1) = 3 × 10^(-1) = 0.3 ✓ 精确!
内部实现:intCompact(存扩大后的整数)、scale(小数点后位数)、precision(有效位数)。没有二进制截断,完全十进制精确。
💭 思考:BigDecimal 存的是「整数 + scale」,那 0.1+0.2 在它眼里就是
(1+2)×10⁻¹,跟十进制手算一模一样——所以它能精确。可为什么 JDK 不默认所有浮点都用它?因为软件整数运算比硬件浮点指令慢几个数量级,默认全用 BigDecimal 全盘性能会崩。
最经典的坑:构造器传 double
BigDecimal a = new BigDecimal(0.1); // ❌ 0.1 作为 double 已经有精度丢失!
System.out.println(a); // 0.1000000000000000055511151231257827021181583404541015625
BigDecimal b = new BigDecimal("0.1"); // ✅ 字符串 → 精确 0.1
BigDecimal c = BigDecimal.valueOf(0.1); // ✅ 内部也是 Double.toString(0.1) = "0.1"
new BigDecimal(double) 接收的是已经丢失精度的 double 值。 字符串构造或 BigDecimal.valueOf(double)(底层走 Double.toString() 规范表示)才是正确做法。
💭 思考:
new BigDecimal(0.1)和new BigDecimal("0.1")到底差在哪?差在「转换发生的时机」:0.1字面量在编译期就已是 double(已被截断),构造器只能照单全收;而"0.1"是精确的十进制文本,构造器才拿到准确值。valueOf内部正是先Double.toString把它还原成"0.1"再解析。
除法与 RoundingMode
BigDecimal a = new BigDecimal("1");
BigDecimal b = new BigDecimal("3");
a.divide(b); // ❌ ArithmeticException: Non-terminating decimal expansion
a.divide(b, 2, RoundingMode.HALF_UP); // ✅ 0.33
1/3 是无限循环小数,必须指定精度和舍入模式。这是 BigDecimal 特有的”必设项”——没有默认行为(保护你)。
💭 思考:为什么
divide遇到 1/3 直接抛异常、不给你默认舍入?因为金额场景里静默舍入会把误差藏起来、越滚越大——宁可用异常逼你显式声明精度和舍入模式,也不悄悄丢精度。这是「保护你」的设计。
BigDecimal vs Double
| Double | BigDecimal | |
|---|---|---|
| 存储 | IEEE 754 二进制 64bit | unscaledValue + scale + precision |
| 0.1 表示 | 近似值 | 精确值 |
| 计算速度 | 硬件指令(纳秒级) | 软件实现(微秒级) |
| 适用场景 | 科学计算、图形渲染 | 金融计算(金额/利率) |
金额永远用 BigDecimal,决不要用 double。 0.01 元的累积误差在亿级流水后就是万元级别的差错了。
章末提问
追问 1:0.1+0.2 为什么不等于 0.3,但 0.5+0.25 却精确等于 0.75?
结论先行:因为 0.5、0.25 是 2 的负整数次幂,二进制能有限精确表示;而 0.1、0.2 转二进制是无限循环小数,只能被截断成近似值。
因为:IEEE 754 用二进制分数表示小数,只有分母是 2 的幂的数(1/2、1/4、1/8…)才有有限二进制表示;0.1=1/10 的分母含因子 5,转二进制必然无限循环,尾数被截断后相加再转回十进制就出现了 0.30000000000000004。
追问 2:new BigDecimal(0.1) 和 new BigDecimal("0.1") 有什么区别?valueOf 内部做了什么?
结论先行:前者拿到的是已被截断的 double 值(会得到 0.1000…0555 的长串),后者是精确的 0.1;valueOf 内部用 Double.toString(0.1) 把 double 还原成 “0.1” 再走字符串构造。
因为:0.1 字面量在编译期就已按 IEEE 754 存储、精度已丢失,new BigDecimal(double) 只能忠实记录这个「已经错掉」的值;而字符串构造直接解析十进制文本,不存在二进制中转。valueOf 之所以精确,是因为 Double.toString 输出的是能唯一区分该 double 的最短十进制表示。
追问 3:金额计算为什么必须用 BigDecimal,它比 double 慢多少、慢在哪?
结论先行:BigDecimal 用软件整数运算保证十进制精确,代价是比硬件浮点指令慢几个数量级(纳秒级 → 微秒级)。
因为:double 的加减乘除直接映射到 CPU 硬件指令;BigDecimal 需要对齐 scale、用 intCompact/BigInteger 做软件整数运算并维护 precision,每一步都是多条指令。金额对精度是刚需(0.01 的累积误差在亿级流水后就是万元级差错),性能换正确性是值得的。