All articles

// Knowledge.log — 技術記事

BigDecimal.equals: the 10.0 that misses 10.00 in a balance Map

JDK 25: HashMap.get(10.00) is null after put(10.0). equals is false, compareTo is 0. TreeMap hits. long cents or setScale(2) recover the key.

A value goes into a Map<BigDecimal, Long> as 10.0. Later, another part of the code looks it up as 10.00. That one came from a different parser, or from a field formatted to two decimal places. The get returns null and the balance test fails. Nobody changed the number. It's still ten. The HashMap just doesn't agree that it's the same key.

The cause is scale. To BigDecimal.equals, 10.0 and 10.00 are different objects. To compareTo, they're equal. Each kind of Map uses one of those two rules, so the same pair of values gets one answer from a HashMap and a different one from a TreeMap.

Below are six JUnit tests. They show the bug, how TreeMap behaves, and two fixes. All six pass on JDK 25.

Environment and versions

  • Temurin 25.0.4, Maven 3.9.12, maven.compiler.release=25, no --enable-preview.
  • junit-jupiter 6.1.3, maven-surefire-plugin 3.6.0, maven-compiler-plugin 3.14.0.
  • mvn -q test produced a Surefire report with tests=6 failures=0 errors=0 skipped=0.
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>devdojo</groupId>
  <artifactId>bigdecimal-map-key</artifactId>
  <version>1.0.0</version>
  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.release>25</maven.compiler.release>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>6.1.3</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.14.0</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.6.0</version>
      </plugin>
    </plugins>
  </build>
</project>

All the tests are in a single class. Here is its header, with the imports and the two constants used in the sections below:

package devdojo.money;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.HashMap;
import java.util.Map;
import java.util.TreeMap;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

class BigDecimalMapKeyTest {

    private static final BigDecimal TEN_POINT_ZERO = new BigDecimal("10.0");
    private static final BigDecimal TEN_POINT_ZERO_ZERO = new BigDecimal("10.00");

    // métodos de teste nas seções abaixo
}

equals compares value and scale

A BigDecimal stores two things: an integer with no decimal point (the unscaled value) and a scale, which says how many of that integer's digits sit after the decimal point. "10.0" becomes [100, 1] and "10.00" becomes [1000, 2]. The Java 25 javadoc for BigDecimal.equals) says it plainly:

> Unlike compareTo, this method considers two BigDecimal objects equal only if they are equal in value and scale. Therefore 2.0 is not equal to 2.00 …

hashCode is computed from the unscaled value and the scale too. The docs say numerically equal values with different scales "will generally not have the same hash code". compareTo), on the other hand, treats 2.0 and 2.00 as equal, and its own API note warns that the class's natural ordering is "inconsistent with equals".

@Test
void equalsRequiresValueAndScale() {
    assertEquals(1, TEN_POINT_ZERO.scale());
    assertEquals(2, TEN_POINT_ZERO_ZERO.scale());
    assertEquals(java.math.BigInteger.valueOf(100), TEN_POINT_ZERO.unscaledValue());
    assertEquals(java.math.BigInteger.valueOf(1000), TEN_POINT_ZERO_ZERO.unscaledValue());
    assertFalse(TEN_POINT_ZERO.equals(TEN_POINT_ZERO_ZERO));
    assertNotEquals(TEN_POINT_ZERO.hashCode(), TEN_POINT_ZERO_ZERO.hashCode());
    assertEquals(0, TEN_POINT_ZERO.compareTo(TEN_POINT_ZERO_ZERO));
}

Values observed on JDK 25.0.4:

expressiontoStringscaleHashMap.get after put("10.0")
new BigDecimal("10.0")10.011000
new BigDecimal("10.00")10.002null
new BigDecimal("10")100not tested in this map
new BigDecimal("10.00").stripTrailingZeros()1E+1-1n/a (tested in a separate map, further down)

Be careful when you build an example like this one. new BigDecimal(10.0d) has scale 0 and prints 10. The double constructor never gives you the 10.0/10.00 pair. Use the String constructor, or BigDecimal.valueOf(100L, 1) and BigDecimal.valueOf(1000L, 2). The last test in the class pins that behavior down:

@Test
void doubleConstructorIsNotScaleOne() {
    BigDecimal fromDouble = new BigDecimal(10.0d);
    assertEquals(0, fromDouble.scale());
    assertEquals("10", fromDouble.toString());
    assertFalse(fromDouble.equals(TEN_POINT_ZERO));
    assertEquals(0, fromDouble.compareTo(TEN_POINT_ZERO));
}

HashMap misses the key, TreeMap finds it and overwrites

HashMap.get) looks for a mapping where key.equals(k). It doesn't use compareTo, and the numeric value has no say of its own.

@Test
void hashMapMissesDifferentScaleOfSameMagnitude() {
    Map<BigDecimal, Long> balances = new HashMap<>();
    balances.put(TEN_POINT_ZERO, 1000L);

    assertEquals(1000L, balances.get(new BigDecimal("10.0")));
    assertNull(balances.get(new BigDecimal("10.00")));
    assertFalse(balances.containsKey(new BigDecimal("10.00")));
    assertEquals(1, balances.size());
}

That's the balance bug. The map holds exactly one entry, its key is worth ten, and it tells you it has nothing for ten.

A TreeMap with natural ordering compares keys with compareTo. So get("10.00") finds the entry stored under "10.0". A second put with "10.00" doesn't add another entry, either. It replaces the value of the one that's already there.

@Test
void treeMapTreatsDifferentScaleAsSameKey() {
    TreeMap<BigDecimal, Long> tree = new TreeMap<>();
    tree.put(new BigDecimal("10.0"), 1000L);

    assertEquals(1000L, tree.get(new BigDecimal("10.00")));
    assertTrue(tree.containsKey(new BigDecimal("10.00")));
    assertEquals(1000L, tree.put(new BigDecimal("10.00"), 2000L));
    assertEquals(1, tree.size());
    assertEquals(1, tree.firstKey().scale());
    assertEquals(2000L, tree.get(new BigDecimal("10.0")));
}

Look at the detail: the value is now 2000, but the key is still the original object with scale 1. The TreeMap swaps the value without a word and keeps the old key.

This is not a JDK bug. The TreeMap docs say its behavior "is well-defined even if its ordering is inconsistent with equals; it just fails to obey the general contract of the Map interface." Give both maps the same keys and the HashMap ends up with two entries while the TreeMap has one. Switching from HashMap to TreeMap to "fix" the balance only trades one surprise for another. The real fix is to make the key arrive in the same shape every time.

Fix: long cents or a fixed scale

There are two ways out, and both are tested:

package devdojo.money;

import java.math.BigDecimal;
import java.math.RoundingMode;

public final class MoneyKeys {
    private MoneyKeys() {}

    public static BigDecimal twoDecimalPlaces(BigDecimal amount) {
        return amount.setScale(2, RoundingMode.UNNECESSARY);
    }

    public static long cents(BigDecimal amount) {
        return twoDecimalPlaces(amount).movePointRight(2).longValueExact();
    }
}
@Test
void longCentsOrFixedScaleRecoverTheEntry() {
    Map<Long, Integer> byCents = new HashMap<>();
    byCents.put(MoneyKeys.cents(new BigDecimal("10.00")), 42);
    assertEquals(42, byCents.get(MoneyKeys.cents(new BigDecimal("10.0"))));
    assertEquals(1000L, MoneyKeys.cents(new BigDecimal("10.00")));

    Map<BigDecimal, Long> byScale = new HashMap<>();
    byScale.put(MoneyKeys.twoDecimalPlaces(new BigDecimal("10.0")), 1000L);
    assertEquals(1000L, byScale.get(MoneyKeys.twoDecimalPlaces(new BigDecimal("10.00"))));
    assertEquals(2, MoneyKeys.twoDecimalPlaces(new BigDecimal("10.0")).scale());
    assertThrows(ArithmeticException.class,
            () -> new BigDecimal("10.001").setScale(2, RoundingMode.UNNECESSARY));
}

With cents, the key is 1000L, and Long.equals has no scale to trip over. With a fixed scale, both 10.0 and 10.00 become 10.00 before they reach the map. That only works if the normalization runs on both the put and the get. If one side forgets, the null comes back.

RoundingMode.UNNECESSARY throws ArithmeticException when the value has more digits than the requested scale, as "10.001" does. That's exactly what you want. A value with three decimal places in a two-decimal system is bad data. Switching to HALF_UP just to make the exception go away hides that data instead of fixing whatever produced it.

stripTrailingZeros doesn't normalize the key

The obvious move is to call stripTrailingZeros() and consider the problem solved. Here's what the test says about that:

@Test
void stripTrailingZerosIsNotASafeMapKey() {
    BigDecimal stripped = new BigDecimal("10.00").stripTrailingZeros();
    assertEquals("1E+1", stripped.toString());
    assertEquals(-1, stripped.scale());
    assertEquals("10", stripped.toPlainString());
    assertFalse(stripped.equals(new BigDecimal("10")));

    Map<BigDecimal, Long> keys = new HashMap<>();
    keys.put(stripped, 1L);
    assertNull(keys.get(new BigDecimal("10")));
    assertNull(keys.get(new BigDecimal("10.00")));
    assertEquals(1L, keys.get(stripped));
}

10.00 turns into 1E+1, with scale -1. The stripTrailingZeros javadoc) uses the example of 600.0 becoming 6E2, so a negative scale is documented behavior, not an accident. The trap is the display. toPlainString() prints "10", and every log line and screen seems to confirm all is well. But new BigDecimal("10") has scale 0, so it isn't equals to the stripped value, and the map finds neither "10" nor "10.00". The logs look fine. The map still says no.

What DevDojo would do

If the amount has a fixed number of decimal places, DevDojo would key the map on a long in cents (or whatever the currency's smallest integer unit is). If the code already uses BigDecimal everywhere, setScale(2, RoundingMode.UNNECESSARY) on every put and every get works too, as long as every map access goes through the same method, like MoneyKeys.twoDecimalPlaces.

When to revisit the design: when nobody on the team can say whether "10.0" and "10.00" are the same key without checking scale(). At that point the key is arriving raw from somewhere, and the next null is a matter of time.

Next step

Find the Map<BigDecimal, …> and Set<BigDecimal> declarations in your code and check where their keys come from. If they come straight from JSON, YAML or a formatted column, convert them to cents or to an agreed scale before the first put. Then add a test with "10.0" and "10.00". It costs one line and covers exactly the case that broke the balance.

javabigdecimalcollections

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles