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

135 lines
8.0 KiB
Markdown

# 01 — get() vs getReference()
[← Previous: 00 — Versions](00-versions.md) | [Next: 02 — merge() vs refresh() →](02-merge-vs-refresh.md)
Backs [ankurm.com: Hibernate 7 — get() vs load()](https://ankurm.com/hibernate-7-get-vs-load-which-one-should-you-actually-use/).
Test class: [`GetVsGetReferenceTest`](../src/test/java/com/ankurm/hibernatedemo/GetVsGetReferenceTest.java).
Run it yourself:
```bash
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`](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:
```java
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](00-versions.md) | [Next: 02 — merge() vs refresh() →](02-merge-vs-refresh.md)