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.
135 lines
8.0 KiB
Markdown
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)
|