equals() e hashCode(): contrato e armadilhas comuns
1. Visão geral
equals() e hashCode() são dois métodos que parecem simples, mas se implementados incorretamente, HashSet conterá elementos duplicados e HashMap “perderá” chaves. Esses erros geralmente não lançam exceções. O programa continua executando, apenas com a lógica errada.
Este artigo apresenta o contrato (contract) que Java estabelece para esses dois métodos. Depois, percorrer os erros mais comuns, cada um com um exemplo que o reproduz. Após ler, você saberá implementar corretamente os dois métodos, quando usar record e como escrever testes para validar.
2. O contrato do equals()
Por padrão, Object.equals() compara referências, isto é, this == obj. Ao fazer override, você deve preservar as cinco propriedades a seguir para todas as referências não-nulas:
- Reflexiva:
x.equals(x)é sempretrue. - Simétrica:
x.equals(y)étruese e somente sey.equals(x)étrue. - Transitiva: se
x.equals(y)ey.equals(z), entãox.equals(z). - Consistente: se o objeto não muda, chamadas repetidas devem retornar o mesmo resultado.
- Com null:
x.equals(null)é semprefalse.
Uma implementação padrão para uma classe Customer fica assim:
import java.util.Objects;
public final class Customer {
private final String email;
private final String name;
public Customer(String email, String name) {
this.email = email;
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Customer other)) return false;
return Objects.equals(email, other.email)
&& Objects.equals(name, other.name);
}
@Override
public int hashCode() {
return Objects.hash(email, name);
}
}
Giải thích cada parte:
this == oé um atalho quando ambas as referências apontam para o mesmo objeto.instanceof Customer otherusa pattern matching (Java 16+). Essa expressão retornafalsequandooénull, então a propriedade “com null” é garantida sem necessidade de verificação separada.Objects.equalsé seguro com camposnull.Objects.hashcalcula o hash dos campos exatos usados emequals().
A classe é declarada final. A razão ficará clara na seção 6.
3. O contrato do hashCode()
hashCode() tem três regras:
- Chamadas repetidas no mesmo objeto imutável devem retornar o mesmo valor.
- Se
a.equals(b), entãoa.hashCode() == b.hashCode(). Esta é a regra mais importante. - Objetos diferentes podem ter o mesmo código hash (colisão). No entanto, um hash bem distribuído ajuda as coleções a executarem mais rapidamente.
O inverso da regra 2 não é verdadeiro. Ter o mesmo hash code não significa ser igual.
Para ver por que a regra 2 é importante, veja como HashMap encontra uma chave. Primeiro usa hashCode() para selecionar um bucket. Depois usa equals() para comparar os elementos naquele bucket. Se dois objetos são “iguais” mas têm hashes diferentes, ficam em buckets diferentes e equals() nunca é chamado.
4. Erro 1: Override de equals() sem hashCode()
Este é o erro clássico. Suponha que você remova hashCode() da classe na seção 2:
public class Customer {
private final String email;
public Customer(String email) { this.email = email; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Customer other)) return false;
return email.equals(other.email);
}
// falta hashCode()
}
Set<Customer> customers = new HashSet<>();
customers.add(new Customer("an@example.com"));
customers.add(new Customer("an@example.com"));
System.out.println(customers.size());
System.out.println(customers.contains(new Customer("an@example.com")));
2
false
Dois objetos são iguais de acordo com equals(). Mas hashCode() ainda é o padrão de Object, geralmente diferente para cada instância. O resultado é que HashSet contém dois elementos “duplicados” e contains() retorna false. Este resultado é quase certo de ocorrer. Raramente dois hashes padrão coincidem, então o erro é difícil de reproduzir consistentemente.
Nota: IDEs e ferramentas como SonarQube alertam sobre esse erro. Não desative esse aviso.
5. Erro 2: Overload em vez de override
Este erro é muito mais difícil de notar:
public class Order {
private final long id;
public Order(long id) { this.id = id; }
public boolean equals(Order other) { // ERRADO: parâmetro é Order, não Object
return other != null && id == other.id;
}
@Override
public int hashCode() { return Long.hashCode(id); }
}
Order a = new Order(1);
Order b = new Order(1);
System.out.println(a.equals(b)); // chama equals(Order)
System.out.println(List.of(a).contains(b)); // chama equals(Object)
true
false
equals(Order) é um novo método (overload). Ele não faz override de equals(Object). Coleções sempre chamam equals(Object), então elas ainda usam a comparação de referência de Object.
A prevenção é muito simples: sempre use @Override. Se você adicionar @Override ao método acima, o compilador relatará o erro imediatamente.
6. Erro 3: Quebrar a simetria ao herdar
Considere a classe Point e a subclasse ColorPoint que adiciona o campo color:
public class Point {
protected final int x, y;
public Point(int x, int y) { this.x = x; this.y = y; }
@Override
public boolean equals(Object o) {
if (!(o instanceof Point p)) return false;
return x == p.x && y == p.y;
}
@Override
public int hashCode() { return Objects.hash(x, y); }
}
public class ColorPoint extends Point {
private final String color;
public ColorPoint(int x, int y, String color) { super(x, y); this.color = color; }
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint cp)) return false;
return super.equals(cp) && color.equals(cp.color);
}
@Override
public int hashCode() { return Objects.hash(x, y, color); }
}
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, "RED");
System.out.println(p.equals(cp));
System.out.println(cp.equals(p));
true
false
A simetria foi quebrada. Além disso, p.equals(cp) é true, mas os hashes dos dois objetos são diferentes, então a regra 2 da seção 3 também é violada.
Não há maneira de adicionar um campo significativo a uma subclasse e manter o contrato com instanceof. Você tem três opções:
- Declare a classe
finalou userecord. Esta é a forma mais simples. - Use
getClass() != o.getClass()em vez deinstanceof. Assim,PointeColorPointnunca serão iguais. Isso preserva o contrato, mas uma subclasse que não adiciona campos (como um proxy) também não será igual à classe pai. - Prefira composition: deixe
ColorPointconter umPointem vez de herdar dePoint.
7. Erro 4: Campo mutável como chave
hashCode() é calculado quando o objeto é adicionado a HashSet. Se um campo usado no hash mudar depois, o objeto fica no bucket antigo:
public class Product {
private String sku;
public Product(String sku) { this.sku = sku; }
public void setSku(String sku) { this.sku = sku; }
@Override
public boolean equals(Object o) {
return o instanceof Product p && Objects.equals(sku, p.sku);
}
@Override
public int hashCode() { return Objects.hashCode(sku); }
}
Product product = new Product("SKU-1");
Set<Product> catalog = new HashSet<>();
catalog.add(product);
product.setSku("SKU-2");
System.out.println(catalog.contains(product));
System.out.println(catalog.remove(product));
System.out.println(catalog.size());
false
false
1
O objeto ainda está no conjunto, mas você não consegue encontrá-lo e também não consegue removê-lo. O resultado é um pequeno vazamento de memória e lógica incorreta.
Nota: Use apenas campos imutáveis (immutable) em
equals()/hashCode(). Se você precisa usar um campo mutável, não modifique o objeto enquanto ele é uma chave emHashMapou elemento emHashSet. A maneira correta é remover o objeto, modificá-lo e depois adicioná-lo novamente.
8. Erro 5: As armadilhas do BigDecimal e arrays
Alguns tipos no JDK têm equals() não como você esperaria.
8.1. BigDecimal compara também a escala
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");
System.out.println(a.equals(b));
System.out.println(a.compareTo(b) == 0);
false
true
BigDecimal.equals() considera 1.0 e 1.00 diferentes porque têm scales diferentes. Se sua classe Money usa BigDecimal em equals(), normalize a scale primeiro. Por exemplo, chame amount.setScale(2, RoundingMode.HALF_UP) no construtor. Note que stripTrailingZeros() também normaliza, mas pode resultar em scales negativas, por exemplo com 100.
8.2. Arrays comparam referência
Arrays não fazem override de equals(). Portanto, new int[]{1, 2}.equals(new int[]{1, 2}) retorna false. Quando um campo é um array, use Arrays.equals() e Arrays.hashCode(). Essa armadilha também se aplica a record, como você verá na seção seguinte.
9. Use record para evitar a maioria dos erros
A partir do Java 16, record gera automaticamente equals(), hashCode() e toString() com base em todos os componentes:
public record OrderLine(String productSku, int quantity) {}
var line1 = new OrderLine("SKU-1", 2);
var line2 = new OrderLine("SKU-1", 2);
System.out.println(line1.equals(line2));
System.out.println(line1.hashCode() == line2.hashCode());
true
true
record é uma classe final, os campos são final e os dois métodos são gerados corretamente. Os erros nas seções 4, 5 e 6 desaparecem. O erro na seção 7 também praticamente desaparece, a menos que o componente seja um objeto mutável, como uma List.
Dois pontos que você precisa lembrar ao usar record:
- Componentes que são arrays ainda são comparados por referência. Use
Listem vez de arrays. - Componentes
BigDecimalainda têm a armadilha de scale. Normalize no constructor compacto:
public record Money(BigDecimal amount, String currency) {
public Money {
amount = amount.setScale(2, RoundingMode.HALF_UP);
}
}
Com essa declaração, new Money(new BigDecimal("1.0"), "VND") é igual a new Money(new BigDecimal("1.00"), "VND"), porque ambas são normalizadas para scale 2.
10. Testando equals() e hashCode()
Você deve ter testes para o comportamento principal. Abaixo está um teste com JUnit 5 e AssertJ para a classe Customer da seção 2:
import org.junit.jupiter.api.Test;
import java.util.HashSet;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class CustomerTest {
@Test
void equalCustomers_shouldHaveSameHashCode_andDeduplicateInSet() {
var a = new Customer("an@example.com", "An");
var b = new Customer("an@example.com", "An");
assertThat(a).isEqualTo(b);
assertThat(b).isEqualTo(a);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
Set<Customer> set = new HashSet<>(Set.of(a));
assertThat(set).contains(b);
}
@Test
void customer_shouldNotEqualNullOrOtherType() {
var a = new Customer("an@example.com", "An");
assertThat(a).isNotEqualTo(null);
assertThat(a).isNotEqualTo("an@example.com");
}
}
O primeiro teste verifica simetria, a regra “se iguais, então mesmo hash” e o comportamento real ao usar em HashSet. O segundo teste verifica o caso de null e o caso de tipo diferente.
Escrever todos os casos manualmente é bastante trabalhoso. A biblioteca EqualsVerifier verifica o contrato inteiro com apenas uma linha:
<dependency>
<groupId>nl.jqno.equalsverifier</groupId>
<artifactId>equalsverifier</artifactId>
<version>3.16.1</version>
<scope>test</scope>
</dependency>
@Test
void equalsContract() {
EqualsVerifier.forClass(Customer.class).verify();
}
Se você esquecer um campo em hashCode(), usar um campo não-final, ou uma classe não-final com instanceof, o teste falhará com uma mensagem indicando qual regra foi violada.
11. Quando NÃO fazer override
Nem toda classe precisa fazer override de equals()/hashCode():
- Classes com identidade natural por instância, como
Thread,PaymentServiceou beans Spring. Dois serviços são dois objetos diferentes e comparar referências é correto. - Entity JPA: evite usar todos os campos. Também não use
@Idingenuamente, poisidénullantes de persistir, causando mudanças de hash após salvar. A forma comum é comparar por chaves de negócio imutáveis. Outra forma é comparar peloidquando não-nulo e retornar uma constante por classe emhashCode(), comogetClass().hashCode(). - Lombok
@Dataem entity ou classes com relações bidirecionais: essa anotação geraequals()/hashCode()usando todos os campos. Isso pode causarStackOverflowErrorquando duas entidades se referenciam, ou ativar lazy loading indesejadamente.
12. Conclusão
O contrato entre equals() e hashCode() gira em torno de uma regra central: dois objetos iguais devem ter o mesmo hash code. A maioria dos erros práticos vem de cinco causas:
- esquecer de fazer override de
hashCode(); - fazer overload de
equals(Order)em vez de override deequals(Object); - herança que quebra a simetria;
- usar campo mutável na chave;
- as armadilhas do
BigDecimale arrays.
Em código novo, prefira record para objetos de valor. Para classes comuns, declare final, sempre use @Override e use apenas campos imutáveis. Por fim, adicione um teste com EqualsVerifier para a ferramenta verificar o contrato para você.