💾 Раздел 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, освобождая кучу
        }
    }
}

Альтернатива: Использование 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().

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