The Persistence Context Tax on Read-Heavy Workloads
The Persistence Context Tax on Read-Heavy Workloads
Every entity loaded through Hibernate enters the persistence context. The persistence context is a per-Session identity map that tracks every managed entity, stores snapshots for dirty checking, and enforces the guarantee that two lookups for the same ID return the same Java object reference. This guarantee is useful when you modify entities. For read-only queries, it is pure overhead.
The Lie
The persistence context is lightweight. Loading entities is the natural way to read data. The overhead is negligible.
The Reality
For each managed entity, Hibernate stores:
- The entity instance itself (your Java object)
- The hydrated state array (an
Object[]containing the value of every persistent field, used for dirty checking) - An EntityKey (entity class + ID) as the map key
- An EntityEntry tracking the entity’s status (MANAGED, DELETED, etc.), lock mode, and loaded state reference
The hydrated state array is the expensive part. It is a copy of every field value at load time. For an entity with 20 fields, that is an Object[20] containing boxed primitives, String references, and collection wrappers. At flush time, Hibernate compares each current field value against this array to detect changes.
// What Hibernate stores for ONE managed Product entity:
//
// 1. Product instance:
// - 25 fields × ~32 bytes avg = ~800 bytes
//
// 2. Hydrated state Object[25]:
// - Array header: 16 bytes
// - 25 references: 200 bytes
// - Referenced values: ~800 bytes (copies of field values)
// - Total: ~1016 bytes
//
// 3. EntityKey: ~48 bytes (class ref + id)
//
// 4. EntityEntry: ~128 bytes (status, lock mode, refs)
//
// Per-entity overhead: ~1192 bytes beyond the entity itself
// For 10,000 entities: ~11.6 MB of pure bookkeeping
The Evidence
// BAD: Loading 100,000 entities for a report
@Transactional(readOnly = true)
public SalesReport generateAnnualReport() {
List<Order> orders = orderRepository.findByYearAndStatus(
2024, OrderStatus.COMPLETED);
// 100,000 orders loaded. Each is managed.
// Memory: 100,000 × ~2 KB (entity + overhead) = ~200 MB
// Even with readOnly=true (skips hydrated state):
// 100,000 × ~1 KB = ~100 MB
BigDecimal totalRevenue = orders.stream()
.map(Order::getTotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return new SalesReport(totalRevenue, orders.size());
}
You loaded 100,000 full entities, with all their columns, all their persistence context overhead, to sum one field. A projection query would accomplish the same with a fraction of the memory.
The Fix
Option 1: Aggregate Query (Best for Summaries)
// BETTER: Let the database do the aggregation
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("""
SELECT new com.example.dto.SalesReport(
SUM(o.total), COUNT(o))
FROM Order o
WHERE YEAR(o.createdAt) = :year AND o.status = :status
""")
SalesReport calculateAnnualReport(@Param("year") int year,
@Param("status") OrderStatus status);
}
// Generated SQL:
// select sum(o.total), count(o.id) from orders o
// where extract(year from o.created_at) = ? and o.status = ?
//
// Result: 1 row. ~100 bytes. No entities loaded. No persistence context.
Option 2: Stream Processing with StatelessSession
When you need to process individual rows but not manage them:
// BETTER: StatelessSession for large read-only result sets
@Service
public class ReportService {
@Autowired
private EntityManagerFactory emf;
public SalesReport generateDetailedReport(int year) {
SessionFactory sf = emf.unwrap(SessionFactory.class);
try (StatelessSession session = sf.openStatelessSession()) {
BigDecimal total = BigDecimal.ZERO;
long count = 0;
try (ScrollableResults<Order> scroll = session
.createQuery(
"FROM Order o WHERE YEAR(o.createdAt) = :year " +
"AND o.status = :status", Order.class)
.setParameter("year", year)
.setParameter("status", OrderStatus.COMPLETED)
.setFetchSize(1000)
.scroll(ScrollMode.FORWARD_ONLY)) {
while (scroll.next()) {
Order order = scroll.get();
total = total.add(order.getTotal());
count++;
// order is NOT managed. No persistence context.
// No dirty checking. No snapshot storage.
// GC can collect it as soon as we move to the next row.
}
}
return new SalesReport(total, count);
}
}
}
StatelessSession has no persistence context. No identity map. No dirty checking. No lazy loading (associations must be fetched eagerly or via explicit queries). Entities returned from a StatelessSession are detached immediately. This makes it ideal for large read-only workloads where you process rows sequentially.
Option 3: DTO Stream with Spring Data
// BETTER: Stream DTO projections
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("""
SELECT new com.example.dto.OrderSummary(o.id, o.total, o.createdAt)
FROM Order o
WHERE YEAR(o.createdAt) = :year AND o.status = :status
""")
@QueryHints(@QueryHint(name = HINT_FETCH_SIZE, value = "1000"))
Stream<OrderSummary> streamByYearAndStatus(
@Param("year") int year,
@Param("status") OrderStatus status);
}
// Usage:
@Transactional(readOnly = true)
public SalesReport generateReport(int year) {
try (Stream<OrderSummary> stream = orderRepository
.streamByYearAndStatus(year, OrderStatus.COMPLETED)) {
// Each OrderSummary is a lightweight record, not a managed entity
// Memory: bounded by fetch size, not total result count
return stream.collect(Collectors.teeing(
Collectors.reducing(BigDecimal.ZERO,
OrderSummary::total, BigDecimal::add),
Collectors.counting(),
SalesReport::new));
}
}
The Cost Model
For 100,000 orders, year-end report scenario:
| Approach | Peak Memory | DB Round Trips | CPU (dirty check) |
|---|---|---|---|
| Full entity load | ~200 MB | 1 (but large result set) | ~200ms at commit |
| Full entity, readOnly | ~100 MB | 1 | 0 |
| StatelessSession scroll | ~2 MB (fetch buffer) | 1 (streaming) | 0 |
| DTO projection stream | ~2 MB (fetch buffer) | 1 (streaming) | 0 |
| Aggregate query | ~100 bytes | 1 | 0 |
If you are summing a column, use an aggregate query. If you need to process individual rows for logic that cannot be expressed in SQL, use streaming with DTOs or StatelessSession. Loading 100,000 managed entities to iterate them once is never the right choice for read-only workloads.