skip to content

A batch job iterates a million rows through a single Hibernate Session and the JVM heap climbs until it runs out of memory, even though each row is processed and then never referenced again. Why does the session grow, and what techniques keep memory flat?

level: seniorimportance: should knowfreq 50%

answer

  1. identity map never evicts; strong refs for the session's life
  2. entity + loaded-state snapshot ≈ 2x per row
  3. dirty-check scan grows -> flushes slow down before the OOM
  4. flush() then clear() every N; never the reverse
  5. read-only skips the snapshot; StatelessSession has no context at all

basics

~20 s

The persistence context holds a strong reference to every entity it loads, plus a snapshot copy of its loaded values for dirty checking — roughly double per entity, never released until evict, clear or close. Fix it with periodic flush()+clear(), read-only or stateless access, and paged or scrolled reads.

solid answer

~50 s

The first-level cache is an identity map that **never evicts on its own**. Every entity you load or persist stays strongly referenced for the session's lifetime, and beside it Hibernate keeps a *loaded-state snapshot* array used by dirty checking — so the footprint is roughly two copies per entity, plus collection snapshots. Garbage collection cannot help while the session is alive. Flush cost degrades too: dirty checking scans the whole context, so flushes get slower as it grows. Remedies, in order of preference: 1. **`flush()` then `clear()` every N entities** — writes the batch and releases both instances and snapshots. Pair it with `hibernate.jdbc.batch_size` for real JDBC batching. 2. **Read-only access** (`setReadOnly`, a read-only query hint) — Hibernate skips the snapshot entirely, roughly halving memory for pure reads. 3. **`StatelessSession`** — no persistence context, no dirty checking, no cascades; direct row-by-row work. 4. **Bounded reads** — keyset pagination or a scrolled result set instead of materialising a million-row list.

code

java · 11 lines
java
// hibernate.jdbc.batch_size = 50, order_inserts = true
int i = 0;
for (Row r : rows) {
    em.persist(toEntity(r));
    if (++i % 50 == 0) {
        em.flush();   // emit the batch
        em.clear();   // release instances + snapshots (all entities now detached)
    }
}
em.flush();
em.clear();

go deeper

for a junior

Say the session keeps every loaded entity until it is cleared, and that flush() followed by clear() at intervals is the standard fix.

for a middle

Add the snapshot copy behind dirty checking, the growing flush cost, and that everything is detached after clear().

for a senior

Compare the options with their trade-offs — read-only, StatelessSession, keyset or scrolled reads, JDBC batch size — and describe diagnosing it from a heap dump and rising flush latency.

for a principal

Decide whether the ORM belongs in this job at all: set-based SQL or a stateless path for the largest volumes, restartable chunked units of work, and explicit memory and runtime budgets per batch.

## Why a session grows When Hibernate loads an entity it stores three things: the instance itself in the identity map, an `EntityEntry` with bookkeeping (status, id, version, lock mode), and a **loaded-state snapshot** — an `Object[]` copy of the property values as read from the database. The snapshot is what dirty checking compares against at flush. All of these are held by **strong references** for as long as the session lives. That is not a leak; it is the identity guarantee doing its job (one instance per row per session, so a reload returns the same object). But it means "process and forget" is impossible by default: the session remembers everything, and the garbage collector cannot reclaim an entity you finished with two hundred thousand rows ago. Rough memory model: entity object + snapshot array ≈ **two copies of the row's data**, plus per-entity bookkeeping, plus a snapshot per persistent collection you touched. A million entities at a few hundred bytes each puts you comfortably past a default heap. ## The second, quieter cost Dirty checking at flush iterates every managed entity and compares it field-by-field against its snapshot. With automatic flushing, a query in the middle of the loop triggers that scan. The result is quadratic-feeling behaviour: as the context grows, every flush gets slower, so a batch that starts fast crawls near the end even before the heap runs out. Slow-then-OOM is the classic signature. ## Techniques that keep memory flat ### 1. flush() + clear() at a fixed cadence ``` int i = 0; for (Row r : rows) { em.persist(toEntity(r)); if (++i % 50 == 0) { em.flush(); em.clear(); } } em.flush(); em.clear(); ``` `flush()` pushes the pending SQL out; `clear()` empties the identity map, releasing instances *and* snapshots. Order matters absolutely — `clear()` first discards the work. Choose the batch size to match `hibernate.jdbc.batch_size` so JDBC batching actually kicks in, and remember that after `clear()` every entity you still hold is **detached**: keep only ids across the boundary, never objects you intend to mutate. ### 2. Read-only when you are not writing For a report or an export, no snapshot is needed. `session.setDefaultReadOnly(true)`, `session.setReadOnly(entity, true)`, or the query hint `org.hibernate.readOnly` tells Hibernate not to take the loaded-state snapshot at all. Entities stay managed and lazily loadable, dirty checking skips them, and the memory per entity roughly halves. It also removes the flush-scan cost. ### 3. StatelessSession Hibernate's `StatelessSession` has **no persistence context**: no identity map, no dirty checking, no cascades, no lifecycle callbacks, no second-level cache interaction. You call `insert`, `update`, `delete` explicitly and every operation goes straight to SQL. Memory stays flat by construction and throughput is the best available, at the price of losing every convenience — you must handle associations yourself, and identity is no longer guaranteed (loading the same row twice yields two objects). ### 4. Do not materialise the input `getResultList()` on a million rows loads a million rows. Prefer: - **Keyset pagination** — `where id > :last order by id` with a bounded fetch size, each page in its own short transaction/session. Robust and restartable. - **Scrolled results** (`ScrollableResults`, or a `Stream` query) with a JDBC fetch size set, so the driver streams rather than buffering. Combine with `evict`/`clear` per chunk, and be aware that some drivers only stream under specific settings. - Offset pagination degrades on deep pages; prefer keyset for large jobs. ### 5. Targeted evict `session.evict(entity)` releases one entity after you are done with it. Precise, but easy to get wrong in a loop (a missed path leaks), and it does not drop collection snapshots you never touched. `clear()` at a cadence is usually more reliable. ## Choosing between them - Writing many rows, needs cascades/callbacks/validation → **flush + clear** with JDBC batching. - Writing many rows, no ORM semantics needed → **StatelessSession** or plain JDBC/`INSERT ... SELECT`. Often the honest answer for the biggest jobs is to push the work into SQL and skip the ORM entirely. - Reading many rows for export → **read-only + streaming/keyset**, clear per chunk. - Long conversation rather than a batch → the real fix is a shorter session, not a bigger heap. ## Diagnosing it in production A heap dump shows the giveaway: a single `StatefulPersistenceContext` retaining hundreds of thousands of entity entries and `Object[]` snapshots. Complementary signals are flush duration growing over the run and full GCs increasing in frequency while live-set size climbs monotonically. Raising `-Xmx` moves the failure later; the fix is bounding the context.

  • When would you reach for StatelessSession instead of flush+clear, and what do you give up?
    Use it for high-volume, mechanical work where ORM semantics add nothing: bulk inserts or updates of flat rows, migrations, exports. It has no persistence context, so memory is flat by construction and there is no dirty-check overhead. You give up cascades, lifecycle callbacks, automatic dirty checking, lazy-loading convenience, second-level cache interaction and the one-instance-per-row identity guarantee, so every association and every write must be handled explicitly.
  • You added flush()+clear() and memory still climbs. What else could be retaining entities?
    Something outside the persistence context is holding strong references: a list you are accumulating results into, an event or audit collector, a logging/metrics structure keyed by entity, or entities captured in a closure or a cache. Also check whether the input itself is fully materialised — getResultList() over the whole table retains every row regardless of what the session does. A heap dump showing the dominator tree settles it quickly.
  • Does clearing the session affect entities you are still holding references to in the loop?
    Yes — they become detached immediately. Their unflushed changes are lost, lazy associations can no longer be initialised, and a later find() for the same id returns a different instance. The discipline is to finish with an entity before the clear boundary and to carry only identifiers, not objects, across it.

saying these in an interview costs you the question

  • Calling it a memory leak in Hibernate rather than the documented behaviour of an identity map that never evicts.
  • Calling clear() before flush() and losing the batch.
  • Forgetting the loaded-state snapshot, and so underestimating memory by about half.
  • Raising the heap as the fix instead of bounding the persistence context.
  • Assuming entities kept after clear() are still managed and will save changes.

context