BigDecimal的equals陷阱:new BigDecimal("1.0")和new BigDecimal("1")不相等
BigDecimal的equals陷阱:new BigDecimal("1.0")和new BigDecimal("1")不相等
适读人群:Java中级开发者、金融/电商后端工程师 | 阅读时长:约12分钟 | 文章类型:深度剖析+踩坑实录
开篇故事
上一篇讲了浮点数精度,有读者留言问了一个很好的问题:"老张,我已经换成BigDecimal了,但比较金额时发现equals还是不对,这咋回事?"
这个问题很典型。我团队里就发生过这么一件事:
支付系统有一个账单对账逻辑,大概意思是:用户实付金额要和订单应付金额核对一致,才能完成对账。代码是这样写的:
BigDecimal actualPaid = getActualPaidAmount(); // 从支付回调取,比如1.00
BigDecimal orderAmount = getOrderAmount(); // 从订单表取,比如1.0
if (!actualPaid.equals(orderAmount)) {
// 触发人工复核
flagForManualReview(orderId);
}结果大量本来应该自动对账成功的订单,全被打上了"需人工复核"的标记。客服团队那天处理了几百个本不需要处理的工单。
问题就在这个equals——new BigDecimal("1.0")和new BigDecimal("1"),数值相同,但equals返回false。
一、BigDecimal的内部结构
要理解这个问题,先了解BigDecimal是什么。
BigDecimal由两部分组成:
- unscaledValue(非标度值):一个不带小数点的整数
- scale(标度):小数点右边的位数
比如:
new BigDecimal("1.0")→ unscaledValue=10, scale=1,表示 10 × 10^(-1) = 1.0new BigDecimal("1.00")→ unscaledValue=100, scale=2,表示 100 × 10^(-2) = 1.00new BigDecimal("1")→ unscaledValue=1, scale=0,表示 1 × 10^0 = 1
三个对象数学值完全相同,都是1。但它们的内部表示不同。
二、核心原理深挖
equals的实现:同时比较值和scale
BigDecimal的equals方法源码(JDK 17简化版):
@Override
public boolean equals(Object x) {
if (!(x instanceof BigDecimal xDec))
return false;
if (x == this)
return true;
if (scale != xDec.scale) // scale不同,直接返回false!
return false;
// ...比较unscaledValue
}关键在第三行:scale不同,直接返回false,甚至不看值是不是相等。
这就解释了:
new BigDecimal("1.0").equals(new BigDecimal("1.00")) // false(scale不同:1 vs 2)
new BigDecimal("1.0").equals(new BigDecimal("1")) // false(scale不同:1 vs 0)
new BigDecimal("1.0").equals(new BigDecimal("1.0")) // true(scale和value都相同)compareTo:只比较数学值
compareTo方法则不同,它只比较数学值:
new BigDecimal("1.0").compareTo(new BigDecimal("1.00")) // 0(数值相等)
new BigDecimal("1.0").compareTo(new BigDecimal("1")) // 0(数值相等)
new BigDecimal("1.0").compareTo(new BigDecimal("2")) // -1(1.0 < 2)这就是为什么BigDecimal的相等性比较要用compareTo,不用equals。
scale是怎么产生的
不同来源的BigDecimal,scale可能不同:
| 来源 | 示例 | scale |
|---|---|---|
| 字符串直接构造 | new BigDecimal("1.0") | 1 |
| 字符串直接构造 | new BigDecimal("1") | 0 |
| 算术运算结果 | 1.0 + 1.0 | 1 |
| 算术运算结果 | 1.0 * 2.0 | 2(加法结果scale取最大) |
| 数据库DECIMAL(10,2) | 读出来的DECIMAL | 通常是2 |
| setScale处理后 | .setScale(2, HALF_UP) | 2 |
所以在实际系统里,相同数学值的BigDecimal,scale可能各不相同,这就是equals经常"不对"的原因。
BigDecimal的比较体系
三、完整代码实现
代码一:BigDecimal equals行为全面验证
package com.laozhang.trap.bigdecimal;
import java.math.BigDecimal;
import java.math.RoundingMode;
/**
* BigDecimal equals陷阱全面验证
*/
public class BigDecimalEqualsTest {
public static void main(String[] args) {
System.out.println("=== equals vs compareTo ===");
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");
BigDecimal c = new BigDecimal("1");
BigDecimal d = new BigDecimal("1.0");
// equals比较
System.out.println("\"1.0\".equals(\"1.00\"): " + a.equals(b)); // false
System.out.println("\"1.0\".equals(\"1\"): " + a.equals(c)); // false
System.out.println("\"1.0\".equals(\"1.0\"): " + a.equals(d)); // true
// compareTo比较
System.out.println("\n\"1.0\".compareTo(\"1.00\")==0: " + (a.compareTo(b) == 0)); // true
System.out.println("\"1.0\".compareTo(\"1\")==0: " + (a.compareTo(c) == 0)); // true
System.out.println("\n=== scale查看 ===");
System.out.println("\"1.0\" scale: " + a.scale()); // 1
System.out.println("\"1.00\" scale: " + b.scale()); // 2
System.out.println("\"1\" scale: " + c.scale()); // 0
System.out.println("\n=== 运算后的scale ===");
BigDecimal x = new BigDecimal("1.5");
BigDecimal y = new BigDecimal("2.50");
BigDecimal sum = x.add(y);
BigDecimal product = x.multiply(y);
System.out.println("1.5 + 2.50 = " + sum + " (scale=" + sum.scale() + ")"); // scale=2
System.out.println("1.5 * 2.50 = " + product + " (scale=" + product.scale() + ")"); // scale=3
System.out.println("\n=== stripTrailingZeros归一化 ===");
BigDecimal n1 = new BigDecimal("1.00");
BigDecimal n2 = new BigDecimal("1.0");
BigDecimal n3 = new BigDecimal("1");
System.out.println("\"1.00\".stripTrailingZeros() = " + n1.stripTrailingZeros()); // 1
System.out.println("\"1.0\".stripTrailingZeros() = " + n2.stripTrailingZeros()); // 1
System.out.println("\"1\".stripTrailingZeros() = " + n3.stripTrailingZeros()); // 1
// 归一化后equals
System.out.println("\n归一化后equals:");
System.out.println(n1.stripTrailingZeros().equals(n2.stripTrailingZeros())); // true
System.out.println(n1.stripTrailingZeros().equals(n3.stripTrailingZeros())); // true
System.out.println("\n=== 用HashSet存BigDecimal(equals和hashCode陷阱)===");
java.util.Set<BigDecimal> set = new java.util.HashSet<>();
set.add(new BigDecimal("1.0"));
set.add(new BigDecimal("1.00")); // 不会被去重!
set.add(new BigDecimal("1")); // 不会被去重!
System.out.println("Set大小: " + set.size()); // 3!不是1!
System.out.println("\n=== 正确:用TreeSet+compareTo排序去重 ===");
java.util.Set<BigDecimal> treeSet = new java.util.TreeSet<>();
treeSet.add(new BigDecimal("1.0"));
treeSet.add(new BigDecimal("1.00")); // 会被去重(TreeSet用compareTo)
treeSet.add(new BigDecimal("1")); // 会被去重
System.out.println("TreeSet大小: " + treeSet.size()); // 1
}
}代码二:对账系统的正确实现
package com.laozhang.trap.bigdecimal;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
/**
* 对账系统BigDecimal正确使用模式
*
* 核心原则:
* 1. 金额比较用compareTo,不用equals
* 2. 存入Set/Map作为key时,先统一scale或用TreeMap/TreeSet
* 3. 计算后统一setScale,避免scale不一致
*/
public class ReconciliationService {
private static final int MONEY_SCALE = 2;
private static final RoundingMode ROUNDING = RoundingMode.HALF_UP;
/**
* 对账:判断实付金额是否等于订单金额
* 错误写法 vs 正确写法
*/
public ReconcileResult reconcile(String orderId,
BigDecimal actualPaid,
BigDecimal orderAmount) {
// 错误写法(可能导致本应成功的对账被标记为失败)
// if (!actualPaid.equals(orderAmount)) {
// return ReconcileResult.MISMATCH;
// }
// 正确写法1:compareTo
if (actualPaid.compareTo(orderAmount) != 0) {
return new ReconcileResult(false,
"金额不符: 实付=" + actualPaid + ", 应付=" + orderAmount);
}
return new ReconcileResult(true, "对账成功");
}
/**
* 正确写法2:统一scale后比较
*/
public boolean isAmountEqual(BigDecimal a, BigDecimal b) {
if (a == null && b == null) return true;
if (a == null || b == null) return false;
// 统一到相同scale再比较
BigDecimal normalizedA = a.setScale(MONEY_SCALE, ROUNDING);
BigDecimal normalizedB = b.setScale(MONEY_SCALE, ROUNDING);
return normalizedA.equals(normalizedB); // scale相同了,equals才可靠
}
/**
* BigDecimal作为Map的key:必须处理scale不一致的问题
*/
public void mapKeyDemo() {
// 错误:直接用BigDecimal作为HashMap的key
Map<BigDecimal, String> wrongMap = new HashMap<>();
wrongMap.put(new BigDecimal("1.0"), "price_1");
String v1 = wrongMap.get(new BigDecimal("1.00")); // null!因为equals不相等
String v2 = wrongMap.get(new BigDecimal("1.0")); // "price_1" ✓
System.out.println("wrongMap.get(\"1.00\"): " + v1); // null
System.out.println("wrongMap.get(\"1.0\"): " + v2); // price_1
// 正确方式1:用TreeMap(compareTo决定相等性)
TreeMap<BigDecimal, String> correctMap = new TreeMap<>();
correctMap.put(new BigDecimal("1.0"), "price_1");
String v3 = correctMap.get(new BigDecimal("1.00")); // "price_1" ✓
System.out.println("correctMap.get(\"1.00\"): " + v3); // price_1
// 正确方式2:用统一scale的BigDecimal作为key
Map<BigDecimal, String> normalizedMap = new HashMap<>();
normalizedMap.put(normalize(new BigDecimal("1.0")), "price_1");
String v4 = normalizedMap.get(normalize(new BigDecimal("1.00"))); // "price_1" ✓
System.out.println("normalizedMap.get(\"1.00\"): " + v4); // price_1
}
/**
* 归一化BigDecimal(统一scale,用于作为Map key或hashCode计算)
*/
public BigDecimal normalize(BigDecimal amount) {
return amount == null ? null : amount.setScale(MONEY_SCALE, ROUNDING);
}
/**
* 对账结果
*/
static class ReconcileResult {
final boolean success;
final String message;
ReconcileResult(boolean success, String message) {
this.success = success;
this.message = message;
}
@Override
public String toString() {
return "ReconcileResult{success=" + success + ", message='" + message + "'}";
}
}
public static void main(String[] args) {
ReconciliationService service = new ReconciliationService();
// 测试对账
BigDecimal paid = new BigDecimal("100.00"); // 支付回调
BigDecimal order = new BigDecimal("100.0"); // 订单金额
ReconcileResult result = service.reconcile("ORDER001", paid, order);
System.out.println("对账结果: " + result); // 对账成功
// 测试isAmountEqual
System.out.println("isAmountEqual: " + service.isAmountEqual(paid, order)); // true
System.out.println("直接equals: " + paid.equals(order)); // false
// 测试Map key
System.out.println("\n=== Map Key测试 ===");
service.mapKeyDemo();
}
}四、踩坑实录
坑1:equals比较金额导致对账/校验逻辑误判
报错现象:
没有Exception,但业务逻辑错误。金额明明相同,但判断为不等;或者在Set去重时,相同数值的BigDecimal没有被去重。
根本原因:
equals比较scale,而不同来源(字符串直接构造、数据库读取、计算结果)的BigDecimal有不同的scale。
具体解法:
金额相等性比较,统一使用compareTo(other) == 0,或者提供统一的isEqual工具方法。
坑2:BigDecimal用于HashMap key,get失败
报错现象:
Map<BigDecimal, String> priceMap = new HashMap<>();
priceMap.put(new BigDecimal("1.5"), "category_A");
String cat = priceMap.get(new BigDecimal("1.50")); // 返回null!根本原因:
HashMap用hashCode定位bucket,再用equals比较key。new BigDecimal("1.5")和new BigDecimal("1.50")的hashCode不同(hashCode基于value和scale),所以get失败。
具体解法:
// 方案1:用TreeMap,它用compareTo
TreeMap<BigDecimal, String> priceMap = new TreeMap<>();
// 方案2:统一scale后作为key
priceMap.put(normalize(price), value);
String cat = priceMap.get(normalize(new BigDecimal("1.50")));
// 方案3:用String作为key(转成统一格式的字符串)
Map<String, String> priceMap2 = new HashMap<>();
priceMap2.put(normalize(price).toPlainString(), value);坑3:BigDecimal放入TreeSet/TreeMap时,compareTo==0的元素被去重了
这个是"意外"去重,有时候是你不想要的。
触发代码:
TreeSet<BigDecimal> set = new TreeSet<>();
set.add(new BigDecimal("1.0")); // 添加成功
set.add(new BigDecimal("1.00")); // 被认为"已存在"(compareTo==0),添加失败
System.out.println(set.size()); // 1,而不是2根本原因:
TreeSet判断重复用compareTo(返回0就认为重复),不用equals。如果你需要区分1.0和1.00为不同元素(虽然数值相同但scale不同),TreeSet会把它们当同一个。
具体解法:
如果需要按数学值去重,用TreeSet,这是正确行为。 如果需要精确区分不同scale的BigDecimal,用LinkedHashSet(equals+hashCode比较):
LinkedHashSet<BigDecimal> set = new LinkedHashSet<>();
set.add(new BigDecimal("1.0"));
set.add(new BigDecimal("1.00"));
System.out.println(set.size()); // 2五、总结与延伸
BigDecimal的equals坑,根本原因是API设计的一个权衡——equals遵循a.equals(b)意味着a.hashCode() == b.hashCode()的契约(Object规范)。如果equals只比较数值而不比较scale,那hashCode怎么设计?hashCode只能考虑数值,但两个不同scale的BigDecimal可能有相同数值,hashCode会相等,但现在equals要返回false,这就违反了契约。
所以equals的行为其实是合理的——它在遵守Java对象的基本契约。问题在于很多人误用了equals来做"数值相等性"判断。
一个简单的记忆方法:
- equals = 精确相等(包括表示方式):
1.0 ≠ 1.00 - compareTo==0 = 数学相等(只看数值):
1.0 == 1.00
金额比较用compareTo,这是在BigDecimal上做数值比较的正确方式。
另外,如果你在用Java 14+的record或者Lombok的@EqualsAndHashCode,要注意这些自动生成的equals可能直接调用BigDecimal的equals,也会有同样的坑。需要自定义equals逻辑:
@Override
public boolean equals(Object o) {
if (!(o instanceof OrderDTO)) return false;
OrderDTO other = (OrderDTO) o;
return this.id.equals(other.id) &&
this.amount.compareTo(other.amount) == 0; // 金额用compareTo
}