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
    }
}
Why modifying a collection during a for-each throws ConcurrentModificationException Modifying the collection during the for-each 1. The for-each creates an Iterator, which memorizes the list's current modCount: 0 2. tasks.remove("rest") modifies the list from outside: its modCount becomes 1 3. On the next round, next() compares the memorized modCount (0) with the current one (1) 4. They differ → ConcurrentModificationException. The iterator declares itself stale. The two correct ways to delete while iterating it.remove() → deletes through the iterator itself, which syncs its modCount and stays valid list.removeIf(t -> t.startsWith("r")) → one line, no explicit iterator. The modern way.
The exception does not protect the collection: it protects you from walking a structure that shifted under your feet and silently skipping elements.
// 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
    }
}
The three return values of compareTo and what they mean for sorting What a.compareTo(b) returning each value means negative a comes BEFORE b a is "smaller" zero they are equivalent for this ordering positive a comes AFTER b a is "greater" The classic trap: return this.age - other.age; With large values that subtraction overflows the int and returns the wrong sign: the list comes out misordered with no exception to warn you. Always use Integer.compare(this.age, other.age).
The exact value does not matter, only the sign. Which is why returning the subtraction seems to work — until the day the numbers get big.

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.

Comparable defines one internal order; Comparator allows many external ones Comparable — the natural order, written INSIDE the class class Book implements Comparable<Book> public int compareTo(Book other) { ... } There can be only ONE per class. Used by Collections.sort(list), TreeSet, and TreeMap by default. Comparator — external orderings, as many as you need Book class untouched Comparator.comparing(Book::getTitle) Comparator.comparingInt(Book::getPages).reversed() comparing(Book::getAuthor).thenComparing(Book::getTitle) Comparator even works for classes you did not write and cannot modify. Comparable does not.
The natural order is a property of the class; a comparator is a decision made by whoever sorts. Which is why many can coexist.
// 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:

RuleWhat it means
Reflexivea.equals(a) is always true.
SymmetricIf a.equals(b), then b.equals(a).
TransitiveIf a.equals(b) and b.equals(c), then a.equals(c).
ConsistentCalling it ten times returns the same, as long as nothing changed.
Against nulla.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, not Book o. Writing public boolean equals(Book o) is overloading, not overriding, and collections — which call equals(Object) — will keep using the inherited version. It is exactly the bug @Override catches, 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:

What happens when two objects are equal but have different hash codes equals() done right, hashCode() left unoverridden book1 = new Book("1984") hashCode() inherited from Object = 366712642 book2 = new Book("1984") hashCode() inherited from Object = 1829164700 equals() → true bucket 2 bucket 11 different drawers set.add(book1); set.add(book2); → set.size() == 2 The Set never compared them: landing in different drawers, equals() was never even called. THE RULE: if a.equals(b) is true, then a.hashCode() == b.hashCode() MUST be true. The converse is not required: two different objects may share a hash code. That is a collision, and it is normal.
A 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

MistakeWhat happensHow to fix it
Overriding equals but not hashCodeHashSet 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 - bWith large values the int overflows and the order inverts, with no exception.Integer.compare(a, b).
Using == to compare StringIt 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 HashSetIts 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-eachConcurrentModificationException.removeIf(...) or iterator.remove().
compareTo inconsistent with equalsA 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

  1. Create Book with title, author, and pages, all immutable.
  2. Implement equals and hashCode using the three fields.
  3. Implement Comparable<Book> with a natural order by title.
  4. Show that a HashSet discards the duplicate.
  5. Sort a list by natural order and then with three different Comparators.
  6. Show what happens with a TreeSet whose compareTo only 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-each does not talk to the collection: it uses its Iterator behind the scenes.
  • ConcurrentModificationException has nothing to do with threads: it is the iterator detecting that the collection changed from outside.
  • To delete while iterating: removeIf(...), or iterator.remove().
  • Comparable = one natural order, inside the class. Comparator = many orders, outside, and for classes you do not control.
  • In compareTo only the sign matters, and never use a - b: use Integer.compare(a, b).
  • If you override equals, override hashCode. With the same fields. No exceptions.
  • The correct signature is equals(Object o). With Book o you are overloading and collections will not use it.
  • TreeSet and TreeMap ignore equals and decide duplicates by compareTo == 0. Keep them consistent.
  • A record gives you correct equals and hashCode for free.