Skip to content
Chapter 25 · Spring Boot

JPA Part 3: The First-Level Cache ​

This part explains JPA's first-level cache: why reading the same entity twice in one transaction goes to the database only once.

This part relies on the entity lifecycle from part 2. A quick recap with one example:

java
UserDetails a = new UserDetails("xyz", "xyz@example.com");   // new
entityManager.getTransaction().begin();
entityManager.persist(a);   // managed: now in the persistence context
entityManager.remove(a);    // removed: still in the context, marked for deletion
entityManager.persist(a);   // managed again: changed my mind
entityManager.getTransaction().commit();   // flush: the context is synced with the database

The persistence context keeps track of the entities and their state in memory. The database is brought in sync at the flush, at the latest at commit. That in-memory store is what gives JPA its first-level cache.

The observation ​

java
@Entity
public class UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private String email;
    // constructors, getters and setters
}
java
@Service
public class UserDetailsService {

    @Autowired
    UserDetailsRepository userDetailsRepository;

    public void saveUser(UserDetails user) {
        userDetailsRepository.save(user);
    }

    public UserDetails getUser(Long primaryKey) {
        return userDetailsRepository.findById(primaryKey).get();
    }
}
java
@RestController
@RequestMapping("/api")
public class UserController {

    @Autowired
    UserDetailsService userDetailsService;

    @GetMapping("/test-jpa")
    public UserDetails getUser() {
        UserDetails userDetails = new UserDetails("xyz", "xyz@example.com");
        userDetailsService.saveUser(userDetails);
        return userDetailsService.getUser(1L);
    }

    @GetMapping("/read-jpa")
    public UserDetails getUser2() {
        UserDetails output1 = userDetailsService.getUser(1L);
        UserDetails output2 = userDetailsService.getUser(1L);
        return output2;
    }
}

With spring.jpa.show-sql=true, calling /api/test-jpa prints:

Hibernate: insert into user_details (email,name,id) values (?,?,default)

An insert, but no select, even though getUser fetched the row. Then /api/read-jpa, which fetches twice:

Hibernate: select ud1_0.id,ud1_0.email,ud1_0.name from user_details ud1_0 where ud1_0.id=?

One select for two fetches.

What happens ​

save calls entityManager.persist(). Hibernate's implementation (SessionImpl) puts the entity into the persistence context, which is essentially a map from the entity's key (type + id) to the object. save is @Transactional, so at the end the transaction commits and the database has the row too.

findById calls entityManager.find(), which first looks in the persistence context. If the entity is there, it is returned without touching the database. Only if it is missing does Hibernate run a SELECT, and it then puts the result into the context, so the next find hits the cache.

The rules:

  • One EntityManager has one persistence context.
  • Persistence contexts are isolated: what one holds is invisible to another.

So the cache works only while the same EntityManager is used.

One EntityManager per web request ​

Why do saveUser and getUser, two separate repository calls, share one EntityManager?

Spring Boot enables Open EntityManager in View by default (spring.jpa.open-in-view=true). It registers OpenEntityManagerInViewInterceptor, which the DispatcherServlet runs in applyPreHandle, before the controller. It creates an EntityManager from the factory and binds it to the request. Every repository call during that request uses it:

java
// OpenEntityManagerInViewInterceptor.preHandle, simplified
EntityManagerFactory emf = obtainEntityManagerFactory();
if (TransactionSynchronizationManager.hasResource(emf)) {
    // already bound for this request: reuse it
} else {
    EntityManager em = createEntityManager();
    TransactionSynchronizationManager.bindResource(emf, new EntityManagerHolder(em));
}
  • /test-jpa is one request, so one EntityManager: persist put the user in its context, and findById found it there. No select.
  • /read-jpa is a new request, so a new EntityManager with an empty context: the first findById goes to the database, the second is a cache hit.

Spring Boot logs a warning at startup about this default ("spring.jpa.open-in-view is enabled by default..."), because it keeps a database session open for the whole request. With spring.jpa.open-in-view=false, each repository call outside a transaction gets its own EntityManager, and /test-jpa does print the select.

The same thing by hand ​

The rules are easier to see with your own EntityManager objects:

java
@Service
public class UserDetailsService {

    @Autowired
    EntityManagerFactory entityManagerFactory;

    public UserDetails saveUser(UserDetails user) {
        EntityManager entityManager = entityManagerFactory.createEntityManager();   // session 1
        entityManager.getTransaction().begin();
        entityManager.persist(user);
        entityManager.find(UserDetails.class, 1L);
        UserDetails output = entityManager.find(UserDetails.class, 1L);
        System.out.println("found the data, name is: " + output.getName());
        entityManager.getTransaction().commit();
        entityManager.close();                                                       // session 1 closed

        EntityManager entityManager2 = entityManagerFactory.createEntityManager();  // session 2
        entityManager2.getTransaction().begin();
        entityManager2.find(UserDetails.class, 1L);
        UserDetails output2 = entityManager2.find(UserDetails.class, 1L);
        System.out.println("session 2: found the data, name is: " + output2.getName());
        entityManager2.getTransaction().commit();
        entityManager2.close();

        return output2;
    }
}
Hibernate: insert into user_details (email,name,id) values (?,?,default)
Hibernate: select ud1_0.id,ud1_0.email,ud1_0.name from user_details ud1_0 where ud1_0.id=?
  • Session 1: persist puts the user in context 1. Both find calls are answered from context 1: no select.
  • Session 2: context 2 is new and knows nothing of context 1. The first find runs the select and caches the result; the second is a cache hit.

One insert, one select.

When does the insert run? Normally Hibernate waits until the flush (commit) to write changes. But with GenerationType.IDENTITY the database generates the id, and a managed entity must have one, so Hibernate runs the INSERT immediately at persist() to get it. Other changes (updates, removes) still wait for the flush.

The limitation ​

The first-level cache lives only as long as one EntityManager, typically one request or one transaction. Two requests asking for the same row both go to the database. Sharing cached data across EntityManagers is the job of the second-level cache, next part.

Next: The first-level cache ends with its EntityManager, so separate requests cannot share it. Part 4 adds a second-level cache that lives across requests.

Check yourself ​

  1. What is the first-level cache in JPA?
  2. What is the relationship between an EntityManager and a persistence context? Can one context see another's entities?
  3. What does entityManager.find() do before querying the database?
  4. In one request, save(user) and then findById(id) print no select. Why?
  5. Who creates the per-request EntityManager, and which property controls it?
  6. /read-jpa calls findById(1) twice. How many selects run, and why?
  7. In the by-hand example, how many inserts and selects run, and in which session?
  8. With GenerationType.IDENTITY, when is the INSERT executed?
  9. What problem does the first-level cache not solve?
Answers
  1. The persistence context: entities an EntityManager has already loaded or persisted are returned from memory instead of the database.
  2. One-to-one. No, they are isolated.
  3. It looks in the persistence context for the entity with that id.
  4. Both calls use the same request-scoped EntityManager, so the entity saved into its persistence context is found there.
  5. OpenEntityManagerInViewInterceptor, run by the DispatcherServlet before the controller; spring.jpa.open-in-view (default true).
  6. One. The first misses the new request's empty context and queries; the second hits the cache.
  7. One insert (session 1, at persist), one select (the first find of session 2).
  8. Immediately at persist(), because the database must generate the id.
  9. Sharing cached entities across EntityManagers (requests); that is the second-level cache.