💾 Раздел 16 · Вопрос #9

Что такое кэш первого уровня в Hibernate

Кэш первого уровня (First-Level Cache, L1) — это встроенный механизм кэширования в оперативной памяти JVM, жестко привязанный к жизненному циклу текущего сеанса взаимодействия с...

Кэш первого уровня (First-Level Cache, L1) — это встроенный механизм кэширования в оперативной памяти JVM, жестко привязанный к жизненному циклу текущего сеанса взаимодействия с базой данных (EntityManager в JPA или Session в Hibernate). Он представляет собой реализацию паттерна Identity Map (Карта Идентичности) и является фундаментальным ядром ORM, обеспечивающим согласованность данных, отслеживание изменений (Dirty Checking) и минимизацию избыточных обращений к БД.


🟢 Junior Level

Что такое L1 Cache и зачем он нужен

Кэш первого уровня — это локальное хранилище объектов внутри EntityManager / Session. Когда приложение запрашивает сущность по ее первичному ключу (find()), Hibernate сначала заглядывает в кэш L1, и только если объекта там нет, выполняет реальный SQL-запрос к СУБД.

// Первый запрос: объект отсутствует в кэше L1 -> выполняется физический SQL SELECT
User user1 = entityManager.find(User.class, 1L);
// Hibernate генерирует: SELECT * FROM users WHERE id = 1;

// Второй запрос с тем же ID в той же сессии:
User user2 = entityManager.find(User.class, 1L);
// Физический SQL-запрос НЕ выполняется! Объект мгновенно возвращается из памяти L1.

// Проверка идентичности ссылок:
assert user1 == user2; // TRUE! Это строго один и тот же экземпляр в Heap!

Ключевые характеристики кэша первого уровня

  1. Включен всегда и безальтернативно: Кэш L1 нельзя отключить настройками конфигурации, так как без него невозможно функционирование механизмов Dirty Checking и каскадов.
  2. Область видимости (Scope) — одна транзакция/сессия: Кэш создается в момент открытия EntityManager и уничтожается при его закрытии. Другие потоки и другие транзакции не имеют доступа к чужому кэшу L1.
  3. Гарантия идентичности (Identity Guarantee): В рамках одной сессии одной строке в таблице БД с заданным первичным ключом соответствует ровно один Java-объект в оперативной памяти. Сравнение через == двух результатов выборки вернет true.
                  АРХИТЕКТУРА КЭША ПЕРВОГО УРОВНЯ (L1)
                  
 [ Поток приложения ]
          │
          ▼
   [ EntityManager / Session ]
   ┌───────────────────────────────────────────────┐
   │             PersistenceContext (L1)           │
   │                                               │
   │   entitiesByKey: Map<EntityKey, Object>       │
   │   ┌───────────────────────────────────────┐   │
   │   │  EntityKey(User, 1L) ──> User@0x4f12  │   │
   │   │  EntityKey(User, 2L) ──> User@0x7a31  │   │
   │   └───────────────────────────────────────┘   │
   │                                               │
   │   entityEntries: Map<Object, EntityEntry>     │
   │   ┌───────────────────────────────────────┐   │
   │   │  User@0x4f12 ──> [loadedState: Snapshot]  │
   │   └───────────────────────────────────────┘   │
   └──────────────────────┬────────────────────────┘
                          │ (Если объект не найден в L1)
                          ▼
                 [ База данных (SQL) ]

🟡 Middle Level

Поведение L1 Cache при JPQL / Criteria API запросах

Частое заблуждение новичков: «Если объект с ID=1 лежит в кэше L1, то вызов JPQL SELECT u FROM User u WHERE u.id = 1 не пойдет в базу данных».

Это не так:

  1. JPQL/HQL/Criteria запросы всегда выполняют физический SQL SELECT в базу данных, минуя проверку L1 кэша.
  2. Однако, когда результат SQL возвращается в Hibernate, движок проверяет первичный ключ каждой строки в entitiesByKey.
  3. Если сущность с таким ID уже загружена в контекст, Hibernate выбрасывает только что пришедшие из БД сырые данные и возвращает существующую управляемую ссылку из кэша L1!
  4. Зачем это нужно: Чтобы не перезатереть несохраненные локальные изменения, которые разработчик успел сделать в памяти над управляемой сущностью.
User user = entityManager.find(User.class, 1L);
user.setName("Новое имя в памяти");

// Выполняем JPQL:
User userFromJpql = entityManager.createQuery("SELECT u FROM User u WHERE u.id = :id", User.class)
        .setParameter("id", 1L)
        .getSingleResult();

// SQL SELECT физически выполнится в БД!
// Но userFromJpql.getName() вернет "Новое имя в памяти"!
assert user == userFromJpql; // true

Программное управление кэшем L1

Метод EntityManager Действие над кэшем L1 Практическое применение
detach(entity) / evict(entity) Удаляет один конкретный объект из L1. Сущность становится Detached. Исключение «тяжелого» объекта из транзакции для экономии RAM.
clear() Полностью очищает весь L1 кэш текущей сессии. Обязателен при пакетной обработке (Batch processing) для предотвращения OOM.
refresh(entity) Перезагружает состояние объекта напрямую из БД, затирая кэш. Синхронизация с БД после выполнения триггеров или изменений извне.
contains(entity) Проверяет, находится ли экземпляр в текущем L1 кэше. Проверка статуса Managed перед операциями.

Опасность переполнения памяти (Memory Leak) в Batch операциях

Поскольку L1 кэш сохраняет жесткие ссылки на все сущности, загруженные или созданные в сессии, пакетная вставка или обработка приводит к java.lang.OutOfMemoryError:

// ❌ ОПАСНО: Кэш L1 удержит в памяти все 100 000 объектов со снимками!
@Transactional
public void badBatchInsert() {
    for (int i = 0; i < 100_000; i++) {
        User user = new User("User_" + i);
        entityManager.persist(user); // Объект и его снимок навечно оседают в L1
    }
}

// ✅ ПРАВИЛЬНО: Пакетный сброс и очистка контекста
@Transactional
public void correctBatchInsert() {
    int batchSize = 50;
    for (int i = 0; i < 100_000; i++) {
        User user = new User("User_" + i);
        entityManager.persist(user);

        if (i > 0 && i % batchSize == 0) {
            entityManager.flush(); // Отправляем накопленный батч INSERT в БД
            entityManager.clear(); // Очищаем L1 кэш, освобождая кучу JVM
        }
    }
}

🔴 Senior Level

Внутреннее устройство: StatefulPersistenceContext

В Hibernate сессия (SessionImpl) делегирует управление кэшем объекту StatefulPersistenceContext.

Внутри него работают две ключевые структуры данных:

public class StatefulPersistenceContext implements PersistenceContext {
    // 1. Быстрый поиск сущностей по ID и Типу
    private Map<EntityKey, Object> entitiesByKey = new HashMap<>();

    // 2. Метаданные и снимки исходного состояния
    private IdentityMap<Object, EntityEntry> entityEntries = IdentityMap.instantiateSequentially();
    
    // 3. Отслеживание коллекций (PersistentBag, PersistentSet)
    private IdentityMap<PersistentCollection<?>, CollectionEntry> collectionEntries;
}

Что хранится в EntityEntry:

  • Status status: текущий статус (MANAGED, READ_ONLY, DELETED, SAVING).
  • Object[] loadedState: массив значений всех полей сущности на момент ее чтения из БД или первоначального сохранения. Именно с этим массивом механизм Dirty Checking побайтово сравнивает текущие поля объекта во время flush().
  • Object id и Object version: первичный ключ и номер версии (для @Version).

Read-Only оптимизация для кэша первого уровня

Если транзакция выполняет только чтение большого объема аналитических данных, поддержание снимков loadedState для Dirty Checking — пустая трата CPU и RAM.

Hibernate предоставляет механизмы отключения снимков L1:

// Вариант 1: JPA Query Hint
List<User> users = entityManager.createQuery("SELECT u FROM User u", User.class)
    .setHint(org.hibernate.jpa.HibernateHints.HINT_READ_ONLY, true)
    .getResultList();

// Вариант 2: Для конкретной сущности через Session
Session session = entityManager.unwrap(Session.class);
User user = session.get(User.class, 1L);
session.setReadOnly(user, true);

Внутренний эффект: Для READ_ONLY сущностей массив loadedState в EntityEntry либо очищается (null), либо игнорируется при flush(). Это экономит до 50% памяти на сущность и снижает время сброса контекста до нуля.

Альтернатива: StatelessSession

Для массивных миграций данных или генерации отчетов Hibernate предлагает интерфейс StatelessSession:

  • В ней полностью отсутствует L1 кэш (PersistenceContext == null).
  • Не работает Dirty Checking (все обновления требуют явного update()).
  • Не работают каскады (CascadeType) и отложенная загрузка (Lazy Loading).
  • Запросы выполняются напрямую через JDBC без накладных расходов на создание снимков объектов.

4 Tricky Questions

1. Мы загрузили User(id=1), затем напрямую через чистый jdbcTemplate.update("UPDATE users SET name = 'DB' WHERE id = 1") изменили строку в БД. Что вернет последующий вызов entityManager.find(User.class, 1L) в рамках той же транзакции?

Ответ:

  1. entityManager.find(User.class, 1L) вернет старое значение, которое находится в Java-объекте внутри кэша L1!
  2. Никакого запроса в БД выполнено не будет, поскольку find() проверяет наличие ключа EntityKey(User, 1L) в карте entitiesByKey.
  3. Чтобы подтянуть актуальное значение, записанное через JDBC в обход Hibernate, необходимо явно вызвать entityManager.refresh(user).

2. Если в рамках одной сессии вызвать entityManager.getReference(User.class, 1L), а затем entityManager.find(User.class, 1L), вернет ли find() реальный класс или Proxy?

Ответ:

  1. entityManager.find() вернет тот же самый ByteBuddy-прокси, который был создан методом getReference(), а не реальный экземпляр User!
  2. Причина: правило Identity Guarantee в кэше L1. В entitiesByKey уже зарегистрирован прокси-объект. Hibernate не может заменить ссылку на другой объект в Heap, так как это нарушило бы ссылочную целостность (userRef == userFind).
  3. При обращении к любому бизнес-методу (например, userFind.getName()) прокси внутри себя выполнит SQL SELECT и проинициализирует целевой объект.

3. Почему плохая реализация equals() и hashCode() у составного ключа (@EmbeddedId / @IdClass) ломает кэш первого уровня?

Ответ:

  1. Ключом в карте L1 кэша entitiesByKey является объект EntityKey, который вычисляет свой hashCode и сравнивает равенство на основе переданного первичного ключа: int hashCode = 37 * entityName.hashCode() + id.hashCode().
  2. Если класс составного ключа не переопределяет или некорректно реализует equals() и hashCode():
    • Два поиска по логически одинаковому ключу не найдут существующий объект в entitiesByKey.
    • Hibernate попытается повторно загрузить строку и создать дублирующий объект, что приведет либо к NonUniqueObjectException, либо к нарушению согласованности Identity Map.

4. Создает ли entityManager.find() снимок состояния loadedState, если сущность помечена аннотацией @org.hibernate.annotations.Immutable?

Ответ:

  1. Для сущностей с аннотацией @Immutable Hibernate регистрирует объект в кэше entitiesByKey (чтобы работал быстрый find() по ID).
  2. Однако Hibernate не создает и не сохраняет снимок loadedState в EntityEntry, а статус сущности устанавливается в Status.READ_ONLY.
  3. Во время выполнения flush() фаза Dirty Checking полностью пропускает такие сущности, что исключает генерацию любых SQL UPDATE и существенно экономит память JVM.

🎯 Шпаргалка для интервью

30-секундный ответ (Elevator Pitch)

«Кэш первого уровня (L1) в Hibernate/JPA — это встроенный, неотключаемый кэш уровня сессии (EntityManager), реализующий паттерн Identity Map. Он решает три ключевые задачи: гарантирует ссылочную идентичность объектов (один ID в БД = один экземпляр в куче JVM), хранит начальные снимки колонок (loadedState) для автоматического Dirty Checking и устраняет дублирующие SELECT при повторном вызове find(). Важно помнить: JPQL-запросы всегда выполняют реальный SQL-запрос в базу, но заменяют полученные строки на уже управляемые объекты из L1. При обработке больших массивов данных L1 необходимо периодически очищать через flush() и clear(), либо использовать StatelessSession, чтобы избежать OutOfMemoryError».

Ключевые метрики для Production-мониторинга

  • Hibernate Session Entity Count (session.getStatistics().getEntityCount()): резкий рост числа управляемых объектов сигнализирует об утечке памяти в L1.
  • Длительность Flush Time: большое количество объектов в L1 линейно увеличивает время работы dirty checking при каждом коммите транзакции.

Красные флаги (Чего говорить НЕЛЬЗЯ)

  • ❌ «Я отключил кэш первого уровня через свойства в application.properties» — Кэш L1 неотключаем, это архитектурная основа JPA.
  • ❌ «Кэш L1 разделяется между несколькими параллельными запросами пользователей» — Кэш L1 строго изолирован в рамках одного потока/транзакции (EntityManager).
  • ❌ «JPQL-запрос не обращается к БД, если данные уже есть в кэше L1» — JPQL всегда обращается к БД через физический SQL SELECT.
  • ❌ «Для освобождения памяти L1 достаточно вызвать System.gc()» — Ссылки в StatefulPersistenceContext жесткие (Strong References), объекты не будут удалены сборщиком мусора до вызова clear() или закрытия сессии.

Связанные темы