Iterators, Ordering, and the equals/hashCode Contract
The previous lesson left three debts: why a HashSet sometimes stores duplicates, how to sort a collection of your own objects, and what that ConcurrentModificationException is that shows up when you delete while iterating.
All three share one root: Java’s collections ask questions about your objects, and if your objects answer badly, everything fails silently.
1. What is really behind a for-each
This loop you have been using since the Arrays and String Handling in Java lesson:
for (String name : names) {
System.out.println(name);
}
The compiler translates it into this:
Iterator<String> it = names.iterator();
while (it.hasNext()) {
String name = it.next();
System.out.println(name);
}
An Iterator is an object with just three methods: hasNext(), next(), and remove(). And a collection is for-each-able only if it implements Iterable, which demands exactly one method: iterator().
That is why you can walk an ArrayList, a HashSet, and an ArrayDeque with identical syntax even though internally they have nothing in common. The for-each does not talk to the collection: it talks to its iterator.
2. ConcurrentModificationException: why it happens
This code looks reasonable and fails every time:
List<String> tasks = new ArrayList<>(List.of("study", "rest", "practice"));
for (String t : tasks) {
if (t.startsWith("r")) {
tasks.remove(t); // ← ConcurrentModificationException
}
}
// Option 1: the explicit iterator
Iterator<String> it = tasks.iterator();
while (it.hasNext()) {
if (it.next().startsWith("r")) {
it.remove(); // the iterator deletes AND stays consistent
}
}
// Option 2: removeIf — since Java 8, and what you will use 95% of the time
tasks.removeIf(t -> t.startsWith("r"));
The exception’s name is misleading: it has nothing to do with threads or concurrency. It happens just the same in a single-threaded program. It is named that way because the collection was modified “concurrently” with respect to the traversal in progress.
3. Comparable: a class’s natural order
Try to sort a list of your own objects and Java stops you:
List<Book> books = new ArrayList<>();
Collections.sort(books); // ERROR: Book is not Comparable
And it is right to: sort by what? Title, author, pages, year? Java cannot guess. You have to say.
Comparable defines the natural order: the default one, the order that makes sense when nobody asks for anything else.
public class Book implements Comparable<Book> {
private final String title;
private final String author;
private final int pages;
@Override
public int compareTo(Book other) {
return this.title.compareTo(other.title); // natural order: by title
}
}
With Comparable implemented, the whole Java ecosystem works on its own: Collections.sort(), list.sort(null), TreeSet, TreeMap, and Arrays.sort().
4. Comparator: every other order
The problem with Comparable is that you only get one. What if sometimes you want to sort by pages and sometimes by author?
That is what Comparator is for: an ordering that lives outside the class.
// Sort by pages, ascending
books.sort(Comparator.comparingInt(Book::getPages));
// Descending
books.sort(Comparator.comparingInt(Book::getPages).reversed());
// By author and, within an author, by title
books.sort(
Comparator.comparing(Book::getAuthor)
.thenComparing(Book::getTitle)
);
// With nulls last, without blowing up
books.sort(Comparator.comparing(Book::getAuthor,
Comparator.nullsLast(Comparator.naturalOrder())));
Note that sort orders the original list in place. If you need the original order preserved, copy first: new ArrayList<>(books).sort(...).
5. The equals contract: five rules
By default, Object.equals() compares references: it returns true only when they are literally the same object on the Heap. For almost any domain class, that is wrong:
Book a = new Book("1984", "Orwell", 328);
Book b = new Book("1984", "Orwell", 328);
System.out.println(a == b); // false — two distinct objects, obviously
System.out.println(a.equals(b)); // false — but THIS should be true
Overriding equals means signing a five-clause contract:
| Rule | What it means |
|---|---|
| Reflexive | a.equals(a) is always true. |
| Symmetric | If a.equals(b), then b.equals(a). |
| Transitive | If a.equals(b) and b.equals(c), then a.equals(c). |
| Consistent | Calling it ten times returns the same, as long as nothing changed. |
Against null | a.equals(null) is false, and never throws. |
@Override
public boolean equals(Object o) {
if (this == o) return true; // shortcut: same object
if (o == null || getClass() != o.getClass()) return false;
Book other = (Book) o;
return pages == other.pages
&& Objects.equals(title, other.title) // tolerates null on both sides
&& Objects.equals(author, other.author);
}
The parameter is
Object o, notBook o. Writingpublic boolean equals(Book o)is overloading, not overriding, and collections — which callequals(Object)— will keep using the inherited version. It is exactly the bug@Overridecatches, as we saw in the Arrays of Objects: Holding and Iterating Many Instances lesson.
6. hashCode: the one that breaks everything when missing
Here is the real problem. equals alone is not enough:
HashSet does not scan everything comparing: it computes the drawer first. If the drawer is wrong, the comparison never happens.Which is why the rule is absolute: if you override equals, override hashCode. It is not optional or a nice-to-have: it is a precondition for collections to work.
@Override
public int hashCode() {
return Objects.hash(title, author, pages); // the SAME fields as equals
}
Objects.hash(...) combines the values with a proven formula. Use exactly the same fields in equals and hashCode: if equals compares three fields and hashCode uses two, the contract still holds; if it uses one that equals ignores, it breaks.
The shortcut: record
If your class is a plain data carrier, a record generates correct equals, hashCode, and toString automatically:
public record Book(String title, String author, int pages) implements Comparable<Book> {
@Override
public int compareTo(Book other) {
return this.title.compareTo(other.title);
}
}
Three lines and the contract is guaranteed by the compiler. That is why the record from the Constructors, Access Modifiers, and Getters/Setters lesson shows up so much in modern code.
7. Common mistakes
| Mistake | What happens | How to fix it |
|---|---|---|
Overriding equals but not hashCode | HashSet stores duplicates and HashMap.get() returns null with the correct key. | Always override both, with the same fields. |
Writing equals(Book o) instead of equals(Object o) | It is an overload, not an override. Collections keep comparing by reference. | Signature equals(Object o) with @Override. |
compareTo returning a - b | With large values the int overflows and the order inverts, with no exception. | Integer.compare(a, b). |
Using == to compare String | It compares references; works with literals thanks to the string pool and fails with constructed strings. | .equals(), or Objects.equals() if null is possible. |
Modifying an object already stored in a HashSet | Its hashCode changes, it stays in the old drawer, and contains() returns false on an object that is inside. | Immutable keys and Set elements. |
Deleting with list.remove(x) inside a for-each | ConcurrentModificationException. | removeIf(...) or iterator.remove(). |
compareTo inconsistent with equals | A TreeSet drops elements that equals considers distinct, because to it compareTo == 0 means duplicate. | Make compareTo return 0 exactly when equals is true. |
8. Guided hands-on exercise
Challenge: the Book class
- Create
Bookwithtitle,author, andpages, all immutable. - Implement
equalsandhashCodeusing the three fields. - Implement
Comparable<Book>with a natural order by title. - Show that a
HashSetdiscards the duplicate. - Sort a list by natural order and then with three different
Comparators. - Show what happens with a
TreeSetwhosecompareToonly looks at the title.
See suggested solution
import java.util.*;
public final class Book implements Comparable<Book> {
private final String title;
private final String author;
private final int pages;
public Book(String title, String author, int pages) {
if (title == null || title.isBlank()) {
throw new IllegalArgumentException("Title is required");
}
if (pages <= 0) {
throw new IllegalArgumentException("Pages must be positive");
}
this.title = title;
this.author = author;
this.pages = pages;
}
public String getTitle() { return title; }
public String getAuthor() { return author; }
public int getPages() { return pages; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Book other = (Book) o;
return pages == other.pages
&& Objects.equals(title, other.title)
&& Objects.equals(author, other.author);
}
@Override
public int hashCode() {
return Objects.hash(title, author, pages); // the same three fields
}
@Override
public int compareTo(Book other) {
return this.title.compareTo(other.title); // natural order: by title
}
@Override
public String toString() {
return String.format("%-28s %-18s %4d p.", title, author, pages);
}
public static void main(String[] args) {
Book a = new Book("1984", "Orwell", 328);
Book b = new Book("1984", "Orwell", 328); // identical to 'a'
System.out.println("a == b → " + (a == b)); // false
System.out.println("a.equals(b) → " + a.equals(b)); // true
System.out.println("same hash → " + (a.hashCode() == b.hashCode())); // true
// 4. The HashSet discards the duplicate thanks to the equals/hashCode pair
Set<Book> deduplicated = new HashSet<>(List.of(a, b));
System.out.println("\nHashSet size → " + deduplicated.size()); // 1
List<Book> books = new ArrayList<>(List.of(
new Book("Hopscotch", "Cortazar", 736),
new Book("The Aleph", "Borges", 146),
new Book("1984", "Orwell", 328),
new Book("Fictions", "Borges", 174)
));
// 5a. Natural order: uses compareTo
Collections.sort(books);
System.out.println("\nBy title (natural order):");
books.forEach(x -> System.out.println(" " + x));
// 5b. By pages, descending
books.sort(Comparator.comparingInt(Book::getPages).reversed());
System.out.println("\nBy pages (descending):");
books.forEach(x -> System.out.println(" " + x));
// 5c. By author, then by title
books.sort(Comparator.comparing(Book::getAuthor)
.thenComparing(Book::getTitle));
System.out.println("\nBy author, then by title:");
books.forEach(x -> System.out.println(" " + x));
// 6. The TreeSet trap
Set<Book> tree = new TreeSet<>(books);
System.out.println("\nBooks in the list: " + books.size());
System.out.println("Books in the TreeSet: " + tree.size());
System.out.println("(equal, because no two titles repeat)");
Book sameTitleDifferentBook = new Book("1984", "Another Author", 500);
tree.add(sameTitleDifferentBook);
System.out.println("\nAfter adding another book titled \"1984\": " + tree.size());
System.out.println("The TreeSet REJECTED it: to it, compareTo == 0 means duplicate,");
System.out.println("even though equals() says they are different books.");
}
}
The most important part of this exercise is point 6.
equals compares title, author, and pages. compareTo only looks at the title. They are inconsistent, and nobody minds until the object enters a TreeSet or a TreeMap: those structures ignore equals entirely and decide duplicates by compareTo == 0.
Result: a book that equals considers distinct vanishes from the set with no error, no exception, and no warning.
The fix, when the natural order must be consistent:
@Override
public int compareTo(Book other) {
int byTitle = this.title.compareTo(other.title);
if (byTitle != 0) return byTitle;
int byAuthor = Objects.compare(this.author, other.author,
Comparator.nullsFirst(Comparator.naturalOrder()));
if (byAuthor != 0) return byAuthor;
return Integer.compare(this.pages, other.pages); // never the subtraction
}
Now compareTo returns 0 exactly when equals returns true, and both families of collections agree.
Key takeaways
- The
for-eachdoes not talk to the collection: it uses itsIteratorbehind the scenes. ConcurrentModificationExceptionhas nothing to do with threads: it is the iterator detecting that the collection changed from outside.- To delete while iterating:
removeIf(...), oriterator.remove(). Comparable= one natural order, inside the class.Comparator= many orders, outside, and for classes you do not control.- In
compareToonly the sign matters, and never usea - b: useInteger.compare(a, b). - If you override
equals, overridehashCode. With the same fields. No exceptions. - The correct signature is
equals(Object o). WithBook oyou are overloading and collections will not use it. TreeSetandTreeMapignoreequalsand decide duplicates bycompareTo == 0. Keep them consistent.- A
recordgives you correctequalsandhashCodefor free.