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.
This commit is contained in:
134
docs/01-get-vs-load.md
Normal file
134
docs/01-get-vs-load.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user