Skip to main content
the lies your orm tells you

The Persistence Context Tax on Read-Heavy Workloads

5 min read Chapter 20 of 30

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:

  1. The entity instance itself (your Java object)
  2. The hydrated state array (an Object[] containing the value of every persistent field, used for dirty checking)
  3. An EntityKey (entity class + ID) as the map key
  4. 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:

ApproachPeak MemoryDB Round TripsCPU (dirty check)
Full entity load~200 MB1 (but large result set)~200ms at commit
Full entity, readOnly~100 MB10
StatelessSession scroll~2 MB (fetch buffer)1 (streaming)0
DTO projection stream~2 MB (fetch buffer)1 (streaming)0
Aggregate query~100 bytes10

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.