DEV Community

Silver_dev
Silver_dev

Posted on

Hibernate Under the Hood. Part 7: Performance and Common Pitfalls

Performance issues are rarely the fault of Hibernate itself; more often, they are caused by its misuse. The key to success is identifying these issues before they manifest in production.

How to Find Problems Before Production

Performance issues are barely visible on a small test database with a single user, but they manifest catastrophically on real data under load. Therefore, you must start by configuring diagnostics.

1. SQL and Hibernate Statistics Logging

Enable SQL query and statistics logging to see exactly what Hibernate is sending to the database.

# Show SQL in logs (in dev environment)
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# Collect detailed statistics (number of queries, execution time)
hibernate.generate_statistics=true
logging.level.org.hibernate.stat=DEBUG
Enter fullscreen mode Exit fullscreen mode

Statistics will show the total number of queries, their execution time, and the most crucial metric — the number of JDBC calls per session.

2. Slow Query Logging

Starting from Hibernate 5.4.5, you can specify a threshold, and Hibernate will log only those queries that take longer than that.

# Log queries that take longer than 100 ms
hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS=100
Enter fullscreen mode Exit fullscreen mode

This helps quickly identify "heavy" (bottleneck) areas.

Problem #1: N+1 (and How to Fix It)

This is the most common cause of performance issues in Java applications using ORM. We have already discussed its mechanism: instead of a single query, Hibernate executes 1 + N queries.

Here are the primary treatment strategies, from most preferred to auxiliary.

Strategy 1: JOIN FETCH (or @EntityGraph)

This is the most direct and effective approach when you know in advance that the data will be needed. You explicitly instruct Hibernate to load the association in the same SQL query via a JOIN.

// In a JPQL query
@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.status = :status")
List<Order> findWithCustomerByStatus(@Param("status") Status status);
Enter fullscreen mode Exit fullscreen mode

@EntityGraph does the same thing, but declaratively at the Spring Data repository level:

@EntityGraph(attributePaths = {"customer", "items"})
List<Order> findByStatus(Status status);
Enter fullscreen mode Exit fullscreen mode
  • Pros: A single database query, maximum performance for known scenarios.
  • Cons: Works cleanly only for a single collection association per query. Attempting a JOIN FETCH on two List collections simultaneously will result in a MultipleBagFetchException or a Cartesian product.

Strategy 2: Batch Fetching (@BatchSize / default_batch_fetch_size)

This is a "safety net" and an excellent option when you cannot (or do not want to) change all queries. Hibernate continues to use lazy loading, but when you access the first entity in a "batch", it collects the IDs of the rest and loads them in a single query with an IN (...) clause.

# Global setting: Hibernate will group lazy loads into batches of 50
spring.jpa.properties.hibernate.default_batch_fetch_size=50
Enter fullscreen mode Exit fullscreen mode

Or targeted on a specific collection: @BatchSize(size = 25).

  • Pros: Does not require query modifications, easy to enable globally, works excellently as a "safety net" against accidental N+1.
  • Cons: Does not eliminate N+1 completely, but reduces the number of queries from N to N / batchSize. Several database round-trips remain.

Strategy 3: DTO Projections

If you only need a few fields of the entity for reading, do not load the entire entity. Write a query that returns a DTO directly (e.g., a Java record).

@Query("SELECT new com.example.dto.OrderSummary(o.id, o.status, c.name) " +
       "FROM Order o JOIN o.customer c WHERE o.status = :status")
List<OrderSummary> findSummariesByStatus(@Param("status") Status status);
Enter fullscreen mode Exit fullscreen mode
  • Pros: The fastest and safest approach for read-only endpoints. You load exactly the data you need, avoid creating managed entities, and completely eliminate N+1 and dirty checking.
  • Cons: Suitable only for reading; update operations require managed entities.
  • Selection hierarchy: Ideally, choose the strategy consciously for each access scenario. In practice, a combination is often used: JOIN FETCH for known "hot" paths and default_batch_fetch_size as a global safety net.

Problem #2: The Open Session in View (OSIV) Anti-Pattern

Spring Boot enables spring.jpa.open-in-view=true by default. This means the Hibernate session remains open for the entire HTTP request, including response rendering or JSON serialization.

Why this is dangerous:

  • Hides N+1: When Jackson serializes an object and triggers a lazy collection, Hibernate silently executes a query. You might not even suspect this until you check the logs.
  • Unpredictable queries: Database queries can be executed during the serialization phase, already after the transaction has been closed.
  • Long-held connections: Although OSIV does not hold the physical connection open the entire time, it allows queries to be executed at any point in the request lifecycle, which can lead to unpredictable connection pool usage.

Solution: Always disable OSIV in production.

spring.jpa.open-in-view=false
Enter fullscreen mode Exit fullscreen mode

After disabling it, any lazy loading issues will become explicit (LazyInitializationException), forcing you to solve them correctly — via JOIN FETCH, @EntityGraph, or DTOs — rather than sweeping them under the rug.

Problem #3: Slow Batching (Bulk Inserts/Updates)

If you save 1,000 entities via saveAll(), Hibernate will execute 1,000 separate INSERT statements by default, which can take minutes. Batching allows you to group them into a few packets.

Configuration to enable batching:

spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
spring.jpa.properties.hibernate.jdbc.batch_versioned_data=true
Enter fullscreen mode Exit fullscreen mode
  • batch_size: the number of statements in a single batch (optimal is 20–50).
  • order_inserts / order_updates: group similar operations together, which is necessary for effective batching.

Critical Pitfall: GenerationType.IDENTITY

If your entity uses @GeneratedValue(strategy = GenerationType.IDENTITY), batching will not work. Hibernate is forced to execute each INSERT individually to immediately obtain the database-generated ID. Use GenerationType.SEQUENCE instead. Hibernate can pre-allocate a block of IDs from the sequence and then execute all inserts in a single batch.

Summary

  1. Hibernate performance issues are almost always a consequence of its misuse, not its internal flaws.
  2. Diagnostics come first. Enable show-sql and generate_statistics in the dev environment to see the actual number and execution time of queries.
  3. N+1 is enemy #1. Treat it with JOIN FETCH (for known scenarios) or @BatchSize (as a global safety net). For read-only endpoints, DTO projections are the cleanest and fastest solution.
  4. open-in-view is an anti-pattern for REST APIs. Disable it (spring.jpa.open-in-view=false). This will make lazy loading issues explicit and force you to solve them correctly.
  5. Batching does not work with IDENTITY. Use GenerationType.SEQUENCE for efficient bulk inserts.
  6. Consciously choose the fetching strategy for each scenario. LAZY is the correct default, but it requires explicit management via JOIN FETCH, @EntityGraph, or DTOs, especially when OSIV is disabled.

Top comments (0)