Mastering Object Equality in Java

Introduction

The == operator is used to compare references, while the equals method is used to compare the contents of objects.

Comparison for predefined types

Primitive types

The comparison operator works with predefined types, such as integers, booleans, and characters:

int num = 2;
if (num == 2) {
    System.out.println("num equals two");
}
boolean gameIsOver = true;
if (gameIsOver) {  // Equivalent to: if (gameIsOver == true)
    System.out.println("The game is over");
}

Strings

However, there is a widely used predefined type that does not follow these rules: the String type.

String station = "SUMMER";
if (station.toLowerCase() == "summer") {  // INCORRECT!!
    System.out.println("It's summer");
}

This does not display the expected message.
The reason is that the == operator, when applied to strings, compares references, that is, memory locations. In principle, each variable is at a different memory location.

To compare the contents of strings, you should use the equals method defined in Java:

if (station.toLowerCase().equals("summer")) {
    System.out.println("It's summer");
}

How to compare objects

For objects of user-defined classes, you also use the equals method:

class Person {
    String name;
    int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // getters and setters
}
Person person1 = new Person("Sara", 23);
Person person2 = new Person("Sara", 23);
if (person1.equals(person2)) {
    System.out.println("person1 and person2 are the same person");
}

The message is not displayed, so you can deduce that they are considered different people. This is because, by default, the equals method in the Object class only compares references, not the content of the fields.

The solution is to compare all the relevant fields:

if (person1.getName().equals(person2.getName()) && person1.getAge() == person2.getAge()) {
    System.out.println("person1 and person2 are the same person");
}

To avoid repeating these conditions every time, you can define the equals() method:

public boolean equals(Person person) {
    if (person.getName().equals(this.getName()) && person.getAge() == this.getAge()) {
        return true;
    } else {
        return false;
    }
}

Overriding equals()

All classes created in Java inherit methods from the Object class. One of these methods is:

public boolean equals(Object object);

By default, equals() in Object checks if two references point to the same object (i.e., using ==). Overriding equals() allows you to define equality based on the object's content:

@Override
public boolean equals(Object object) {
    // Explicit cast from Object to the type we're interested in
    Person person = (Person) object;
    return person.getName().equals(this.getName()) && person.getAge() == this.getAge()) {
}

The equals method must return false when one of the objects is null:

@Override
public boolean equals(Object object) {
    if (object == null) {
        return false;
    }
    // ...
}

Using instanceof

You can use instanceof to check the type before casting:

@Override
public boolean equals(Object object) {
    if (!(object instanceof Person)) {
        return false;
    }
    Person person = (Person) object;
    // ...
}

The null check is unnecessary because the instanceof operator returns false if its first operand is null.

Using Objects.equals()

When comparing object fields, the previous approach is not null-safe. That means the condition will throw a NullPointerException if a field is null.
To avoid this, we use the Objects.equals() method, which returns false if either object is null and true if both are null:

@Override
public boolean equals(Object object) {
    if (!(object instanceof Person)) return false;
    Person person = (Person) object;
    return Objects.equals(name, person.name) && age == person.age;
}

The disadvantage of using Objects.equals() is that it is not type-safe, so type checks are still necessary.

Preserve the equality when subclassing

The equals() method generated by many IDEs uses getClass() instead of instanceof.
When you use getClass(), you are performing a strict comparison. This means that derived classes or subclasses cannot be compared-they will not be considered equal to the parent class. If you want to compare subclasses with the parent class, you need to use the instanceof operator.

Some authors prefer using instanceof to allow subclasses to be compared, because inheritance is one of the fundamental properties of object-oriented programming. Additionally, symmetry is maintained, since this.equals(employee) gives the same result as employee.equals(this)

Let's take a look at an example when comparing two objects of the same class hierarchy.

class Person {
    protected String name;
    protected int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public boolean equals(Object obj) {
        // Check for reference equality
        if (this == obj)
            return true;

        // if (obj == null || getClass() != obj.getClass())
        // return false;
        if (!(obj instanceof Person)) {
            return false;
        }

        // Field comparison
        Person other = (Person) obj;
        return getAge() == other.getAge() && Objects.equals(getName(), other.getName());
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

}
class Employee extends Person {

    public Employee(String name, int age) {
        super(name, age);
    }

    @Override
    public boolean equals(Object obj) {
        // Check for reference equality
        if (this == obj)
            return true;

        // if (obj == null || getClass() != obj.getClass())
        if (!(obj instanceof Person))
            return false;

        // Field comparison
        Person other = (Person) obj;
        return getAge() == other.getAge() && Objects.equals(getName(), other.getName());
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
}
import java.util.List;
import java.util.ArrayList;
import java.util.Objects;

public class SubclassComparison {
    public static void main(String[] args) {
        // List of objects of the parent class
        List<Person> personsList = new ArrayList<>();
        Person alice = new Person("Alice", 30);
        Person bob = new Person("Bob", 25);
        personsList.add(alice);
        personsList.add(bob);

        // List of objects of the subclass
        List<Employee> employeesList = new ArrayList<>();
        Employee aliceEmployee = new Employee("Alice", 30);
        Employee bobEmployee = new Employee("Bob", 25);
        employeesList.add(aliceEmployee);
        employeesList.add(bobEmployee);

        // Compare the lists
        boolean areEqual = personsList.equals(employeesList);
        System.out.println("Employee lists are equal: " + areEqual); 
    }
}

HashCode

The hashCode() method is used to generate a hash code for an object.
The hash code is a unique value that represents the object in hash-based collections.

Overriding hashCode() is crucial because it ensures that objects behave correctly when used in hash-based collections like HashMap, HashSet, and Hashtable.

Records

If you use a record, introduced in Java 16, the compiler implicitly creates the constructor, getters, equals(), hashCode(), and toString() methods.

record Person(String name, int age) { }

Conclusion

Understanding the difference between the == operator and the equals() method is essential.

The == operator compares object references, while equals() should be overridden to compare the actual content of objects.

When overriding equals(), always override hashCode() as well to ensure correct behavior in hash-based collections.

Using instanceof in your equals() implementation allows for greater flexibility and supports inheritance, which is a core principle of object-oriented programming.

For modern Java applications, records offer a concise way to create immutable data classes with proper equality and hashing behavior generated automatically by the compiler.

0
Subscribe to my newsletter

Read articles from José Ramón (JR) directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

José Ramón (JR)
José Ramón (JR)

Software Engineer for quite a few years. From C programmer to Java web programmer. Very interested in automated testing and functional programming.