为什么 0.1 + 0.2 不等于 0.3?金额计算为什么要用 BigDecimal?
简化版
float/double 用二进制表示小数,而很多十进制小数(如 0.1、0.2)无法用有限位二进制精确表示,只能存一个近似值,所以 0.1 + 0.2 结果是 0.30000000000000004 而非 0.3。金额、财务等要求精确的计算必须用 BigDecimal——它用十进制存储、不丢精度。但用 BigDecimal 有个大坑:必须用字符串构造 new BigDecimal("0.1"),不能用 new BigDecimal(0.1)(传 double 会把 double 的误差带进来);除法要指定精度和舍入模式,否则除不尽会抛异常。
详细版
为什么浮点数不精确:计算机用二进制,小数部分是 1/2 + 1/4 + 1/8 + ... 的组合。0.5=2⁻¹、0.25=2⁻² 能精确表示,但 0.1 换成二进制是无限循环小数 0.0001100110011...,double 只有 52 位尾数,存不下就截断,留下微小误差。
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
BigDecimal 正确用法:
// ✗ 错误:传 double,误差已经产生
BigDecimal wrong = new BigDecimal(0.1); // 0.1000000000000000055511151231257827021181583404541015625
// ✓ 正确:传字符串
BigDecimal a = new BigDecimal("0.1");
BigDecimal b = new BigDecimal("0.2");
System.out.println(a.add(b)); // 0.3 精确!
// ✓ 或用 valueOf(内部走 Double.toString,相对安全)
BigDecimal c = BigDecimal.valueOf(0.1); // 0.1
// 除法必须指定精度和舍入模式,否则除不尽抛 ArithmeticException
BigDecimal result = a.divide(b, 2, RoundingMode.HALF_UP);
BigDecimal 的比较:用 compareTo 不用 equals:
new BigDecimal("1.0").equals(new BigDecimal("1.00")); // false!equals 还比 scale(小数位数)
new BigDecimal("1.0").compareTo(new BigDecimal("1.00")); // 0,相等(只比数值)
⚠️ 三个必记铁律:① 构造用字符串
new BigDecimal("0.1"),别用 double;② 除法指定舍入模式,否则除不尽抛异常;③ 比较用 compareTo 不用 equals(equals 还会比较小数位数 scale)。
完整版教学
一、根源:十进制小数在二进制里存不下
计算机内存里只有 0 和 1,浮点数用二进制科学计数法存储。整数和「能被 2 的幂表示的小数」没问题,但大多数十进制小数会变成二进制的无限循环小数:
0.5 → 0.1(二进制) 精确(= 1/2)
0.25 → 0.01(二进制) 精确(= 1/4)
0.1 → 0.00011001100110011...(二进制无限循环) 存不下!
double 用 64 位:1 符号位 + 11 指数位 + 52 尾数位
0.1 的二进制无限循环,只能取前 52 位尾数,后面截断
→ 存进去的其实是 0.1000000000000000055511151231257827021...(近似值)
这就像十进制里 1/3 = 0.3333… 永远写不完一样,只是换到二进制后,「写不完」的小数变成了 0.1、0.2 这些日常数字。所以浮点误差不是 bug,是「用有限位二进制近似表示十进制小数」的必然结果。
二、0.1 + 0.2 为什么是 0.30000000000000004
明白了单个数就有误差,加法就顺理成章了:
0.1 存进 double ≈ 0.1000000000000000055511...
0.2 存进 double ≈ 0.2000000000000000111022...
两个近似值相加 ≈ 0.3000000000000000444089...
double 显示时四舍五入到能区分的位数 → 0.30000000000000004
而 0.3 单独存进 double ≈ 0.2999999999999999888977...
两者的近似值不同 → 0.1+0.2 == 0.3 为 false
关键:0.1、0.2、0.3 各自存的都是近似值,且近似的方向和大小不同,所以「0.1 和 0.2 的近似值之和」不等于「0.3 的近似值」。这也是为什么永远不要用 == 比较浮点数——要比较得判断差的绝对值是否小于一个极小误差 Math.abs(a-b) < 1e-9,或者干脆用 BigDecimal。
三、BigDecimal 怎么做到精确
BigDecimal 不用二进制浮点,而是用「一个整数 + 小数点位置(scale)」的方式,本质是十进制存储:
BigDecimal("3.14") 内部存:
unscaledValue = 314(一个大整数,用 BigInteger 存)
scale = 2(小数点后有 2 位)
值 = 314 × 10⁻² = 3.14
因为用十进制整数存,0.1 就是精确的 "1,scale=1",没有二进制截断问题
运算时按十进制规则算,全程不丢精度
代价是:BigDecimal 是对象(不是基本类型),运算要调方法(add/subtract/multiply/divide)而非 + - * /,性能比 double 低、内存占用大。所以它只用在「必须精确」的场景(金额、利率、税费),对精度不敏感的科学计算、图形、统计仍用 double(够快)。
四、构造陷阱:为什么必须用字符串
这是 BigDecimal 最大的坑。new BigDecimal(double) 会把 double 已有的误差原封不动搬进 BigDecimal:
new BigDecimal(0.1)
// = 0.1000000000000000055511151231257827021181583404541015625
// ↑ double 的近似值被完整还原,误差全带进来了
new BigDecimal("0.1")
// = 0.1 ← 字符串直接按十进制解析,精确
原因:传 double 时,那个 0.1 在进入构造器之前就已经是带误差的近似值了,BigDecimal 只是忠实记录这个误差值。而传字符串,BigDecimal 直接按十进制字符解析,没有二进制转换,自然精确。铁律:BigDecimal 构造一律用字符串(或 BigDecimal.valueOf(double),它内部走 Double.toString 相对安全,但字符串最保险)。
五、除法与舍入:除不尽会抛异常
BigDecimal 的除法是另一个必考陷阱。除不尽(无限小数)时,如果不指定精度和舍入模式,会抛 ArithmeticException:
new BigDecimal("1").divide(new BigDecimal("3"));
// ✗ ArithmeticException: Non-terminating decimal expansion
// 1/3 = 0.333... 无限循环,BigDecimal 不知道保留几位,直接报错
new BigDecimal("1").divide(new BigDecimal("3"), 2, RoundingMode.HALF_UP);
// ✓ 0.33 指定保留 2 位、四舍五入
常见舍入模式:
| RoundingMode | 含义 |
|---|---|
HALF_UP | 四舍五入(最常用) |
HALF_EVEN | 银行家舍入(四舍六入五取偶,减少统计偏差) |
DOWN | 直接截断(向零舍入) |
CEILING / FLOOR | 向上 / 向下取整 |
金融场景常用 HALF_EVEN(银行家舍入)——因为大量数据用 HALF_UP 会系统性偏大,HALF_EVEN 让「.5」一半舍上一半舍下,长期更公平。除法必须显式给精度和舍入模式,这是硬性要求。
六、比较陷阱:compareTo 而非 equals
BigDecimal 的相等判断也有坑。equals 会同时比较数值和 scale(小数位数):
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");
a.equals(b); // false!数值相等,但 scale 一个是 1 一个是 2
a.compareTo(b); // 0,相等(compareTo 只比数值,忽略 scale)
所以判断两个 BigDecimal 数值是否相等,必须用 compareTo(x) == 0,不能用 equals。equals 返回 false 不代表数值不等,只是「小数位数」不同。这个坑在金额比较时极易踩——1.0 元和 1.00 元数值相同但 equals 不等。相关地,去重放进 HashSet 也会因 scale 不同而「去不掉重」,要先 stripTrailingZeros() 归一化。
记忆钩子:「浮点二进制存不下十进制小数 → 0.1+0.2≠0.3、别用 == 比浮点;金额用 BigDecimal,但构造用字符串、除法给舍入模式、比较用 compareTo」。
七、常见误区与追问
- 误区:0.1+0.2≠0.3 是 Java 的 bug。 是 IEEE 754 浮点标准的固有特性,所有用二进制浮点的语言(C、JS、Python)都一样,不是 bug。
- 误区:用
new BigDecimal(0.1)就能精确。 传 double 会把 double 的误差带进来,得到一长串近似值;必须用字符串new BigDecimal("0.1")。 - 误区:BigDecimal 除法直接 divide 就行。 除不尽会抛 ArithmeticException,必须指定保留位数和 RoundingMode 舍入模式。
- 误区:BigDecimal 用 equals 比较相等。 equals 会连 scale 一起比,
1.0和1.00不等;判断数值相等要用compareTo == 0。 - 追问:为什么不能用 == 比较浮点数? 每个浮点数存的是近似值,运算后误差累积,
0.1+0.2 == 0.3为 false;应判断Math.abs(a-b) < 极小值或用 BigDecimal。 - 追问:金额一定要用 BigDecimal 吗,能用 long 存分吗? 也可以用 long 以「分」为单位存(避免小数),简单高效;但涉及复杂计算(利率、汇率、多次除法)时 BigDecimal 更灵活,两种方案都常见。
- 追问:BigDecimal.valueOf(0.1) 和 new BigDecimal(“0.1”) 有区别吗? valueOf 内部走
Double.toString(0.1)得到 “0.1” 再解析,结果通常正确;但最保险仍是直接传字符串,尤其是从外部读入的金额。
八、加强记忆
浮点不精确的根源是「十进制小数在二进制里往往是无限循环,double 只有 52 位尾数存不下、只能存近似值」——就像十进制写不完 1/3 一样,二进制写不完 0.1。所以 0.1+0.2 是三个近似值凑出的 0.30000000000000004,且永远不要用 == 比较浮点数。要精确(金额、财务、利率)就用 BigDecimal,它用「大整数 + scale」十进制存储、不丢精度,但有三条必记铁律:① 构造用字符串 new BigDecimal("0.1")(传 double 会把误差带进来);② 除法必须给精度和 RoundingMode(除不尽否则抛 ArithmeticException,金融常用银行家舍入 HALF_EVEN);③ 比较用 compareTo==0 而非 equals(equals 连 scale 一起比,1.0≠1.00)。金额也可用 long 以「分」为单位存。一句话「二进制存不下十进制小数所以浮点有误差、别用 == 比、金额用 BigDecimal 且构造用字符串、除法给舍入、比较用 compareTo」。