1. 概述

在编程中精确处理数字至关重要,尤其是金融或高精度计算场景。Java 提供了 BigDecimal 类来避免浮点数的精度问题(如舍入误差和溢出)。BigDecimalequals()compareTo() 方法存在非直观行为,本文将深入分析两者的差异、实现原理及实际影响。

2. compareTo()

compareTo() 方法行为符合直觉:比较两个数值并返回整数结果(-1/0/1)。它忽略尾随零,认为数值相同的数即相等:

static Stream<Arguments> decimalCompareToProvider() {
    return Stream.of(
      Arguments.of(new BigDecimal("0.1"), new BigDecimal("0.1"), true),
      Arguments.of(new BigDecimal("1.1"), new BigDecimal("1.1"), true),
      Arguments.of(new BigDecimal("1.10"), new BigDecimal("1.1"), true), // 尾随零被忽略
      Arguments.of(new BigDecimal("0.100"), new BigDecimal("0.10000"), true),
      Arguments.of(new BigDecimal("0.10"), new BigDecimal("0.1000"), true),
      Arguments.of(new BigDecimal("0.10"), new BigDecimal("0.1001"), false),
      Arguments.of(new BigDecimal("0.10"), new BigDecimal("0.1010"), false),
      Arguments.of(new BigDecimal("0.2"), new BigDecimal("0.19999999"), false),
      Arguments.of(new BigDecimal("1.0"), new BigDecimal("1.1"), false),
      Arguments.of(new BigDecimal("0.01"), new BigDecimal("0.0099999"), false)
    );
}

@ParameterizedTest
@MethodSource("decimalCompareToProvider")
void givenBigDecimals_WhenCompare_ThenGetReasonableResult(BigDecimal firstDecimal,
  BigDecimal secondDecimal, boolean areComparablySame) {
    assertEquals(firstDecimal.compareTo(secondDecimal) == 0, areComparablySame);
}

核心特点0.10.100.100 被视为相同数值。

3. compareTo() 与 equals() 的对比

虽然 Java 建议保持 compareTo()equals() 的一致性,但 BigDecimal 明确违反了这一约定

Comparable 接口文档指出:强烈建议(非强制)自然排序与 equals 一致

BigDecimal 的官方文档明确说明差异

equals() 要求数值和精度(scale)完全相同,而 compareTo() 仅要求数值相同。因此 2.0 不等于 2.00(前者 scale=1,后者 scale=2)。

⚠️ 开发者常见误区:很多人会忽略文档中的这一细节,导致意外行为。

4. equals()

equals() 的核心差异在于严格校验精度(scale)

  • 1.01.00(scale 不同)
  • 2.02.00(scale 不同)

4.1. 精度(Scale)

BigDecimal 内部将 scale 存储为独立字段:

public class BigDecimal extends Number implements Comparable<java.math.BigDecimal> {
    //...
    private final int scale; // 精度字段
    //...
}

技术层面:scale 不同的对象字段值不同,因此 equals() 返回 false

4.2. hashCode()

hashCode() 的实现与 equals() 一致,受 scale 影响

@Override
public int hashCode() {
    if (intCompact != INFLATED) {
        long val2 = (intCompact < 0)? -intCompact : intCompact;
        int temp = (int)( ((int)(val2 >>> 32)) * 31  + (val2 & LONG_MASK));
        return 31*((intCompact < 0) ?-temp:temp) + scale; // scale 参与计算
    } else
        return 31*intVal.hashCode() + scale; // scale 参与计算
}

官方注释:数值相同但 scale 不同的 BigDecimal(如 2.0 和 2.00)通常 hash code 不同

4.3. 运算

精度不同会导致相同运算结果不同。例如除法运算:

@ParameterizedTest
@CsvSource({
  "2.0, 2.00",
  "4.0, 4.00"
})
void givenNumbersWithDifferentPrecision_WhenPerformingTheSameOperation_TheResultsAreDifferent(
  String firstNumber, String secondNumber) {
    BigDecimal firstResult = new BigDecimal(firstNumber).divide(BigDecimal.valueOf(3), HALF_UP);
    BigDecimal secondResult = new BigDecimal(secondNumber).divide(BigDecimal.valueOf(3), HALF_UP);
    assertThat(firstResult).isNotEqualTo(secondResult);
}

关键差异

  • 4.0 / 3 = 1.3(保留 1 位小数)
  • 4.00 / 3 = 1.33(保留 2 位小数)

4.4. toString()

toString() 会输出原始精度,但equals() 无关

new BigDecimal("1.0").toString()  // "1.0"
new BigDecimal("1.00").toString() // "1.00"

⚠️ 注意:切勿用 toString() 结果判断数值相等!

5. 需要注意的额外问题(陷阱)

5.1. distinct()

Stream 的 distinct() 依赖 equals(),可能导致意外结果:

// 输入: ["0.0", "0.00", "0.0"]
Stream.of("0.0", "0.00", "0.0")
      .map(BigDecimal::new)
      .distinct()
      .toArray() 
// 结果: [0.0, 0.00](因 equals() 认为两者不同)

排序后问题更严重

Stream.of("0.0", "0.00", "0.0")
      .map(BigDecimal::new)
      .sorted() // 使用 compareTo() 排序
      .distinct() // 依赖 equals() 去重
      .toArray()
// 结果: [0.0, 0.00, 0.0](排序后元素未按 equals() 分组)

已知 BugJDK-8223933 尚未修复。

5.2. Map 和 Set

HashSet(依赖 equals())

static Stream<Arguments> decimalProvider() {
    return Stream.of(Arguments.of(Arrays.asList(
      new BigDecimal("1.1"),
      new BigDecimal("1.10"), // 与 1.1 数值相同但精度不同
      new BigDecimal("1.100"),
      new BigDecimal("0.10"),
      new BigDecimal("0.100"),
      new BigDecimal("0.1000"),
      new BigDecimal("0.2"),
      new BigDecimal("0.20"),
      new BigDecimal("0.200")),

      Arrays.asList(
        new BigDecimal("1.1"),
        new BigDecimal("0.10"),
        new BigDecimal("0.2"))));
}

@ParameterizedTest
@MethodSource("decimalProvider")
void givenListOfDecimals_WhenAddingToHashSet_ThenUsingEquals(List<BigDecimal> decimalList) {
    Set<BigDecimal> decimalSet = new HashSet<>(decimalList);
    assertThat(decimalSet).hasSameElementsAs(decimalList); // 所有元素均被保留
}

✅ 结果:所有元素都被保留(因 equals() 认为它们互不相同)。

TreeSet(依赖 compareTo())

@ParameterizedTest
@MethodSource("decimalProvider")
void givenListOfDecimals_WhenAddingToSortedSet_ThenUsingCompareTo(List<BigDecimal> decimalList,
  List<BigDecimal> expectedDecimalList) {
    Set<BigDecimal> decimalSet = new TreeSet<>(decimalList);
    assertThat(decimalSet).hasSameElementsAs(expectedDecimalList); // 仅保留数值不同的元素
}

✅ 结果:仅保留 1.10.100.2(因 compareTo() 认为数值相同即重复)。

6. 去除尾随零

最佳实践:使用 stripTrailingZeros() 统一精度后再比较:

@ParameterizedTest
@MethodSource("decimalEqualsProvider")
void givenBigDecimals_WhenCheckEqualityWithoutTrailingZeros_ThenTheSameAsCompareTo(BigDecimal firstDecimal,
  BigDecimal secondDecimal) {
    BigDecimal strippedFirst = firstDecimal.stripTrailingZeros();
    BigDecimal strippedSecond = secondDecimal.stripTrailingZeros();
    assertEquals(strippedFirst.equals(strippedSecond),
      strippedFirst.compareTo(strippedSecond) == 0); // 结果一致
}

⚠️ 性能提示:此方法会消耗额外 CPU 周期,但能确保一致性。

7. 总结

  • ✅ **compareTo()**:仅比较数值,忽略尾随零(符合直觉)。
  • ❌ **equals()**:严格校验数值和精度(易踩坑)。
  • ⚠️ 关键影响
    • HashSet/HashMap 依赖 equals(),可能保留精度不同的相同数值。
    • TreeSet/TreeMap 依赖 compareTo(),会去重数值相同的元素。
    • Stream distinct() 在排序后可能失效。
  • 💡 解决方案:使用 stripTrailingZeros() 统一精度后再比较。

所有示例代码见 GitHub


原始标题:BigDecimal equals() vs. compareTo()