13 KiB
Executable File
11 — Proxies and LazyInitializationException, verified against Hibernate 7.4.5.Final
← Previous: 10 — Mocking JNDI datasources | Next: 12 — Association mappings →
Backs ankurm.com: proxies and the LazyInitializationException.
Companion code: src/main/java/com/ankurm/hibernatedemo/proxy/ (entities —
ProxyBook,
ProxyPublisher,
ProxyReview) and
src/test/java/com/ankurm/hibernatedemo/proxy/ (six test classes, twelve tests, all green).
Raw output: docs/output/proxy-*.txt. Environment: Hibernate ORM 7.4.5.Final, Spring Boot
4.1.1, Spring Framework 7.0.9, Jakarta Persistence 3.2.0, JDK 25 (Temurin), H2 2.4.240.
This chapter and chapter 01 are the same mechanism seen from two directions:
01 measures what get() vs getReference() actually do to the persistence context and when a
SELECT fires; this chapter measures what the object getReference() hands back actually is,
and what breaks when it outlives its session. Read together, they cover the whole proxy
lifecycle from creation to LazyInitializationException.
What a proxy actually is, and the two ways to break naive code with it
getReference() does not hand you back an instance of your entity class. In 7.4.5.Final it
hands you a ByteBuddy-generated nested class named $HibernateProxy —
com.ankurm.hibernatedemo.proxy.ProxyBook$HibernateProxy, confirmed live in
docs/output/proxy-lazy-init-and-identity.txt. That is not the Javassist-era naming scheme
(EntityName_$$_javassist_N) that still circulates in older blog posts and even in some current
ones — Hibernate moved its default bytecode provider to ByteBuddy years ago, and 7.4.5 keeps the
proxy as a literal static nested class of the entity itself, not a same-package sibling class.
Two things follow from that, and both are demonstrated in ProxyIdentityTest:
proxy instanceof ProxyBookistrue— the generated class extends your entity.proxy.getClass() == ProxyBook.classisfalse. Any code that branches ongetClass()instead ofinstanceof, or relies on the JPA-defaultequals()/hashCode()(object identity), silently breaks: aHashSet<ProxyPublisher>containing the real, managed instance does not recognize a proxy for the exact same row as a member (proxyIdentityTest .naiveEqualsAndHashSet_cannotRecognizeProxyAndRealInstanceAsTheSameRow, asserted, not claimed). Chapter 01's own proxy-identity experiment reaches the identical conclusion from theget()/getReference()side — see01 — get() vs getReference(), Proxy identity experiment.
Hibernate.getClass(proxy) is the fix — it unwraps to the real entity class regardless of proxy
state, and it's asserted to return ProxyPublisher.class even for an uninitialized reference.
The exception, verbatim — and it is not one message, it's two
Every blog post on this topic (including the one this replaces) quotes a single wording for
LazyInitializationException. Running it for real turns up two different message templates,
depending on whether the frozen thing is a to-one association or a collection:
# Collection (ProxyBook.reviews, a LAZY @OneToMany), accessed after session close:
Cannot lazily initialize collection of role 'com.ankurm.hibernatedemo.proxy.ProxyBook.reviews' with key '1' (no session)
# To-one proxy (a getReference() result), accessed after session close:
Could not initialize proxy [com.ankurm.hibernatedemo.proxy.ProxyBook#3] - no session
Note the capitalization on the second one too: it's Could (capital) and no session
(lower-case s) — not the commonly-quoted "could not initialize proxy ... no Session". Both
are org.hibernate.LazyInitializationException (verified: ex.getClass().getName() printed and
asserted equal to that string), but if you're grepping logs for one exact phrase, you need both.
See LazyInitializationTest for both reproductions and docs/output/proxy-lazy-init-and-identity.txt
for the verbatim capture (also mirrored, unfiltered, in docs/output/proxy-lazy-and-identity-run.txt).
Hibernate.initialize() / isInitialized() / unproxy() — confirmed against the 7.4.5 jar
javap against hibernate-core-7.4.5.Final.jar (full output in
docs/output/proxy-settings-javap.txt) shows the signatures actually shipped:
public static void initialize(java.lang.Object) throws org.hibernate.HibernateException;
public static boolean isInitialized(java.lang.Object);
public static java.lang.Object unproxy(java.lang.Object);
public static <T> T unproxy(T, java.lang.Class<T>);
unproxy() has two overloads, not one — a no-cast version that returns Object, and a
typed version that takes the target Class<T> and returns T directly. Both are exercised in
LazyInitializationTest.hibernateUnproxy_.... A side effect worth knowing: calling
Hibernate.unproxy() on an uninitialized proxy initializes it — isInitialized() flips from
false to true as a consequence, not just as a precondition.
fetchgraph vs. loadgraph, demonstrated, not just stated
Every article states the rule ("loadgraph keeps defaults for what's not named; fetchgraph forces
everything not named to LAZY") and almost none show it happening. EntityGraphFetchTest builds
one entity (ProxyBook) with two associations of different default fetch types — publisher
(@ManyToOne, default EAGER) and reviews (@OneToMany, explicit LAZY) — and one named graph
that mentions only reviews:
| Hint | SQL joins | publisher after fetch |
reviews after fetch |
|---|---|---|---|
none (plain find()) |
left join proxy_publisher only |
initialized (mapped EAGER) | not initialized (mapped LAZY) |
jakarta.persistence.loadgraph |
left join proxy_publisher and left join proxy_review |
initialized | initialized |
jakarta.persistence.fetchgraph |
left join proxy_review only |
not initialized (forced to LAZY, despite EAGER mapping) | initialized |
The middle column is the whole point: with fetchgraph, book.getPublisher() comes back as a
ProxyPublisher$HibernateProxy even though the mapping says EAGER, because fetchgraph treats
the graph as the entire fetch plan rather than an addition to the mapping's defaults. Verbatim
SQL for all three shapes is in docs/output/proxy-entitygraph-fetch-vs-load.txt (raw run in
docs/output/proxy-entitygraph-run.txt). Chapter 12 covers the collection-side counterpart of this same
lazy/eager boundary — N+1 counting and @BatchSize — see
12 — Association mappings.
hibernate.enable_lazy_load_no_trans: still there, still works, now flagged @Unsafe
This setting has a reputation for being on its way out. It is not gone in 7.4.5.Final —
org.hibernate.cfg.TransactionSettings.ENABLE_LAZY_LOAD_NO_TRANS still resolves to the string
hibernate.enable_lazy_load_no_trans (confirmed by javap, see
docs/output/proxy-settings-javap.txt). What is new-ish and worth a headline: the constant now
carries a @org.hibernate.cfg.Unsafe marker annotation — an empty marker interface Hibernate's
own source uses to flag settings it does not want you reaching for, without actually removing
them. OpenInViewAndLazyLoadNoTransTest turns it on
(spring.jpa.properties.hibernate.enable_lazy_load_no_trans=true) and confirms it functionally
does what it always did: a to-one proxy, accessed after its originating session and transaction
have both closed, initializes successfully instead of throwing — Hibernate opens a temporary
session behind the scenes to service exactly that one access. The @Unsafe label is
Hibernate's own commentary on why you generally shouldn't reach for this, not a sign it's been
deprecated or removed.
OSIV: the default, its warning, and what it actually masks
spring.jpa.open-in-view defaults to true when left unset, and Boot prints a warning at
startup that says exactly that — verified verbatim (not paraphrased) against
org.springframework.boot.jpa.autoconfigure.JpaBaseConfiguration$JpaWebConfiguration's
bytecode:
spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed
during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
(Note the package: org.springframework.boot.jpa.autoconfigure, not org.springframework.boot .autoconfigure.orm.jpa — Boot 4 split its autoconfigure module and JPA's auto-configuration now
lives in the separate spring-boot-jpa artifact.)
Proving the masking claim required a real HTTP request — OSIV is implemented by
OpenEntityManagerInViewInterceptor, which only exists for actual servlet requests, so
OsivDefaultWarningTest and OsivDisabledExceptionTest spin up a real embedded Tomcat
(@SpringBootTest(webEnvironment = RANDOM_PORT)) with one controller
(OsivBookController/OsivBookService) that returns a ProxyBook entity directly and lets
Jackson serialize getReviews() on the response-writing thread — deliberately not a DTO,
because the DTO is the fix, and the point here is what happens without it. Same code, same
data, only the property differs:
# open-in-view left unset (Boot default true): GET /osiv/books/1
200 {"title":"OSIV Default Book","publisher":{...},"id":1,"reviews":[{"comment":"Rendered fine","id":1}]}
# open-in-view=false: GET /osiv/books/2 (identical entity graph, identical controller)
500 {"timestamp":"...","status":500,"error":"Internal Server Error","path":"/osiv/books/2"}
Server-side log for the second case (captured via CapturedOutput, not inferred):
Resolved [org.springframework.http.converter.HttpMessageNotWritableException: Could not write
JSON: Cannot lazily initialize collection of role 'com.ankurm.hibernatedemo.proxy.ProxyBook.reviews'
with key '2' (no session)]
Worth calling out as its own gotcha: the HTTP error body Boot's default BasicErrorController
returns is a bare {"timestamp":...,"status":500,...} even with
server.error.include-message=always set — the failure happens inside the Jackson
HttpMessageConverter while writing the response, which Boot reports as a generic 500 rather
than routing the original exception's message into the error attributes map. If you only look at
the HTTP response, you will not learn why it failed; you have to check the application log,
which is exactly what real incident response looks like for this failure mode. Full sequence in
docs/output/proxy-osiv-and-no-trans.txt (raw run in docs/output/proxy-osiv-run.txt).
Bytecode enhancement (hibernate-enhance-maven-plugin) — not run here
The article draft this replaces implies bytecode enhancement changes proxy behavior in ways
worth demonstrating. Two settings for it are real and confirmed present in 7.4.5.Final —
org.hibernate.cfg.BytecodeSettings.ENHANCER_ENABLE_LAZY_INITIALIZATION,
ENHANCER_ENABLE_DIRTY_TRACKING, and ENHANCER_ENABLE_ASSOCIATION_MANAGEMENT all exist
(docs/output/proxy-settings-javap.txt). Wiring up the actual Maven plugin to enhance classes at
build time and diff the resulting behavior was not attempted: a quick check for
org.hibernate.orm.tooling:hibernate-enhance-maven-plugin:7.4.5.Final and
org.hibernate.orm:hibernate-enhance-maven-plugin:7.4.5.Final on Maven Central both came back
"could not be resolved" in this sandbox. Per the brief's own guidance, this looked like a rabbit
hole rather than a quick add — flagging it honestly rather than guessing at what enhancement
would do differently here.
Testcontainers, Docker, and the honest gap
Not applicable to this chapter directly (see chapter 09 for that), but it's worth noting here too: everything above ran against a single shared H2 in-memory database in a sandboxed container. No Docker was available, so nothing here was cross-checked against a real Postgres/MySQL proxy-and-lazy-loading story — the LazyInitializationException and proxy identity mechanics are Hibernate-internal and database-agnostic, but that claim itself rests on understanding of Hibernate's architecture rather than an executed cross-database test.
← Previous: 10 — Mocking JNDI datasources | Next: 12 — Association mappings →