Um valor entra num Map<BigDecimal, Long> como 10.0. Depois, outro trecho do código procura por 10.00, que veio de outro parser ou de um campo formatado com duas casas. O get devolve null e o teste de saldo falha. Ninguém mudou o número: ele continua valendo dez. O HashMap só não concorda que seja a mesma chave.
A causa é a escala. Para BigDecimal.equals, 10.0 e 10.00 são objetos diferentes. Para compareTo, são iguais. Cada tipo de Map usa um desses dois critérios, e por isso o mesmo par de valores dá uma resposta no HashMap e outra no TreeMap.
Abaixo estão seis testes JUnit que mostram o erro, o comportamento do TreeMap e duas correções. Os seis passaram no JDK 25.
Ambiente e versões
- Temurin 25.0.4, Maven 3.9.12,
maven.compiler.release=25, sem--enable-preview. junit-jupiter6.1.3,maven-surefire-plugin3.6.0,maven-compiler-plugin3.14.0.mvn -q testgerou o relatório Surefire comtests=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>
Todos os testes ficam numa única classe. Este é o cabeçalho dela, com os imports e as duas constantes usadas nas seções seguintes:
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 compara valor e escala
Um BigDecimal guarda duas coisas: um valor inteiro sem vírgula (unscaled value) e uma escala, que diz quantas casas decimais esse inteiro representa. "10.0" vira [100, 1] e "10.00" vira [1000, 2]. O javadoc de BigDecimal.equals no Java 25) diz isso diretamente:
> 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 …
O hashCode também é calculado a partir do unscaled value e da escala. Na documentação, números iguais com escalas diferentes "will generally not have the same hash code". Já o compareTo) considera 2.0 e 2.00 iguais, e a própria API note avisa que a ordem natural da classe é "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));
}
Valores observados no JDK 25.0.4:
| expressão | toString | scale | HashMap.get após put("10.0") |
|---|---|---|---|
new BigDecimal("10.0") | 10.0 | 1 | 1000 |
new BigDecimal("10.00") | 10.00 | 2 | null |
new BigDecimal("10") | 10 | 0 | não testado neste mapa |
new BigDecimal("10.00").stripTrailingZeros() | 1E+1 | -1 | n/a (testado em outro mapa, mais abaixo) |
Um cuidado ao montar esse tipo de exemplo: new BigDecimal(10.0d) tem escala 0 e imprime 10. O construtor que recebe double não gera o par 10.0/10.00. Prefira o construtor com String ou BigDecimal.valueOf(100L, 1) e BigDecimal.valueOf(1000L, 2). O último teste da classe registra esse comportamento:
@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 não encontra a chave, TreeMap encontra e sobrescreve
O HashMap.get) procura um mapeamento em que key.equals(k). Ele não usa compareTo, e o valor numérico não entra na decisão por conta própria.
@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());
}
Esse é o bug do saldo. O mapa tem exatamente uma entrada, de valor dez, e responde que não tem nada para dez.
O TreeMap com ordem natural compara as chaves com compareTo. Por isso get("10.00") encontra a entrada gravada com "10.0". E um segundo put com "10.00" não cria outra entrada: substitui o valor da que já existe.
@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")));
}
Repare no detalhe: o valor passa a ser 2000, mas a chave continua sendo o objeto original, com escala 1. O TreeMap troca o valor sem avisar e guarda a chave antiga.
Isso não é bug do JDK. A documentação do TreeMap diz que o comportamento é "well-defined even if its ordering is inconsistent with equals; it just fails to obey the general contract of the Map interface". Com as mesmas chaves, o HashMap guarda duas entradas e o TreeMap guarda uma. Trocar HashMap por TreeMap para "corrigir" o saldo só troca um comportamento inesperado por outro. A correção de verdade é fazer a chave chegar sempre no mesmo formato.
Correção: centavos em long ou escala fixa
Duas saídas, as duas testadas:
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));
}
Com centavos, a chave é 1000L e o equals de Long não tem escala para confundir. Com escala fixa, 10.0 e 10.00 viram 10.00 antes de chegar ao mapa. Isso só funciona se a normalização for aplicada tanto no put quanto no get. Se um dos lados esquecer, o null volta.
RoundingMode.UNNECESSARY lança ArithmeticException quando o valor tem mais casas do que a escala pedida, como "10.001". É exatamente o que você quer. Um valor com três casas num sistema de duas é dado inválido, e trocar para HALF_UP só para o erro sumir esconde esse dado em vez de corrigir a origem.
stripTrailingZeros não normaliza a chave
A tentação é chamar stripTrailingZeros() e considerar o problema resolvido. O teste mostra o que acontece:
@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));
}
O 10.00 vira 1E+1, com escala -1. O javadoc de stripTrailingZeros) dá o exemplo de 600.0 virando 6E2. Escala negativa é comportamento documentado, não acidente. O que engana é a exibição: toPlainString() mostra "10", e qualquer log ou tela parece confirmar que está tudo certo. Só que new BigDecimal("10") tem escala 0, então não é equals ao valor normalizado, e o mapa não encontra nem "10" nem "10.00".
O que a DevDojo faria
Se o valor tem número fixo de casas decimais, a DevDojo usaria como chave long em centavos (ou outra unidade inteira menor da moeda). Se o código já trabalha com BigDecimal em todo lugar, setScale(2, RoundingMode.UNNECESSARY) em todo put e todo get também resolve, desde que todo acesso ao mapa passe pelo mesmo método, como o MoneyKeys.twoDecimalPlaces.
Hora de rever o desenho: quando ninguém no time consegue dizer se "10.0" e "10.00" são a mesma chave sem olhar o scale(). Nessa situação a chave está chegando crua de algum lugar, e o próximo null é questão de tempo.
Próximo passo
Procure os Map<BigDecimal, …> e Set<BigDecimal> do seu código e veja de onde vêm as chaves. Se vêm direto de JSON, YAML ou de uma coluna formatada, converta para centavos ou para uma escala combinada antes do primeiro put. Depois adicione um teste com "10.0" e "10.00". Ele custa uma linha e cobre exatamente o caso que fez o saldo falhar.