💾 Розділ 16 · Питання #7

Опишіть життєвий цикл Entity в Hibernate

У JPA та Hibernate сутність — це не просто стандартний Java-об'єкт (POJO), а об'єкт, чий стан синхронізується з рядком у реляційній базі даних через посередника — контекст збере...


🟢 Junior Level

Що таке життєвий цикл сутності (Entity Lifecycle)

У JPA та Hibernate сутність — це не просто стандартний Java-об’єкт (POJO), а об’єкт, чий стан синхронізується з рядком у реляційній базі даних через посередника — контекст збереження (PersistenceContext, також відомий як кеш першого рівня L1).

Життєвий цикл сутності описується чотирма фундаментальними станами:

  1. Transient (Новий / Тимчасовий): Об’єкт щойно створено через оператор new. Він існує виключно в купі (JVM Heap), у нього немає первинного ключа (id == null), і контекст PersistenceContext про нього нічого не знає.
  2. Managed / Persistent (Керований): Об’єкт прив’язаний до поточної сесії (Session / EntityManager) і має присвоєний ідентифікатор (id). Будь-які зміни полів об’єкта автоматично відстежуються механізмом Dirty Checking і зберігаються в БД при завершенні транзакції.
  3. Detached (Від’єднаний): Об’єкт має відповідний рядок та ідентифікатор у БД, але його сесію було закрито (або було викликано detach() / clear()). Зміни його полів більше не відстежуються Hibernate.
  4. Removed (Позначений на видалення): Керований об’єкт позначено на видалення викликом remove(). При найближчій синхронізації з базою (flush) буде згенеровано SQL DELETE.
                    ЖИТТЄВИЙ ЦИКЛ СУТНОСТІ (JPA STATE MACHINE)
                    
                      [ Transient ] (new User())
                           │
                 persist() │ merge()
                           ▼
  ┌─────────────────> [ Managed ] <─────────────────┐
  │                    │       │                    │
  │   detach()/clear() │       │ remove()           │ merge()
  │                    ▼       ▼                    │
  │             [ Detached ]  [ Removed ] ──────────┘
  │                    │       │           persist()
  └────────────────────┘       ▼
                          (SQL DELETE при flush)

Базовий приклад переходу за станами

// 1. Стан TRANSIENT: об'єкт у пам'яті, у БД запису немає
User user = new User();
user.setName("Олексій");

// 2. Перехід TRANSIENT -> MANAGED
entityManager.persist(user); 
// Тепер об'єкт відстежується сесією. 

user.setName("Олександр"); // Dirty checking зафіксує зміну без виклику update!

// 3. Перехід MANAGED -> DETACHED
entityManager.detach(user); 
user.setName("Іван"); // Ця зміна НЕ потрапить у БД, оскільки об'єкт detached!

// 4. Перехід DETACHED -> MANAGED
User managedUser = entityManager.merge(user); // Повертає НОВУ керовану копію

// 5. Перехід MANAGED -> REMOVED
entityManager.remove(managedUser); // У БД піде DELETE при коміті транзакції

🟡 Middle Level

Карта методів керування станами

Вихідний стан Метод EntityManager Новий стан Дія в базі даних
Transient persist(e) Managed Буде виконано INSERT (при flush)
Transient merge(e) Managed (копія) Виконує SELECT, потім INSERT
Managed detach(e) / clear() Detached Жодних дій з БД
Managed remove(e) Removed Буде виконано DELETE (при flush)
Managed refresh(e) Managed Виконує SELECT і перезаписує поля з БД
Detached merge(e) Managed (копія) Виконує SELECT, потім UPDATE
Detached persist(e) ❌ Помилка Викидає EntityExistsException
Detached remove(e) ❌ Помилка Викидає IllegalArgumentException
Removed persist(e) Managed Скасовує видалення

Головна пастка merge(): Ігнорування значення, що повертається

Поширена помилка розробників:

User detachedUser = getDetachedUser();
detachedUser.setName("Нове ім'я");

entityManager.merge(detachedUser); // ПОМИЛКА: Значення, що повертається, проігноровано!
detachedUser.setEmail("new@test.com"); // НЕ збережеться в БД!

Чому це відбувається: Метод merge() НЕ робить переданий об’єкт керованим!

  • Вихідний об’єкт detachedUser так і залишається в стані Detached.
  • merge() знаходить або створює новий екземпляр у PersistenceContext, копіює в нього поля з detachedUser і повертає посилання на цей новий managed-об’єкт:
    // ПРАВИЛЬНО:
    User managedUser = entityManager.merge(detachedUser);
    managedUser.setEmail("new@test.com"); // Збережеться в БД завдяки dirty checking
    

Життєві слухачі та колбеки (JPA Entity Callbacks)

JPA дозволяє перехоплювати зміни станів за допомогою анотацій безпосередньо в класі сутності:

@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;

    @PrePersist
    protected void onPrePersist() {
        this.createdAt = LocalDateTime.now();
        this.updatedAt = this.createdAt;
    }

    @PreUpdate
    protected void onPreUpdate() {
        this.updatedAt = LocalDateTime.now();
    }

    @PostLoad
    protected void onPostLoad() {
        // Викликається відразу після завантаження рядка з БД в пам'ять
    }
}
  • @PrePersist / @PostPersist — до та після вставки в БД (persist).
  • @PreUpdate / @PostUpdate — до та після виконання SQL UPDATE.
  • @PreRemove / @PostRemove — до та після видалення рядка (DELETE).
  • @PostLoad — відразу після матеріалізації сутності з ResultSet.

🔴 Senior Level

Внутрішня будова PersistenceContext: EntityEntry та LoadedState

Усередині реалізації Hibernate Session (SessionImpl) контекст збереження представлений класом StatefulPersistenceContext.

У ньому зберігаються дві ключові карти:

  1. entitiesByKey: Map<EntityKey, Object> — кеш першого рівня (L1 Cache). Запобігає дублюванню об’єктів одного ID в пам’яті.
  2. entityEntries: Map<Object, EntityEntry> — метадані відстеження стану.
StatefulPersistenceContext
├── entitiesByKey: [ User#1 -> User@0x123 ]
└── entityEntries:
    └── User@0x123 -> EntityEntry {
            status: Status.MANAGED,
            loadedState: ["Олексій", "alex@mail.com", 25],  // Початковий знімок (Snapshot)
            id: 1,
            version: 0
        }

Механіка роботи Dirty Checking при Flush:

  1. Під час виклику flush() Hibernate обходить карту entityEntries.
  2. Для кожного об’єкта зі статусом Status.MANAGED він порівнює поточні значення полів Java-об’єкта зі знімком loadedState.
  3. Якщо значення відрізняються, Hibernate формує SQL UPDATE суворо за зміненими полями (або за всіма колонками, якщо не ввімкнено @DynamicUpdate).
  4. Після фіксації транзакції знімок loadedState оновлюється.

Вплив стратегії генерації ID на момент виконання INSERT

На співбесідах сеньйорів часто запитують: «Коли саме виконується фізичний SQL INSERT при виклику persist()?».

Відповідь залежить від стратегії @GeneratedValue:

  1. GenerationType.IDENTITY (Вимушений Immediate INSERT):
    • Щоб перевести сутність у стан Managed, Hibernate зобов’язаний знати її первинний ключ.
    • При IDENTITY первинний ключ генерується базою даних через автоінкремент (AUTO_INCREMENT / SERIAL) лише в момент фізичної вставки рядка.
    • Наслідок: Виклик entityManager.persist(user) негайно виконує SQL INSERT у базу даних, ламаючи відкладене скидання (Write-Behind) і унеможливлюючи пакетну вставку (JDBC Batching)!
  2. GenerationType.SEQUENCE / TABLE (Delayed INSERT):
    • Hibernate запитує наступне число з послідовності через SELECT nextval('seq_user') заздалегідь.
    • Ідентифікатор присвоюється Java-об’єкту в пам’яті миттєво.
    • Фізичний SQL INSERT відкладається до виклику flush() або фіксації транзакції. Пакетна вставка (JDBC Batching) працює на максимальній швидкості.

4 Tricky Questions

1. Що станеться, якщо передати об’єкт у стані Detached у метод entityManager.remove()?

Відповідь: Специфікація JPA прямо забороняє видалення від’єднаних сутностей.

  1. Виклик entityManager.remove(detachedUser) негайно завершиться винятком: java.lang.IllegalArgumentException: Removing a detached instance com.example.User#1.
  2. Щоб видалити сутність, яка прийшла із зовнішнього шару (наприклад, DTO з ID з REST-запиту), її необхідно спочатку перевести в стан Managed:
    // Варіант 1: Через merge (спочатку робить SELECT, потім видаляє)
    User managed = entityManager.merge(detachedUser);
    entityManager.remove(managed);
    
    // Варіант 2: Завантажити посилання без зайвого SELECT (getReference / find)
    User ref = entityManager.getReference(User.class, id);
    entityManager.remove(ref);
    

2. Чому в методах життєвих колбеків (@PrePersist, @PreUpdate) категорично заборонено викликати EntityManager або Repository?

Відповідь:

  1. Методи колбеків викликаються Hibernate у критичні моменти синхронізації внутрішнього стану (flush або каскадний обхід дерева об’єктів).
  2. Спроба викликати методи entityManager.persist(), merge() або виконати JPQL-запит усередині @PrePersist призводить до повторного рекурсивного входу у внутрішній процесор станів Hibernate (ActionQueue).
  3. Це призводить до розсинхронізації карти entityEntries, зависання транзакції або викидання непередбачуваних винятків виду AssertionFailure та ConcurrentModificationException.
  4. Правило: Усередині колбеків дозволено лише змінювати локальні поля самого об’єкта (таймстемпи, генерація UUID, обчислення хешів).

3. Об’єкт перебуває в стані Managed. Ми змінюємо його поле user.setName("New"), а потім викликаємо entityManager.refresh(user). Що станеться з об’єктом і базою даних?

Відповідь:

  1. Метод refresh() виконує примусове перезавантаження стану сутності безпосередньо з бази даних через SQL SELECT.
  2. Усі незбережені локальні зміни, зроблені в пам’яті (user.setName("New")), будуть безповоротно стерті та перезаписані тими даними, які фізично зберігаються в рядку таблиці БД.
  3. Механізм Dirty Checking не згенерує SQL UPDATE, оскільки поля об’єкта знову стануть ідентичними знімку loadedState.

4. У чому різниця між викликом entityManager.detach(user) та entityManager.clear()?

Відповідь:

  • entityManager.detach(user): від’єднує лише один конкретний екземпляр сутності від контексту збереження. Усі інші об’єкти в сесії залишаються Managed.
  • entityManager.clear(): повністю очищає весь контекст збереження (PersistenceContext). Абсолютно всі сутності, раніше завантажені в межах поточної сесії, одночасно переходять у стан Detached. Будь-які накопичені, але не скинуті через flush() зміни скасовуються.

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь (Elevator Pitch)

«Життєвий цикл Entity в JPA складається з чотирьох станів: Transient (новий об’єкт у купі, не прив’язаний до сесії), Managed (прив’язаний до PersistenceContext, має ID, відстежується через Dirty Checking), Detached (сесію закрито, об’єкт не відстежується) та Removed (позначений на видалення при flush). Переходи виконуються методами persist(), merge(), remove(), detach() та refresh(). Критичний нюанс: метод merge() не робить вихідний об’єкт managed, а повертає нову керовану копію. Використання стратегії GenerationType.IDENTITY форсує негайний INSERT у момент виклику persist(), тоді як SEQUENCE відкладає вставку до фіксації транзакції».

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

  • Час фази скидання контексту (Flush Duration у Hibernate Statistics).
  • Кількість сутностей, що одночасно утримуються в PersistenceContext (ризик витоків пам’яті за відсутності викликів clear() у батчах).

Червоні прапорці (Чого говорити НЕ МОЖНА)

  • ❌ «Метод merge(entity) робить переданий об’єкт керованим» — Ні, він повертає новий managed-об’єкт, а старий залишається detached.
  • ❌ «Щоб оновити сутність у БД, потрібно обов’язково викликати em.update() або em.merge()» — Для managed-сутностей це не потрібно, працює автоматичний Dirty Checking.
  • ❌ «Будь-який виклик persist() відкладає SQL INSERT до кінця транзакції» — При GenerationType.IDENTITY вставка виконується негайно.
  • ❌ «Detached-об’єкт можна видалити напряму через em.remove(detached)» — Це викине IllegalArgumentException.

Пов’язані теми