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.
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 aSELECTagainst 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() →