Definition
- Naive loop problem -- each
save()adds a managed entity and a dirty-checking snapshot to the persistence context, which grows linearly with row count. - OutOfMemoryError -- for hundreds of thousands of rows, the heap fills with entities and snapshots that cannot be garbage-collected until the transaction ends.
- flush() -- pushes the current batch's SQL to the database within the transaction.
- clear() -- detaches all managed entities, making them eligible for garbage collection.
- Bounded memory -- after flush+clear, the context is empty and the next batch starts fresh, keeping resident memory at N entities.
- JDBC batching -- configure
batch_size,order_inserts, and SEQUENCE generation so the flushed inserts go out efficiently.
Imagine Rajesh at National Bank of India entering a million new account forms. The naive way is to spread all one million forms across his desk at once, keeping a carbon copy of each for checking -- the desk overflows. The flush+clear way is to work in stacks of fifty: fill in fifty forms, hand them to the vault (flush), then clear those fifty off the desk (clear) before starting the next fifty. The desk never holds more than fifty forms.
Understanding the Concepts
Why does a naive save() loop balloon memory?
The persistence context holds a strong reference to every managed entity plus a snapshot of its loaded state for dirty checking. In a bulk insert loop, each save() adds a managed entity and queues an INSERT. Because the transaction stays open, nothing is released.
package com.bank.batch;
import com.bank.model.Account;
import com.bank.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
@Service
public class NaiveBatchInsert {
private final AccountRepository accountRepo;
public NaiveBatchInsert(AccountRepository accountRepo) {
this.accountRepo = accountRepo;
}
// WRONG: memory balloons, OOM for large datasets
@Transactional
public void importAccounts(List<String[]> rows) {
for (String[] row : rows) {
Account a = new Account(row[0], row[1], new BigDecimal(row[2]));
accountRepo.save(a); // entity stays managed, snapshot held
// Context grows with every iteration
}
// 1 million rows = 1 million entities + 1 million snapshots in heap
}
}
Memory profile (naive):
Rows processed: 1K 10K 100K 500K 1M
Heap usage: 20MB 200MB 2GB 10GB OOM!
(linear growth, no release until TX ends)
How does the flush+clear pattern fix the memory problem?
Process a fixed batch of N rows, call flush() to push their SQL to the database, then clear() to detach everything. After clear, the context is empty and the garbage collector reclaims the entities.
package com.bank.batch;
import com.bank.model.Account;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
@Service
public class FlushClearBatchInsert {
private static final int BATCH = 50;
private final EntityManager em;
public FlushClearBatchInsert(EntityManager em) {
this.em = em;
}
@Transactional
public void importAccounts(List<String[]> rows) {
for (int i = 0; i < rows.size(); i++) {
String[] row = rows.get(i);
Account a = new Account(row[0], row[1], new BigDecimal(row[2]));
em.persist(a);
if ((i + 1) % BATCH == 0) {
em.flush(); // send this batch's INSERTs to the DB
em.clear(); // detach all entities -- free memory
}
}
em.flush(); // final partial batch
}
}
Memory profile (flush+clear every 50):
Rows processed: 50 100 500K 1M
Heap usage: 5MB 5MB 5MB 5MB
(sawtooth pattern, stays flat regardless of total rows)
What configuration do you need for efficient JDBC batching?
The flush+clear pattern bounds memory. JDBC batching makes the flushed inserts efficient by grouping them into a single round trip.
# application.yml
spring:
jpa:
properties:
hibernate:
jdbc.batch_size: 50
order_inserts: true
order_updates: true
open-in-view: false
package com.bank.model;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "acct_gen")
@SequenceGenerator(name = "acct_gen", sequenceName = "account_seq",
allocationSize = 50)
private Long id;
private String holderName;
private BigDecimal balance;
// SEQUENCE generation: ids pre-fetched, inserts deferrable, batching works
}
Configuration checklist:
1. hibernate.jdbc.batch_size = 50 (match your flush interval)
2. hibernate.order_inserts = true (group inserts by table)
3. SEQUENCE generation with allocationSize = 50 (pre-fetch ids)
4. PostgreSQL: add reWriteBatchedInserts=true to JDBC URL
5. DO NOT use IDENTITY generation (defeats batching!)
Why does IDENTITY generation defeat batching?
With GenerationType.IDENTITY, the database generates the id via auto-increment. Hibernate must execute each INSERT immediately to read the key, so it cannot defer and batch them.
// WRONG: IDENTITY defeats batching
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 50 persists = 50 immediate inserts = 50 round trips (no batching!)
// CORRECT: SEQUENCE enables batching
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "g")
@SequenceGenerator(name = "g", sequenceName = "account_seq", allocationSize = 50)
private Long id;
// 50 persists = 1 seq call + 1 JDBC batch = 2 round trips
IDENTITY: 50,000 inserts = 50,000 round trips
SEQUENCE + batch: 50,000 inserts = ~2,000 round trips (25x faster)
How do you choose the batch size?
Choose N to balance memory against round trips. Common values are 50 to 1000. Match your jdbc.batch_size to the flush interval so each flush sends a full batch.
Batch size trade-offs:
N = 10: Very small memory footprint, many round trips
N = 50: Good balance for most workloads
N = 500: Fewer round trips, more memory per chunk
N = 5000: Risky -- large memory spike before each flush
Rule of thumb:
- Start with 50, measure, adjust
- Match jdbc.batch_size to your flush interval
- Monitor: Hibernate statistics show actual batch counts
What about transaction scope for very large loads?
The whole flush+clear loop is still one transaction. A failure anywhere rolls back everything flushed so far and holds locks for the entire duration. For very large loads, consider committing per batch to bound the rollback window.
package com.bank.batch;
import com.bank.model.Account;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
@Service
public class PerBatchCommitService {
private static final int BATCH = 500;
private final EntityManager em;
public PerBatchCommitService(EntityManager em) {
this.em = em;
}
// Called from a non-transactional orchestrator
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processOneBatch(List<String[]> batch) {
for (String[] row : batch) {
em.persist(new Account(row[0], row[1], new BigDecimal(row[2])));
}
em.flush();
em.clear();
// Transaction commits when this method returns
// Partial progress persists even if later batches fail
}
}
One large transaction:
- 1M inserts, failure at row 999,000 -> ALL 999,000 rolled back
- Locks held for the entire duration
Per-batch commits:
- 1M inserts in 2000 batches of 500
- Failure at batch 1999 -> first 999,500 rows committed and safe
- Each batch holds locks only during its commit
What are common mistakes with batch inserts?
The most common mistake is using saveAll() and thinking it automatically batches. Spring Data's saveAll() calls save() in a loop -- you still need flush+clear and JDBC batch config.
// WRONG: saveAll does NOT automatically batch
@Transactional
public void importWrong(List<Account> accounts) {
accountRepo.saveAll(accounts);
// Calls save() in a loop -- no flush+clear, no memory management
// IDENTITY ids: no JDBC batching either
}
// CORRECT: manual persist + flush+clear + SEQUENCE + batch config
@Transactional
public void importCorrect(List<Account> accounts) {
for (int i = 0; i < accounts.size(); i++) {
em.persist(accounts.get(i));
if ((i + 1) % 50 == 0) {
em.flush();
em.clear();
}
}
em.flush();
}
Common mistakes:
1. Relying on saveAll() for batching -- it does not batch by itself
2. Using IDENTITY generation -- silently disables JDBC batching
3. Not configuring batch_size and order_inserts
4. Forgetting the final em.flush() for the last partial batch
5. Touching detached entities after clear()
What should a senior engineer know?
A senior engineer verifies batching with Hibernate statistics, uses generate_statistics=true to confirm batch counts, and matches the loop's flush interval to the configured batch_size. They consider per-batch commits for very large loads, disable second-level cache for the batch, and know that after clear() any held reference is detached.
package com.bank.config;
// Verify batching with statistics
// spring.jpa.properties.hibernate.generate_statistics=true
// Log output:
// HHH000117: ... executed 50 JDBC statements in batch
// If this says "executed 1 JDBC statement" 50 times, batching is broken
// Senior batch configuration:
// 1. SEQUENCE generation (allocationSize = batch_size)
// 2. hibernate.jdbc.batch_size = 50
// 3. hibernate.order_inserts = true
// 4. hibernate.order_updates = true
// 5. PostgreSQL: reWriteBatchedInserts=true
// 6. Disable 2nd-level cache for batch entities
// 7. Monitor with generate_statistics = true
Senior checklist:
1. Verify batching with Hibernate statistics
2. Match allocationSize to batch_size
3. PostgreSQL: reWriteBatchedInserts=true
4. Disable 2nd-level cache during bulk loads
5. Consider per-batch commits for huge loads
6. After clear(), held references are detached
7. Final flush() for the last partial batch
Visual Overview
Naive save() loop:
Heap ▲
│ ╱ OOM!
│ ╱
│ ╱
│ ╱
│ ╱
│ ╱
│ ╱
│ ╱
└────────────────────────► Rows
(linear growth, no release)
flush+clear pattern:
Heap ▲
│ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐
│ ─┘ └─┬─┘ └─┬─┘ └─┬─┘ └─┬─┘ └─
│ flush│clear │ │ │
└──────────────────────────────► Rows
(sawtooth, bounded at N entities)
Complete batch insert pipeline:
┌────────────┐ ┌──────────────┐ ┌───────────┐
│ Pre-fetch │ │ persist() │ │ flush() │
│ IDs from │───►│ entities │───►│ JDBC batch│
│ SEQUENCE │ │ (N at a time)│ │ to DB │
└────────────┘ └──────────────┘ └─────┬─────┘
│
▼
┌───────────┐
│ clear() │
│ free heap │
└───────────┘
Key Takeaway
A naive bulk insert loop that calls save() for each entity inside one transaction balloons memory because the persistence context holds a strong reference and a dirty-checking snapshot for every managed entity, and nothing is released until the transaction ends. For a million rows, the heap fills with a million objects and a million snapshots until the JVM throws OutOfMemoryError.
The fix is the flush+clear pattern. Process a fixed batch of N rows, call flush() to push the batch's inserts to the database, then clear() to detach everything so the garbage collector reclaims it. After clear the context is empty, so memory oscillates in a narrow band instead of climbing. Combine this with JDBC batching by configuring batch_size, order_inserts, and SEQUENCE id generation, because IDENTITY forces an immediate insert per row and silently disables batching.
At National Bank of India, the nightly import of new accounts from a partner file crashed with an OutOfMemoryError around four hundred thousand rows. Rajesh rewrote it to persist in batches of fifty, flush then clear after each batch, and switched the id from IDENTITY to SEQUENCE with a pooled allocation size. With JDBC batch config, the inserts batched and the job imported a million rows with flat memory and 25x fewer round trips.
Remember one sentence: flush+clear every N rows bounds memory, SEQUENCE generation enables batching, and IDENTITY silently kills both.
---