equals() y hashCode(): contrato y errores comunes

1. Resumen

equals() y hashCode() son dos métodos que parecen simples, pero si los implementas mal, HashSet contendrá elementos duplicados y HashMap “perderá” claves. Estos errores normalmente no lanzan excepciones. El programa sigue ejecutándose, solo que incorrectamente.

Este artículo presenta el contrato que Java establece para ambos métodos. Luego veremos los errores más comunes, cada uno con un ejemplo que lo reproduce. Después de leerlo, implementarás correctamente ambos métodos, sabrás cuándo usar record y cómo escribir tests para verificarlos.

2. El contrato de equals()

Por defecto, Object.equals() compara referencias, es decir, this == obj. Cuando lo sobrescribes, debes preservar cinco propiedades para cualquier referencia distinta de null:

  • Reflexividad: x.equals(x) siempre es true.
  • Simetría: x.equals(y) es true si y solo si y.equals(x) es true.
  • Transitividad: si x.equals(y) e y.equals(z), entonces x.equals(z).
  • Consistencia: si el objeto no cambia, múltiples llamadas deben retornar el mismo resultado.
  • Con null: x.equals(null) siempre es false.

Una implementación correcta para la clase Customer se ve así:

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);
    }
}

Explicación de cada parte:

  • this == o es un atajo cuando ambas referencias apuntan al mismo objeto.
  • instanceof Customer other usa pattern matching (Java 16+). Esta expresión retorna false cuando o es null, así que la propiedad “con null” se garantiza sin una comprobación separada.
  • Objects.equals es seguro con campos null.
  • Objects.hash calcula el hash de exactamente los campos usados en equals().

La clase se declara como final. La razón quedará clara en la sección 6.

3. El contrato de hashCode()

hashCode() tiene tres reglas:

  1. Llamarlo múltiples veces en el mismo objeto sin cambios debe retornar el mismo valor.
  2. Si a.equals(b), entonces a.hashCode() == b.hashCode(). Esta es la regla más importante.
  3. Dos objetos diferentes pueden tener el mismo código hash (colisión). Sin embargo, una buena distribución de hashes hará que la colección sea más rápida.

La dirección inversa de la regla 2 no es verdadera. El mismo código hash no implica igualdad.

Para ver por qué la regla 2 es importante, mira cómo HashMap busca una clave. Primero usa hashCode() para seleccionar el bucket. Luego usa equals() para comparar los elementos dentro de ese bucket. Si dos objetos “son iguales” pero tienen hashes diferentes, están en buckets diferentes, y equals() nunca se llama.

4. Error 1: Sobrescribir equals() pero olvidar hashCode()

Este es el error clásico. Supón que omites hashCode() de la clase en la sección 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

Ambos objetos son iguales según equals(). Pero hashCode() sigue siendo el de la clase Object, que normalmente es diferente para cada instancia. El resultado es que HashSet contiene dos elementos “duplicados” y contains() retorna false. Este resultado es casi seguro. Es raro que dos hashes por defecto coincidan, así que el error es difícil de reproducir de manera consistente.

Nota: El IDE y herramientas como SonarQube advierten sobre este error. No desactives esa advertencia.

5. Error 2: Sobrecargar en lugar de sobrescribir

Este error es mucho más sutil:

public class Order {
    private final long id;

    public Order(long id) { this.id = id; }

    public boolean equals(Order other) {   // INCORRECTO: parámetro es Order, no 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));                  // llama a equals(Order)
System.out.println(List.of(a).contains(b));       // llama a equals(Object)
true
false

equals(Order) es un nuevo método (sobrecarga). No sobrescribe equals(Object). Las colecciones siempre llaman a equals(Object), así que sigue usando la comparación de referencias de Object.

La forma de prevenirlo es muy simple: siempre añade @Override. Si añades @Override al método anterior, el compilador reportará un error de inmediato.

6. Error 3: Romper simetría con herencia

Considera la clase Point y su subclase ColorPoint que añade un 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

La simetría se ha roto. Además, p.equals(cp) es true pero los hashes de ambos objetos son diferentes, así que la regla 2 de la sección 3 también se viola.

No hay forma de añadir campos significativos a una subclase y mantener el contrato completo con instanceof. Tienes tres opciones:

  • Declara la clase como final o usa record. Esta es la forma más simple.
  • Usa getClass() != o.getClass() en lugar de instanceof. Entonces Point y ColorPoint nunca son iguales. Esta forma mantiene el contrato, pero una subclase que no añade campos (como un proxy) tampoco sería igual a su superclase.
  • Prefiere composición: que ColorPoint contenga un Point en lugar de heredar de Point.

7. Error 4: Campo mutable como clave

hashCode() se calcula cuando el objeto se añade a HashSet. Si después el campo que participa en el cálculo del hash cambia, el objeto sigue en el bucket antiguo:

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

El objeto sigue en el conjunto, pero no puedes encontrarlo ni eliminarlo. El resultado es una pequeña fuga de memoria y lógica incorrecta.

Nota: Solo usa campos inmutables en equals()/hashCode(). Si debes usar campos mutables, no los modifiques mientras el objeto sea una clave en HashMap o un elemento en HashSet. La forma correcta es sacarlo, modificarlo y volver a añadirlo.

8. Error 5: Las trampas de BigDecimal y arrays

Algunos tipos en el JDK tienen equals() no como esperas.

8.1. BigDecimal compara incluso la 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 que 1.0 y 1.00 son diferentes porque tienen escala diferente. Si tu clase Money usa BigDecimal en equals(), normaliza la escala primero. Por ejemplo, llama a amount.setScale(2, RoundingMode.HALF_UP) en el constructor. Ten en cuenta que stripTrailingZeros() también normaliza, pero puede dar escala negativa, por ejemplo con 100.

8.2. Arrays comparan referencias

Los arrays no sobrescriben equals(). Por eso new int[]{1, 2}.equals(new int[]{1, 2}) retorna false. Cuando un campo es un array, usa Arrays.equals() y Arrays.hashCode(). Esta trampa también se aplica a record, como verás en la siguiente sección.

9. Usar record para evitar la mayoría de errores

Desde Java 16, record genera automáticamente equals(), hashCode() y toString() basados en todos sus 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 es una clase final, sus campos son final, y ambos métodos se generan correctamente según el contrato. Los errores de las secciones 4, 5 y 6 desaparecen. El error de la sección 7 también desaparece casi completamente, excepto si el componente es un objeto mutable, como una List.

Dos puntos a recordar cuando uses record:

  • Un componente que es un array se sigue comparando por referencia. Usa List en lugar de arrays.
  • Un componente BigDecimal sigue cayendo en la trampa de la escala. Normaliza en el constructor compacto:
public record Money(BigDecimal amount, String currency) {
    public Money {
        amount = amount.setScale(2, RoundingMode.HALF_UP);
    }
}

Con esta declaración, new Money(new BigDecimal("1.0"), "VND") es igual a new Money(new BigDecimal("1.00"), "VND"), porque ambos se llevan a escala 2.

10. Probando equals() y hashCode()

Deberías tener tests para el comportamiento principal. Aquí hay un test con JUnit 5 y AssertJ para la clase Customer de la sección 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");
    }
}

El primer test verifica simetría, la regla “iguales entonces mismo hash”, y el comportamiento real en HashSet. El segundo test verifica los casos de null y tipo diferente.

Escribir todos los casos a mano es bastante tedioso. La librería EqualsVerifier verifica el contrato completo con una sola línea:

<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();
}

Si olvidas un campo en hashCode(), usas un campo no final, o la clase no es final pero usas instanceof, el test fallará con un mensaje que indica exactamente qué regla se viola.

11. Cuándo NO sobrescribir

No toda clase necesita sobrescribir equals()/hashCode():

  • Clases con identidad según instancia, como Thread, PaymentService o beans de Spring. Dos servicios son dos objetos diferentes, y comparar referencias es correcto.
  • Entidades JPA: evita usar todos los campos. Tampoco uses @Id ingenuamente, porque id es null antes de persistir, haciendo que el hash cambie después de guardar. La forma común es comparar por claves de negocio inmutables. Otra forma es comparar por id cuando es distinto de null, y que hashCode() retorne una constante por clase, como getClass().hashCode().
  • Anotación @Data de Lombok en entidades o clases con relaciones bidireccionales: esta anotación genera equals()/hashCode() usando todos los campos. Puede causar StackOverflowError si dos entidades se referencian entre sí, o activar lazy loading involuntariamente.

12. Conclusión

El contrato entre equals() y hashCode() gira en torno a una regla central: dos objetos iguales deben tener el mismo código hash. La mayoría de errores reales vienen de cinco causas:

  • olvidar sobrescribir hashCode();
  • sobrecargar equals(Order) en lugar de sobrescribir equals(Object);
  • herencia que rompe simetría;
  • usar campos mutables en claves;
  • las trampas de BigDecimal y arrays.

En código nuevo, prefiere record para objetos de valor. Con clases normales, declárala final, siempre añade @Override, y usa solo campos inmutables. Finalmente, añade un test con EqualsVerifier para que la herramienta verifique el contrato por ti.

javaequalshashcodecollectionsrecord