浮点数精度丢失:为什么0.1+0.2不等于0.3,如何正确处理
浮点数精度丢失:为什么0.1+0.2不等于0.3,如何正确处理
适读人群:Java初中级开发者、做过金融/电商金额计算的后端工程师 | 阅读时长:约14分钟 | 文章类型:原理剖析+实战方案
开篇故事
做电商的同学应该都知道,金额计算是个雷区。
有个前同事,做一个促销折扣系统。原价乘以折扣率,得到折扣后的价格,再加上运费,算出最终应付金额。代码大概是这样的:
double price = 99.9;
double discount = 0.9;
double shipping = 5.0;
double total = price * discount + shipping;
System.out.println("应付: " + total);结果输出:
应付: 94.91000000000001客服收到投诉:用户支付页面显示的金额多了一分钱!
他改成了Math.round(total * 100) / 100.0,看起来对了。但后来又碰到了另一个case,round之后又不对了……来找我的时候,已经改了四五次,每次改完又出新问题。
这类问题的根本解法只有一个:涉及金额的计算,用BigDecimal,不用double/float。但要真正理解为什么,还是要从浮点数的底层表示说起。
一、浮点数精度问题的根本原因
二进制无法精确表示某些十进制小数
计算机里所有数字都用二进制表示。整数的转换没问题,但小数就麻烦了。
十进制的0.1换算成二进制,是一个无限循环的二进制小数:
0.1 (十进制) = 0.0001100110011... (二进制,无限循环)类似于十进制里1/3 = 0.333...,永远除不尽。
IEEE 754标准规定了double的存储格式:1位符号,11位指数,52位尾数,共64位。52位尾数不够存下这个无限小数,所以只能截断,这就引入了精度误差。
System.out.println(0.1 + 0.2);
// 输出:0.30000000000000004
// 不是0.3!这不是Java的bug,是IEEE 754浮点数的基本特性,所有语言(Python、JavaScript、C++)都一样。
double的精度范围
float:约6-7位有效十进制数字double:约15-17位有效十进制数字
对于金额计算,15位数字听起来够用,但误差是累积的。一个计算里有多次乘除法,误差会放大,最终可能在小数点后几位产生可见偏差。
二、核心原理深挖
IEEE 754表示double的原理
double用64位存储:
[符号位 1位][指数 11位][尾数 52位]数值 = (-1)^符号 × 2^(指数-1023) × 1.尾数
对于0.1:
- 二进制:1.100110011...(循环) × 2^(-4)
- 52位尾数截断后:最近的可表示值
- 实际存储的值约为:0.1000000000000000055511151231257827021181583404541015625
这就是为什么打印0.1 + 0.2不等于0.3的原因。
各种处理方案的对比
三、完整代码实现
代码一:BigDecimal的正确用法
package com.laozhang.trap.floating;
import java.math.BigDecimal;
import java.math.RoundingMode;
/**
* BigDecimal正确使用指南
* 金额计算的标准做法
*/
public class BigDecimalCorrectUsage {
public static void main(String[] args) {
System.out.println("=== double精度问题验证 ===");
double d1 = 0.1;
double d2 = 0.2;
System.out.println("0.1 + 0.2 = " + (d1 + d2)); // 0.30000000000000004
System.out.println("0.1 + 0.2 == 0.3: " + (d1 + d2 == 0.3)); // false
System.out.println("\n=== BigDecimal正确创建方式 ===");
// 错误:从double构造,带入了double的精度误差
BigDecimal wrong = new BigDecimal(0.1);
System.out.println("new BigDecimal(0.1) = " + wrong);
// 输出:0.1000000000000000055511151231257827021181583404541015625
// 正确方式1:从字符串构造
BigDecimal correct1 = new BigDecimal("0.1");
System.out.println("new BigDecimal(\"0.1\") = " + correct1); // 0.1
// 正确方式2:BigDecimal.valueOf(内部用Double.toString转换)
BigDecimal correct2 = BigDecimal.valueOf(0.1);
System.out.println("BigDecimal.valueOf(0.1) = " + correct2); // 0.1
System.out.println("\n=== BigDecimal四则运算 ===");
BigDecimal a = new BigDecimal("0.1");
BigDecimal b = new BigDecimal("0.2");
System.out.println("0.1 + 0.2 = " + a.add(b)); // 0.3
System.out.println("0.3 - 0.1 = " + new BigDecimal("0.3").subtract(a)); // 0.2
System.out.println("0.1 * 3 = " + a.multiply(new BigDecimal("3"))); // 0.3
System.out.println("\n=== 除法(必须指定精度)===");
BigDecimal ten = new BigDecimal("10");
BigDecimal three = new BigDecimal("3");
// 不指定精度的除法,如果是无限小数会抛异常
try {
System.out.println(ten.divide(three)); // ArithmeticException: Non-terminating decimal expansion
} catch (ArithmeticException e) {
System.out.println("除法异常: " + e.getMessage());
}
// 正确:指定精度和舍入模式
BigDecimal result = ten.divide(three, 2, RoundingMode.HALF_UP);
System.out.println("10 / 3 (保留2位小数,四舍五入) = " + result); // 3.33
System.out.println("\n=== 金额计算实战 ===");
BigDecimal price = new BigDecimal("99.90");
BigDecimal discount = new BigDecimal("0.9");
BigDecimal shipping = new BigDecimal("5.00");
BigDecimal discountedPrice = price.multiply(discount)
.setScale(2, RoundingMode.HALF_UP); // 保留2位小数
BigDecimal total = discountedPrice.add(shipping);
System.out.println("原价: " + price);
System.out.println("折扣后: " + discountedPrice);
System.out.println("运费: " + shipping);
System.out.println("应付: " + total); // 94.91,精确!
}
}代码二:金额工具类的工程实践
package com.laozhang.trap.floating;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Objects;
/**
* 金额工具类
* 封装BigDecimal常见操作,统一精度和舍入规则
*
* 设计原则:
* 1. 金额统一使用BigDecimal存储和计算
* 2. 比较用compareTo,不用equals(因为scale不同时equals返回false)
* 3. 输出时setScale(2, HALF_UP)统一格式
* 4. 除法必须指定精度
*/
public class MoneyUtils {
/** 金额统一保留2位小数 */
public static final int MONEY_SCALE = 2;
/** 统一舍入规则:四舍五入 */
public static final RoundingMode ROUNDING_MODE = RoundingMode.HALF_UP;
/** 零金额 */
public static final BigDecimal ZERO = BigDecimal.ZERO.setScale(MONEY_SCALE, ROUNDING_MODE);
/** 禁止实例化 */
private MoneyUtils() {}
/**
* 将字符串/double转为金额BigDecimal
* 统一用字符串中转,避免double精度问题
*/
public static BigDecimal of(String amount) {
if (amount == null || amount.trim().isEmpty()) {
return ZERO;
}
return new BigDecimal(amount).setScale(MONEY_SCALE, ROUNDING_MODE);
}
public static BigDecimal of(double amount) {
// 用Double.toString避免直接用double构造BigDecimal的精度问题
return of(Double.toString(amount));
}
public static BigDecimal of(long amountInCents) {
// 分换算成元(推荐:数据库存分,计算时转换)
return BigDecimal.valueOf(amountInCents)
.divide(BigDecimal.valueOf(100), MONEY_SCALE, ROUNDING_MODE);
}
/**
* 加法
*/
public static BigDecimal add(BigDecimal a, BigDecimal b) {
validateNotNull(a, b);
return a.add(b).setScale(MONEY_SCALE, ROUNDING_MODE);
}
/**
* 减法(允许负数)
*/
public static BigDecimal subtract(BigDecimal a, BigDecimal b) {
validateNotNull(a, b);
return a.subtract(b).setScale(MONEY_SCALE, ROUNDING_MODE);
}
/**
* 乘法(用于折扣、税率等)
*/
public static BigDecimal multiply(BigDecimal amount, BigDecimal rate) {
validateNotNull(amount, rate);
return amount.multiply(rate).setScale(MONEY_SCALE, ROUNDING_MODE);
}
/**
* 除法(用于平摊、单价计算等)
*/
public static BigDecimal divide(BigDecimal a, BigDecimal b) {
validateNotNull(a, b);
if (b.compareTo(BigDecimal.ZERO) == 0) {
throw new ArithmeticException("除数不能为零");
}
return a.divide(b, MONEY_SCALE, ROUNDING_MODE);
}
/**
* 比较(用compareTo,不用equals)
* BigDecimal.equals要求value和scale都相同
* new BigDecimal("1.0").equals(new BigDecimal("1.00")) -> false
* 但 compareTo 返回 0 -> 数值相等
*/
public static boolean isEqual(BigDecimal a, BigDecimal b) {
if (a == null && b == null) return true;
if (a == null || b == null) return false;
return a.compareTo(b) == 0;
}
public static boolean isGreaterThan(BigDecimal a, BigDecimal b) {
validateNotNull(a, b);
return a.compareTo(b) > 0;
}
public static boolean isLessThan(BigDecimal a, BigDecimal b) {
validateNotNull(a, b);
return a.compareTo(b) < 0;
}
public static boolean isNonNegative(BigDecimal amount) {
return amount != null && amount.compareTo(BigDecimal.ZERO) >= 0;
}
/**
* 转换为分(整数,适合数据库存储)
*/
public static long toCents(BigDecimal amount) {
return amount.setScale(MONEY_SCALE, ROUNDING_MODE)
.multiply(BigDecimal.valueOf(100))
.longValue();
}
/**
* 格式化输出(保留2位小数)
*/
public static String format(BigDecimal amount) {
if (amount == null) return "0.00";
return amount.setScale(MONEY_SCALE, ROUNDING_MODE).toPlainString();
}
private static void validateNotNull(BigDecimal... values) {
for (BigDecimal v : values) {
Objects.requireNonNull(v, "金额参数不能为null");
}
}
// 使用示例
public static void main(String[] args) {
BigDecimal price = of("99.90");
BigDecimal discount = of("0.90");
BigDecimal shipping = of("5.00");
BigDecimal discountedPrice = multiply(price, discount);
BigDecimal total = add(discountedPrice, shipping);
System.out.println("折扣价: " + format(discountedPrice)); // 89.91
System.out.println("总计: " + format(total)); // 94.91
// 分期付款平摊
BigDecimal installmentTotal = of("100.00");
BigDecimal perInstallment = divide(installmentTotal, of("3"));
System.out.println("每期: " + format(perInstallment)); // 33.33
// 注意:3 × 33.33 = 99.99,最后一期要单独处理差额
BigDecimal lastInstallment = subtract(installmentTotal,
multiply(perInstallment, of("2")));
System.out.println("最后一期: " + format(lastInstallment)); // 33.34
// 比较
System.out.println("compareTo: " + isEqual(of("1.0"), of("1.00"))); // true
System.out.println("equals: " + new BigDecimal("1.0").equals(new BigDecimal("1.00"))); // false!
}
}四、踩坑实录
坑1:new BigDecimal(double)带入精度误差
报错现象:
金额计算结果在小数点后出现一长串数字,如0.1000000000000000055511...。
根本原因:
new BigDecimal(0.1) // 从double构造,double本身就不精确具体解法:
// 精确方式
new BigDecimal("0.1") // 字符串
BigDecimal.valueOf(0.1) // 内部用Double.toString,等价于"0.1"坑2:BigDecimal除法抛ArithmeticException
报错现象:
java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.
at java.base/java.math.BigDecimal.divide(BigDecimal.java:1748)
at com.example.service.PriceService.calcUnitPrice(PriceService.java:55)根本原因:
new BigDecimal("10").divide(new BigDecimal("3"))
// 10/3 = 3.3333...无限循环,BigDecimal不知道保留几位,直接抛异常具体解法:
// 必须指定scale和舍入模式
new BigDecimal("10").divide(new BigDecimal("3"), 2, RoundingMode.HALF_UP) // 3.33坑3:BigDecimal.equals比较金额不符合预期
报错现象:
明明应该相等的两个金额,equals返回false,导致业务逻辑判断错误。
触发代码:
BigDecimal a = new BigDecimal("1.0"); // scale=1
BigDecimal b = new BigDecimal("1.00"); // scale=2
System.out.println(a.equals(b)); // false!
System.out.println(a.compareTo(b) == 0); // true,数值相等
// 实际场景:从数据库读出来的1.0和代码里写的1.00
if (orderAmount.equals(feeAmount)) { ... } // 可能不进去具体解法:
// 金额比较统一用compareTo
if (orderAmount.compareTo(feeAmount) == 0) { ... }
// 或者统一scale后再比较
BigDecimal normalized1 = a.setScale(2, RoundingMode.HALF_UP);
BigDecimal normalized2 = b.setScale(2, RoundingMode.HALF_UP);
System.out.println(normalized1.equals(normalized2)); // true五、总结与延伸
关于浮点数精度,记住这几条原则就够了:
1. 涉及金额、财务计算,永远用BigDecimal,不用double
这不是建议,是强制要求。一分钱的误差在电商系统里就是严重bug。
2. BigDecimal必须从字符串构造,或者用BigDecimal.valueOf(double)
不要new BigDecimal(double),这个构造器是陷阱。
3. BigDecimal除法必须指定scale和RoundingMode
不指定直接抛异常,这个设计其实是好的——强制你思考舍入策略。
4. BigDecimal比较用compareTo,不用equals
1.0和1.00equals是false,compareTo是0。
5. 推荐在数据库里用整数(分)存金额
BIGINT存分,读出来除以100得到元。完全避免小数,精度问题从根上解决。
如果你用Java 8+,可以考虑用JSR 354(JavaMoney)库,有更完善的货币金额抽象。但对于大多数场景,封装好的BigDecimal工具类已经足够用了。
