Когда вы работаете с Hibernate, между вашим Java-кодом и базой данных стоит невидимый посредник — persistence context. Он решает, когда и что отправить в базу, и следит за тем, чтобы один и тот же ряд таблицы не превратился в два разных объекта в памяти.
Контекст держит две вещи: карту «ключ → объект», чтобы одна строка таблицы не превратилась в два объекта, и снимок полей, сделанный при загрузке. Второй 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/.
Переходы между четырьмя состояниями; обратно из 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 и с порядком строк в вашем методе не совпадает.
Порядок, в котором 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, иначе чужая правка теряется молча.
Что почитать дальше
- Ленивая и жадная загрузка — как Hibernate решает, когда идти в базу за связанными данными.
- Транзакции и блокировки — оптимистичные и пессимистичные блокировки,
@Version. - Частые ошибки при работе с Hibernate — N+1, случайные UPDATE, проблемы с каскадами.
- Spring Data JPA — репозитории и как они работают поверх
EntityManager.