Second-Level Cache
Sharing Data Between Sessions
The second-level cache belongs to the SessionFactory, so it survives after a transaction ends and is shared by every request. It is off by default. You turn it on and then mark each entity that should be cached.
Good candidates are entities that are read often and changed rarely: Category, product catalogue data, countries, tax slabs. Bad candidates: Order, CartItem, stock counters that change every second.
Note: The L2 cache does not store your Java objects. It stores a dehydrated copy (an array of column values). On a hit, Hibernate builds a new managed object from that array, so every session still gets its own instance.
How a Lookup Works
Example
A simulation of find() with both caches. The second request is a new session (empty L1) but it finds the data in the shared L2 region:
import java.util.*;
public class Main {
static final Map<Long, Object[]> DB = new TreeMap<>(Map.of(1L, new Object[]{"Electronics"}, 2L, new Object[]{"Fashion"}));
static final Map<Long, Object[]> L2_CATEGORY_REGION = new HashMap<>(); // shared by all sessions
static int hits, misses, puts, selects;
public static void main(String[] args) {
System.out.println("-- request 1 --");
Session s1 = new Session();
Category a = s1.find(1L);
s1.find(1L);
System.out.println("-- request 2 (new session) --");
Session s2 = new Session();
Category b = s2.find(1L);
System.out.println("same Java object? " + (a == b) + ", same data? " + a.name.equals(b.name));
System.out.printf("L2 hits=%d misses=%d puts=%d, SQL selects=%d%n", hits, misses, puts, selects);
}
}
class Category {
final Long id; final String name;
Category(Long id, String name) { this.id = id; this.name = name; }
}
class Session {
private final Map<Long, Category> l1 = new HashMap<>();
Category find(Long id) {
if (l1.containsKey(id)) { System.out.println("L1 hit"); return l1.get(id); }
Object[] state = Main.L2_CATEGORY_REGION.get(id);
if (state != null) {
Main.hits++; System.out.println("L2 hit, building a new object from cached state");
} else {
Main.misses++; Main.selects++;
System.out.println("L2 miss -> select ... from categories where id=" + id);
state = Main.DB.get(id);
Main.L2_CATEGORY_REGION.put(id, state.clone()); Main.puts++;
}
Category c = new Category(id, (String) state[0]); // "hydrate"
l1.put(id, c);
return c;
}
}Output
-- request 1 -- L2 miss -> select ... from categories where id=1 L1 hit -- request 2 (new session) -- L2 hit, building a new object from cached state same Java object? false, same data? true L2 hits=1 misses=1 puts=1, SQL selects=1
Cache Concurrency Strategies
Each cached entity chooses how the cache stays consistent with the database when data changes:
| Strategy | Use when | Notes |
|---|---|---|
READ_ONLY | data never changes (countries, GST slabs) | fastest; updating the entity throws an exception |
NONSTRICT_READ_WRITE | rare updates, a short stale window is fine | cache entry is invalidated after commit; no locks |
READ_WRITE | normal read-mostly data (categories, products) | soft locks keep readers from seeing uncommitted data; the usual choice |
TRANSACTIONAL | JTA + a transactional cache (Infinispan) | strict consistency, rarely needed in Spring Boot apps |