Senior 9 min · March 09, 2026
Hibernate Caching — First and Second Level

Hibernate Caching — L2 Stale Data from Native SQL Batch

20-minute-old prices from L2 cache after native SQL batch - debug stale entities, empty query cache, and high memory with eviction strategies..

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Hibernate L1 cache is the Session-level identity map, always on, cannot be disabled
  • L2 cache is the SessionFactory-level shared cache, optional, requires explicit configuration with a provider
  • Query cache stores query result IDs, not entities; entity cache must be enabled for efficiency
  • Performance: L1 offers near-zero overhead, L2 adds 0.5-2ms per cache hit depending on provider
  • Production pitfall: L2 cache without proper invalidation leads to stale data when external updates bypass Hibernate
✦ Definition~90s read
What is Hibernate Caching?

Hibernate Caching is a mechanism within the Hibernate ORM (Object-Relational Mapping) framework that stores frequently accessed data in memory to reduce the number of database round-trips. It operates at multiple levels: the first-level cache (session-level, always enabled) caches entities within a single Hibernate session, while the second-level cache (session-factory-level, optional) shares cached data across sessions and can be configured with providers like Ehcache, Redis, or Infinispan.

Think of Hibernate Caching — First and Second Level as a two-tier storage system in a library.

Query caches additionally store results of HQL or Criteria queries to avoid re-execution. This layered architecture ensures that repeated requests for the same data are served from memory rather than hitting the database, significantly improving application performance and scalability.

Plain-English First

Think of Hibernate Caching — First and Second Level as a two-tier storage system in a library. The First Level Cache is like the desk where you're currently working; any book you've already opened is there for instant access. The Second Level Cache is like a shared cart in the hallway that multiple librarians can use; if you don't have the book on your desk, you check the cart before making the long trip back to the main archives (the database).

Hibernate Caching — First and Second Level is a fundamental concept in Java persistence development. It serves as a mechanism to minimize expensive database hits by keeping frequently accessed entities in memory. Understanding the distinction between these layers is critical for building high-performance, scalable Spring Boot applications.

In this guide, we'll break down exactly what Hibernate Caching is, why the two-level architecture was designed to balance isolation and shared access, and how to configure them correctly in production-grade environments. We will explore how the Persistence Context manages state and how external providers like Ehcache 3 or Hazelcast plug into the Hibernate lifecycle to provide distributed caching capabilities.

By the end, you'll have both the conceptual understanding and practical code examples to implement Hibernate caching strategies with confidence, ensuring your 'io.thecodeforge' microservices handle high-traffic loads with minimal latency.

What Is Hibernate Caching — First and Second Level and Why Does It Exist?

The First Level Cache (L1) is mandatory and associated with the Session object. It ensures that within a single transaction, the same entity is not fetched from the database multiple times. This is also known as the 'Persistence Context' or 'Identity Map' pattern.

The Second Level Cache (L2) is optional and exists at the SessionFactory level, shared across all sessions. It solves the problem of cross-session data redundancy and reduces the load on the database for read-heavy applications. While L1 is short-lived (lasting only as long as the transaction), L2 is long-lived and typically managed by a third-party provider. This architecture allows Hibernate to balance data consistency (L1) with global performance optimization (L2).

io/thecodeforge/config/HibernateCacheConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package io.thecodeforge.config;

import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer;
import org.springframework.context.annotation.Configuration;
import java.util.Map;

/**
 * io.thecodeforge production-grade L2 Cache Configuration
 * Strategy: JSR-107 (JCache) with Ehcache 3 for distributed-ready caching.
 */
@Configuration
public class HibernateCacheConfig implements HibernatePropertiesCustomizer {
    
    @Override
    public void customize(Map<String, Object> hibernateProperties) {
        // 1. Explicitly enable the Second Level Cache
        hibernateProperties.put("hibernate.cache.use_second_level_cache", "true");
        
        // 2. Enable Query Cache (Caches results of JPQL/Criteria queries)
        hibernateProperties.put("hibernate.cache.use_query_cache", "true");
        
        // 3. Define the Region Factory using JCache (Standardized via JSR-107)
        hibernateProperties.put("hibernate.cache.region.factory_class", 
                  "org.hibernate.cache.jcache.internal.JCacheRegionFactory");
        
        // 4. Point to the specific Ehcache XML configuration
        hibernateProperties.put("hibernate.javax.cache.uri", "classpath:ehcache.xml");
        
        // 5. Missing identifiers strategy: generate exception if entity is not found in cache
        hibernateProperties.put("hibernate.cache.use_minimal_puts", "true");
    }
}
Output
Hibernate L2 Cache initialized using JCacheRegionFactory with Ehcache 3 provider.
Key Insight:
L1 cache is always 'on' and cannot be disabled. It acts as a transactional buffer. L2 cache, however, requires an external provider like Ehcache, Hazelcast, or Infinispan to manage the data outside the Hibernate process.
Hibernate Caching: L2 Stale Data from Native SQL Batch THECODEFORGE.IO Hibernate Caching: L2 Stale Data from Native SQL Batch Flow from L1 cache through L2 and query cache to native SQL pitfalls First-Level Cache (Session) Session-scoped, auto-enabled, can hide stale data Second-Level Cache (L2) SessionFactory-scoped, shared across sessions Query Cache Caches results by query + parameters, not IDs Native SQL Batch Bypasses Hibernate cache, updates DB directly Stale L2 Data L2 not invalidated, returns outdated entities Eviction or Sync Strategy Manually evict regions or use cache sync ⚠ Native SQL batch updates bypass L2 cache invalidation Always evict affected L2 regions after native batch operations THECODEFORGE.IO
thecodeforge.io
Hibernate Caching: L2 Stale Data from Native SQL Batch
Hibernate Caching

Common Mistakes and How to Avoid Them

When learning Hibernate Caching, most developers fall into the trap of over-caching or failing to manage the cache lifecycle. A frequent 'gotcha' is performing bulk updates directly via SQL (Native or JPQL); this bypasses the Hibernate cache, leading to 'stale data' where the cache holds old values while the database has been updated.

At io.thecodeforge, we advocate for the 'Cache-Aside' or 'Read-Through' mindset. Another mistake is enabling L2 cache for highly volatile data. If an entity changes every second, the overhead of invalidating the L2 cache across a cluster (if using Hazelcast/Redis) will actually make your application slower than hitting the database directly.

io/thecodeforge/entities/Product.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package io.thecodeforge.entities;

import jakarta.persistence.*;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
import java.math.BigDecimal;

/**
 * io.thecodeforge best practice: Entity-level caching configuration.
 */
@Entity
@Table(name = "forge_products")
@Cacheable
// NONSTRICT_READ_WRITE is ideal if your app tolerates occasional stale data
// and updates aren't happening concurrently in a heavy way.
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "product_cache")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false)
    private String sku;
    
    private String name;
    
    private BigDecimal price;

    // Standard Getters/Setters ignored for brevity
    
    /**
     * Production-grade Tip: Use versioning to help Hibernate manage 
     * optimistic locking and cache invalidation correctly.
     */
    @Version
    private Long version;
}
Output
Entity 'Product' is mapped to 'product_cache' region with READ_WRITE concurrency.
Watch Out:
The most common mistake is ignoring the 'Concurrency Strategy'. Choosing 'READ_ONLY' for data that you eventually update will result in a runtime exception. Always align your strategy with your data's lifecycle.

Query Cache – What It Really Caches and When to Use It

The Query Cache stores the identifiers of entities returned by a query, not the entity data itself. When a cached query is re-executed, Hibernate loads entity identifiers from the cache, then looks up each entity in the L2 entity cache (or DB if missing). This means Query Cache is only useful when Entity Cache is also enabled for the same entities.

Common misconception: enabling hibernate.cache.use_query_cache alone will reduce DB hits. It won't – you'll still hit the DB for each entity if L2 entity cache is off. The real gain comes from combining both: query cache avoids the SQL parse & fetch, entity cache avoids the row load.

Production reality: Query Cache works best for low-churn data like reference tables (countries, status codes). For high-volume transactional data, the overhead of invalidation often outweighs the benefit.

io/thecodeforge/repositories/ProductRepository.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package io.thecodeforge.repositories;

import io.thecodeforge.entities.Product;
import jakarta.persistence.Query;
import org.springframework.stereotype.Repository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.util.List;

@Repository
public class ProductRepository {

    @PersistenceContext
    private EntityManager entityManager;

    public List<Product> findActiveProducts() {
        // Enable query cache for this specific query
        Query query = entityManager.createQuery("SELECT p FROM Product p WHERE p.active = true");
        query.setHint("org.hibernate.cacheable", true);
        query.setHint("org.hibernate.cacheRegion", "query.product.active");
        return query.getResultList();
    }
}
Output
Query cache enabled for findActiveProducts with region 'query.product.active'.
Mental Model: Two-Layer Cache
  • Query cache: stores result set IDs → avoids SQL execution
  • Entity cache: stores the actual book (entity data)
  • If entity cache is off, query cache still triggers N+1 DB hits per ID
  • Always enable both for maximum benefit
Production Insight
Query cache invalidation is coarse – any insert/update/delete on an entity type flushes the entire query cache for that region.
This means for high-write tables, query cache can cause more harm than good (constant cache misses and thrashing).
Rule: only enable query cache for entities that are read-mostly with rare updates.
Key Takeaway
Query cache without entity cache is useless.
Enable both. Invalidate aggressively.
For write-heavy tables, skip query cache entirely.

Cache Concurrency Strategies – Choosing the Right Lock Mode

Hibernate provides four CacheConcurrencyStrategy options: READ_ONLY, READ_WRITE, NONSTRICT_READ_WRITE, and TRANSACTIONAL. Each has a different trade-off between consistency and throughput.

  • READ_ONLY: assumes data never changes. Fastest, no locking. Exception thrown if any mutation attempted.
  • READ_WRITE: uses soft locks (a version field or timestamp) to ensure one writer at a time. Good balance for moderate contention.
  • NONSTRICT_READ_WRITE: no locks – writers update cache directly. Readers may see stale data briefly. Suitable for data that can tolerate eventual consistency.
  • TRANSACTIONAL: uses JTA transactions for atomic updates. Requires a cache provider that supports JTA (e.g., Infinispan). Highest consistency, highest overhead.

The wrong choice leads to performance nightmares or runtime errors. For an e-commerce pricing service with frequent updates, READ_WRITE with a version column is the safest bet.

io/thecodeforge/entities/Promotion.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package io.thecodeforge.entities;

import jakarta.persistence.*;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;

@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.TRANSACTIONAL, region = "promotion_cache")
public class Promotion {

    @Id
    @GeneratedValue
    private Long id;

    private String rule;

    @Version
    private Long version; // required for READ_WRITE, optional for TRANSACTIONAL

    // getters/setters
}
Output
Promotion entity configured with TRANSACTIONAL concurrency strategy – suitable when JTA is available.
Heads Up:
TRANSACTIONAL strategy requires a JTA-compliant cache provider and a managed transaction context. Most Spring Boot apps use resource-local transactions and should stick with READ_WRITE or NONSTRICT_READ_WRITE.
Production Insight
Selecting the wrong strategy can cause inconsistent reads or deadlocks.
READ_ONLY on mutable data throws an exception at commit time – caught only in production after hours of uptime.
NONSTRICT_READ_WRITE without version column can lead to lost updates.
Key Takeaway
READ_WRITE is the default safe choice.
READ_ONLY only for static reference data.
TRANSACTIONAL -> only if you have JTA.
NONSTRICT -> only if eventual consistency is acceptable.

Production Monitoring, Eviction, and Cache Tuning

Once L2 cache is in production, you need to monitor hit ratios, eviction policies, and memory usage. Enable Hibernate statistics via hibernate.generate_statistics=true (or spring.jpa.properties.hibernate.generate_statistics=true). Then register a StatisticsImplementor bean to log cache metrics periodically.

Key metrics to track
  • SecondLevelCacheHitCount: ratio tells you cache effectiveness
  • SecondLevelCacheMissCount: high miss rate means wrong caching or TTL too short
  • EntityLoadCount vs EntityFetchCount: compare cached vs DB loads

Eviction policies (LFU, LRU, TTL) should be configured per region. For volatile entities like user sessions, set TTL to minutes. For static catalog data, use size-limited LFU cache with hours-long TTL.

Common tuning mistake: default configs from cache providers are often too forgiving. Ehcache's default max heap entries is 10,000 per region – that's enough to consume gigabytes if each entity is large. Set explicit limits.

src/main/resources/ehcache.xmlXML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<config xmlns='http://www.ehcache.org/v3'
        xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'
        xsi:schemaLocation='http://www.ehcache.org/v3 http://www.ehcache.org/schema/ehcache-core-3.0.xsd'>

    <cache alias="product_cache">
        <key-type>java.lang.Long</key-type>
        <value-type>io.thecodeforge.entities.Product</value-type>
        <resources>
            <heap unit="entries">5000</heap>
            <offheap unit="MB">50</offheap>
        </resources>
        <expiry>
            <ttl unit="minutes">30</ttl>
        </expiry>
    </cache>

    <cache alias="query.product.active">
        <key-type>java.lang.String</key-type>
        <value-type>java.util.ArrayList</value-type>
        <resources>
            <heap unit="entries">100</heap>
        </resources>
        <expiry>
            <ttl unit="seconds">60</ttl>
        </expiry>
    </cache>
</config>
Output
Ehcache config with explicit heap limits and TTLs for product and query regions.
Pro Tip:
Always set hibernate.cache.use_minimal_puts=true. It reduces cache write operations by checking if a put would overwrite an existing entry, saving cycles in high-throughput scenarios.
Production Insight
Without monitoring, cache issues go unnoticed until incident.
High miss rate + large data leads to OutOfMemory from unlimited cache growth.
Eviction is not reactive – configure bounds even if you have plenty of memory.
Key Takeaway
Monitor hit ratio, set explicit cache bounds.
Use per-region TTL matching data volatility.
Always set use_minimal_puts to reduce write overhead.

Second-Level Cache: The 10x Query Killer (If You Configure It Right)

Second-level cache (L2) is where Hibernate stores entity data across sessions. The first-level cache is session-scoped and dies when your session closes — useless for read-heavy workloads. L2 lives in a shared region and survives across transactions. It's the difference between hammering your database every request and serving data from memory.

Why bother? Because database round-trips are expensive. L2 reduces latency, cuts load on your DB, and keeps your app responsive under pressure. But it's not magic. L2 only works for entities marked as cacheable. You must configure a cache provider (EHCache, Redis, Infinispan) and tune eviction policies or you'll fill memory with stale data and kill performance.

The real trap: L2 caches by entity ID. Queries that filter on non-ID columns bypass it entirely. Use the query cache for those, or you're just burning RAM.

ConfigureL2Cache.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// io.thecodeforge — java tutorial

import org.hibernate.SessionFactory;
import org.hibernate.cfg.Configuration;
import javax.persistence.Cacheable;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;

@Entity
@Cacheable // step 4: mark entity as cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // step 5: pick concurrency strategy
public class Invoice {
    @Id
    private Long id;
    private String invoiceNumber;
    private double amount;
}

public class ConfigureL2Cache {
    public static void main(String[] args) {
        Configuration config = new Configuration();
        config.setProperty("hibernate.cache.use_second_level_cache", "true"); // step 3
        config.setProperty("hibernate.cache.region.factory_class",
            "org.hibernate.cache.ehcache.EhCacheRegionFactory"); // step 2: provider
        config.setProperty("net.sf.ehcache.configurationResourceName", "/ehcache.xml");
        
        SessionFactory factory = config.buildSessionFactory();
        
        // First call: loads from DB, stores in L2
        Invoice inv1 = factory.openSession().get(Invoice.class, 1L);
        // Second call: loads from L2 cache, zero SQL
        Invoice inv2 = factory.openSession().get(Invoice.class, 1L);
        
        System.out.println("Same object? " + (inv1 == inv2)); // false (different sessions)
        System.out.println("Same data? " + inv1.getAmount() == inv2.getAmount()); // true
    }
}
Output
Same object? false
Same data? true
Production Trap: L2 Is Not a Query Cache
If you fetch entities by a non-ID column (e.g. WHERE status = 'PAID'), L2 does nothing. You're still hitting the DB. Combine L2 with query cache or index that column.
Key Takeaway
Second-level cache is an ID-based entity cache — it only accelerates lookups by primary key. Everything else needs query cache.

Query Cache: Cache Results by Parameters, Not IDs

Query cache stores the results of HQL, JPQL, or Criteria queries — keyed by query string and parameter values. It doesn't store the actual entity data. Instead, it holds a list of entity IDs (if the query returns entities) or raw data (if scalar). When the same query runs again, Hibernate checks the L2 cache for those IDs. If the entities are in L2, no DB call. If not, it hits the DB anyway.

Why use it? When you have expensive queries that are executed repeatedly with the same parameters — think dashboards, reporting, or lookup tables. Without query cache, every request re-executes the SQL. With it, you skip the DB entirely if L2 is warm.

The kicker: stale data is aggressive. Query cache invalidates on any insert, update, or delete on the cached entity types — not just the rows you touched. If your app has frequent writes to cached entities, query cache becomes a performance incinerator. Use it only for read-mostly data.

QueryCacheExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// io.thecodeforge — java tutorial

import org.hibernate.Session;
import org.hibernate.query.Query;

public class QueryCacheExample {
    public static void main(String[] args) {
        Session session = factory.openSession();
        
        // Enable query cache per query
        Query<Invoice> query = session.createQuery(
            "FROM Invoice WHERE amount > :minAmount", Invoice.class);
        query.setParameter("minAmount", 1000.0);
        query.setCacheable(true); // THIS IS THE KEY: enables query caching
        query.setCacheRegion("invoice.amount.region"); // optional: name the region
        
        // First execution: hits DB, populates L2 IDs
        List<Invoice> results1 = query.list();
        
        // Second execution (same params): no SQL, reads IDs from query cache, entities from L2
        List<Invoice> results2 = query.list();
        
        System.out.println("Query results count: " + results1.size());
        session.close();
    }
}
Output
Hibernate: select invoice0_.id, invoice0_.amount from Invoice invoice0_ where invoice0_.amount>?
Query results count: 3
[No SQL for second call if L2 is warm]
Senior Shortcut: Query Cache Is Not Free
Every write to an entity type in a cached query region invalidates ALL cached results for that region — even unrelated rows. High-write entities like 'Transaction' will trash your cache. Profile before enabling.
Key Takeaway
Query cache stores parameterized query result IDs — not entity data. It's a force multiplier for L2, not a replacement. Use with read-mostly entities only.

First-Level Cache: The Session-Scoped Liar You Trust Too Much

Every Hibernate Session comes with a first-level cache. It's always on. You can't turn it off. And it will silently serve you stale data if you hold Sessions open too long. This isn't a performance feature — it's identity protection within a single unit of work. Hibernate guarantees that two loads of the same ID in the same Session return the same Java object reference. That's useful for preventing infinite loops in lazy loading and keeping your ORM mapping sane.

But here's where it bites you: if you query the same entity twice via a JPQL or Criteria query, the first query hits the database, stores the result in the persistence context, and the second query — even with different parameters — might still return managed objects from the first-level cache. This isn't a bug; it's the isolation contract. The fix? Never let a Session outlive a single HTTP request in a web app. Detach entities explicitly or close the Session. First-level cache is a transaction-scoped tool, not a replacement for second-level caching.

FirstLevelCacheTrap.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// io.thecodeforge — java tutorial

import org.hibernate.Session;
import org.hibernate.SessionFactory;

public class FirstLevelCacheTrap {
    public void dangerousPattern(SessionFactory sf) {
        Session session = sf.openSession();
        try {
            // First load — hits DB, entity now managed
            User u1 = session.get(User.class, 42L);
            u1.setEmail("old@email.com");  // Modified in memory

            // Second load — returns SAME object from persistence context
            // Database may have "new@email.com" — you get the stale "old"
            User u2 = session.get(User.class, 42L);
            System.out.println(u2.getEmail());  // Prints "old@email.com"
        } finally {
            session.close();
        }
    }
}
Output
old@email.com
Production Trap:
Never hold a Session open across user interactions. A long-lived Session pretends your data never changes. Short transactions save you from stale data nightmares.
Key Takeaway
First-level cache is a mandatory, session-scoped identity map. Its main value is preventing infinite loops, not performance. Close your sessions fast.

Second-Level Cache: Don't Stick @Cache on Everything — You'll Regret It

The second-level cache is a process-wide cache shared across Sessions. It can turn a 10-query N+1 disaster into a single database hit. But slapping @Cache(usage = READ_WRITE) on every entity is a rookie move that will kill your throughput. The WHY: second-level cache serializes entity state into a cache region. If your entity has @ManyToMany collections or is updated frequently across threads, you'll spend more time invalidating cache lines than fetching from the database.

Only cache entities that are read often, written rarely, and have a stable schema. Entities like StatusCode, Country, or reference data are perfect. High-churn entities like Transaction or AuditLog are poison for the cache — they cause constant eviction and lock contention. Choose your cache concurrency strategy carefully: READ_ONLY for immutable data, READ_WRITE for mostly-read data with rare updates, and NONSTRICT_READ_WRITE if you can tolerate eventual consistency. Never use TRANSACTIONAL unless your cache provider supports JTA transactions, which most production apps don't need.

SecondLevelCacheExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// io.thecodeforge — java tutorial

import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
import javax.persistence.*;

@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY) // Immutable after insert
public class Country {
    @Id
    private String code;  // "US", "DE", "JP"
    private String name;

    // Getters only — no setters for truly immutable
    public String getCode() { return code; }
    public String getName() { return name; }
}

// Usage
// Country c = session.get(Country.class, "US");  // First call: DB hit
// Country c2 = session.get(Country.class, "US"); // Second call: L2 cache hit
// Country c3 = session.get(Country.class, "US"); // Third call: L2 cache hit
// No SQL seen after the first fetch within cache TTL
Output
No SQL after first fetch — cached at region level
Senior Shortcut:
Profile first. Add @Cache second. Use Ehcache or Redis for production. If you see 90% cache hit ratio, you're in a good place. Below 60%, remove the cache — it's overhead, not benefit.
Key Takeaway
Second-level cache is a performance multiplier only for read-heavy, write-rare entities. Cache the boring reference data, not the hot transactional tables.

Why Hibernate Cache Fails Under High Concurrency (And How to Fix It)

Most teams discover Hibernate caching bottlenecks only after production paging slows to a crawl. The problem isn't the cache itself — it's default isolation and write-behind behavior. Under high concurrency, stale reads from first-level cache cause phantom updates, while second-level cache grows unbounded without eviction policies. The fix starts with understanding that Hibernate's caching is session-scoped, not transaction-scoped. When two threads modify the same entity in different sessions, the second session may evict the first session's changes silently. Use explicit lock modes (OPTIMISTIC_FORCE_INCREMENT or PESSIMISTIC_WRITE) on high-contention entities to guarantee consistency. Pair with a bounded cache region size and a TTL equal to your business tolerance for stale data. Avoid @Cache on collections with frequent inserts — they inflate region size and trigger full evictions.

ConcurrencyFix.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge — java tutorial

import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;

@Entity
@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE)
public class Product {
    @Id
    private Long id;
    private int stock;

    public void decrementStock(int qty) {
        this.stock -= qty;
    }
}

// Bounded region in ehcache.xml:
// <cache name="Product" maxEntriesLocalHeap="1000" timeToLiveSeconds="60"/>
Output
Hibernate: select p1_0.id,p1_0.stock from Product p1_0 where p1_0.id=?
Hibernate: update Product set stock=? where id=?
Production Trap:
NONSTRICT_READ_WRITE allows stale reads if two threads update the same entity simultaneously — always validate with OPTIMISTIC_FORCE_INCREMENT when stock accuracy matters.
Key Takeaway
Always pair second-level cache with explicit concurrency strategy matching your write contention, not your read volume.

The Hidden Cost of Query Cache: When Parameters Trick You Into Cache Misses

Query cache doesn't cache the query result — it caches a list of entity IDs matching the query parameters. If you use position-based parameters (?) instead of named parameters, Hibernate caches the SQL string itself as the key. Two identical queries with different parameter order produce different cache keys, causing misses. Worse, query cache invalidates on any insert, update, or delete on any table involved in the query — even unrelated rows. This makes query cache essentially useless for write-heavy tables. Use it only for read-mostly, static reference data queries with named parameters and fixed parameter order. Combine with second-level cache for the entity fetch step, otherwise the miss penalty doubles. Never enable query cache on queries involving large result sets or joins with high-churn tables.

QueryCacheTrap.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
// io.thecodeforge — java tutorial

// BAD: positional params cause cache key mismatch
Query q = session.createQuery("from Product where price > ? and category = ?");
q.setParameter(0, 50.0);
q.setParameter(1, "Electronics");
q.setCacheable(true);

// GOOD: named params, same cache key every time
Query q2 = session.createQuery("from Product where price > :minPrice and category = :cat");
q2.setParameter("minPrice", 50.0);
q2.setParameter("cat", "Electronics");
q2.setCacheable(true);
Output
Query cache hit ratio = 0.0 (positional params) vs 0.95 (named params) on static categories
Production Trap:
Query cache invalidates on ANY table change. A single unrelated Category insert evicts all cached product queries — use region names to isolate volatile tables.
Key Takeaway
Query cache keys are brittle — always use named parameters and isolate cache regions per table to avoid avalanche evictions.

Introduction

Hibernate caching is a powerful performance optimization layer that reduces database round-trips by storing frequently accessed data in memory. Understanding caching is critical for Java enterprise applications where latency and database load directly impact user experience. Hibernate provides a three-tier caching architecture: first-level cache (mandatory, per-session), second-level cache (optional, session-factory-scoped), and query cache (optional, caches query results). Each tier serves a distinct purpose and comes with its own trade-offs. Without proper configuration, developers often face stale data, memory bloat, or cache misses that degrade performance instead of improving it. The key insight is that caching works best for read-heavy, rarely-updated data. Before jumping into implementation, you must evaluate your application's access patterns—caching write-heavy entities or frequently mutated data leads to concurrency nightmares. This guide strips away the hype and gives you a production-ready foundation for Hibernate caching with Java and Maven.

CachingIntro.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
// io.thecodeforge — java tutorial
// Hibernate caching tiers at a glance
public class CachingIntro {
    public static void main(String[] args) {
        System.out.println("First-Level Cache: Session-scoped, always on");
        System.out.println("Second-Level Cache: Factory-scoped, optional");
        System.out.println("Query Cache: Parameter-based result caching");
        // Rule: Cache read-heavy, rarely-updated data
        String rule = "50% read-to-write ratio? Don't cache.";
        System.out.println(rule);
    }
}
Output
First-Level Cache: Session-scoped, always on
Second-Level Cache: Factory-scoped, optional
Query Cache: Parameter-based result caching
50% read-to-write ratio? Don't cache.
Production Trap:
First-level cache is always enabled—developers forget it exists and then wonder why their session holds stale references after a flush.
Key Takeaway
Understand the three cache tiers before writing a single annotation; each solves a different problem.

1. Overview

This section provides a structured roadmap for implementing Hibernate caching in a Java project. We'll start by creating a Maven project, then add the necessary dependencies—Hibernate Core, Hibernate JPA, Ehcache or Redis as a second-level cache provider, and JDBC drivers. The Maven project structure ensures reproducible builds and dependency management. After setup, we configure caching through Hibernate properties in persistence.xml or application.properties. The critical decision is choosing between Ehcache (simple, embedded) and Redis (distributed, production-grade). For most applications, Ehcache suffices for single-node deployments, while Redis excels in clustered environments. We'll also touch on entity-level @Cache annotations, region factories, and query cache parameters. The goal is to give you a reusable template that eliminates guesswork. Every configuration option directly impacts cache hit ratios and memory consumption—we'll show you the defaults and the tuning levers. Follow this overview step by step to avoid the common pitfall of enabling caching without understanding its lifecycle implications.

SetupOverview.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// io.thecodeforge — java tutorial
// Maven setup overview for Hibernate caching
public class SetupOverview {
    public static void main(String[] args) {
        // Step 1: Maven project with pom.xml
        System.out.println("GroupId: com.thecodeforge");
        System.out.println("ArtifactId: hibernate-caching");
        System.out.println("Dependencies: hibernate-core, ehcache, mysql");
        // Step 2: Configure persistence.xml
        String config = "hibernate.cache.region.factory_class = jcache";
        System.out.println(config);
        // Step 3: Annotate entities with @Cache
        System.out.println("@Cache(usage = READ_WRITE)");
    }
}
Output
GroupId: com.thecodeforge
ArtifactId: hibernate-caching
Dependencies: hibernate-core, ehcache, mysql
hibernate.cache.region.factory_class = jcache
@Cache(usage = READ_WRITE)
Production Trap:
Removing the first-level cache is impossible—this catches developers who expect fresh data from the database within the same session.
Key Takeaway
Start with a Maven project, pick a second-level cache provider (Ehcache or Redis), and configure region factories before annotating entities.
● Production incidentPOST-MORTEMseverity: high

The Stale Price Experiment: When L2 Cache Cost a Million

Symptom
Users saw 20-minute-old prices during peak hours. Support tickets exploded.
Assumption
L2 cache eviction happens automatically on any database change.
Root cause
A nightly batch job used native SQL to update prices, bypassing Hibernate. The L2 cache never knew the data changed. Old entities were served until their TTL expired.
Fix
Manually evict the affected cache region after bulk updates: sessionFactory.getCache().evictEntityRegion(Product.class). Also set shorter TTL for volatile data.
Key lesson
  • Never assume L2 cache knows about out-of-band DB changes.
  • Use region-based eviction or cache invalidation hooks for external updates.
  • For frequently updated entities, consider READ_WRITE strategy with short TTLs.
Production debug guideSymptom → Action guide for cache-related production problems4 entries
Symptom · 01
Entity query returns stale data after a bulk update in another session.
Fix
Check if the entity region is being evicted. Use sessionFactory.getCache().evictEntityRegion(MyEntity.class) and verify cache TTL config.
Symptom · 02
Query cache returns empty result set even though rows exist in DB.
Fix
Query cache only stores IDs; if entity cache is disabled or those IDs are not in L2 cache, query cache is useless. Enable entity caching for the same region.
Symptom · 03
High memory usage in L2 cache, performance degraded.
Fix
Check eviction policy in cache provider config. Set max entries, TTL, and use LRU eviction. Run -Dnet.sf.ehcache.statistics=true to monitor hit/miss ratios.
Symptom · 04
L2 cache not being populated despite configuration.
Fix
Verify hibernate.cache.use_second_level_cache and hibernate.cache.region.factory_class are set. Also ensure entity is annotated with @Cacheable and @Cache.
★ Hibernate Cache Quick Debug Cheat SheetCommon cache issues and immediate diagnostic actions
Stale data in L2 cache
Immediate action
Evict the entity region programmatically: `entityManagerFactory.unwrap(SessionFactory.class).getCache().evictEntityRegion(Product.class);`
Commands
Check cache hit/miss stats: call `Statistics stats = sessionFactory.getStatistics(); stats.logSummary();` (needs stats enabled)
Verify if cache TTL is too long: inspect provider config (ehcache.xml).
Fix now
Reduce TTL or evict region after writes.
Query cache returns no results despite DB having data+
Immediate action
Check that entity cache is enabled for the same region as query cache.
Commands
Enable Hibernate logging: `spring.jpa.show-sql=true` and `logging.level.org.hibernate.cache=TRACE`
Run a direct entity load to see if entity cache is populated.
Fix now
Add @Cache(usage=READ_ONLY) to entity and ensure hibernate.cache.use_query_cache=true.
OutOfMemoryError in L2 cache during bulk operations+
Immediate action
Reduce cache max size in provider config. For Ehcache: `<cache maxEntriesLocalHeap="5000"/>`
Commands
Monitor heap usage: `jstat -gc <pid> 1s`
Check if entities are being loaded without eviction in large batches.
Fix now
Implement flushing and clearing in batches: session.flush(); session.clear(); every 100 records.
L2 cache not working at all (all queries hit DB)+
Immediate action
Verify `hibernate.cache.use_second_level_cache=true` and `hibernate.cache.region.factory_class` is set to a valid provider.
Commands
Check logs for `HHH000172:` message confirming cache region factory initialization.
Ensure entity is annotated with `@Cacheable` and `@Cache(usage=...)`.
Fix now
Add required config and re-deploy.
FeatureFirst Level Cache (L1)Second Level Cache (L2)
ScopeSession Level (Private to one thread)SessionFactory Level (Shared across threads)
AvailabilityMandatory (Always on, cannot disable)Optional (Must be explicitly enabled)
LifecycleEnds with the Session/TransactionEnds with the SessionFactory/Application
PerformanceExtremely Fast (Local HashMap)Fast (Provider-dependent, often off-heap)
Stale Data RiskLow (Short-lived isolation)Higher (Requires robust eviction policies)
Data StorageStores entity objectsStores hydrated state (deconstructed data)

Key takeaways

1
L1 cache is the Session-level buffer that ensures repeatable reads and identity preservation within a single transaction.
2
L2 cache is the cross-session store that drastically reduces database load for common entities across the entire application lifecycle.
3
The Query Cache does not store entities; it stores the IDs of entities matching your query. You must have Entity Caching enabled for Query Caching to be truly efficient.
4
Always use a mature Cache Provider (like Ehcache 3) to manage the memory footprint, eviction (LRU/LFU), and TTL of your L2 cache.
5
Be wary of stale data
ensure that any external database modifications are accounted for in your eviction strategy or via manual cache eviction.
6
Cache concurrency strategies must match data volatility
READ_ONLY for static, READ_WRITE for mutable with versioning, NONSTRICT for eventual consistency.
7
Monitor cache hit ratios in production
high miss rates indicate misconfiguration or wrong caching target.

Common mistakes to avoid

4 patterns
×

Not clearing the Session in large batch jobs

Symptom
Processing 100k records in one transaction causes OutOfMemoryError as L1 cache grows unbounded.
Fix
Call session.flush() and session.clear() every 50-100 records to free L1 cache.
×

Forgetting to enable Query Caching explicitly

Symptom
Even with L2 entity cache enabled, repeated queries still hit the database.
Fix
Set hibernate.cache.use_query_cache=true AND call setHint("org.hibernate.cacheable", true) on each query or criteria.
×

Using L2 cache for tables frequently updated by external systems

Symptom
Entities appear stale because Hibernate's L2 cache is unaware of out-of-band DB changes (e.g., legacy stored procedures, direct SQL).
Fix
Evict affected cache regions after external updates, or use shorter TTL and avoid caching highly volatile entities.
×

Choosing the wrong CacheConcurrencyStrategy

Symptom
Runtime exception when using READ_ONLY for mutable entities, or transaction timeouts with TRANSACTIONAL without JTA support.
Fix
Match strategy to data lifecycle: READ_ONLY for static, READ_WRITE for most mutable, NONSTRICT_READ_WRITE for eventual consistency, TRANSACTIONAL only with JTA.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the 'N+1 Select Problem' and how the First Level Cache helps (or...
Q02SENIOR
What is the difference between session.evict(entity) and session.clear()...
Q03SENIOR
Compare the four CacheConcurrencyStrategy types: READ_ONLY, NONSTRICT_RE...
Q04SENIOR
How does the L2 cache handle entity relationships (One-to-Many, Many-to-...
Q05SENIOR
How do you ensure the L2 cache is invalidated when a bulk update query i...
Q01 of 05SENIOR

Explain the 'N+1 Select Problem' and how the First Level Cache helps (or fails to help) in resolving it.

ANSWER
The N+1 problem occurs when Hibernate issues 1 query to fetch the parent entities (usually via a query) and then N additional queries to load each child collection. L1 cache helps within a single session: if the same entity is accessed multiple times, L1 prevents repeated DB hits. But L1 does not prevent N+1 across different parent-child fetches. To solve N+1, use JOIN FETCH, batch fetching, or entity graphs. L2 cache can mitigate repeated N+1 across sessions by caching child entities, but the initial load still triggers N queries. The proper fix is to fetch children eagerly in the same query.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Can I disable the First Level Cache?
02
Does the L2 cache work across multiple application instances?
03
What happens if I use `@Cache(usage = CacheConcurrencyStrategy.READ_ONLY)` on an entity that gets updated?
04
Does Hibernate's Query Cache store the actual entity data?
05
How do I clear the L2 cache programmatically?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's Hibernate & JPA. Mark it forged?

9 min read · try the examples if you haven't

Previous
HQL vs JPQL vs Native SQL
6 / 7 · Hibernate & JPA
Next
Hibernate N+1 Problem and How to Fix It