Challenge

Break the map with a mutable key

20 minintermediate28 yrs

Edge cases. You have to reason, and two valid fixes differ.

What this teaches

  • A key's hash must not change while it is in the map
  • A stale-hash entry is unreachable but still counted by size()
  • Immutable keys vs excluding fields from hashCode are NOT equivalent fixes

Starter

Starter.java
import java.util.*;

/**
 * CHALLENGE — break the map with a mutable key, then explain it.
 *
 * Run as-is. An entry you just inserted becomes unreachable, while size()
 * still counts it. Nothing throws.
 *
 * TASKS
 *   1. Predict every line of output BEFORE running. Write your prediction down.
 *   2. Run it. Explain precisely why the entry is lost — name the bucket.
 *   3. Fix it. There are two valid fixes and they are not equivalent:
 *        (a) make the key immutable
 *        (b) exclude the mutable field from hashCode()
 *      Implement (a). Then say in a comment what (b) silently costs you.
 *   4. Prove the entry is still physically present even though get() fails.
 */
public class Starter {

    /** A key whose hash depends on a field you can change afterwards. */
    static class OrderKey {
        String region;      // mutable — this is the landmine
        final int orderId;

        OrderKey(String region, int orderId) {
            this.region = region;
            this.orderId = orderId;
        }

        @Override
        public int hashCode() {
            return Objects.hash(region, orderId);
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof OrderKey k)) return false;
            return orderId == k.orderId && Objects.equals(region, k.region);
        }

        @Override
        public String toString() {
            return region + "#" + orderId;
        }
    }

    public static void main(String[] args) {
        Map<OrderKey, String> orders = new HashMap<>();

        OrderKey key = new OrderKey("APAC", 1001);
        orders.put(key, "Widget order");

        System.out.println("get before mutation = " + orders.get(key));
        System.out.println("size before         = " + orders.size());

        // somewhere far away, someone "just updates the region"
        key.region = "EMEA";

        System.out.println("get after mutation  = " + orders.get(key));
        System.out.println("containsKey         = " + orders.containsKey(key));
        System.out.println("size after          = " + orders.size());
        System.out.println("remove returns      = " + orders.remove(key));
        System.out.println("size after remove   = " + orders.size());

        // The entry is unreachable by key — but is it gone?
        System.out.print("still iterable      = ");
        for (Map.Entry<OrderKey, String> e : orders.entrySet()) {
            System.out.print(e.getKey() + "=" + e.getValue() + " ");
        }
        System.out.println();
    }
}

Run it locally:

cd exercises/java/collections/hashmap-internals/02-challenge
javac Starter.java -d /tmp/out && java -cp /tmp/out Starter

Hints

  1. Hint 1

    The entry sits in the bucket chosen by the hash it had at put() time.

  2. Hint 2

    get() computes the CURRENT hash and looks in a different bucket.

  3. Hint 3

    Iterate the entrySet. Is the entry really gone, or just unreachable?

Done when

  • OrderKey is immutable; get() works after any attempted mutation
  • A comment explains what excluding region from hashCode() would cost

← Back to How does HashMap work internally?