1
0
Files
hibernate-demo/docs/01-get-vs-load.md
Ankur Mhatre 7b06d653e4 Add hibernate-demo: get() vs load(), merge() vs refresh(), inserting objects (Hibernate 7.4.1.Final + Spring Boot 4.1.0)
Adds a JUnit test suite (GetVsGetReferenceTest, MergeRefreshTest, OptimisticLockTest,
IdentityBatchTest, SequenceBatchTest, AllocationSizeSweepTest, BatchSizeSweepTest) so every
surprising behavior described in the three companion posts has a reproducible test, alongside
the original CommandLineRunner scenarios. Rewrites all three doc chapters and the README around
the new experiments: the get()/getReference() same-session matrix, the merge()/refresh()
experiments (including exactly when OptimisticLockException surfaces and a corrected LAZY-plus-
cascade merge() result), and two new sweeps (allocationSize, batch_size) for batch inserts.
2026-08-26 18:05:00 +00:00

8.0 KiB

01 — get() vs getReference()

← Previous: 00 — Versions | Next: 02 — merge() vs refresh() →

Backs ankurm.com: Hibernate 7 — get() vs load().

Test class: GetVsGetReferenceTest. Run it yourself:

git clone https://ankurm.com/git.app/asmhatre/hibernate-demo.git
cd hibernate-demo
./mvnw -Dtest=GetVsGetReferenceTest test

Every number and exception class name below came from that command, not from documentation or memory. Raw captured output: docs/output/get-vs-getreference-tests.txt.

Contract vs observation

The JPA/Hibernate contract for these two methods is short: get() fetches now and may return null; getReference() defers and may throw once accessed. That contract is real and both methods honor it. What it doesn't tell you is what happens once the same id has already been touched once in the same session — and that's where the interesting behavior lives, because it's governed by the persistence context, not by the method you happen to call second.

Mental model

Stop thinking of get() vs getReference() as "eager vs lazy." Think of it as what you're telling Hibernate you need:

  • get() says "I need the entity." Hibernate will do whatever it takes — including firing a SELECT against an id it already has a reference for — to hand you something with real data behind it.
  • getReference() says "I need a reference." Hibernate will hand you the cheapest possible object that satisfies that and defers everything else, including telling you the row doesn't exist.

That framing predicts the session-matrix results in the next section better than "eager vs lazy" does — see the getReference()get() row in particular.

Four calls, four outcomes

# Call Fires a SELECT at the call site? Row missing Row exists
1 session.get(Book.class, id) Yes, immediately returns null returns the real entity
2 session.get(Book.class, id) (missing id) Yes, immediately returns null
3 session.getReference(Book.class, id) No — deferred to first non-id accessor proxy returned, no error yet proxy returned, no SELECT yet
4 session.getReference(Book.class, id) (missing id), then .getTitle() Yes, on first accessor call throws jakarta.persistence.EntityNotFoundException on access

Row 4 is worth being precise about: the exception class is jakarta.persistence.EntityNotFoundException, not org.hibernate.ObjectNotFoundException — the name still used in a lot of older Hibernate discussion. Running it against 7.4.1.Final settles which one this version actually throws.

Same-session matrix

Four combinations, both calls against the same id in the same session, each with statistics cleared right before the second call so prepareStatementCount reflects only that call:

First call Second call prepareStatementCount for 2nd call 2nd call returns
get() get() 0 the same instance (L1 cache hit)
getReference() getReference() 0 the same proxy instance
get() getReference() 0 the same, already-real instance — not a new proxy
getReference() get() 1 the same instance, now initialized

The last row is the one that doesn't follow from "it's already in the L1 cache, so nothing happens." It does happen: calling get() against an id that already has an uninitialized proxy sitting in the persistence context still fires a SELECT. get()'s contract is "hand back a real, usable entity" — an uninitialized proxy doesn't satisfy that, so Hibernate initializes it in place and returns the same object reference, now with real data behind it. The reverse direction (get() then getReference()) needs nothing further, because a real, fully-loaded instance already satisfies whatever getReference() was going to ask for.

This wasn't something I went looking for — it fell out of writing the fourth row of the matrix and reading the log, which is the actual argument for building the matrix instead of reasoning about two of the four cells and assuming the rest.

Proxy identity experiment

Six checks against the same proxy, all in one test:

assertThat(proxy).isInstanceOf(Book.class);                       // true
assertThat(Hibernate.getClass(proxy)).isEqualTo(Book.class);       // true -- the REAL class
assertThat(proxy.getClass()).isNotEqualTo(Book.class);             // true -- proxy.getClass() is Book$HibernateProxy
assertThat(real.equals(proxy)).isFalse();                          // false
assertThat(proxy.equals(real)).isFalse();                          // false, both directions
assertThat(new HashSet<>(List.of(real)).contains(proxy)).isFalse();// a HashSet can't see they're the same row

instanceof and Hibernate.getClass() both see through the proxy to the real type. getClass() does not — a Hibernate proxy's runtime class is a generated Book$HibernateProxy, never Book itself, which is why Hibernate.getClass() exists as the "give me the real entity class" escape hatch. equals() breaks in both directions because Book never overrides it, so Java's default falls back to reference identity — this is not a Hibernate quirk, it's plain Java doing exactly what an un-overridden equals() always does once two different objects (a proxy and a loaded instance) represent the same row. The HashSet check is the concrete cost of that: a collection built on default equals()/hashCode() cannot recognize the proxy and the real instance as the same database row, silently.

A seventh check, in a separate test, confirms the other well-known proxy trap: a proxy that outlives the session that created it throws org.hibernate.LazyInitializationException the moment a non-id accessor is called on it — a different failure from EntityNotFoundException, worth not confusing with it.

What surprised me building this

Two things, not one.

The proxy-equals-breaking result was expected going in, just not in its full shape — I expected equals() to be asymmetric or to depend on which side calls it. It doesn't; it fails identically in both directions, which is simpler and worse than a half-remembered version of this story usually gets described.

The one I didn't expect at all was the getReference()get() row of the session matrix. The intuitive prediction — "the id is already in the L1 cache, so the second call is free" — is true for three of the four matrix combinations and wrong for exactly this one, because get()'s contract requires more than presence in the cache; it requires the object behind that cache entry to actually be usable as loaded data. Predicting three cells right and getting the fourth wrong in a way that only shows up by actually building all four is the whole argument for running the matrix instead of describing two of its cells from memory.

Decision table

You have You need Call
An id, unsure if the row exists The actual data, or a safe existence check get()
An id, certain the row exists Only a reference to set a foreign key getReference()
An id already fetched once this session Anything Whatever's already loaded is reused — see the matrix above for exactly when a SELECT still fires anyway
A proxy that might outlive this session Safe access later Initialize it now (Hibernate.initialize(proxy)), or don't let it leave the session
Two references to the same row from mixed get()/getReference() calls, going into a Set or equals()-based comparison Correct identity behavior Override equals()/hashCode() on the id — the un-overridden default will not survive the proxy boundary

← Previous: 00 — Versions | Next: 02 — merge() vs refresh() →