Hibernate 6.6.x: Unexpected QueryImplEngine Memory Usage Causing OOM

Hi @beikov ,

We’re experiencing OOM (Out of Memory) issues on our server after upgrading from Hibernate 5.6.x to 6.6.x and have conducted a detailed heap dump analysis to identify the root cause. Our comparison between the previous version (5.6.x) and current version (6.6.x) heap dumps reveals that the QueryImplEngine class is consuming significantly more memory in 6.6.x, which was not present or problematic in the 5.6.x heap dumps. We’re wondering if this increased memory consumption by QueryImplEngine is expected behavior in 6.6.x, and whether there are any known memory optimizations, configuration changes, or query caching/compilation changes in the newer version that we should be aware of. Please refer to the attached image that shows the memory consumption differences, and since no major application code changes were made during the upgrade, we’re looking for insights into why QueryImplEngine might be consuming more memory and any potential mitigation strategies you might recommend. Any guidance would be greatly appreciated as this is currently blocking our production upgrade.

Without knowing details, it’s hard to say for sure, but you were always able to configure the query plan cache to a lower size if you run into memory pressure, though that might affect performance.
If you need further help, we need an application that reproduces the problem or an analysis from your side to be able to further look into this.

Hi @beikov ,

Regarding the Query Plan cache, the documentation indicates a default value of false in query settings, yet the QueryEngineImpl initialization uses QUERY_PLAN_CACHE_ENABLED = "hibernate.query.plan_cache_enabled", which appears to enable it by default.

In prior Hibernate versions, I don’t recall the existence of this QueryPlanCache. Could you provide the rationale behind its implementation? Also, does this feature have any potential impact on Hibernate’s performance?

The documentation should have been fixed via HHH-19208. If you think something is wrong, the please create a new Jira and kindly provide a documentation fix as PR.
The query plan cache was around for a long time. You just have to search for it in the documentation of the older Hibernate ORM versions e.g. 5.6.

Hi @beikov ,

We’ve been investigating issue that’s causing OutOfMemory errors on our server after upgrading from Hibernate 5.6.13 to 6.6.13. I’d appreciate your insights on our findings.

Background

  • Previous setup (5.6.13): Used Hibernate native APIs with legacy Criteria
  • Current setup (6.6.13): Migrated to JPA Criteria but still use native API initialization due to extensive hbm.xml usage
  • Query creation: We obtain CriteriaBuilder from Session and use Session#createQuery(CriteriaQuery)

Issue Investigation

Hibernate 5.6.13 Behavior

In 5.6.13, we confirmed that Criteria queries don’t use QueryPlanCache. Even when executing identical criteria queries multiple times, getQueryPlanCacheHitCount() and getQueryPlanCacheMissCount() remain at 0, which aligns with our understanding that QueryPlanCache is only for Native/HQL queries.

Hibernate 6.6.13 Behavior

In 6.6.13, we discovered that when using native APIs with JPA Criteria, the behavior changes significantly:

  1. When creating a TypedQuery via Session#createQuery(CriteriaQuery), it creates a QuerySqmImpl
  2. In the constructor, it checks the hibernate.criteria.copy_tree setting
  3. Key finding: When copy_tree=false (default for native APIs), the code explicitly calls setQueryPlanCacheable(true)
  4. This causes criteria queries to be cached in QueryPlanCache, leading to memory bloat
// Code from QuerySqmImpl Constructor
if ( producer.isCriteriaCopyTreeEnabled() ) {
  sqm = criteria.copy( SqmCopyContext.simpleContext() );
}
else {
  sqm = criteria;
  // Cache immutable query plans by default
  setQueryPlanCacheable( true );
}

Root Cause

Since we use native APIs, hibernate.criteria.copy_tree defaults to false. Without copying, each unique CriteriaQuery object becomes a separate cache key, causing the QueryPlanCache to grow indefinitely when creating new criteria instances.

Questions

  1. Is this intended behavior? Should criteria queries be cached when using native APIs in 6.x?
  2. Design rationale: Given that QueryPlanCache traditionally doesn’t work for Criteria queries (as confirmed in 5.6.13 and the code comments), why is caching now being enabled for criteria queries in 6.x when using hibernate native APIs? Are we missing some configuration or concept that would make this caching effective?
  3. Performance impact of copy_tree=true: If we enable hibernate.criteria.copy_tree=true to prevent the memory issue, what’s the performance impact of deep-copying criteria objects?
  4. Best practice recommendation: For applications using native APIs with JPA Criteria, what’s the recommended approach to avoid memory issues while maintaining performance?

Test Cases

5.6.13 (Legacy Criteria - No Caching)

SessionFactory sessionFactory = HibernateUtil.getSessionFactory();
Statistics statistics = sessionFactory.getStatistics();
Session session = sessionFactory.openSession();

for (int i = 0; i < 10; i++) {
    Criteria criteria = session.createCriteria(SessionInfo.class);
    criteria.add(Restrictions.eq("applicationName", "123"));
    List list = criteria.list();
    System.out.println("Size: " + list.size());
}

System.out.println("Hit count: " + statistics.getQueryPlanCacheHitCount());
System.out.println("Miss count: " + statistics.getQueryPlanCacheMissCount());

Output: Hit count: 0, Miss count: 0

6.6.13 (JPA Criteria with copy_tree=false - Caching Enabled)

Explanation: We explicitly set hibernate.criteria.copy_tree=false in persistence.xml to simulate native API behavior, even though we’re using JPA EntityManagerFactory in this test.

EntityManagerFactory emf = Persistence.createEntityManagerFactory("examplePU");
EntityManager em = emf.createEntityManager();
SessionFactory unwrap = emf.unwrap(SessionFactory.class);
Statistics statistics = unwrap.getStatistics();
HibernateCriteriaBuilder builder = unwrap.getCriteriaBuilder();
Session session = em.unwrap(Session.class);

for (int i = 0; i < 10; i++) {
    JpaCriteriaQuery<SessionInfo> query = builder.createQuery(SessionInfo.class);
    JpaRoot<SessionInfo> rootElement = query.from(SessionInfo.class);
    JpaPredicate equal = builder.equal(rootElement.get("applicationName"), "123" + i);
    query.where(equal);
    
    org.hibernate.query.Query<SessionInfo> tp = session.createQuery(query);
    List<SessionInfo> resultList = tp.getResultList();
    System.out.println("Size: " + resultList.size());
}

System.out.println("Hit count: " + statistics.getQueryPlanCacheHitCount());
System.out.println("Miss count: " + statistics.getQueryPlanCacheMissCount());

Additional Testing

We also tested the same use cases with Native Queries and HQL queries in both Hibernate versions. The QueryPlanCache worked correctly for these query types in both 5.6.13 and 6.6.13, showing proper hit/miss counts and expected caching behavior. This confirms that the issue is specifically related to Criteria queries and the hibernate.criteria.copy_tree setting behavior.

Potential Solutions We’re Considering

  1. Enable copy_tree: Set hibernate.criteria.copy_tree=true to prevent caching
  2. Shifting from Hibernate Native API to JPA Native API in next upgrade.

Any guidance on the intended behavior and recommended approach would be greatly appreciated. Thank you for your time.

Cache Behavior Clarification

Important note: The QueryPlanCache does work for criteria queries in 6.6.13, but only under specific conditions:

  • Cache HIT: When reusing the same CriteriaQuery object and TypedQuery instance multiple times
  • Cache MISS: When creating new CriteriaQuery objects each time (even with identical logic)
EntityManagerFactory emf = Persistence.createEntityManagerFactory("examplePU");
EntityManager em = emf.createEntityManager();
SessionFactory unwrap = emf.unwrap(SessionFactory.class);
Statistics statistics = unwrap.getStatistics();
HibernateCriteriaBuilder builder = unwrap.getCriteriaBuilder();
Session session = em.unwrap(Session.class);

for (int i = 0; i < 10; i++) {
    JpaCriteriaQuery<SessionInfo> query = builder.createQuery(SessionInfo.class);
    JpaRoot<SessionInfo> rootElement = query.from(SessionInfo.class);
    JpaPredicate equal = builder.equal(rootElement.get("applicationName"), "123");
    query.where(equal);
    
    org.hibernate.query.Query<SessionInfo> tp = session.createQuery(query);
    List<SessionInfo> resultList = tp.getResultList();
    System.out.println("Size: " + resultList.size());
}

System.out.println("Hit count: " + statistics.getQueryPlanCacheHitCount());
System.out.println("Miss count: " + statistics.getQueryPlanCacheMissCount());
Hit count: 0
Miss count: 10

Note: We corrected the parameter from "123" + i to "123" to ensure identical query logic. Even with the same parameter value, creating new CriteriaQuery objects each iteration results in 10 cache misses and 0 hits, demonstrating that each new CriteriaQuery object creates a separate cache entry regardless of logical equivalence.

Should criteria queries be cached when using native APIs in 6.x?

The behavior is on purpose, though I agree that caching is not going to help for cases where you always rebuild the criteria query in ORM 6.6. In Hibernate ORM 7.1+, the issues surrounding criteria query caching are fixed and 7.2 will even improve caching further: HHH-19556 Introduce SqmCacheable for query caching via #isCompatible and #cacheHashCode instead of equals/hashCode by beikov · Pull Request #10899 · hibernate/hibernate-orm · GitHub

Without copying, each unique CriteriaQuery object becomes a separate cache key, causing the QueryPlanCache to grow indefinitely when creating new criteria instances.

It can’t grow indefinitely, because the cache is bounded. You can even configure the cache size via hibernate.query.plan_cache_max_size.

If we enable hibernate.criteria.copy_tree=true to prevent the memory issue, what’s the performance impact of deep-copying criteria objects?

It’s best that you measure it yourself and if you are unhappy with it, share a benchmark or reproducer of the problems you’re facing for us to look into. Overall, I’d say that the copying is negligible compared to everything else, though I would rather suggest that you disable query plan caching for criteria queries instead by calling .setQueryPlanCacheable(false) on the query if you always rebuild the criteria query instance. Unless you upgrade to Hibernate ORM 7.1+ which implements proper caching, then you don’t need to change anything.

For applications using native APIs with JPA Criteria, what’s the recommended approach to avoid memory issues while maintaining performance?

Like I already wrote, the cache is bounded, so if you have memory issues, then that is due to the cache size not being adequate for your application and JVM configuration. If you can’t upgrade to ORM 7.1+, then you can disable query plan caching for the criteria queries by calling .setQueryPlanCacheable(false) on the built query. Enabling criteria tree copying certainly is the easiest option to disable query plan caching for now. You can easily remove that config later when you upgrade to ORM 7.1+.

You’re absolutely right about the bounded cache. However, the default size of 2048 proved too large for our use case. Before performing our action, the QueryEngineImpl (which contains the plan cache) was consuming ~250MB, but afterward it grew to ~850MB - a significant increase. Even though LRU eviction is in place, we couldn’t retrieved the actual map size from heap dumps. Given that the cache is bounded, we’re planning to reduce the size from the default 2048 to a more conservative value.

We appreciate this suggestion, but we extensively use Native and HQL queries throughout our application, and we want to preserve query plan caching for those query types since they benefit significantly from it. We’re planning to upgrade to 7.1+ early next year, so we’re looking for a interim solution.

We’re considering this option, but since we’ve already performance-tested our application with QueryPlanCache enabled, we’re leaning toward reducing the cache size as a less disruptive interim solution.

This is excellent news! We’ve already tested the criteria caching improvements in the latest stable release and can confirm it works well. We’re looking forward to leveraging these enhancements for better performance once we upgrade. Thank you to the Hibernate team for addressing this issue - it will definitely improve our criteria query performance moving forward.