Hibernate Internals: Persistence Context, Dirty Checking and Flush
Most JPA bugs come from not understanding the persistence context. This page explains entity states, the first-level cache, when SQL is really executed, and how Hibernate detects changes, so that transactions, lazy loading and batch processing stop being magic.
Entity states
new Product() em.find() / query
│ │
┌────▼─────┐ persist()/save() ┌────▼─────┐ remove() ┌──────────┐
│ TRANSIENT│ ───────────────────────────►│ MANAGED │ ────────────►│ REMOVED │
│ (no id, │ │ (tracked │ │ (delete │
│ unknown) │◄──── garbage collected │ by the │◄─ persist ───│ at flush)│
└──────────┘ │ context) │ └──────────┘
└────┬─────┘
detach(), clear(), │ ▲ merge()
close, tx ends ▼ │
┌──────────┐
│ DETACHED │ has id, but changes are NOT tracked
└──────────┘Persistence context = first-level cache
Inside a transaction, the EntityManager keeps a map of all loaded entities (key = entity type and id). If you ask for the same id again, Hibernate returns the same Java object without a new SQL query. It also keeps a snapshot of each entity as it was loaded, for dirty checking.
@Service
public class DemoService {
@PersistenceContext
private EntityManager em;
@Transactional
public void demo() {
Product a = em.find(Product.class, 1L); // SQL: select ... where id=1
Product b = em.find(Product.class, 1L); // NO SQL, from the persistence context
System.out.println(a == b); // true (same object)
a.setName("New name"); // just change the object
// no save() call. At commit Hibernate compares a with its snapshot,
// sees the difference and runs: update products set name=? where id=?
}
}When is SQL executed? Flush
persist() or setters do not run SQL immediately. Hibernate collects changes in an action queue and sends them to the database at flush time. With FlushMode AUTO (default), a flush happens: (1) before a query that could be affected by pending changes, (2) before the transaction commits, and (3) when you call flush() yourself.
@Transactional method
em.persist(order) ─► queued INSERT (may run at once for IDENTITY ids)
order.setStatus("PAID") ─► change detected later
em.createQuery("select o from Order o ...") ─► AUTO FLUSH first, so the query sees the changes
...
method returns ─► flush ─► COMMIT
Flush order inside Hibernate: inserts, updates, collection changes, deletes.
The database only makes changes visible to other transactions after COMMIT.Dirty checking in detail
- At load time Hibernate stores a copy of the property values (snapshot).
- At flush it compares every managed entity with its snapshot (cost grows with the number of managed entities).
- Only changed entities generate UPDATE. By default all columns are updated. @DynamicUpdate updates only changed columns.
- Read-only work: use @Transactional(readOnly = true) or query hints so Hibernate skips snapshots and dirty checking, or use DTO projections.
persist vs merge vs save
| Method | Behavior |
|---|---|
| persist(entity) | Makes a NEW entity managed. The same object becomes managed. Fails for detached entities |
| merge(entity) | Copies the state of a detached (or new) object into a managed instance and RETURNS the managed instance. The passed object stays detached |
| save() of Spring Data | Calls persist if the entity is new (id null or version null), otherwise merge. Always use the returned object |
| saveAndFlush() | save + flush immediately |
ID generation and batching
| Strategy | How | Batch inserts? |
|---|---|---|
| IDENTITY | Database auto increment. The INSERT must run immediately to get the id | No. JDBC batching is disabled for inserts |
| SEQUENCE | Hibernate takes ids from a database sequence, in blocks (allocationSize, default 50) | Yes. Recommended for PostgreSQL and Oracle |
| UUID | Generated in the application | Yes, but random UUID keys hurt index locality |
@Entity
public class Invoice {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "invoice_seq")
@SequenceGenerator(name = "invoice_seq", sequenceName = "invoice_seq", allocationSize = 50)
private Long id;
}spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true@Transactional
public void importInvoices(List<InvoiceDto> dtos) {
int i = 0;
for (InvoiceDto dto : dtos) {
em.persist(new Invoice(dto));
if (++i % 50 == 0) {
em.flush(); // send the batch
em.clear(); // detach everything so memory does not grow
}
}
}Lazy loading and proxies
- A LAZY association is a proxy object. Its fields are loaded with an extra SELECT the first time you access them, and only while the session is open.
- getReferenceById(id) returns a proxy without SQL. It is useful to set a foreign key: order.setCustomer(customerRepo.getReferenceById(5L)).
- Accessing a lazy field after the transaction ends throws LazyInitializationException.
Second-level cache (optional)
The first-level cache lives for one transaction. A second-level cache is shared across sessions and needs a provider (Ehcache, Caffeine through JCache, Infinispan). It helps for read-mostly reference data such as countries or currencies. It adds complexity (invalidation, clusters), so use it only after measuring.
@Entity
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_ONLY)
@Cacheable
public class Country {
@Id private String code;
private String name;
}
// spring.jpa.properties.hibernate.cache.use_second_level_cache=true
// spring.jpa.properties.hibernate.cache.region.factory_class=jcacheCommon mistake: Loading millions of rows in one transaction and calling save() in a loop. The persistence context keeps every entity (memory grows) and dirty checking becomes slower each time. Process data in chunks and call flush() and clear(), or use bulk queries and JDBC batching.
Interview questions
Why did my entity change get saved without calling save()?
The entity is managed inside the transaction. Hibernate detected the change by dirty checking and issued an UPDATE at flush time.
Why is my JDBC batch insert not batching?
Typical reasons: GenerationType.IDENTITY, hibernate.jdbc.batch_size not set, or entities with different types not ordered (set order_inserts=true).
What is the N+1 problem in terms of the persistence context?
Each lazy access on an unloaded association runs a separate SELECT because the data is not in the first-level cache yet. Loading it together with a fetch join or entity graph fixes it.