Checkout already put "ORD-1001" → that Order in a HashMap. You look it up with a freshly built id that has the same characters and get null, while size() still counts the entry. The map did not lose memory. The key type answered “same value?” in a way the table cannot use.

Equal objects must share a hashCode; a hash collection finds a bin, then asks equals. This post owns the Object contract. The Collections Roadmap keeps the one-liner. HashMap owns table mechanics. Hash Tables owns layout.

Mental model

== asks “is this the same object?” equals asks “is this the same value?” — and Object’s default equals is ==. hashCode is the integer a hash table uses to pick a bin. Object’s default hashCode is identity-based too. Override neither, and two new OrderId("ORD-1001") instances are different keys. Override only equals, and they look equal in a test and still miss in a HashMap.

The lab is the same checkout domain the collections hub already named:

public record Order(
        String id,
        String customerEmail,
        List<LineItem> items,
        BigDecimal total,
        boolean active) {}

public record LineItem(String sku, int quantity, BigDecimal unitPrice) {}

Order.id is a map key. LineItem.sku is a set element. A custom OrderId you write by hand is where the contract usually breaks.

A hash collection does two steps, in that order:

1. hashCode(key)  →  pick one bin
2. in that bin, == then equals  →  confirm it is the same key / member

The pairing rule is one direction: if a.equals(b), then a.hashCode() == b.hashCode(). Collisions are allowed — two unequal ids may share a hash. The reverse implication is not a law. A constant 0 satisfies the pairing rule and still turns every lookup into a walk of one bin; that layout bill lives on Hash Tables.

Same object vs same value

Two string ids that you constructed separately are not ==. They are equals, and String already paired hashCode to those characters. That is why a map keyed by Order.id finds a lookup you typed again:

Map<String, Order> byId = new HashMap<>();
byId.put(order.id(), order);

String again = new String("ORD-1001");
System.out.println(order.id() == again);          // false — two objects
System.out.println(order.id().equals(again));     // true  — same chars
System.out.println(byId.get(again));              // the Order — hash then equals

== is identity; equals is the value question. Use == for enums, for this == o inside equals, and when you genuinely mean this instance. Use equals for “same id,” “same SKU,” “same email.” Do not write order.id() == "ORD-1001" and hope interned literals save you.

Two Order records with the same components are equal even though they are different objects. That is the record doing its job — details stay in Java Records. Two records that differ only in total are not equal. If you meant “same order id,” you wanted a key of id (or a tiny OrderId record), not the whole Order.

The Object contract

Object.equals documents five rules. They are not interview trivia. Break one and a HashMap or HashSet will disagree with a unit test that only called equals.

RuleMeaning
Reflexivex.equals(x) is true
Symmetricx.equals(y) if and only if y.equals(x)
Transitiveif x.equals(y) and y.equals(z), then x.equals(z)
Consistentrepeat calls answer the same while the fields you compared did not change
Nullx.equals(null) is false — it must not throw

Reflexive and null are the easy ones. An instanceof check takes care of both: null instanceof OrderId is false, and an object is always an instance of its own class.

final class OrderId {
    final String id;

    OrderId(String id) {
        this.id = Objects.requireNonNull(id);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        return o instanceof OrderId other && id.equals(other.id);
    }
}

this == o is a cheap hit for the same reference. Objects.equals(id, other.id) is the null-safe form when a field may be null; here id is not.

Symmetry is the rule people break on purpose. An equals that also accepts a String looks convenient at the call site and lies when the string answers:

@Override
public boolean equals(Object o) {
    if (o instanceof String s) {
        return id.equals(s);          // OrderId.equals(String) can be true
    }
    return o instanceof OrderId other && id.equals(other.id);
}

Call equals both ways and the lie is obvious:

new OrderId("ORD-1001").equals("ORD-1001")   // true
"ORD-1001".equals(new OrderId("ORD-1001"))   // false — String does not know OrderId

Do not let equals accept a foreign type. Compare OrderId to OrderId. Compare LineItem to LineItem. Transitivity falls out of comparing the same fields on the same type. Mixing in a subclass with extra fields and instanceof (instead of getClass()) is the other classic symmetry bug; prefer a final class or a record and you do not have that subclass.

Consistent means: if you mutate a field that equals reads, the answer may change. That is legal for a standalone object and fatal once the object is already a map key. The mutable-id demo later is that rule meeting a hash table.

Pair both methods, or use a record

Equal OrderIds that still use Object.hashCode land in different bins. get hashes the lookup key, walks that bin, and never visits the node you put.

final class OrderId {
    final String id;

    OrderId(String id) {
        this.id = Objects.requireNonNull(id);
    }

    @Override
    public boolean equals(Object o) {
        return o instanceof OrderId other && id.equals(other.id);
    }
    // hashCode inherited — identity, not id
}

Map<OrderId, Order> byId = new HashMap<>();
byId.put(new OrderId("ORD-1001"), order);
byId.get(new OrderId("ORD-1001")); // null — equal keys, different hashes, wrong bin

A test that only asserts new OrderId("ORD-1001").equals(new OrderId("ORD-1001")) stays green. Checkout still cannot find the order.

The fix is the same fields in both methods. Objects.hash and Objects.equals are the JDK helpers; they already handle null components.

final class OrderId {
    final String id;

    OrderId(String id) {
        this.id = Objects.requireNonNull(id);
    }

    @Override
    public boolean equals(Object o) {
        return o instanceof OrderId other && Objects.equals(id, other.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}

byId.put(new OrderId("ORD-1001"), order);
byId.get(new OrderId("ORD-1001")); // the Order

Override both, or override neither. hashCode must use a subset of the fields equals uses — usually the same set. Extra fields in hashCode that equals ignores can give equal objects different hashes, which is a contract break. Fewer fields in hashCode is legal and just collides more; do not “optimize” down to a constant.

Which fields: the ones that are the identity you mean. For a map of orders, that is id, not customerEmail, not items, not total. Include total and a repriced order is a different key. Leave id out and two distinct orders can collapse into one entry.

The same choice shows up in a Set. Put LineItem in a HashSet and uniqueness is the whole line — SKU, quantity, and price. Two lines with sku WB-40 and different quantities are two members. If the question is “have I already seen this SKU?”, put item.sku() (or a one-component Sku record), not the LineItem.

A record is the honest default when the type is those fields. You do not write equals / hashCode at all:

public record OrderId(String value) {}

Map<OrderId, Order> byId = new HashMap<>();
byId.put(new OrderId("ORD-1001"), order);
byId.get(new OrderId("ORD-1001")); // hit — components define both methods

The compiler keeps them in sync when you add a component. That is the whole reason to reach for a record as a key. Syntax, compact constructors, and when not to use a record stay in Java Records. Here: a record key is honest because the header is the state.

Key by order.id() (String) or by a small record of the identifying components. Do not key by a mutable Order bean and then change id.

Arrays compare by identity

Arrays do not override equals or hashCode. Two String[] with the same SKUs are == only if they are the same object. A hash map does not call Arrays.equals for you.

String[] bundle = {"WB-40", "NUT-M8"};
Map<String[], Order> byBundle = new HashMap<>();
byBundle.put(bundle, order);

byBundle.get(bundle);                              // hit — same reference
byBundle.get(new String[] {"WB-40", "NUT-M8"});    // null — different array, identity hash

Content equality is Arrays.equals / Arrays.hashCode; the map never calls them. Do not use an array as a HashMap key or a HashSet member. Wrap the values in a record — record SkuBundle(List<String> skus) with List.copyOf in a compact constructor — so equality is the list contents, which are value-based. Mutating a live array (or a mutable list) after insert is the same bug as mutating id.

What HashMap and HashSet do with a broken pair

HashMap hashes, indexes a bin, and confirms with == then equals. HashSet is that membership question on the element you inserted. Set is the contract: at most one member that equals another. If equals and hashCode disagree, get / contains look in the wrong bin. If you mutate a field either method reads after put / add, the node stays where the old hash put it and lookup uses the new hash.

That second failure is MutableOrderId:

final class MutableOrderId {
    String id;
    MutableOrderId(String id) { this.id = id; }

    @Override
    public boolean equals(Object o) {
        return o instanceof MutableOrderId m && id.equals(m.id);
    }

    @Override
    public int hashCode() {
        return id.hashCode();
    }
}

Map<MutableOrderId, Order> byMutable = new HashMap<>();
MutableOrderId key = new MutableOrderId("ORD-1001");
byMutable.put(key, order);

key.id = "ORD-9999";
byMutable.get(new MutableOrderId("ORD-1001")); // null — wrong bin
byMutable.get(new MutableOrderId("ORD-9999")); // null — looks in the new bin, node is in the old
byMutable.containsValue(order);                // true — the node is still in the table

The entry did not vanish from memory. It vanished from the hash’s point of view. size() still counts it. You will not find it by key. The same story with HashSet.add / contains. Table mechanics — spreading, treeify, resize — stay on the HashMap post. Layout stays on Hash Tables.

Record keys work because components define equals and hashCode, and those fields are final. Two records with the same components are equal even if they are different objects. That is what you want in a map key.

Pitfalls

Forgot hashCode. The OrderId that only overrides equals is the usual production bug. Tests that never round-trip a HashMap do not catch it. Put the type in a map in the test.

Wrong fields. equals on all of Order when you meant id. Or equals on id while hashCode also mixes in total — a repriced order then hashes differently from an equal key. Pick the identifying fields once; use that set in both methods.

BigDecimal in a key. equals checks value and scale. new BigDecimal("10.0") and new BigDecimal("10.00") are not equal. If Order.total participates in equality, two totals that look the same on a packing slip are different keys. Prefer id. If money must be a key, normalize scale first.

Arrays and mutable lists. Identity equals on arrays; content that changes after insert on a live List. Copy on the way in (List.copyOf) if a record holds a list.

equals that accepts String. Convenient, asymmetric, and a HashMap will not treat "ORD-1001" as your OrderId anyway.

A constant hashCode. It obeys the pairing rule. Every key shares a bin. Lookup is a scan. Do not ship return 0; to “make equals work.”

Sorted uniqueness is a different contract: TreeSet / TreeMap place nodes with compareTo / Comparator, which must stay consistent with equals. That lecture is Navigable Collections, not this post.

When not to override

Leave Object’s identity equals / hashCode when the question really is this instance — the same Order object appearing twice in a graph walk, a serializer that must not write the same LineItem object twice. If that is the map you are building, IdentityHashMap is the type that asks == on purpose. Do not override equals on Order and then wonder why the identity map “ignores” it; it never called your method.

Skip a hand-written pair when a record already generated it. Do not override one method on a record “for performance” and leave the other derived — the compiler’s versions stay paired; yours may not.

Do not override on a type whose identifying fields change while it lives in a collection. Fix the type (immutable id, record key) rather than hoping the map will follow the mutation.

Do not override so that two snapshots of an order compare equal by id if the rest of checkout treats different items / total as different values. Identity of a row in a database and equality of a value object are different questions. Pick one per type.

Cheat sheet

One screen for the pairing rule, the helpers, and the habits that keep a key findable:

==             same reference
equals         same value; Object’s default is ==
hashCode       bin picker; Object’s default is identity-ish
Pairing        a.equals(b)  ⇒  a.hashCode() == b.hashCode()
Contract       reflexive, symmetric, transitive, consistent, not-null
Helpers        Objects.equals / Objects.hash; same fields in both
Records        header is the state; do not reimplement the pair
Arrays         identity equals/hashCode — wrap values, do not key by array
Keys           immutable; id (or a tiny record), not a bean you then mutate

Do:

  • Override both equals and hashCode, or use a record.
  • Compare the identifying fields only — Order.id, LineItem.sku when SKU is the question.
  • Keep keys and set members immutable after insert.
  • Test the type through HashMap.get / HashSet.contains, not only equals.

Don’t:

  • Override equals and forget hashCode.
  • Mutate id (or any hashed field) after put / add.
  • Use an array as a key.
  • Let equals accept a String (or any foreign type).
  • Key by a whole mutable Order when you mean id.

Wrap-up

== is identity. equals is the value you define. hashCode must follow: equal objects share a hash, and those two methods read the same identifying fields. A record is the default honest key. A forgotten hashCode, an array, or a mutable OrderId after put is how an entry stays in size() and disappears from get. The collections hub still holds the one-liner; this post is the contract that one-liner assumed.

Next optional step in the series Write List<Order> once and mean it — PECS, erasure, and raw types. Generics: Write List<Order> Once and Mean It