Appearance
Spring Boot JPA Part 3 | First Level Caching in JPA
Introduction: Why Fetch the Same Data Twice?
Imagine you are a chef in a restaurant. A customer orders a meal. You go to the pantry, bring out the ingredients, and cook the dish. Five minutes later the same customer asks you for the same dish again. Would you walk back to the pantry a second time, or would you simply use the ingredients you already have on your counter?
Any sensible chef keeps recently used ingredients close at hand. This is the intuition behind caching: keep data you have already fetched nearby so you can serve it again without making an expensive trip to the source.
In JPA, the "trip to the pantry" is a database query. Queries take time, use network resources, and load the database. If your code reads the same entity twice within the same session, it would be wasteful to hit the database both times. This is exactly what first level caching solves.
Quick Recap: Entity Lifecycle and Persistence Context
Before understanding first level caching, recall from the previous chapter that every EntityManager maintains a Persistence Context — an in memory area that tracks all entities read or written during the current session.
When an entity transitions from Transient to Persistent (via persist() or a find() call), it lands in the persistence context. That entity object now lives inside the EntityManager's memory for the rest of the session.
This persistence context is the first level cache.
What Is First Level Caching?
First level caching (also called L1 caching) is the automatic, always-on caching provided by the JPA EntityManager (and its Persistence Context). It operates at the EntityManager level, meaning the cache lives only as long as the EntityManager lives.
Key properties:
- Automatically enabled: You cannot turn it off; it is always active.
- Scoped to a single
EntityManager: Two differentEntityManagerinstances have completely separate L1 caches. - Scoped to a single HTTP request (in Spring MVC by default): Each HTTP request gets its own
EntityManagerand therefore its own L1 cache. - No sharing across requests: If two different users make two different HTTP requests at the same time, each has their own cache with no cross request sharing.
How It Works: Step by Step
The mechanism is straightforward. When you ask the EntityManager to find an entity:
- The
EntityManagerlooks inside the Persistence Context (L1 cache) for that entity ID. - Cache hit: If found, the entity object is returned immediately. No SQL is generated.
- Cache miss: If not found, a
SELECTquery is sent to the database. The result is stored in the Persistence Context. Future calls for the same ID in the same session return from cache.
This means within a single transaction, the same entity is never fetched from the database more than once.
Spring MVC and EntityManager Lifecycle
In Spring MVC, the DispatcherServlet handles every HTTP request. Before the actual controller method runs, the DispatcherServlet creates an EntityManager (via EntityManagerFactory.createEntityManager()). After the response is sent, the EntityManager is closed and the L1 cache is destroyed.
This means:
- Each HTTP request → one
EntityManager→ one L1 cache. - If user A and user B make requests at the same moment, they have completely independent caches.
- After the HTTP response is returned, the cache is gone.
Code Example: First Level Cache in Action
The Setup
java
@Entity
@Table(name = "user_details")
public class UserDetail {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String name;
private String phone;
// constructors, getters, setters
}java
@Repository
public interface UserRepository extends JpaRepository<UserDetail, Integer> {
}java
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
// methods below
}Demonstrating the Cache: Two Finds Within One Transaction
The following service method finds the same user twice within the same transaction:
java
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
// Single transaction — one EntityManager, one L1 cache
@Transactional
public void demonstrateL1Cache(int userId) {
System.out.println("First find call:");
// This WILL hit the database — cache miss
Optional<UserDetail> first = userRepository.findById(userId);
System.out.println("Found: " + first.map(UserDetail::getName).orElse("not found"));
System.out.println("Second find call:");
// This will NOT hit the database — cache hit
Optional<UserDetail> second = userRepository.findById(userId);
System.out.println("Found: " + second.map(UserDetail::getName).orElse("not found"));
}
}With spring.jpa.show-sql=true, you will see only ONE SELECT statement in the logs, even though findById was called twice. The second call returned the entity directly from the L1 cache.
Console Output (Illustrative)
First find call:
Hibernate: select u.id, u.name, u.phone from user_details u where u.id=?
Found: Alice
Second find call:
Found: AliceNotice the second SELECT never appears. That is the L1 cache working.
The Danger: Different EntityManagers Cannot Share L1 Cache
Now consider two separate HTTP requests, each finding the same user:
Request 1 (EntityManager A):
java
UserDetail user = userRepository.findById(1).orElseThrow();
// SELECT runs — user fetched from DB
// user is in EntityManager A's cacheRequest 2 (EntityManager B):
java
UserDetail user = userRepository.findById(1).orElseThrow();
// SELECT runs AGAIN — EntityManager B has no idea about EntityManager A's cacheBoth requests hit the database. This is expected and correct because L1 is purely session scoped. This limitation is exactly why a second level cache (L2 cache) is needed for cross session sharing, which is covered in the next chapter.
EntityManager Lifecycle With Entity States (Concrete walkthrough)
Let us trace exactly what happens at the EntityManager level during a typical POST then GET flow.
Saving a New User
java
@Transactional
public UserDetail createUser(String name, String phone) {
// 1. UserDetail is created — state: TRANSIENT
UserDetail user = new UserDetail();
user.setName(name);
user.setPhone(phone);
// 2. save() calls persist internally — state: PERSISTENT
// User object is now in the L1 cache (persistence context)
UserDetail saved = userRepository.save(user);
// 3. Transaction commits — Hibernate flushes:
// INSERT INTO user_details (name, phone) VALUES (?, ?)
// EntityManager closes — L1 cache destroyed
return saved; // returned with generated id
}Fetching the Same User Later
java
@Transactional
public void getUser(int id) {
// A NEW EntityManager is created for this HTTP request
// Its L1 cache is empty
// 1. findById — cache miss — SELECT fires
Optional<UserDetail> user = userRepository.findById(id);
// 2. Entity is now in THIS EntityManager's L1 cache
// Calling findById(id) again → cache hit, no SQL
Optional<UserDetail> same = userRepository.findById(id);
// 3. Transaction ends — EntityManager closes — cache gone
}Insert Then Find Within the Same Transaction
A subtle but important scenario: you save a new entity and then try to find it in the same transaction.
java
@Transactional
public void insertAndFetch() {
// Insert a new user
UserDetail user = new UserDetail("Charlie", "5555555555");
userRepository.save(user); // INSERT — entity goes into L1 cache
// Now find by ID within the SAME transaction
int generatedId = user.getId();
Optional<UserDetail> found = userRepository.findById(generatedId);
// Cache HIT — no SELECT needed — the user was already in the L1 cache from persist()
System.out.println("Found from L1 cache: " + found.map(UserDetail::getName).orElse("?"));
}Result: one INSERT, zero SELECT statements. The entity was already in the persistence context from the persist() call, so the findById did not touch the database.
What Happens When You Flush Manually?
Normally Hibernate flushes (sends pending SQL to the DB) at transaction commit. But if you call entityManager.flush() manually, the SQL is sent immediately while the L1 cache remains intact. The entity is still managed.
java
@Transactional
public void flushExample(EntityManager entityManager) {
UserDetail user = new UserDetail("Dave", "4444444444");
entityManager.persist(user); // state: PERSISTENT, in L1 cache
entityManager.flush(); // INSERT sent to DB immediately
// Entity is still PERSISTENT in L1 cache
Optional<UserDetail> found = userRepository.findById(user.getId());
// Still a cache hit — entity is still in L1 cache after flush
}Detach Breaks the Cache
If you explicitly detach an entity, it leaves the L1 cache. A subsequent findById for that entity will hit the database:
java
@Transactional
public void detachExample(EntityManager entityManager) {
UserDetail user = entityManager.find(UserDetail.class, 1);
// SELECT fires — entity in L1 cache
entityManager.detach(user);
// Entity removed from L1 cache — state: DETACHED
UserDetail reloaded = entityManager.find(UserDetail.class, 1);
// SELECT fires AGAIN — cache miss because we detached
}Summary
- First level caching is built into every
EntityManagervia the Persistence Context. - It is always on and cannot be disabled.
- Within the same
EntityManager(same transaction / same HTTP request in Spring), the same entity is fetched from the database only once. - Subsequent reads for the same entity ID return the cached object — no SQL generated.
- The cache is destroyed when the
EntityManagercloses (end of request or transaction). - Two separate
EntityManagerinstances never share L1 cache data. - The next chapter covers L2 caching, which solves the cross session sharing problem.
Interview Questions
Q1: What is first level caching in JPA?
First level caching is the automatic in memory cache maintained by each EntityManager through its Persistence Context. Within a single EntityManager lifecycle (typically one HTTP request in Spring MVC), an entity with a given ID is fetched from the database only once. Subsequent reads for the same ID return the cached object without any SQL.
Q2: Is the first level cache enabled by default? Can it be disabled?
Yes, it is enabled by default and is always active. It cannot be disabled. The first level cache is an integral part of how the EntityManager and Persistence Context work in JPA.
Q3: What is the scope of the first level cache?
The scope is the EntityManager instance. In a Spring MVC application, each HTTP request gets a new EntityManager, so each request has its own isolated first level cache. When the HTTP response is sent and the EntityManager closes, the cache is destroyed.
Q4: Can two different HTTP requests share the first level cache?
No. Each request has its own EntityManager, and L1 caches are never shared across EntityManager instances. Request A's cache is completely invisible to Request B. This is why L2 (second level) caching exists — it operates at the application level and is shared across all sessions.
Q5: What happens when you call findById() twice within the same transaction for the same ID?
The first call produces a SELECT query to the database and stores the result in the L1 cache. The second call finds the entity already in the Persistence Context and returns it immediately — no SQL is generated.
Q6: What does detaching an entity do to the L1 cache?
Detaching an entity removes it from the Persistence Context (L1 cache). The entity object still holds its data in memory, but changes to it are no longer tracked by Hibernate. A subsequent find() for the same ID will result in a new database query because the entity is no longer in cache.
Q7: If you save a new entity and then find it by its generated ID in the same transaction, does a SELECT run?
No. When persist() is called, the entity enters the Persistence Context (L1 cache) immediately. A subsequent findById() for that entity's ID finds it in the cache and returns it without any SELECT statement.
Q8: What is the difference between flush and commit in the context of L1 caching?
Flush sends pending SQL (INSERT, UPDATE, DELETE) from the Persistence Context to the database while keeping the transaction open and the L1 cache intact. The entity remains managed. Commit finalizes the transaction, making changes permanent. After commit, the EntityManager typically closes, destroying the L1 cache.