skip to content

Explain why a Java equals() implementation that starts with getClass() != other.getClass(), or that reads the other object's fields directly, can misbehave when Hibernate hands you a lazily-loaded proxy — and what to write instead.

level: seniorimportance: should knowfreq 42%

answer

  1. Proxy = ByteBuddy subclass implementing HibernateProxy
  2. getClass() -> Order$HibernateProxy, instanceof accepts it
  3. Proxy fields never populated — getters only
  4. Hibernate.getClass() unwraps; unproxy() initializes
  5. Never dereference associations inside equals

basics

~20 s

A lazy reference is a generated subclass of your entity, so getClass() returns Order$HibernateProxy, not Order, and a getClass() check rejects the same row. Direct field access on a proxy reads the uninitialized subclass fields (null), bypassing the interception. Use instanceof and call getters.

solid answer

~50 s

When an association is `LAZY`, Hibernate gives you a **proxy**: a runtime-generated subclass of your entity that holds only the identifier and delegates method calls to the real instance once initialized. Two consequences hit `equals`. First, the IDE-standard `if (o == null || getClass() != o.getClass()) return false;` compares `Order` with `Order$HibernateProxy$abc` and returns false, even though both represent row 42. Use `instanceof` (or `Hibernate.getClass(o)` / `Hibernate.unproxy(o)`) instead — `instanceof` accepts the subclass, which is exactly the Liskov-correct behavior here. Second, inside `equals` you typically write `other.id`. Direct field access on a *proxy* reads the proxy subclass's own fields, which are never populated — you get `null` and conclude "not equal". Calling `other.getId()` goes through the intercepted method and returns the real value (and for the identifier specifically, without even triggering initialization). So: `instanceof` for the type check, getters for the state, and never touch associations inside `equals`.

code

java · 17 lines
java
// BROKEN with lazy proxies
@Override public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false; // Order vs Order$HibernateProxy
    Order other = (Order) o;
    return Objects.equals(id, other.id);                        // reads empty proxy field
}

// SAFE
@Override public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
    Order other = (Order) o;
    return id != null && id.equals(other.getId());              // getter is intercepted
}

@Override public int hashCode() { return Hibernate.getClass(this).hashCode(); }

go deeper

for a junior

Know that a lazy reference is a generated subclass, so getClass() differs from the entity class, and that instanceof is the safer check.

for a middle

Explain both failure modes — the type check and empty proxy fields — and write the getter-based implementation from memory.

for a senior

Diagnose from symptoms: a Set holding the same order twice, contains() disagreeing between code paths, an NPE inside equals; and know Hibernate.getClass/isInitialized and bytecode enhancement as alternatives.

for a principal

Position it as an argument for keeping identity out of the ORM's way entirely — assigned identifiers and value-object semantics — and for enforcing the equals pattern with an ArchUnit or static-analysis rule rather than review vigilance.

## What a proxy is When you map `@ManyToOne(fetch = FetchType.LAZY)` or call `em.getReference(Order.class, 42L)`, Hibernate does not return an `Order`. It returns an instance of a class it generated at runtime — historically via Javassist/CGLIB, in Hibernate 6 via ByteBuddy — that **extends** `Order` and implements `HibernateProxy`. That object carries a `LazyInitializer` holding the entity name and identifier. Every method call on it is intercepted: the first call that needs real state triggers a SELECT, loads the underlying entity instance, and delegates from then on. Two structural facts follow, and both bite `equals`: - `proxy.getClass()` is `Order$HibernateProxy$XyZ`, not `Order`. - The proxy's inherited fields (`id`, `name`, …) are **never assigned**. The real values live on a separate, wrapped instance. Only *method* calls reach it. ## Failure 1 — the getClass() type check The canonical IDE template is: ```java if (o == null || getClass() != o.getClass()) return false; ``` This is the textbook-correct choice for ordinary value classes because it keeps `equals` symmetric under subclassing. For entities it is wrong in practice: the "subclass" is a proxy for the very same row, and rejecting it produces `order.equals(orderProxy) == false` for a single database row. Symptoms are a `Set<Order>` that contains the same order twice, a `contains()` that returns false in one code path and true in another depending on whether the object came from a lazy association, and `remove()` calls that silently do nothing. The fix is `instanceof` (or Java's pattern form): ```java if (!(o instanceof Order other)) return false; ``` `instanceof` accepts the proxy subclass. Note the asymmetry you must accept: `instanceof` also accepts *genuine* entity subclasses under `@Inheritance`, so if your hierarchy has real subtypes with distinct identity semantics you need `Hibernate.getClass(this) == Hibernate.getClass(o)`, which unwraps proxies before comparing — the best of both. ## Failure 2 — direct field access Within the same class, Java lets you read `other.id` directly, and IDEs generate exactly that. On a proxy, `other.id` reads the proxy instance's own (never-populated) field and yields `null`. Your `equals` then concludes the objects differ, or worse, throws an NPE. `other.getId()` goes through the intercepted method and returns the real identifier — and for the identifier property specifically, Hibernate can serve it from the `LazyInitializer` without a database round trip, provided the getter is a plain identifier accessor and the entity does not use field-level `@Id` access with a non-trivial getter. This is why every safe entity `equals` uses **getters on the other object**, never fields. ## Failure 3 — touching associations Any `equals`/`hashCode` that dereferences a lazy association (`other.getCustomer().getName()`) forces initialization. Inside an open session that is an unwanted extra query; outside one it fails. In a bidirectional relationship, a symmetric all-fields `equals` on both sides recurses until the stack overflows. Keep `equals` to a single, non-association identifier. ## The composed, safe pattern ```java @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false; Order other = (Order) o; return id != null && id.equals(other.getId()); } @Override public int hashCode() { return Hibernate.getClass(this).hashCode(); } ``` `Hibernate.getClass` returns the *persistent* class behind a proxy, so it is exact about real inheritance while transparent about proxying. `Hibernate.unproxy(o)` is the alternative when you need the underlying instance itself; be aware it initializes the proxy. ## Related things worth knowing - Hibernate 6 offers **bytecode-enhanced lazy loading** (`@LazyToOne` behaviours / enhancement at build time), where no proxy subclass is created for the to-one and the instance is a real `Order` with interception woven in. That removes the `getClass()` problem but does not make field access safe. - `Hibernate.isInitialized(o)` tells you whether a proxy has been loaded, useful in tests and in mapping code that must not trigger queries. - `equals` on a proxy is itself delegated: `Order$HibernateProxy.equals(x)` calls your implementation on the initialized target, which is why `proxy.equals(entity)` often works even when `entity.equals(proxy)` does not — a genuine symmetry violation you should be able to name. In an interview, the winning answer is the three-part one: proxies are generated subclasses; therefore use `instanceof`/`Hibernate.getClass`; and because their fields are empty, always go through getters and never through associations.

  • Using instanceof instead of getClass() is normally criticised for breaking equals symmetry. Why is it acceptable here?
    The subclass in question is not a different domain type — it is a machine-generated stand-in for the very same row, and its equals delegates back to your implementation, so symmetry is preserved in the case that matters. Where real @Inheritance subtypes exist, Hibernate.getClass() gives you exact type comparison with proxies unwrapped, which is the strictly better option.
  • Does calling getId() on an uninitialized proxy hit the database?
    Normally no: the identifier is held by the proxy's LazyInitializer, so a plain identifier getter is answered without a SELECT. That breaks down if the entity uses property access with logic in the getter, or if you call any other getter — the first non-identifier access triggers initialization. Hibernate.isInitialized() lets you check without forcing it.

A proxy is a stand-in receptionist for the real entity: ask a question (call a getter) and it fetches the answer from the person behind the door; peek at the receptionist's own desk (read a field) and you find it empty.

saying these in an interview costs you the question

  • "getClass() is the correct equals idiom, always" — it rejects proxies of the same row
  • Reading other.id directly inside equals because the compiler permits it within the class
  • Believing a lazy proxy is the entity class itself
  • Comparing a whole association graph in equals, triggering lazy loads or infinite recursion
  • "Hibernate.unproxy is free" — it initializes the proxy, issuing a SELECT

context