Performance — Cheat Sheet
Hibernate & JPA · 7 topics. Download the PDF or the Instagram carousel and share it.
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.
// 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 totalFetch 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
// 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);
}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
// 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);
}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)
<!-- 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
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
# 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();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
# 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 batchDirty 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
@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