Що таке кеш першого рівня в 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!
Ключові характеристики кешу першого рівня
- Увімкнений завжди і безальтернативно: Кеш L1 не можна вимкнути налаштуваннями конфігурації, оскільки без нього неможливе функціонування механізмів Dirty Checking та каскадів.
- Область видимості (Scope) — одна транзакція/сесія: Кеш створюється в момент відкриття
EntityManagerі знищується при його закритті. Інші потоки та інші транзакції не мають доступу до чужого кешу L1. - Гарантія ідентичності (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 не піде до бази даних».
Це не так:
- JPQL/HQL/Criteria запити завжди виконують фізичний SQL
SELECTдо бази даних, оминаючи перевірку L1 кешу. - Однак, коли результат SQL повертається в Hibernate, рушій перевіряє первинний ключ кожного рядка в
entitiesByKey. - Якщо сутність із таким ID вже завантажена в контекст, Hibernate відкидає щойно отримані з БД сирі дані і повертає існуюче кероване посилання з кешу L1!
- Навіщо це потрібно: Щоб не перезатерти незбережені локальні зміни, які розробник встиг зробити в пам’яті над керованою сутністю.
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) у межах тієї ж транзакції?
Відповідь:
entityManager.find(User.class, 1L)поверне старе значення, яке знаходиться в Java-об’єкті всередині кешу L1!- Жодного запиту до БД виконано не буде, оскільки
find()перевіряє наявність ключаEntityKey(User, 1L)у картіentitiesByKey. - Щоб підтягнути актуальне значення, записане через JDBC в обхід Hibernate, необхідно явно викликати
entityManager.refresh(user).
2. Якщо в межах однієї сесії викликати entityManager.getReference(User.class, 1L), а потім entityManager.find(User.class, 1L), чи поверне find() реальний клас або Proxy?
Відповідь:
entityManager.find()поверне той самий ByteBuddy-проксі, який був створений методомgetReference(), а не реальний екземплярUser!- Причина: правило Identity Guarantee у кеші L1. В
entitiesByKeyвже зареєстровано проксі-об’єкт. Hibernate не може замінити посилання на інший об’єкт у Heap, оскільки це порушило б посилальну цілісність (userRef == userFind). - При зверненні до будь-якого бізнес-методу (наприклад,
userFind.getName()) проксі всередині себе виконає SQL SELECT та проініціалізує цільовий об’єкт.
3. Чому погана реалізація equals() та hashCode() у складеного ключа (@EmbeddedId / @IdClass) ламає кеш першого рівня?
Відповідь:
- Ключем у карті L1 кешу
entitiesByKeyє об’єктEntityKey, який обчислює свійhashCodeі порівнює рівність на основі переданого первинного ключа:int hashCode = 37 * entityName.hashCode() + id.hashCode(). - Якщо клас складеного ключа не перевизначає або некоректно реалізує
equals()таhashCode():- Два пошуки за логічно однаковим ключем не знайдуть існуючий об’єкт в
entitiesByKey. - Hibernate спробує повторно завантажити рядок і створити дублюючий об’єкт, що призведе або до
NonUniqueObjectException, або до порушення узгодженості Identity Map.
- Два пошуки за логічно однаковим ключем не знайдуть існуючий об’єкт в
4. Чи створює entityManager.find() знімок стану loadedState, якщо сутність позначена анотацією @org.hibernate.annotations.Immutable?
Відповідь:
- Для сутностей з анотацією
@ImmutableHibernate реєструє об’єкт у кешіentitiesByKey(щоб працював швидкийfind()за ID). - Однак Hibernate не створює і не зберігає знімок
loadedStateвEntityEntry, а статус сутності встановлюється вStatus.READ_ONLY. - Під час виконання
flush()фаза Dirty Checking повністю пропускає такі сутності, що виключає генерацію будь-яких SQLUPDATEта суттєво економить пам’ять 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()або закриття сесії.