Skip to content
Forward Engineering
Go back

The Real Cost of JPA Dirty Checking — readOnly, @DynamicUpdate, and Query Plan Cache Leaks

- views

Table of contents

Open Table of contents

Why this article

The first surprise when learning JPA is automatic UPDATE on setter:

@Transactional
void update(Long id) {
    Order o = repo.findById(id).orElseThrow();
    o.setStatus(CONFIRMED);   // No explicit save
}

Looks magical. But understanding how it works changes how you write batch jobs, choose readOnly, and tune Hibernate properties. This article measures the cost across six scenarios and exposes a senior-level trap on Query Plan Cache leaks.


1. The mechanism

[entity load via findById]

[entity registered in persistence context]

[snapshot — a copy of every column at load time]

... (application calls setters) ...

[flush at commit or em.flush()]

[for each managed entity: compare current vs snapshot]

[changed entities → UPDATE SQL]

Cost = snapshot allocation × N entities + per-entity comparison at flush. 10,000 entities means 10,000 snapshots and 10,000 comparisons.

Reference: Vlad Mihalcea — Anatomy of Hibernate Dirty Checking.


2. Setup

DB:    MySQL 8.0.44 (Docker, 3307)
Table: reply_request_dc (10 columns)
Seed:  10,000 rows via JDBC batchUpdate
Tool:  Java 21, Spring Boot 3.4, Hibernate 6.6
Run:   ./gradlew :runExpW4DirtyChecking

3. Six scenarios

ScenarioUPDATE SQLSnapshot?
S1 dirty checking, readOnly=falseevery column SETyes
S2 readOnly=truenoneno
S3 no @DynamicUpdateevery column SETyes
S4 @DynamicUpdateonly changed columnsyes + Plan Cache pressure
S5 @Modifying bulk JPQLone SQLno, persistence context stale
S6 raw JDBCone SQLno

[Numbers filled in after measurement run.]


4. S2 — what readOnly=true actually saves

Hibernate’s Session.setDefaultReadOnly(true):

  1. Skips the snapshot copy at entity load → memory savings.
  2. Switches FlushMode to MANUAL → no auto-flush, no comparison → CPU savings.

For 10,000 rows of pure read traffic, this is roughly 50% less heap. The kakaopay rule — every read method gets readOnly=true — comes from this. See JPA Transactional 잘 알고 쓰고 계신가요? (Korean).


5. S4 — @DynamicUpdate and the hidden Query Plan Cache leak

@Entity @DynamicUpdate
class Entity { ... }
-- Without @DynamicUpdate
UPDATE entity SET col1=?, col2=?, ..., col10=? WHERE id=?

-- With @DynamicUpdate
UPDATE entity SET col3=? WHERE id=?

Wins: shorter SQL, smaller binlog, less ROW-format replication overhead.

The senior trap: SQL is regenerated per change pattern. If col3 is updated in one call and col5, col7 in another, Hibernate caches both as separate plans in QueryPlanCacheStandardImpl. Without hibernate.query.plan_cache_max_size, dynamic update patterns can cause a permanent old-gen leak. Full GC cannot reclaim it.

Our application.yml:

spring.jpa.properties.hibernate.query.plan_cache_max_size: 2048

LRU bounded — sized for our query diversity.


6. S5 — @Modifying bulk JPQL and persistence-context staleness

@Modifying
@Query("UPDATE ReplyRequestDc r SET r.retryCount = r.retryCount + 1 WHERE r.ownerId = :ownerId")
int bulkIncrementRetry(@Param("ownerId") Long ownerId);

The fastest update — DB does it in one statement. The trap:

@Transactional
public void problematic(Long ownerId) {
    List<Entity> rows = repo.findAll();         // entities now in persistence context
    repo.bulkIncrementRetry(ownerId);            // DB updated; entities stale
    rows.get(0).getRetryCount();                // ← old value
}

Use @Modifying(clearAutomatically = true) or call em.clear() explicitly. Bulk JPQL belongs in batch jobs and admin endpoints, not in mid-transaction code that re-uses the entities.


7. S7 — clear() pattern for large inserts

// Anti-pattern
@Transactional
public void insertMany(int count) {
    for (int i = 0; i < count; i++) em.persist(new Entity(...));
    em.flush();   // 10,000 entities + 10,000 snapshots in memory
}

Standard pattern:

@Transactional
public void insertManyBatched(int count, int batchSize) {
    for (int i = 0; i < count; i++) {
        em.persist(new Entity(...));
        if (i % batchSize == 0 && i > 0) {
            em.flush();
            em.clear();   // bound the persistence context
        }
    }
    em.flush(); em.clear();
}

Memory stays bounded; flush comparison is O(batchSize), not O(N).

Note: with GenerationType.IDENTITY, JDBC batch is structurally disabled regardless of clear() — that is the topic of P4 — saveAll IDENTITY trap.


8. Operational rules

SituationRecommendation
Pure read@Transactional(readOnly = true)
Update 1–10 rowsdirty checking + @DynamicUpdate
Update 10K+ rows@Modifying JPQL UPDATE + clearAutomatically
Insert 10K+ rowsem.persist() + flush/clear every 50
Many dynamic queriesSet query.plan_cache_max_size explicitly

9. Conclusion

Dirty checking cost grows with the number of managed entities. Single-entity is free; ten thousand is OOM. The four levers — readOnly, @DynamicUpdate, @Modifying, clear() — each address a different segment of the lifecycle. None of them is a stand-alone fix.

Next: JPA N+1 + JOIN FETCH deep-dive.


References

Official

Vlad Mihalcea

External

Sister posts


Share this post on:

Next Post
[JPA + Spring Mastery 01] L1 Cache · flush · Transaction Lifecycle — what readOnly really shaves off, dirty checking's true cost