The persistence context, dirty checking and flush
Why an update happens without save(), why two finds cost one SELECT, and why Hibernate can insert before it deletes.
After this lesson you can
- Explain the persistence context as an identity map with change tracking
- Predict the SQL a transaction sends, and when
- Say what repository.save() does for a new entity and for a detached one
Every JPA EntityManager owns a persistence context: a map from
entity id to the one managed instance for that id. In a Spring
application the persistence context normally lives exactly as long as
the transaction. Three behaviours follow from it.
Identity. Inside one transaction, findById(1) twice returns the
same object, and the second call does not query the database — the
entity is already in the map (this map is often called the first-level
cache).
Dirty checking. When an entity is loaded, Hibernate keeps a snapshot
of its state. At flush time it compares every managed entity with
its snapshot and issues an UPDATE for each one that changed. There is
no need to call save() for an entity you loaded in the same
transaction; changing a field is enough. The comparison is of values,
not of calls: set a field and set it back before the flush, and there
is nothing to update. By default the UPDATE writes every mapped
column; @DynamicUpdate on the entity limits it to the changed ones.
@Transactional
public void rename(long id, String name) {
Customer c = customers.findById(id).orElseThrow();
c.setName(name); // no save(): the UPDATE is sent at commit
}
Write-behind. persist and remove do not send SQL straight away.
The changes are queued and sent at flush: before commit, on an explicit
flush(), and (in the default AUTO mode) before a query whose result
the pending changes could affect.
import java.util.*; public class Solution { static final class Customer { final long id; String name; Customer(long id, String name) { this.id = id; this.name = name; } } static final class PersistenceContext { final Map<Long, String> database; final Map<Long, Customer> managed = new LinkedHashMap<>(); final Map<Long, String> snapshots = new HashMap<>(); final List<String> sql = new ArrayList<>(); PersistenceContext(Map<Long, String> database) { this.database = database; } Customer find(long id) { Customer c = managed.get(id); if (c != null) return c; // first-level cache hit sql.add("select customer " + id); c = new Customer(id, database.get(id)); managed.put(id, c); snapshots.put(id, c.name); return c; } void flush() { for (Customer c : managed.values()) { if (!Objects.equals(c.name, snapshots.get(c.id))) { // dirty check sql.add("update customer " + c.id + " set name='" + c.name + "'"); database.put(c.id, c.name); snapshots.put(c.id, c.name); } } } } public static List<String> run() { PersistenceContext pc = new PersistenceContext(new HashMap<>(Map.of(1L, "Ana", 2L, "Ben"))); Customer a = pc.find(1); Customer again = pc.find(1); pc.find(2); // loaded but never changed a.name = "Anna"; // no save() call anywhere pc.flush(); // what commit does List<String> out = new ArrayList<>(pc.sql); out.add("same instance: " + (a == again)); return out; }}Flush order is not call order
When it flushes, Hibernate does not replay your calls in sequence. Its
action queue runs the work by kind: entity inserts first, then
updates, then collection changes, and the deletions you requested with
remove() last, whatever order you called them in. That surprises
people in one classic case: delete a row and insert a replacement with
the same unique value in one transaction. The INSERT runs first,
while the old row still exists, and the unique constraint fails.
import java.util.*; public class Solution { public static List<String> run() { List<String> inserts = new ArrayList<>(), updates = new ArrayList<>(), deletes = new ArrayList<>(); List<String> calls = new ArrayList<>(); // The code, in the order it was written: calls.add("remove(user 7, email=ana@example.com)"); deletes.add("delete from users where id=7"); calls.add("persist(user 8, email=ana@example.com)"); inserts.add("insert into users (id, email) values (8, 'ana@example.com')"); // At flush the queue is executed by category, not by call order. List<String> out = new ArrayList<>(); out.add("calls: " + String.join(" then ", calls)); List<String> flushed = new ArrayList<>(inserts); flushed.addAll(updates); flushed.addAll(deletes); for (int i = 0; i < flushed.size(); i++) out.add("sql " + (i + 1) + ": " + flushed.get(i)); return out; }}The fix is to force the delete out first with an explicit flush()
(repository.flush() or entityManager.flush()) between the two
operations — or to update the existing row instead.
One exception to write-behind: with GenerationType.IDENTITY, the
database generates the id, so inside a transaction Hibernate runs the
INSERT as soon as you persist to learn it (and cannot batch those
inserts). Sequence-generated ids do not have this restriction.
EntityManager.clear() empties the persistence context without
flushing: every entity becomes detached, and changes not yet flushed
are lost.
What save() does
SimpleJpaRepository.save(entity) asks whether the entity is new. By
default that means: if the entity has a @Version field of a wrapper
type, whether the version is null; otherwise whether the id is null
(or 0 for a primitive id). A new entity is passed to persist and
becomes managed. Any other entity is passed to merge, which copies
its state onto a managed instance and returns that instance; the
object you passed in stays detached. Keep using the return value of
save.
Common mistakes
- Calling on an entity loaded in the same transaction "to make sure": harmless, but it shows the dirty checking was not understood.
Exercise · Pro
Dirty checking compares values, not calls
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade