Cheat SheetsHibernate & JPAPerformance

Performance — Cheat Sheet

Hibernate & JPA · 7 topics. Download the PDF or the Instagram carousel and share it.

Cheat Sheet · AiCanCode.org
Performance
Hibernate & JPA7 topicsQuick revision reference
1

N+1 Problem

Loading a list of entities and accessing a lazy association triggers one SELECT per entity; detect with Hibernate statistics or slow query log; fix with JOIN FETCH or entity graphs.

  • N+1 occurs when accessing a lazy association inside a loop — 1 query for N entities + N queries for the association = N+1 total.
  • The problem is invisible in dev (small data) but catastrophic in production; always test with realistic data volumes.
  • JOIN FETCH in JPQL is the most direct fix; it loads the association in a single SQL JOIN query.
  • @EntityGraph achieves the same result declaratively on Spring Data repository methods without writing JPQL.
  • Never JOIN FETCH two collection associations at the same time — this causes a Cartesian product and multiplied rows.
  • Enable hibernate.generate_statistics and spring.jpa.show-sql to detect N+1 during development and code review.
Java — JPA (Bad Example)
// Entity mapping
@Entity
public class Order {
    @Id Long id;
    String reference;

    @ManyToOne(fetch = FetchType.LAZY)  // lazy = not loaded until accessed
    Customer customer;
}

// ❌ N+1 problem — 1 + N queries
List<Order> orders = em.createQuery("SELECT o FROM Order o", Order.class)
                       .getResultList();                   // 1 SELECT orders

for (Order o : orders) {
    System.out.println(o.getCustomer().getName());        // N SELECT customers
    // "SELECT * FROM customer WHERE id = ?"  fired for EACH order!
}

// With 500 orders → 501 queries total
2

Fetch Joins & the N+1 Problem

JOIN FETCH in JPQL or EntityGraph with FETCH eagerly initialises associations in a single query; beware the Cartesian product explosion when fetching multiple collections simultaneously.

  • N+1 problem: N entities loaded → N additional SELECTs for each lazy association = N+1 total
  • JOIN FETCH in JPQL or @EntityGraph loads the association in the same query as the parent
  • JOIN FETCH on @OneToMany multiplies result rows — use DISTINCT or @BatchSize instead
  • @BatchSize(size=N) loads lazy collections in batches using IN(?, ?, ...) — no Cartesian product
  • Enable show-sql + generate_statistics to detect N+1 in development; assert in tests
  • Multiple @OneToMany collections: use @BatchSize on each — one JOIN FETCH per collection creates explosions
Hibernate — detect N+1 with show-sql and statistics
// N+1 example — 100 orders → 101 SELECTs
List<Order> orders = orderRepo.findAll();       // SELECT * FROM orders   (1 query)
for (Order o : orders) {
    System.out.println(o.getCustomer().getName()); // SELECT * FROM customers WHERE id=? × 100
}

// application.properties — detect in development
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.generate_statistics=true

// Test assertion with datasource-proxy (Maven: net.ttddyy:datasource-proxy)
@Test
void shouldLoadOrdersInOneQuery() {
    var counter = new QueryCountHolder();
    List<Order> orders = orderRepo.findAllWithCustomer();
    assertThat(QueryCountHolder.getGrandTotal().getSelect()).isEqualTo(1);
}
3

Entity Graphs

@EntityGraph specifies which associations to load for a specific query without changing the mapping's default fetch type; load hints override EAGER/LAZY per query.

  • @EntityGraph overrides the mapping's default fetch type per query — LAZY by default, eager opt-in where needed
  • @NamedEntityGraph on the entity class + @EntityGraph on the repository method is the cleanest declarative approach
  • EntityGraph.FETCH treats unlisted associations as LAZY; EntityGraph.LOAD uses their mapping default
  • Use @EntityGraph for paginated queries — it avoids Hibernate's in-memory pagination warning from JOIN FETCH
  • JOIN FETCH is safe for single entity lookups; @EntityGraph is safer for paginated list queries
  • Fetching two @OneToMany collections (bags) with JOIN FETCH causes MultipleBagFetchException — use @EntityGraph with two queries or Set instead of List
Java — @NamedEntityGraph with subgraph and @EntityGraph on repository
// Entity — define named entity graphs
@Entity
@Table(name = "orders")
@NamedEntityGraph(
    name = "Order.withCustomerAndItems",
    attributeNodes = {
        @NamedAttributeNode("customer"),         // load customer eagerly
        @NamedAttributeNode(value = "items",
                            subgraph = "items-with-product")  // with sub-graph
    },
    subgraphs = {
        @NamedSubgraph(
            name = "items-with-product",
            attributeNodes = @NamedAttributeNode("product")  // items → product
        )
    }
)
@NamedEntityGraph(
    name = "Order.withCustomerOnly",
    attributeNodes = @NamedAttributeNode("customer")  // lighter graph
)
public class Order {
    @ManyToOne(fetch = FetchType.LAZY)
    private Customer customer;

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> items;
}

// Repository — apply graph per query method
public interface OrderRepository extends JpaRepository<Order, Long> {

    @EntityGraph("Order.withCustomerAndItems")
    Optional<Order> findWithFullGraphById(Long id);

    @EntityGraph("Order.withCustomerOnly")
    List<Order> findByStatus(OrderStatus status);

    // Without @EntityGraph — uses default LAZY fetch
    Page<Order> findByCustomerId(Long customerId, Pageable pageable);
}
4

Second-Level Cache

The second-level cache (Ehcache, Caffeine, Infinispan) caches entities across sessions; annotate entities with @Cache and configure region expiry to avoid stale reads.

  • L2C is shared across all sessions in the same SessionFactory — unlike the per-session first-level cache
  • Entities must opt-in with @Cache — only annotated entities are cached
  • READ_WRITE strategy uses soft locks to prevent stale reads during concurrent writes
  • Bulk JPQL/SQL updates bypass L2C — manually evict affected regions after bulk writes
  • Query cache stores result IDs; invalidated aggressively on any table write — only useful for static data
  • Monitor L2C hit ratio with Hibernate Statistics; enable via setStatisticsEnabled(true)
Spring Boot — enable L2C with Ehcache
<!-- pom.xml -->
<dependency>
  <groupId>org.hibernate.orm</groupId>
  <artifactId>hibernate-jcache</artifactId>
</dependency>
<dependency>
  <groupId>org.ehcache</groupId>
  <artifactId>ehcache</artifactId>
  <classifier>jakarta</classifier>
</dependency>

# application.properties
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.region.factory_class=jcache
spring.jpa.properties.hibernate.javax.cache.provider=\
  org.ehcache.jsr107.EhcacheCachingProvider
spring.jpa.properties.hibernate.cache.use_query_cache=true  # optional
5

Query Cache

The query cache stores query result IDs; it is invalidated whenever any entity in the queried tables changes, making it only beneficial for rarely-changing reference data.

  • Query cache stores result IDs, not entity data — it depends on L2 entity cache for full objects
  • Any insert/update/delete on a cached table invalidates ALL query cache entries for that table
  • Only useful for rarely-changing reference data (countries, currencies, config values)
  • Both hibernate.cache.use_second_level_cache and use_query_cache must be true
  • Mark a query as cacheable with @QueryHint("org.hibernate.cacheable", "true")
  • Monitor hit/miss ratio via SessionFactory.getStatistics() to verify cache effectiveness
Properties / Java — enabling L2 and query cache
# application.properties
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.use_query_cache=true
spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory
spring.jpa.properties.javax.cache.provider=org.ehcache.jsr107.EhcacheCachingProvider

// Mark entity as cacheable
@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY)
public class Country { ... }

// Mark the query result as cacheable
@QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
List<Country> findAll();
6

Batch Inserts & Updates

Enable hibernate.jdbc.batch_size and use SEQUENCE ID generation (not IDENTITY) to allow Hibernate to batch INSERT/UPDATE statements, drastically reducing round-trips.

  • hibernate.jdbc.batch_size groups multiple INSERT/UPDATE into one network round-trip
  • IDENTITY (auto-increment) ID generation silently DISABLES batching — use SEQUENCE instead
  • allocationSize on @SequenceGenerator pre-allocates IDs in bulk — reduces DB sequence calls
  • order_inserts + order_updates groups statements by type for better batch efficiency
  • Flush + clear every N entities to keep memory constant during large bulk imports
  • Verify batching with statistics: getPrepareStatementCount() should be rows/batch_size, not rows
Spring Boot — enable JDBC batching with SEQUENCE
# application.properties
spring.jpa.properties.hibernate.jdbc.batch_size=50        # batch up to 50 statements
spring.jpa.properties.hibernate.order_inserts=true        # group all INSERTs together
spring.jpa.properties.hibernate.order_updates=true        # group all UPDATEs together
spring.jpa.properties.hibernate.jdbc.batch_versioned_data=true  # batch versioned (optimistic lock) entities

# Required: use SEQUENCE (not IDENTITY) for the ID strategy
@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE,
                    generator = "product_seq")
    @SequenceGenerator(name = "product_seq",
                       sequenceName = "product_id_seq",
                       allocationSize = 50)   // pre-allocate 50 IDs per call — efficient
    Long id;
    // ...
}

# Why IDENTITY breaks batching:
# INSERT INTO product ... → DB returns generated ID
# Hibernate needs the ID immediately to set entity.id → cannot batch
7

Dirty Checking

Hibernate tracks managed entity snapshots; on flush it compares current state with the snapshot and issues UPDATE for changed fields — understand this to avoid unexpected SQL statements.

  • Hibernate stores a snapshot on entity load; on flush it compares and generates UPDATEs for dirty fields
  • No explicit save() needed for managed entities — mutation within @Transactional is enough
  • Flush triggers: before same-table query, on @Transactional commit, or explicit em.flush()
  • @DynamicUpdate generates UPDATE with only changed columns — reduces DB overhead for wide entities
  • @Immutable prevents any UPDATE/DELETE generation — ideal for reference/lookup tables
  • Read-only query hints skip snapshot creation — saves memory and flush overhead for large reads
Hibernate — automatic dirty detection in @Transactional
@Transactional
public void updatePrice(Long productId, BigDecimal newPrice) {
    // Loaded entity is NOW MANAGED — Hibernate holds a snapshot
    Product product = productRepo.findById(productId).orElseThrow();

    // Mutate the field — Hibernate detects this as "dirty"
    product.setPrice(newPrice);
    product.setUpdatedAt(Instant.now());

    // NO explicit save() needed!
    // On @Transactional method exit:
    //   Hibernate flushes → compares current state to snapshot
    //   → snapshot.price != current.price → generates:
    //   UPDATE products SET price=?, updated_at=? WHERE id=?
}

// Pitfall: mutating outside a transaction
Product p = productRepo.findById(1L).orElseThrow();  // detached after method returns
p.setPrice(BigDecimal.TEN);   // change is LOST — no persistence context
// Must call productRepo.save(p) to re-attach via merge
Learn this free with Aria, your AI tutor → AiCanCode.org/learn/hibernate