The idea: a unit of work in front of the database
In Hibernate (and in JPA, where it is called an EntityManager) the code does not write SQL for every change. A Session holds a persistence context: the set of entity objects it has loaded or saved in this unit of work. The code changes plain Java objects. At flush time, usually at commit, Hibernate works out which rows must change and sends the SQL. The canvas shows the Java variables on the left, the Session in the middle and the MariaDB tables on the right, with every SQL statement as a round trip.
The identity map and the first-level cache
The persistence context is an identity map: one Java instance per entity type and id, such as Author#1. Calling em.find(Author.class, 1) a second time in the same Session sends no SQL and returns the same object (Author@51 both times). This is the first-level cache. It lives only as long as the Session. getReference() sends no SQL either: it returns a proxy that knows only the id and loads itself on the first getter call (Demo: first-level cache, find twice).
A query also goes through the map. When a row comes back for an entity that is already managed, Hibernate keeps the instance it has and ignores the row's values, so your unflushed changes are never overwritten.
Entity states
| state | in the context? | how you get there |
|---|---|---|
| transient | no, and no id | new Author("Dee") |
| managed | yes, with a snapshot | persist, find, a query, merge |
| detached | no, but has an id | detach, clear, close (after commit) |
| removed | yes, DELETE queued | remove on a managed entity |
A detached object is an ordinary Java object again: setters on it are not tracked. merge(a) finds or loads the managed copy with the same id, copies the detached state onto it and returns that copy; the object you passed in stays detached. That is why the code writes a = em.merge(a) (Demo: detach and merge).
Dirty checking and snapshots
When Hibernate loads an entity it keeps a copy of the loaded values, the snapshot (loadedState in its EntityEntry). A setter such as a.setName("Ann B.") changes only the Java field: no SQL, nothing queued. At flush Hibernate compares every managed entity field by field with its snapshot. A difference queues an UPDATE; then the snapshot takes the new values (Demo: dirty checking at flush). Changing a field and changing it back makes no UPDATE at all.
The cost of this is a comparison of every managed entity on every flush. A Session that loads ten thousand entities for reading pays for it; read-only queries or @Transactional(readOnly = true) in Spring skip the snapshots.
The ActionQueue and flush order
persist and remove do not send SQL either. They put an insert or a delete action in the Session's ActionQueue. At flush the queue runs in a fixed order: all inserts, then all updates, then all deletes (collection changes sit between updates and deletes). The order in which your code called the methods does not matter (Demo: flush order insert, update, delete).
This order can surprise you. Deleting a row and inserting a new one with the same unique key in one flush fails, because the INSERT runs before the DELETE; call em.flush() after the remove to force the order you need.
The id generator matters too. With GenerationType.SEQUENCE (MariaDB has sequences since 10.3) persist only reads nextval(author_seq) and the INSERT waits in the queue. With GenerationType.IDENTITY (AUTO_INCREMENT) only the database knows the id, so persist must send the INSERT at once, and Hibernate cannot batch those inserts. Switch the generator under Settings and run the flush-order demo again.
FlushMode.AUTO and COMMIT
Pending changes live only in the Session. If the code then runs a query, the database would not see them. With FlushMode.AUTO, the default, Hibernate flushes before a query that touches a table with pending changes, so select count(a) from Author a counts the new author: 4 (Demo: FlushMode.AUTO before a query). With FlushMode.COMMIT it flushes only at commit, the query sees 3, and the insert is still queued (Demo: FlushMode.COMMIT, stale count). Commit always flushes, whatever the mode.
A flush is not a commit. Flushed rows are written inside the open MariaDB transaction (orange on the canvas): this connection sees them, other transactions do not, and a rollback undoes them. See MVCC and isolation for what other transactions see.
Lazy loading, LazyInitializationException and N+1
A @OneToMany collection is lazy by default. a.getBooks() returns a PersistentBag that is empty until something touches it; then it sends one SELECT and the Book entities join the identity map. A proxy from getReference(), or a lazy @ManyToOne, works the same way. Loading needs an open Session. After commit and close every entity is detached, and touching a proxy or collection that was never loaded throws LazyInitializationException; one that was loaded before keeps working (Demo: lazy loading, LazyInitializationException).
In a loop, lazy loading becomes the N+1 pattern: one SELECT for the list of authors, then one SELECT per author for its books (Demo: N+1 queries in a loop). join fetch loads authors and books in one join (Demo: JOIN FETCH, one query); it is an inner join, so authors without books drop out unless you write left join fetch. @BatchSize or hibernate.default_batch_fetch_size loads many collections with one IN (…) query. The N+1 Query Problem compares the fixes in detail.
| what the code does | SQL sent |
|---|---|
| find the same id twice | 1 SELECT |
| setter on a managed entity | none until flush, then 1 UPDATE |
| persist with SEQUENCE / IDENTITY | nextval now, INSERT at flush / INSERT now |
| loop over 3 authors touching books | 1 + 3 SELECTs |
| the same with join fetch | 1 SELECT |
In Spring, @Transactional opens the Session and commits it when the method returns; see Spring Core for the proxy that does that, and Connection Pool for where the JDBC connection comes from. The first-level cache is not a shared cache: for that, see Cache Strategies.
What the page leaves out
The page uses literal values in the SQL; Hibernate sends ? parameters in prepared statements. The sequence has allocationSize 1 (Hibernate's default is 50 with a pooled optimizer, so most persist calls need no round trip). It leaves out the second-level and query caches, JDBC batching and order_inserts, cascades and orphan removal, collection actions, @Version optimistic locking, dynamic update, bytecode enhancement, StatelessSession, open-session-in-view, rollback, and how native SQL queries decide whether to auto-flush.
See where this page fits on the Java & the JVM mind map.