← назад к разделу

Когда вы работаете с Hibernate, между вашим Java-кодом и базой данных стоит невидимый посредник — persistence context. Он решает, когда и что отправить в базу, и следит за тем, чтобы один и тот же ряд таблицы не превратился в два разных объекта в памяти.

ваш код persistence context база new Order()ключа нетконтекст пуст em.find(1)дважды order.setStatus(PAID) commit карта: ключ → объект1 → Order@3f1снимокstatus=NEW Order@3f1status=NEW Order@3f1status=PAID≠ SELECT … WHERE id = 1второй em.find(1):из карты, без SQL flush: сравнил со снимкомUPDATE ordersSET status = ? 1. transient: объект создан через new, контекст о нём не знает 2. managed: объект лёг в карту, рядом снимок полей при загрузке 3. сеттер поменял поле — снимок остался прежним 4. flush: поля разошлись со снимком → UPDATE; после commit — detached

Контекст держит две вещи: карту «ключ → объект», чтобы одна строка таблицы не превратилась в два объекта, и снимок полей, сделанный при загрузке. Второй em.find с тем же ключом отвечает из карты и в базу не идёт. При flush Hibernate сравнивает каждую managed-сущность со снимком и на разошедшиеся поля сам пишет UPDATE; после фиксации транзакции контекст закрывается, и объект становится detached.

Обязательно

Что такое persistence context

Persistence context — это рабочая область, которую Hibernate создаёт на время единицы работы (как правило, транзакции). Все сущности, с которыми вы работаете внутри этой области, находятся под наблюдением Hibernate.

В JPA-терминах persistence context создаёт EntityManager. В Hibernate его аналог — Session. На практике Spring управляет этим за вас: обычно один EntityManager живёт в рамках одной транзакции. Оговорка «обычно» здесь не лишняя — в веб-приложении Spring Boot по умолчанию включён режим Open Session In View, и тогда контекст живёт весь HTTP-запрос, переживая границы транзакций внутри него.

@Service
@Transactional
public class OrderService {

    private final EntityManager em;

    public OrderService(EntityManager em) {
        this.em = em;
    }

    public void confirm(Long orderId) {
        Order order = em.find(Order.class, orderId); // сущность теперь managed
        order.setStatus(OrderStatus.CONFIRMED);       // Hibernate видит изменение
        // em.persist() не нужен — flush сам запишет UPDATE
    }
}

Identity map: один объект на одну строку

Первое, что делает persistence context — хранит identity map (карту идентичности). Это таблица «первичный ключ → объект», которая гарантирует: если вы дважды загружаете один и тот же ряд в рамках одного persistence context, вы получаете один и тот же Java-объект.

Order a = em.find(Order.class, 1L);
Order b = em.find(Order.class, 1L);

System.out.println(a == b); // true — один объект, второй SELECT не выполнялся

Второй find не идёт в базу вообще — Hibernate смотрит в identity map и отдаёт уже готовый объект. Это первый уровень кэша Hibernate (L1-кэш), встроенный и всегда включённый. Подробнее о кэшировании — в статье /hibernate/caching/.

transient managed detached persist конец транзакции removed remove

Переходы между четырьмя состояниями; обратно из detached в managed возвращает только merge.

Четыре состояния сущности

У каждой сущности в любой момент времени есть одно из четырёх состояний. Переходы между ними — через явные вызовы EntityManager или через завершение транзакции.

Transient (новое)

Объект создан через new, но Hibernate о нём ничего не знает. Нет первичного ключа, нет связи с базой.

Order order = new Order(); // transient
order.setStatus(OrderStatus.NEW);

Managed (управляемое)

Сущность находится в persistence context — Hibernate следит за ней. Любое изменение поля будет автоматически синхронизировано с базой при flush.

Управляемой сущность становится после:

  • em.persist(entity) — для новых объектов,
  • em.find(...) или JPQL-запроса — для загруженных из базы,
  • em.merge(detachedEntity) — для возвращения detached-объекта.

Detached (отсоединённое)

Сущность была managed, но persistence context закрылся (транзакция завершилась) или вы явно вызвали em.detach(entity). Объект всё ещё существует в памяти и у него есть первичный ключ, но Hibernate его больше не отслеживает.

// внутри транзакции
Order loaded = em.find(Order.class, 1L); // managed
// транзакция завершилась → loaded стал detached

loaded.setStatus(OrderStatus.CANCELLED); // изменение НЕ уйдёт в базу автоматически

Чтобы вернуть detached-сущность под контроль Hibernate, используйте em.merge(loaded) в новой транзакции. Переданный объект merge не трогает: он возвращает managed-экземпляр, и это, как правило, уже другой объект. «Как правило» — потому что сущность с тем же идентификатором может к этому моменту лежать в контексте: тогда merge перельёт поля в неё и вернёт именно её. Из-за этого проверка merged == loaded даёт то false, то true; работать надо с тем, что merge вернул, а не с тем, что вы в него отдали.

Что именно делает merge: находит в контексте, а если нет, загружает из базы управляемую копию с тем же идентификатором, переписывает в неё поля из вашего объекта и возвращает эту копию. Сам переданный объект управляемым не становится, и изменения после merge надо делать на возвращённом.

Removed (удалённое)

Сущность помечена для удаления. При следующем flush Hibernate выполнит DELETE.

Order order = em.find(Order.class, 1L); // managed
em.remove(order); // помечена как removed
// при flush → DELETE FROM orders WHERE id = 1

Dirty checking: откуда берётся UPDATE без явного save

Одна из часто удивляющих возможностей Hibernate — dirty checking (обнаружение изменений). В момент flush Hibernate сравнивает текущее состояние каждой managed-сущности со снимком, который он сделал при её загрузке. Если поля изменились — автоматически генерируется UPDATE.

Коротко: managed-сущность = живая связь с базой, а не просто объект в памяти.

@Transactional
public void updateEmail(Long userId, String newEmail) {
    User user = em.find(User.class, userId); // Hibernate сохраняет снимок
    user.setEmail(newEmail);                 // изменяем поле
    // em.save() нет и не нужен
    // при flush: UPDATE users SET email = ? WHERE id = ?
}

Dirty checking работает только для managed-сущностей. Для transient и detached Hibernate никакого UPDATE не делает.

Ту же механику можно собрать на голой Java, без базы и без Hibernate: карта «ключ → объект», снимок полей при загрузке и сравнение со снимком при сбросе.

живой пример

import java.util.HashMap;
import java.util.Map;

public class PersistenceContextDemo {

    static class Order {
        final long id;
        String status;
        Order(long id, String status) { this.id = id; this.status = status; }
    }

    static class Context {
        private final Map<Long, Order> identityMap = new HashMap<>();
        private final Map<Long, String> snapshot = new HashMap<>();
        int selects;

        Order find(long id) {
            Order known = identityMap.get(id);
            if (known != null) {
                return known;
            }
            selects++;
            Order loaded = new Order(id, "NEW");
            identityMap.put(id, loaded);
            snapshot.put(id, loaded.status);
            return loaded;
        }

        void flush() {
            for (Order order : identityMap.values()) {
                if (!order.status.equals(snapshot.get(order.id))) {
                    System.out.println("UPDATE orders SET status = '" + order.status
                            + "' WHERE id = " + order.id);
                    snapshot.put(order.id, order.status);
                }
            }
        }
    }

    public static void main(String[] args) {
        Context em = new Context();

        Order first = em.find(1L);
        Order second = em.find(1L);
        System.out.println("first == second: " + (first == second));
        System.out.println("запросов в базу: " + em.selects);

        first.status = "CONFIRMED";
        System.out.println("flush после изменения:");
        em.flush();

        System.out.println("flush без изменений:");
        em.flush();
        System.out.println("(тишина — снимок совпал)");
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Оба find вернули один объект, запрос в «базу» ушёл один, а UPDATE появился только после изменения поля. Второй flush молчит: снимок уже обновлён и сравнивать нечего.

Контекст помнит всё: цена и лечение

У карты идентичности есть обратная сторона, о которую спотыкаются в первой же задаче на обработку данных. Контекст держит все загруженные и сохранённые сущности до конца транзакции — вместе со снимками для сравнения. Цикл на сто тысяч строк не освобождает память по ходу дела: он её только занимает.

@Transactional
public void recalculate() {
    for (int i = 0; i < 100_000; i++) {
        Order order = orders.findById(ids.get(i)).orElseThrow();
        order.recalculate();
        // объект и его снимок остаются в контексте до конца метода
    }
}

Лечение — порции с очисткой:

@Transactional
public void recalculate(List<Long> ids) {
    for (int i = 0; i < ids.size(); i++) {
        Order order = em.find(Order.class, ids.get(i));
        order.recalculate();
        if (i % 500 == 0) {
            em.flush();   // отправить накопленные изменения в базу
            em.clear();   // забыть объекты и снимки
        }
    }
}

flush отправляет накопленные изменения в базу, clear очищает контекст. Порядок важен: clear без flush просто потеряет изменения, потому что забывать нечего будет. И после clear все прежние объекты становятся отсоединёнными — трогать их дальше нельзя, ленивые связи у них уже не загрузятся.

Тот же вывод в другой формулировке: findAll() на большой таблице — не «медленный запрос», а способ исчерпать память. Для обработки берут порции по идентификаторам или потоковое чтение, для отчётов — проекции вместо сущностей.

Ссылка без загрузки: getReference

Карта идентичности объясняет и то, зачем нужен getReference. Чтобы проставить заказу покупателя, объект покупателя не нужен — нужен только его идентификатор в колонке:

order.setCustomer(em.getReference(Customer.class, customerId));

Возвращается заместитель: в контексте он есть, а запроса в базу нет, пока не обратились к полям. В Spring Data это getReferenceById. Экономия — один SELECT на каждую проставленную связь, а в цикле сохранения это уже заметно.

Flush: когда изменения уходят в базу

Flush — это синхронизация состояния persistence context с базой данных. Hibernate выполняет SQL, но транзакция ещё не зафиксирована — откат остаётся возможным.

По умолчанию flush происходит:

  • перед commit транзакции,
  • перед выполнением JPQL/HQL-запроса, если есть ожидающие изменения для тех же таблиц,
  • при явном вызове em.flush().

Ручной em.flush() нужен редко. Перед нативным запросом Hibernate сбрасывает изменения и сам, причём делает это грубо: чужой SQL он не разбирает и не знает, каких таблиц тот касается, поэтому в базу уезжает весь накопленный контекст целиком. Типовой случай «поменял сущность и тут же читаю тем же SQL» закрыт без вашего участия. Настоящие поводы — получить сгенерированный базой идентификатор до конца транзакции или поймать нарушение ограничения именно здесь, а не в момент фиксации.

Типичные ошибки, связанные с состояниями

LazyInitializationException — попытка обратиться к lazy-коллекции вне persistence context (после закрытия транзакции). Сущность стала detached, Hibernate не может выдать прокси-запрос в базу.

// НЕПРАВИЛЬНО: транзакция закрылась, items — detached lazy-коллекция
Order order = orderService.findById(1L);
order.getItems().size(); // LazyInitializationException

Решение — загрузить данные внутри транзакции (JOIN FETCH, @EntityGraph) или использовать DTO-проекции. Подробнее — в статье /hibernate/lazy-vs-eager/.

Случайный UPDATE из-за dirty checking — вы загружаете сущность для чтения, но случайно меняете поле — и Hibernate пишет UPDATE. Для read-only операций используйте em.detach(entity) после загрузки или аннотируйте метод @Transactional(readOnly = true) (Spring выставит FlushMode.MANUAL). Второй способ работает просто: flush не случится, значит и UPDATE до базы не доедет. Но сущности всё равно попадают в контекст вместе со снимком полей — памяти такой метод не экономит, и detached они от этого не становятся.

Отдельный случай, который удивляет в Spring Data: save() на сущности с уже заданным идентификатором. Метод смотрит, новая ли сущность (обычно — по тому, пуст ли идентификатор), и при заполненном уходит в merge. А merge обязан сначала прочитать текущее состояние строки — то есть делает лишний SELECT перед UPDATE.

Для естественных ключей, которые назначает приложение (идентификатор пришёл снаружи, собран из бизнес-полей), это означает два запроса на каждое сохранение вместо одного. Обходят это двумя способами: дать сущности реализовать Persistable с собственным ответом на вопрос «новая ли я», или не пользоваться save() для новых объектов — вызвать em.persist(...) явно.

Объект вернулся из формы: что делать с detached

Сценарий типовой: объект отдали клиенту, через десять минут он вернулся изменённым. Это отсоединённая сущность, и «просто сохранить» её опаснее, чем кажется.

Три рабочих подхода. Первый и обычно лучший: не принимать сущность снаружи вовсе. Принять объект передачи данных с нужными полями, загрузить сущность из базы по идентификатору и перенести на неё только те поля, которые клиенту разрешено менять. Тогда никто не сможет прислать чужой идентификатор, изменённую версию или поле, которое менять нельзя.

Второй: merge — когда объект действительно свой и полный. Помнить при этом, что merge не присоединяет ваш объект: он копирует состояние в управляемую копию и возвращает её, а дальше работать надо с возвращённым объектом.

Третий, который делает второй безопасным: возить с объектом версию. Поле @Version уходит клиенту в скрытом поле формы или в теле ответа, возвращается обратно и попадает в merge; если строку тем временем изменил кто-то другой, версии разойдутся и прилетит OptimisticLockException вместо молчаливой потери чужой правки. Подробно — в статье про транзакции и блокировки.

И про границы: «контекст закрылся» — это всегда про границу транзакции, а не про Hibernate. Если метод с @Transactional вызван из соседнего метода того же класса, аннотация не сработала, транзакции нет, и контекст живёт ровно один запрос. Разбор этих границ — в статье про @Transactional.

Порядок SQL при flush — не порядок вашего кода

Ещё одно следствие «буферной» природы persistence context: накопленные изменения уходят в базу не в том порядке, в каком написан код, а в том, который Hibernate назначает сам. Грубо он такой: сначала INSERT, потом UPDATE, затем действия над коллекциями и только в конце DELETE сущностей. Запоминать стоит не эту тройку — она не полная, — а сам факт: порядок фиксирован Hibernate и с порядком строк в вашем методе не совпадает.

INSERT новые сущности UPDATE изменённые поля коллекции вставки и удаления DELETE удалённые сущности

Порядок, в котором Hibernate отправляет накопленный SQL при flush: он фиксирован и с порядком строк в методе не совпадает.

Классическая ловушка: код удаляет строку с уникальным значением и вставляет новую с тем же значением — а при flush INSERT уезжает в базу раньше DELETE, и транзакция падает с нарушением уникальности, хотя «по коду» всё правильно.

Лечится явным entityManager.flush() между удалением и вставкой либо переработкой пары операций в один UPDATE.

Связь со Spring Data JPA

Если вы работаете через JpaRepository, всё описанное работает точно так же — EntityManager просто скрыт внутри Spring Data. Метод save() репозитория вызывает em.persist() для новых сущностей и em.merge() для detached. Managed-сущности, изменённые внутри транзакции, Spring Data не нужно сохранять явно. Подробнее об устройстве репозиториев — в /spring/data-jpa/.

Дополнительно: при первом чтении можно пропустить

Глубже: контекст и потокирасширенное

Контекст персистентности привязан к потоку: EntityManager, который Spring внедряет в сервис, это прокси, находящий «свой» контекст по текущему потоку и транзакции. Из этого следуют три правила для кода, который уходит в другой поток.

Сущность, переданная в @Async-метод или в CompletableFuture.supplyAsync, в новом потоке отсоединена: её контекст остался в потоке запроса. Ленивая связь на ней даст LazyInitializationException, save сделает merge с копированием состояния. Поэтому между потоками передают идентификаторы или DTO, а в новом потоке открывают свою транзакцию и загружают сущность заново:

@Async
@Transactional
public void notify(UUID orderId) {          // не Order, а его идентификатор
    Order order = orders.findById(orderId).orElseThrow();
    mailer.send(order.customer().email(), order);
}

Сам EntityManager и Session не потокобезопасны: один контекст из двух потоков ломается непредсказуемо, от ConcurrentModificationException до тихо потерянных изменений. И транзакция не переходит в дочерний поток: @Transactional снаружи не накрывает работу внутри @Async, у него своя транзакция или её нет вовсе, и откат снаружи не отменит уже сохранённое внутри. То же с параллельными стримами: orders.parallelStream().forEach(o -> repo.save(o)) это несколько потоков в одном контексте, и это не ускорение, а гонка.

Коротко

  • Persistence context — рабочая область Hibernate, живёт в рамках транзакции.
  • Identity map гарантирует: одна строка таблицы = один Java-объект в рамках одного persistence context.
  • Четыре состояния сущности: transient (не известна Hibernate), managed (под наблюдением), detached (знакома, но не отслеживается), removed (помечена для удаления).
  • Dirty checking: Hibernate сам генерирует UPDATE, если managed-сущность изменилась — явный save не нужен.
  • Flush синхронизирует изменения с базой перед коммитом или перед запросом к той же таблице, а порядок SQL назначает Hibernate — с порядком строк в вашем методе он не совпадает.
  • LazyInitializationException — признак обращения к lazy-данным после закрытия persistence context.
  • Контекст живёт в потоке: в @Async и CompletableFuture передают идентификаторы, а не сущности, и открывают свою транзакцию; один EntityManager из двух потоков и parallelStream с save это гонка.
  • Контекст держит все объекты и их снимки до конца транзакции: цикл по сотне тысяч строк требует flush и clear каждые несколько сотен, а findAll() на большой таблице исчерпывает память.
  • getReference проставляет связь без запроса; save() на сущности с заданным идентификатором уходит в merge и делает лишний SELECT.
  • Объект из формы не сохраняют напрямую: принимают объект передачи данных и переносят разрешённые поля, а если всё же merge — то с полем @Version, иначе чужая правка теряется молча.

Что почитать дальше