Skip to main content

Hibernate 7 + Ehcache 3: The Second-Level Cache Claims That Don’t Hold Up

A second-level cache outlives any one Hibernate session. This post configures Ehcache 3 as Hibernate 7’s L2 provider, then measures four widely-repeated claims about it against real captured test output — correcting three of them: the jakarta classifier is not about JCache’s namespace, a query-cache hit costs zero SQL, and both HQL and native bulk updates leave the cache clean rather than stale.

If the same handful of rows get read on nearly every request — a product catalog, a lookup table, a settings row — every one of those reads is still a round trip to the database unless something remembers the answer across requests. Hibernate’s first-level cache doesn’t help here: it dies with the session that created it. This post configures Ehcache 3 as Hibernate 7’s second-level (L2) cache provider, then measures — rather than repeats — four specific claims that get made about it, correcting three of them against real, captured output from a running SessionFactory.

Versions used in this post. Hibernate 7.4.5.Final, Spring Boot 4.1.1, JDK 25, Ehcache 3.10.8 (jakarta classifier), hibernate-jcache, javax.cache:cache-api 1.1.1. Every code and output block below links to the exact file in the companion repository it came from.

What a second-level cache actually buys you

Every Session already has a cache — the first-level (L1) cache, the identity map that keeps an entity from being reloaded twice inside the same session. The question this post answers is different: what happens when a second session asks for the same row a few seconds later? Without anything extra configured, Hibernate hits the database again, because L1 dies with the session that built it. A second-level (L2) cache is a cache that outlives any one session — scoped to the whole SessionFactory, so a row loaded by one request can be served to the next request without touching the database at all.

One SessionFactory, many short-lived Sessions Session 1 (request A) L1 cache (identity map) dies when session closes Session 2 (request B) L1 cache (identity map) dies when session closes Session 3 (request C) L1 cache (identity map) dies when session closes L2 cache (region: productCache) — lives on the SessionFactory Populated once, read by every session above without a SELECT All three sessions can read from the shared region; only the one that misses first has to run a query.

That single distinction — L1 dies with the session, L2 survives it — is the whole mental model. Everything below is really just: how do you tell Hibernate which entities go in that shared region, what does the region actually store, and where does the “obvious” behavior turn out to be wrong.

Turning it on for one entity

Three pieces have to agree: the JCache (JSR-107) bridge module, an Ehcache configuration file defining the region, and two annotations on the entity itself.

<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-jcache</artifactId>
</dependency>
<dependency>
    <groupId>javax.cache</groupId>
    <artifactId>cache-api</artifactId>
    <version>1.1.1</version>
</dependency>
<dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
    <version>3.10.8</version>
    <classifier>jakarta</classifier>
</dependency>

From this repository’s pom.xml.

@Entity
@jakarta.persistence.Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "productCache")
public class CacheProduct {
    @Id @GeneratedValue private Long id;
    @Column(nullable = false) private String name;
    private Double price;
}

From CacheProduct.java. Both annotations are required — @jakarta.persistence.Cacheable is the portable JPA marker that only says “this entity is allowed to be cached”, and Hibernate’s own @Cache is what actually turns it on and picks the region and concurrency strategy. Leave either one off and nothing gets cached, silently.

Run it: get the same id twice, from two different sessions.

RESULT[cache-entity-l2]: session1 (first get() after persist+commit) cumulative queries=0 | session2 (brand-new session, same id) cumulative queries=0, L2 entity cache hits=2

From the captured transcript, source: EntityL2CacheTest.java.

Trap: the “cold” read you think you’re measuring is already warm. The natural assumption is that the very first get() after inserting a row still costs one SELECT — a cold read, before later reads become free. That’s wrong: persist() followed by commit() already populates the L2 region itself, so even the first get() for that id from a brand-new session is a cache hit with zero SQL. If you’re benchmarking cache savings, restart the SessionFactory (or evict the region) between the insert and the read you’re calling “cold”, or you’re measuring the wrong thing.

For the intermediate reader: this generalizes beyond plain entity caching — the same surprise showed up first for @NaturalIdCache, in the natural-id chapter, and this chapter re-confirms it applies to ordinary @Cacheable/@Cache entities too.

The dependency claim that doesn’t hold up: what the jakarta classifier is actually for

A common warning repeated across older posts on this exact topic (including an earlier version of this one) is that Ehcache 3’s plain artifact ships the old javax.cache JCache API, that Hibernate 7 “requires” a jakarta.cache namespace, and that picking the wrong artifact throws a ClassNotFoundException. Checked directly, that’s not what’s going on.

Two Ehcache 3 artifacts, one JCache API either way org.ehcache:ehcache:3.10.8 javax.cache.Caching internal XML parsing: javax.xml.bind …:ehcache:3.10.8:jakarta javax.cache.Caching internal XML parsing: jakarta.xml.bind jakarta.cache.Cache → ClassNotFoundException, always

CacheApiNamespaceTest loads javax.cache.Caching successfully from this exact classpath, then confirms that jakarta.cache.Cache simply does not exist as a class, with or without the classifier:

RESULT[cache-api-namespace]: javax.cache.Caching loads fine from this classpath (jar: file:/.../cache-api-1.1.1.jar)
RESULT[cache-api-no-jakarta-namespace]: Class.forName("jakarta.cache.Cache") -> ClassNotFoundException -- JSR-107 was never renamed to a jakarta.cache package, with or without Ehcache's own "jakarta" classifier on org.ehcache:ehcache.

From the full transcript.

JSR-107 (JCache) was one of the specifications that never migrated to the Jakarta namespace, unlike JPA or Bean Validation — javax.cache.Caching is the one and only entry point, forever. Comparing the two jars’ contents directly shows the actual difference: the jakarta classifier changes which JAXB runtime major version Ehcache imports internally, to parse its own ehcache.xml config file (javax.xml.bind vs. jakarta.xml.bind) — nothing about the public JCache API changes at all.

What this means for the decision you actually have to make: pick the classifier that matches whichever JAXB runtime is already on your classpath — Jakarta EE 9+ stacks, including Spring Boot 3+ and 4+, want the jakarta classifier — not because of any JCache namespace concern. Get it backwards and the failure you’ll see is a JAXB conflict at Ehcache’s config-parsing step, not the ClassNotFoundException the old advice predicts.
  • Going deeper: the chapter 18 doc shows the actual disassembled class lists behind this comparison.

Query cache without entity cache: not the N+1 you’d expect

The next claim worth checking: turn on setCacheable(true) for a query, but don’t put @Cacheable/@Cache on the entity it returns — does the second run of that query cost one SELECT per row while it re-resolves ids into entities? Built with a deliberately uncached entity, UncachedProduct, to make sure there’s no L2 region backstopping the answer.

RESULT[cache-query-without-entity-cache]: first run (cold, new session) SQL statements=1, query-cache misses=1 | second run (new session, query-cache HIT) SQL statements=0, query-cache hits=1, L2 entity-cache hits=0

From the transcript, source: QueryCacheWithoutEntityCacheTest.java.

Zero, not five. The query cache region doesn’t remember a list of ids to re-look-up — it stores the full hydrated row tuples from the moment the query first ran, and Hibernate rebuilds entity instances straight from that stored data on a hit. The zero L2 entity-cache hits in that result rule out the alternative explanation (a secret entity cache doing the work); the entity genuinely has none.

What the query-cache region actually stores Assumed (wrong) region: [id=1, id=2, id=5] re-SELECT each id on every hit → N+1 selects Actual region: [(1,”A”,9.0),(2,”B”,4.5)…] full tuples, rebuilt in memory → zero SQL on a hit

This doesn’t rule out an N+1 appearing for a query returning associations that still need per-row initialization, or a partial-hit case after individual entries are evicted — only the plain case above is measured here.

Bulk mutations and the cache: wrong in two different ways

The classic warning here is that a bulk HQL update runs as direct SQL, bypasses the session, and leaves an already-cached entity stale until you manually call evictEntityData(...). Testing the HQL form directly:

RESULT[cache-bulk-update-hql-not-stale]: price in the database after the bulk HQL update=999.0 | price a brand-new session's get() actually returns=999.0 -- BulkOperationCleanupAction evicts CacheProduct's entire region automatically once the statement's transaction commits, so no manual evictEntityData(...) call is needed for this case.

From the transcript, source: BulkUpdateBypassesCacheTest.java. Disassembling hibernate-core-7.4.5.Final.jar confirms why: every bulk HQL/JPQL update/delete registers an org.hibernate.action.internal.BulkOperationCleanupAction as an after-commit process, and its EntityCleanup step evicts the entire region for every table the statement’s parsed “query spaces” touch.

The natural follow-up: does a native SQL update escape this, since Hibernate never parses a native statement’s tables? Measured, that hypothesis is wrong too — just for a different reason:

RESULT[cache-bulk-update-native-not-stale]: price in the database after the native SQL update=999.0 | price a brand-new session's get() actually returns=999.0 | L2 puts recorded before the native update=1 | L2 hits before/after the post-update read=1/1 -- the entity WAS cached (one L2 put), yet the post-update read is not an L2 hit at all: Hibernate cannot verify a native statement's affected tables, so it conservatively invalidates every region it knows about rather than none of them.

From the transcript.

Two roads to the same empty cache Bulk HQL update Hibernate parses the query spaces BulkOperationCleanupAction runs at commit, evicting exactly the tables the statement touched Native SQL update Hibernate cannot parse the tables so it conservatively invalidates every region it knows about, safe but broad Both leave CacheProduct’s region empty at commit — the underlying mechanism differs.
The trap this creates in the other direction: that “invalidate everything” default means a native SQL statement run through Hibernate can quietly evict L2 regions for entities the statement never touched at all, in an app with many cached entity types. If that blast radius matters at your scale, narrow it explicitly with NativeQuery#addSynchronizedEntityClass() or addSynchronizedQuerySpace() rather than relying on the conservative default.
  • Going deeper: both output files linked above also include the manual sessionFactory.getCache().evictEntityData(...) call and its result, for cases where you do need to force an eviction explicitly.

The missing region that warns instead of crashing

The last claim: omitting default-update-timestamps-region from your Ehcache config causes a startup error, because the query cache needs it. Booting a query-cache-enabled SessionFactory against a config that deliberately omits it:

RESULT[cache-missing-timestamps-region]: SessionFactory built with NO error -- the default MissingCacheStrategy (CREATE_WARN) auto-creates the missing default-update-timestamps-region and only logs HHH90001006

From the transcript, source: MissingUpdateTimestampsRegionTest.java.

Disassembling MissingCacheStrategy.class in hibernate-jcache-7.4.5.Final.jar confirms three values — FAIL, CREATE_WARN (the default, external name create-warn), and CREATE. The default auto-creates a missing region with provider defaults and only logs a warning. If you actually want the hard failure — useful in CI, to catch a forgotten region definition before it reaches production — it’s an explicit opt-in:

RESULT[cache-missing-timestamps-region-strict]: hibernate.javax.cache.missing_cache_strategy=fail -> org.hibernate.service.spi.ServiceException: On-the-fly creation of JCache Cache objects is not supported [default-update-timestamps-region]

From the transcript, using .applySetting("hibernate.javax.cache.missing_cache_strategy", "fail").

  • Going deeper: set the strategy to create instead of the default create-warn if you want the auto-create behavior without even the WARN log line.

Should you turn L2 on at all?

Only if you’ve measured a real read-heavy hotspot and confirmed, with statistics turned on, that the same rows really are being re-read across requests rather than mutated constantly. L2 caching adds a consistency surface you now have to reason about — region invalidation on bulk writes, TTL staleness windows, cross-instance coherence if you scale horizontally — and every one of the “surprises” above is a way that surface behaves differently than the mental model most people start with. If your workload is write-heavy or your entities change every few seconds, skip it; a correctly-sized query and a good database index usually beat a cache you have to keep correct by hand.

  • Pin hibernate.cache.use_second_level_cache: false explicitly if you’re not deliberately turning L2 on everywhere — having hibernate-jcache merely on the classpath is enough to auto-enable it via ServiceLoader discovery, with zero explicit configuration (see chapter 09).
  • Match the Ehcache classifier to your JAXB runtime, not to any JCache namespace assumption.
  • Decide missing_cache_strategy deliberately: create-warn is forgiving in dev, fail catches a missing region in CI.
  • Remember the persist-then-get() surprise when benchmarking — a row inserted in the same session or process is already cached by the time you “cold” read it.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.