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

Що таке стани transient, persistent, detached, removed

Чотири стани сутності (Entity States) визначають взаємовідносини між Java-об'єктом у пам'яті JVM та контекстом персистентності JPA (PersistenceContext / сесією Hibernate). Від п...

Чотири стани сутності (Entity States) визначають взаємовідносини між Java-об’єктом у пам’яті JVM та контекстом персистентності JPA (PersistenceContext / сесією Hibernate). Від поточного стану об’єкта залежить, чи будуть зміни його полів автоматично відстежуватися ORM, які SQL-команди будуть згенеровані і в який момент часу вони вирушать до реляційної бази даних.


🟢 Junior Level

4 фундаментальних стани сутності

Стан Прив’язаний до PersistenceContext? Є рядок та ID в БД? Відстежується Dirty Checking? Опис
Transient (Новий) ❌ Ні ❌ Ні ❌ Ні Звичайний Java-об’єкт, створений через new. Контекст JPA про нього не знає.
Persistent (Managed) ✅ Так ✅ Так (або буде при flush) ✅ Так Сутність керується сесією. Будь-яка зміна полів потрапить у БД.
Detached (Від’єднаний) ❌ Ні ✅ Так ❌ Ні Запис у БД є, але сесія закрита або сутність відв’язана вручну.
Removed (Видалений) ✅ Так (до скидання) ⚠️ Поки що в БД ❌ Ні Сутність помічена на видалення. При виклику flush() виконається DELETE.
                       JPA ENTITY STATE MACHINE
                       
                      [ Transient ] (new User())
                           │
                 persist() │ merge()
                           ▼
  ┌────────────────> [ Persistent ] <────────────────┐
  │                   │          │                   │
  │  detach()/clear() │          │ remove()          │ merge()
  │                   ▼          ▼                   │
  │            [ Detached ]  [ Removed ] ────────────┘
  │                   │          │          persist()
  └───────────────────┘          ▼
                         (SQL DELETE при flush)

Приклади коду для кожного стану

// 1. TRANSIENT: Об'єкт існує лише в Heap, ID дорівнює null
User user = new User();
user.setName("Михайло");

// 2. PERSISTENT (Managed): Прив'язуємо об'єкт до сесії
entityManager.persist(user);
// Тепер user відстежується сесією. 
// Dirty Checking зафіксує зміну поля без явного виклику update/save!
user.setName("Михайло Оновлений");

// 3. DETACHED: Від'єднуємо об'єкт від контексту
entityManager.detach(user);
// user все ще існує в пам'яті та має ID, але Hibernate за ним більше не стежить:
user.setName("Іван"); // Ця зміна НЕ потрапить у БД!

// 4. PERSISTENT (через merge): Повертаємо об'єкт під керування
User managedUser = entityManager.merge(user);
// Увага: сам user залишився Detached, а managedUser — новий Managed-об'єкт!

// 5. REMOVED: Помічаємо об'єкт на видалення
entityManager.remove(managedUser);
// При коміті транзакції або entityManager.flush() виконається SQL DELETE

Як програмно перевірити стан сутності

// 1. Перевірка, чи є об'єкт керованим (Managed / Persistent)
boolean isManaged = entityManager.contains(user);

// 2. Евристична перевірка на Transient / Detached (для GenerationType.IDENTITY/SEQUENCE)
if (user.getId() == null) {
    // Об'єкт гарантовано Transient (новий)
} else if (entityManager.contains(user)) {
    // Об'єкт Persistent
} else {
    // Об'єкт Detached (ID є, але сесія його не відстежує)
}

🟡 Middle Level

Матриця операцій і переходів станів

Вихідний стан Метод EntityManager Результуючий стан Поведінка та SQL
Transient persist(e) Persistent Планує INSERT. При IDENTITY виконує INSERT негайно.
Transient merge(e) Persistent (копія) Створює нову копію, планує INSERT, повертає Managed-копію.
Persistent detach(e) / evict(e) Detached Виключає сутність з L1-кешу. SQL не генерується.
Persistent remove(e) Removed Помічає об’єкт. При скиданні генерує DELETE.
Persistent refresh(e) Persistent Виконує SELECT і перезаписує зміни в пам’яті даними з БД.
Detached merge(e) Persistent (копія) Робить SELECT, копіює поля в Managed-копію, планує UPDATE.
Detached persist(e) ❌ Виняток Викидає EntityExistsException / PersistentObjectException.
Detached remove(e) ❌ Виняток Викидає IllegalArgumentException: Removing a detached instance.
Removed persist(e) Persistent Скасовує видалення, повертаючи об’єкт у статус Managed.

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

Найчастіша помилка при роботі зі станами:

// Отримали detached-об'єкт (наприклад, десеріалізований з JSON у контролері)
User detachedUser = userService.findDetached(1L);
detachedUser.setName("Нове ім'я");

// ПОМИЛКА: Значення, що повертається, проігноровано!
entityManager.merge(detachedUser);

detachedUser.setEmail("new@test.com"); 
// detachedUser залишився в стані Detached! Поле email НЕ збережеться в БД!

Анатомія роботи merge():

  1. Hibernate перевіряє, чи є сутність із таким ID у поточному PersistenceContext.
  2. Якщо немає — завантажує її з БД через SELECT (або створює нову, якщо запису немає).
  3. Копіює стан усіх полів із переданого detachedUser у знайдений/створений Managed екземпляр.
  4. Повертає посилання на новий Managed екземпляр.
  5. Вихідний об’єкт detachedUser залишається Detached.
// ПРАВИЛЬНО:
User managedUser = entityManager.merge(detachedUser);
managedUser.setEmail("new@test.com"); // Збережеться в БД через Dirty Checking

Каскадні переходи станів (CascadeType)

Анотація cascade передає зміну станів від батьківської сутності до дочірніх:

  • CascadeType.PERSIST: при переведенні батька в Persistent діти також стають Persistent.
  • CascadeType.MERGE: виклик merge() на батькові каскадно виконує merge() для всіх пов’язаних сутностей.
  • CascadeType.REMOVE: видалення батька переводить залежні сутності в стан Removed.
  • CascadeType.DETACH: від’єднання батька (detach()) від’єднує і всі пов’язані об’єкти.

🔴 Senior Level

Внутрішня реалізація: StatefulPersistenceContext та EntityEntry

У реалізації Hibernate (SessionImpl) контекст персистентності представлений класом StatefulPersistenceContext. Всередині нього зберігаються дві ключові структури:

  1. entitiesByKey: Map<EntityKey, Object> — Identity Map (Кеш 1-го рівня). Гарантує правило: у межах однієї сесії одному рядку в БД відповідає рівно один Java-об’єкт.
  2. entityEntries: IdentityMap<Object, EntityEntry> — реєстр метаданих станів.
StatefulPersistenceContext
├── entitiesByKey: [ EntityKey(User#1) -> User@0x4a12 ]
└── entityEntries:
    └── User@0x4a12 -> EntityEntry {
            status: Status.MANAGED,
            loadedState: ["Михайло", "misha@mail.com", 30], // Snapshot на момент завантаження
            id: 1,
            version: 0,
            existsInDatabase: true
        }

Життєвий цикл EntityEntry:

  • Status.MANAGED: об’єкт знаходиться в entitiesByKey, масив loadedState заповнений початковими значеннями колонок.
  • При виклику detach() або clear(): об’єкт видаляється з entitiesByKey та entityEntries. Масив loadedState звільняється збирачем сміття.
  • При виклику remove(): статус в EntityEntry змінюється на Status.DELETED. Об’єкт залишається в контексті до фази flush().
  • При виклику flush(): Hibernate перебирає entityEntries, порівнює поточні поля Java-об’єкта зі знімком loadedState (Dirty Checking) і планує SQL у черзі дій ActionQueue.

Проблема assigned ID (ручного встановлення ID) для Transient/Detached

Якщо сутність використовує ручне присвоєння ключа без @GeneratedValue (assigned ID):

@Entity
public class Payment {
    @Id
    private String transactionUuid; // Assigned ID, задається вручну
    private BigDecimal amount;
}

Hibernate не може за полем id == null визначити, чи є об’єкт Transient (новим) або Detached (вже існуючим у БД). При виклику merge() або saveOrUpdate() Hibernate змушений виконати SQL SELECT, щоб перевірити існування запису:

  1. Виконується SELECT transaction_uuid FROM payment WHERE transaction_uuid = ?.
  2. Якщо рядок знайдено — об’єкт вважається Detached (генерується UPDATE).
  3. Якщо рядок не знайдено — об’єкт вважається Transient (генерується INSERT).

Production-рішення: Реалізувати інтерфейс Spring Data Persistable<ID> або використовувати поле @Version. Якщо поле @Version == null, сутність однозначно визнається Transient без зайвого SELECT.

Пакетна обробка та витоки пам’яті L1-кешу

Оскільки всі Persistent об’єкти утримуються сильними посиланнями в StatefulPersistenceContext.entityEntries, масова обробка призводить до OutOfMemoryError:

@Transactional
public void processHugeDataset() {
    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(); // Переводимо всі об'єкти в Detached, звільняючи купу (heap)
        }
    }
}

Альтернатива: Використання StatelessSession в Hibernate, яка не створює контекст персистентності, не відстежує стани і не реалізує кеш L1.


4 Tricky Questions

1. Як Hibernate розрізняє Transient і Detached при виклику merge(), якщо у сутності немає поля @Version і ID призначається вручну?

Відповідь:

  1. Якщо використовується @GeneratedValue, перевірка тривіальна: id == null означає Transient, id != null — Detached.
  2. Якщо ID призначено вручну (Assigned ID) і поле @Version відсутнє, Hibernate виконує попередній запит SELECT за первинним ключем у БД.
  3. Якщо запис виявлено в БД — сутність трактується як Detached і об’єднується через UPDATE.
  4. Якщо запис не виявлено — сутність трактується як Transient і зберігається через INSERT.
  5. Щоб уникнути штрафного SELECT, сутність повинна реалізувати org.springframework.data.domain.Persistable<ID> з методом isNew(), або мати версійне поле @Version.

2. Що станеться, якщо в методі з @Transactional змінити поле Detached об’єкта, потім викликати entityManager.merge(detached), а після продовжити змінювати вихідний Detached об’єкт?

Відповідь:

  1. Зміни, внесені в detached об’єкт до виклику merge(), будуть скопійовані в повернутий Managed об’єкт і потраплять до бази даних.
  2. Зміни, внесені у вихідний detached об’єкт після виклику merge(), до бази даних НЕ потраплять, оскільки вихідне посилання так і залишилося в стані Detached.
  3. До бази даних потраплять лише ті подальші зміни, які були виконані над об’єктом, повернутим із виклику: User managed = entityManager.merge(detached); managed.setField(...).

3. Чи можна повернути об’єкт зі стану Removed назад у стан Persistent? Що станеться?

Відповідь:

  1. Так, специфікація JPA дозволяє викликати entityManager.persist(removedEntity) до того, як було виконано скидання контексту (flush()).
  2. Внутрішній статус сутності в EntityEntry змінюється з Status.DELETED назад на Status.MANAGED.
  3. Запланована операція SQL DELETE видаляється з черги ActionQueue.
  4. Якщо ж flush() уже відбувся, SQL DELETE вже відправлено в БД, і повторний виклик призведе або до помилки цілісності, або до повторної вставки залежно від генератора ключів.

4. Чому виклик entityManager.lock(detachedEntity, LockModeType.OPTIMISTIC) викидає виняток, а merge() працює?

Відповідь:

  1. Блокування (як песимістичні, так і оптимістичні) можуть накладатися лише на керовані сутності (Managed), що знаходяться в активному PersistenceContext.
  2. Спроба накласти блокування на об’єкт у стані Detached негайно завершується винятком IllegalArgumentException: entity not managed.
  3. Щоб заблокувати Detached об’єкт, його необхідно попередньо об’єднати із сесією через User managed = entityManager.merge(detachedUser) і тільки потім передавати managed в entityManager.lock().

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

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

«У JPA існує 4 стани сутності: Transient (новий POJO, не прив’язаний до сесії, немає ID), Persistent (прив’язаний до PersistenceContext, відстежується Dirty Checking, синхронізується з БД), Detached (має ID в БД, але сесія закрита або очищена викликом detach()/clear()), та Removed (помічений на видалення при найближчому flush). Ключовий метод повернення з Detached у Persistent — merge(), який не мутує вихідний об’єкт, а завантажує та повертає керовану копію. При роботі з великими обсягами даних необхідно регулярно викликати flush() та clear(), переводячи сутності в Detached, щоб уникнути переповнення пам’яті першого рівня кешу».

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

  • Hibernate Session Size (Session.getStatistics().getEntityCount()): різке зростання вказує на відсутність очищення контексту (clear()) у пакетних процесах.
  • Тривалість фази flush: час, що витрачається Dirty Checking на обхід карти entityEntries.
  • Кількість штрафних SELECT перед MERGE: виявляє проблеми з відсутністю isNew() / @Version у сутностей з assigned ID.

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

  • ❌ «Метод merge() робить вихідний переданий об’єкт керованим» — Груба помилка. Вихідний об’єкт залишається detached, повертається нова копія.
  • ❌ «Detached-об’єкт можна видалити напряму через entityManager.remove(detached)» — Викине IllegalArgumentException. Спочатку потрібен find/getReference або merge.
  • ❌ «Сутність у стані Transient має ID у базі даних» — Ні, transient-об’єкт ще не зберігався і ID не має (крім ручного призначення UUID).
  • ❌ «Dirty checking працює для Detached об’єктів» — Ні, зміни в Detached об’єктах ігноруються до виклику merge().

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