Transactions, Caching & Performance

Performance Tuning & Profiling

18 min Lesson 9 of 13

Performance Tuning & Profiling

Writing correct Hibernate code is only the first half of the job. In production, a data layer that issues hundreds of queries per request, loads objects that are never read, or submits inserts one row at a time will silently throttle your application long before you hit hardware limits. This lesson gives you the practical toolkit to find those problems and fix them: Hibernate's built-in statistics engine, batch fetching for associations, JDBC-level batch inserts and updates, and the most common anti-patterns to watch for.

Enabling Hibernate Statistics

Hibernate ships with a rich statistics subsystem that is disabled by default. Turning it on adds negligible overhead in development and staging environments, and is invaluable for identifying hot spots before they reach production.

In application.properties:

# Enable Hibernate statistics spring.jpa.properties.hibernate.generate_statistics=true # Log slow queries (threshold in milliseconds) spring.jpa.properties.hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS=25 # Optional: log all SQL with parameters (disable in production) spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE

Once enabled, the statistics object is available programmatically via the SessionFactory:

import org.hibernate.SessionFactory; import org.hibernate.stat.Statistics; import org.springframework.stereotype.Component; @Component public class HibernateStatsLogger { private final SessionFactory sessionFactory; public HibernateStatsLogger(SessionFactory sessionFactory) { this.sessionFactory = sessionFactory; } public void printStats() { Statistics stats = sessionFactory.getStatistics(); System.out.printf("Queries executed : %d%n", stats.getQueryExecutionCount()); System.out.printf("Slowest query ms : %d%n", stats.getQueryExecutionMaxTime()); System.out.printf("Entity loads : %d%n", stats.getEntityLoadCount()); System.out.printf("Entity inserts : %d%n", stats.getEntityInsertCount()); System.out.printf("Second-level hits: %d%n", stats.getSecondLevelCacheHitCount()); System.out.printf("Connection count : %d%n", stats.getConnectCount()); stats.clear(); // reset between test runs } }
Inject EntityManagerFactory, not SessionFactory, in standard JPA code. Unwrap it at startup with emf.unwrap(SessionFactory.class). Spring Boot auto-configures EntityManagerFactory from your JPA properties, so it is always available as a bean.

The N+1 Select Problem

The N+1 problem is the single most common Hibernate performance pitfall. It occurs whenever you load a collection of N parent entities and then, for each one, trigger a lazy-loaded association — producing 1 query for the parents plus N queries for the children.

// PROBLEM: triggers 1 query for all orders + 1 query per order for its items List<Order> orders = orderRepository.findAll(); for (Order o : orders) { // accessing o.getItems() fires a SELECT each time it is not already loaded System.out.println(o.getItems().size()); }

The fix is to tell Hibernate to join-fetch the association in the initial query, eliminating the N extra round-trips:

// SOLUTION A: JPQL JOIN FETCH @Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.status = :status") List<Order> findWithItems(@Param("status") OrderStatus status); // SOLUTION B: Entity Graph (declarative, avoids modifying the query) @EntityGraph(attributePaths = {"items", "items.product"}) List<Order> findByStatus(OrderStatus status);
JOIN FETCH with pagination is a trap. When you add LIMIT/OFFSET to a query that JOIN FETCHes a collection, Hibernate cannot apply the limit in SQL (because one parent row spans multiple result rows). It fetches all rows into memory and paginates there — logged as "HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory!". Fix: paginate on the parent ID, then fetch the collection in a second query, or use a @BatchSize hint.

Batch Fetching with @BatchSize

@BatchSize is a middle-ground strategy between full JOIN FETCH and pure lazy loading. Instead of issuing one SQL per association access, Hibernate groups the IDs of uninitialized proxies into batches and fetches them with a single IN (...) clause. It requires zero change to calling code.

import jakarta.persistence.*; import org.hibernate.annotations.BatchSize; import java.util.List; @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // Without @BatchSize: 1 SELECT per order. With it: 1 SELECT per 25 orders. @OneToMany(mappedBy = "order", fetch = FetchType.LAZY) @BatchSize(size = 25) private List<OrderItem> items; }

You can also apply a global default in application.properties to cover every lazy association at once:

spring.jpa.properties.hibernate.default_batch_fetch_size=25

Choosing the right size involves a trade-off: too small and you still issue many queries; too large and you load data you never need. A value between 16 and 50 is a common starting point — profile first, then tune.

JDBC Batch Inserts and Updates

When you persist or merge hundreds of entities inside one transaction, Hibernate by default sends each INSERT and UPDATE to the database individually. JDBC batching groups those statements and flushes them as a single network round-trip, which can reduce latency by an order of magnitude.

Enable it in application.properties:

# Size of each JDBC batch spring.jpa.properties.hibernate.jdbc.batch_size=50 # Order inserts/updates so rows for the same table are grouped together spring.jpa.properties.hibernate.order_inserts=true spring.jpa.properties.hibernate.order_updates=true # IMPORTANT for MySQL/MariaDB: enable rewrite of batched statements at the driver level spring.datasource.url=jdbc:mysql://localhost:3306/shop?rewriteBatchedStatements=true
IDENTITY generation breaks batching. When you use GenerationType.IDENTITY, the database assigns the primary key on INSERT and returns it immediately. Hibernate must flush each row individually to retrieve its ID — batching is silently disabled. Switch to GenerationType.SEQUENCE (with allocationSize matching your batch size) to restore it.

Example of a bulk-insert loop that benefits from JDBC batching:

import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; @Service public class BulkImportService { @PersistenceContext private EntityManager em; @Transactional public void importProducts(List<ProductDto> dtos) { int batchSize = 50; for (int i = 0; i < dtos.size(); i++) { Product p = new Product(dtos.get(i)); em.persist(p); if ((i + 1) % batchSize == 0) { em.flush(); // write this batch to the database em.clear(); // evict from first-level cache to free memory } } // flush + clear any remaining entities at the end of the transaction } }

The flush() + clear() pair is critical. Without clear(), the first-level cache grows unbounded — every entity you persist is held in memory for the lifetime of the session, and a bulk import of 100,000 rows will eventually cause an OutOfMemoryError.

Common Performance Pitfalls

  • Loading the full entity when only a few columns are needed. Use projections (interface-based or DTO-based) or JPQL SELECT new MyDto(e.id, e.name) to avoid transferring unused columns.
  • Calling findAll() on a large table. Always paginate: repository.findAll(PageRequest.of(page, size)). A table with one million rows will transfer all of them.
  • Mixing entity reads and writes in one loop. Each write triggers dirty checking on all entities in the session. Separate read-only queries (with @Transactional(readOnly = true)) from write operations.
  • Missing database indexes. Hibernate issues correct SQL; but without an index on a filtered or join column, the database performs a full table scan. Use @Index on your entity or manage indexes in migration scripts.
  • Open Session in View anti-pattern. Spring Boot enables OSIV by default (spring.jpa.open-in-view=true). OSIV keeps the Hibernate session open throughout the HTTP request, which lets lazy loading work in views — but it also ties a database connection to the entire request duration, including rendering time. Disable it in high-throughput services and load associations eagerly in the service layer instead.

Profiling with P6Spy and Datasource-Proxy

For deeper analysis in a development environment, tools like P6Spy or datasource-proxy intercept every JDBC call and report the actual SQL with bound parameters, execution time, and call count. Add P6Spy to your test/dev classpath:

<!-- pom.xml (scope: test or provided) --> <dependency> <groupId>p6spy</groupId> <artifactId>p6spy</artifactId> <version>3.9.1</version> </dependency>

Change your datasource URL prefix to jdbc:p6spy:mysql://... and add a spy.properties file. Every SQL statement then appears in the log with full parameter substitution — far more readable than Hibernate's ?-placeholder output.

Summary

Performance tuning in Hibernate is systematic, not guesswork. Enable statistics to measure first, then fix the biggest offenders: resolve N+1 problems with JOIN FETCH or @BatchSize, enable JDBC batching for bulk writes (and switch to SEQUENCE generation to let it work), avoid loading more data than you need, and disable OSIV in services that care about connection pool pressure. Profile in a realistic environment before and after each change to confirm the improvement.

ES
Edrees Salih
1 hour ago

We are still cooking the magic in the way!