== asks "is this the same object?"; equals asks "do these mean the same thing?". By default a class inherits equals from Object, which is just ==, so two Student objects with the same admission number are different and a HashSet keeps both. Override equals to compare the fields that define identity. Always compare strings with equals, never ==.
The contract: if a.equals(b), then a.hashCode() == b.hashCode(). Hash-based collections (HashSet, HashMap) find the bucket by hash code first, so breaking the contract makes equal keys impossible to find. Base both methods on the same fields, ideally fields that never change (Objects.hash(...) helps). Records generate correct equals, hashCode and toString automatically from their components.
Comparable<T> gives a class its natural order (compareTo returns negative, zero or positive), used by TreeSet, TreeMap and Collections.sort. Keep it consistent with equals. For any other order, pass a Comparator, such as Comparator.comparingInt(Student::form).reversed().thenComparing(Student::name), which leaves the class unchanged.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
public class Equality {
// Without equals/hashCode, two objects are "equal" only if they are the same object.
static class PlainStudent {
final String admission;
PlainStudent(String admission) { this.admission = admission; }
}
// Identity = admission number. Name and form can change; the admission number cannot.
static final class Student implements Comparable<Student> {
private final String admission;
private String name;
private int form;
Student(String admission, String name, int form) {
this.admission = Objects.requireNonNull(admission);
this.name = name;
this.form = form;
}
String name() { return name; }
int form() { return form; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Student other)) return false;
return admission.equals(other.admission);
}
@Override
public int hashCode() {
return admission.hashCode(); // equal objects MUST have equal hash codes
}
// Natural order: by admission number. Keep it consistent with equals.
@Override
public int compareTo(Student other) {
return admission.compareTo(other.admission);
}
@Override
public String toString() {
return admission + " " + name + " (Form " + form + ")";
}
}
public static void main(String[] args) {
var a = new PlainStudent("ADM-001");
var b = new PlainStudent("ADM-001");
System.out.println("plain: " + a.equals(b) + ", set size " + new HashSet<>(List.of(a, b)).size()); // false, 2
var amina = new Student("ADM-001", "Amina Hassan", 4);
var aminaAgain = new Student("ADM-001", "Amina H.", 4);
System.out.println("student: " + amina.equals(aminaAgain) + ", same object: " + (amina == aminaAgain));
Set<Student> registered = new HashSet<>(List.of(amina, aminaAgain, new Student("ADM-007", "Juma Said", 3)));
System.out.println("set size " + registered.size()); // 2
Map<Student, Integer> fees = new HashMap<>();
fees.put(amina, 150_000);
System.out.println("fee found by an equal key: " + fees.get(aminaAgain)); // 150000
// Natural order (Comparable) and other orders (Comparator)
List<Student> students = new ArrayList<>(List.of(
new Student("ADM-009", "Neema Kimaro", 4),
new Student("ADM-002", "Ali Mohamed", 3),
amina));
System.out.println(new TreeSet<>(students)); // sorted by admission
students.sort(Comparator.comparingInt(Student::form).reversed().thenComparing(Student::name));
System.out.println(students); // form desc, then name
// Strings: always compare contents with equals, never ==
String typed = new String("Maths");
System.out.println(("Maths" == typed) + " " + "Maths".equals(typed) + " " + "maths".equalsIgnoreCase(typed));
}
}Key points
==is identity; overrideequalsfor meaning and always compare strings withequals.- Equal objects must have equal hash codes; base both on the same unchanging fields.
Comparabledefines the natural order;Comparatorchains any other order.
Exercise
Write an Exam class whose identity is subject + term and use it as a HashMap key for lists of results. Rank the entries by score (highest first, then name) with tied scores sharing a position (1, 2, 2, 4). Then show that a key whose hashCode changes after being added to a HashSet can no longer be found.
Show solution
Try the exercise yourself first — then compare your approach with this one.
Exam bases both equals and hashCode on subject and term, so a new but equal Exam finds the same map entry. The ranking sorts a copy of the list with one Comparator and only advances the position when the score changes, giving 1, 2, 2, 4. The last part shows why hash codes must not depend on fields that change: after the change, the set looks in the wrong bucket.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
public class Ranking {
// Identity is subject + term. A record would generate these methods for you;
// here they are written out to show what equals and hashCode must agree on.
static final class Exam {
private final String subject;
private final String term;
Exam(String subject, String term) {
this.subject = subject;
this.term = term;
}
@Override
public boolean equals(Object o) {
return o instanceof Exam e && subject.equals(e.subject) && term.equals(e.term);
}
@Override
public int hashCode() {
return Objects.hash(subject, term);
}
@Override
public String toString() {
return subject + " " + term;
}
}
record Entry(String name, int score) {}
// Score high to low; equal scores alphabetically, so the order is always the same.
static final Comparator<Entry> BY_SCORE = Comparator.comparingInt(Entry::score).reversed()
.thenComparing(Entry::name);
// Positions like "1, 2, 2, 4": a tie shares a position and the next one is skipped.
static List<String> rank(List<Entry> entries) {
List<Entry> sorted = new ArrayList<>(entries);
sorted.sort(BY_SCORE);
List<String> lines = new ArrayList<>();
int position = 0;
for (int i = 0; i < sorted.size(); i++) {
if (i == 0 || sorted.get(i).score() != sorted.get(i - 1).score()) position = i + 1;
lines.add(position + ". " + sorted.get(i).name() + " " + sorted.get(i).score());
}
return lines;
}
// A key whose hashCode changes after it is stored gets lost in a HashSet.
static final class MutableKey {
String name;
MutableKey(String name) { this.name = name; }
@Override public boolean equals(Object o) { return o instanceof MutableKey k && name.equals(k.name); }
@Override public int hashCode() { return name.hashCode(); }
}
public static void main(String[] args) {
Map<Exam, List<Entry>> results = new HashMap<>();
results.computeIfAbsent(new Exam("Maths", "2026-T1"), k -> new ArrayList<>()).add(new Entry("Amina Hassan", 88));
// A different but equal Exam object finds the same list:
results.computeIfAbsent(new Exam("Maths", "2026-T1"), k -> new ArrayList<>()).addAll(List.of(
new Entry("Ali Mohamed", 71), new Entry("Neema Kimaro", 71), new Entry("Juma Said", 29)));
System.out.println(results.size() + " exam: " + results.keySet());
rank(results.get(new Exam("Maths", "2026-T1"))).forEach(System.out::println);
Set<MutableKey> set = new HashSet<>();
MutableKey key = new MutableKey("Juma");
set.add(key);
key.name = "Juma Said"; // changes the hash code
System.out.println("contains after change: " + set.contains(key)); // false: lost in the wrong bucket
}
}