1. To-many — отдельная песня
Если вы раньше писали код на чистой Java, то List — это просто список. В нём лежат элементы, size() быстрый, for-each понятный, а toString() обычно не открывает портал в ад (максимум — в консоль). В Hibernate мир сложнее и, честно говоря, веселее: коллекция в entity — это часто не та коллекция, которую вы туда положили, и почти никогда не та коллекция, которой вы «наивно доверяете».
Причина проста: Hibernate должен уметь одновременно делать три вещи. Во‑первых, он хочет отложить чтение элементов из БД до момента, когда они действительно понадобятся. Во‑вторых, он должен уметь отслеживать изменения коллекции (добавили/удалили элементы) и корректно записать это в БД при flush(). В‑третьих, он обязан помнить, к какой Session привязан этот объект, иначе он не сможет «дочитать» элементы. Для этого Hibernate подменяет обычный ArrayList на persistent collection — управляемую обёртку.
Чтобы не обсуждать ORM в вакууме, будем держать в голове наши реальные сущности из Commerce Persistence Lab: PurchaseOrder (заказ) и его позиции OrderItem, а также Customer и его CustomerAddress. Это классические to-many кейсы: «у заказа много позиций», «у клиента много адресов».
2. Proxy vs Persistent Collection
Когда вы впервые сталкиваетесь с lazy loading, возникает соблазн свести всё к одной фразе: «Hibernate грузит позже». Эта фраза полезная, но слишком общая. Технически Hibernate делает «позже» по-разному для to-one и to-many, и именно из-за этой разницы начинающие разработчики потом удивляются: «Почему тут proxy, а тут какая-то странная коллекция?».
Для to-one Hibernate часто использует proxy-объект, который притворяется вашей сущностью (например, Customer), но внутри хранит идентификатор и ссылку на Session. Для to-many Hibernate обычно создаёт обёртку над коллекцией, которая реализует List или Set, но при попытке “реально посмотреть элементы” делает запрос в БД. Это похоже на умную коробку: коробка у вас уже есть, но содержимое в неё ещё не положили — оно «на складе» (PostgreSQL) и привезут по первому требованию, если курьер (Session) ещё на работе.
Наглядно это можно представить так:
flowchart TD
A["PurchaseOrder (managed)"] --> B["items: PersistentBag / PersistentSet — ещё НЕ инициализирована"]
B -->|первое реальное чтение элементов| C["SELECT ... FROM order_item WHERE order_id=?"]
C --> D["OrderItem objects в памяти"]
Важно: сама ссылка order.getItems() обычно возвращает не null и не «пустой список», а именно управляемую обёртку. То есть коллекция «есть», но элементы в неё ещё не загружены.
3. Что такое Persistent Collection
Слово “persistent” здесь не значит «вечный» или «сохранённый на диск». Оно означает: «коллекция, которой управляет persistence layer». В Hibernate это семейство классов, которые реализуют коллекционные интерфейсы (List, Set, иногда Map), но дополнительно хранят метаданные: кто владелец, какой ключ связи, какая текущая Session, инициализирована ли коллекция, есть ли отложенные изменения.
В рантайме вы часто увидите такие типы (названия могут отличаться в деталях, но идея одна): PersistentBag, PersistentSet, PersistentList. И вот здесь полезно сделать маленькую “прививку реализма”: если вы в отладчике видите не ArrayList, это не «Hibernate сломал Java», это Hibernate делает свою работу.
Возьмём упрощённый кусок нашей модели заказа. Важно: код ниже — это фрагмент, чтобы не утонуть в полях. Мы пока не превращаем лекцию в квест про owning side/cascade — это будет позже, сейчас нам важен lazy-аспект.
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "purchase_order") // таблица заказов
public class PurchaseOrder {
@Id
private Long id; // идентификатор заказа
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
// Важно: LAZY означает, что элементы не обязаны быть загружены при чтении заказа
private List<OrderItem> items = new ArrayList<>(); // начальное значение подменится на persistent collection
public List<OrderItem> getItems() {
return items; // обычно возвращает managed-обёртку, а не "реальный ArrayList"
}
}
И позиции заказа:
import jakarta.persistence.*;
@Entity
@Table(name = "order_item") // таблица позиций заказа
public class OrderItem {
@Id
private Long id; // идентификатор позиции
@ManyToOne(fetch = FetchType.LAZY)
// Важно: ссылка на заказ тоже обычно проксируется и грузится лениво
@JoinColumn(name = "order_id")
private PurchaseOrder order; // owning side связи
private int quantity; // количество товара в позиции
public int getQuantity() {
return quantity;
}
}
Две важные мысли здесь нужно удержать одновременно. Первая: поле items объявлено как List<OrderItem>, и внешне оно выглядит как “обычная Java-коллекция”. Вторая: когда Hibernate загрузит PurchaseOrder из базы, он почти наверняка подставит в это поле свою реализацию, и она будет уметь лениво загружать элементы.
4. Инициализация коллекции и триггеры
Почти все проблемы с lazy-коллекциями начинаются с одной логической ошибки: мы видим объект List, значит «в списке уже лежат элементы». В Hibernate это неверно. Коллекция может существовать как объект, но быть неинициализированной. Неинициализированная коллекция — это не «пустая коллекция», а «коллекция, которую ещё не наполнили данными из БД».
Что же считается “первым реальным доступом”, после которого Hibernate обязан сходить в базу? В большинстве обычных сценариев это любая операция, которая требует увидеть элементы: обход for-each, stream(), get(0), часто size() или isEmpty(). Можно спорить о нюансах (Hibernate умеет оптимизировать некоторые вещи, есть режимы “extra lazy”), но как инженерная привычка вам нужна простая модель: если вы начали читать содержимое — ждите SQL.
Ниже — маленькая таблица “интуитивных триггеров”. Это не официальный контракт JPA, а практическая памятка, чтобы вы ловили момент, когда “безобидный код” становится запросом.
| Операция с order.getItems() | Что вы ожидаете как Java-разработчик | Что часто происходит в Hibernate |
|---|---|---|
| getItems() | «получил список» | получаете persistent wrapper, без SQL |
| getItems().getClass() | «тип списка» | тип вроде PersistentBag, без SQL |
| getItems().size() | «узнал размер» | обычно инициализация + SELECT |
| for (var i : items) | «прошёлся по элементам» | инициализация + SELECT |
| items.stream().map(...) | «стрим по элементам» | инициализация + SELECT |
| items.toString() | «отладочная строка» | может вызвать чтение элементов (и SQL) |
Самое неприятное здесь то, что код не выглядит опасно. Особенно size(). Глаз человека читает size() как O(1) и без сайд-эффектов. Hibernate читает size() как “нужны ли элементы?” и часто отвечает SQL-ом.
5. Эксперимент: тип коллекции
Сухая теория про PersistentBag запоминается хуже, чем один увиденный println(). Поэтому давайте сделаем очень маленький сервисный фрагмент “посмотреть, что у нас в руках”. В Commerce Persistence Lab мы можем завести учебный сервис (в реальном проекте вы бы так не делали, но в лаборатории это нормально), который загрузит заказ и напечатает тип коллекции.
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderDebugService {
private final EntityManager entityManager; // через него читаем сущности из persistence context
public OrderDebugService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional(readOnly = true) // важно: должна быть открытая транзакция/сессия
public void printItemsCollectionType(long orderId) {
// Загружаем только сам заказ (items пока не обязаны грузиться)
PurchaseOrder order = entityManager.find(PurchaseOrder.class, orderId);
// Здесь мы не итерируемся и не вызываем size(): смотрим только класс обёртки
System.out.println(order.getItems().getClass()); // обычно: class org.hibernate.collection.spi.PersistentBag
}
}
Здесь важно, что мы не делаем size() и не итерируемся. Мы просто смотрим тип объекта. Это почти всегда безопасно: SQL уже ушёл на загрузку PurchaseOrder, но не обязан уходить на загрузку OrderItem.
Если вы увидели в выводе что-то похожее на PersistentBag или PersistentSet, поздравляю: вы только что “вживую” увидели persistent collection. И, что ещё важнее, вы перестаёте ожидать от неё поведения обычного ArrayList.
6. Момент инициализации: один size() — и здравствуй, SQL
Теперь сделаем следующий маленький шаг: проверим, в какой момент Hibernate считает коллекцию инициализированной. Для этого удобно использовать утилиту Hibernate: Hibernate.isInitialized(...). Это не “правильный бизнес-код”, это инструмент диагностики. В deep-dive курсе такие инструменты — почти обязательная часть мышления.
import jakarta.persistence.EntityManager;
import org.hibernate.Hibernate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderInitProbeService {
private final EntityManager entityManager;
public OrderInitProbeService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional(readOnly = true)
public void probe(long orderId) {
PurchaseOrder order = entityManager.find(PurchaseOrder.class, orderId);
// До первого "чтения содержимого" коллекция обычно не инициализирована
System.out.println(Hibernate.isInitialized(order.getItems())); // false
// Невинный size() часто превращается в SELECT по order_item
System.out.println(order.getItems().size()); // 3 (и тут обычно будет SELECT)
// После этого коллекция уже считалась и помечена как initialized
System.out.println(Hibernate.isInitialized(order.getItems())); // true
}
}
Обратите внимание на психологический эффект: вы видите false, потом “невинный” size(), и внезапно true. Это отличный маркер того, где именно в вашем коде происходит переход “коллекция-обёртка” → “коллекция с данными”. Если вы привыкнете мыслить этим переходом, вы будете намного спокойнее читать SQL-лог и ловить “случайные” запросы.
7. SQL при инициализации to-many
Давайте свяжем происходящее в Java с происходящим в PostgreSQL. Когда мы делаем entityManager.find(PurchaseOrder.class, id), Hibernate обычно выполняет SELECT по таблице заказов. Коллекция items при этом остаётся неинициализированной. Когда вы вызываете size() или начинаете обход, Hibernate выполняет второй запрос — уже по таблице order_item, отфильтрованный по order_id.
В логах (в режиме sql-trace) это будет выглядеть примерно так:
select po.id, po.order_number, po.status
from purchase_order po
where po.id = ?
-- чуть позже, в момент size()/iteration:
select oi.id, oi.order_id, oi.quantity
from order_item oi
where oi.order_id = ?
Важный момент: второй запрос “появляется позже” и не связан напрямую с вашим репозиторием. Он связан с тем, что вы «потрогали коллекцию». Это и есть самая опасная часть lazy loading: SQL может возникать там, где вы его не ожидаете, потому что в Java-коде вы просто вызвали метод интерфейса List.
Чтобы закрепить это на другом примере, представьте Customer и его addresses. Модель та же: сначала читаем клиента, потом в момент доступа к адресам уходит запрос по customer_address.
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "customer") // таблица клиентов
public class Customer {
@Id
private Long id; // идентификатор клиента
@OneToMany(mappedBy = "customer", fetch = FetchType.LAZY)
// Адреса по умолчанию могут быть не загружены: это to-many и это нормально
private List<CustomerAddress> addresses = new ArrayList<>(); // будет подменено на persistent collection
public List<CustomerAddress> getAddresses() {
return addresses; // возвращается managed-обёртка
}
}
SQL-паттерн будет таким же: один запрос за customer, второй — за customer_address по customer_id.
8. Скрытые обходы и «безобидные» методы
Один из самых неприятных типов багов в ORM — это когда вы уверены, что “коллекцию я не трогал”, а SQL почему-то ушёл. В таких случаях почти всегда где-то есть скрытый обход: метод, который “слегка” смотрит на элементы, и вы не воспринимаете это как чтение данных. Классика — это size(), isEmpty(), иногда contains(), иногда “красивый лог” или отладочная печать.
Чтобы поймать такие ситуации, полезно мысленно задавать себе вопрос: “Этот метод может потребовать доступ к элементам?” Если да — он потенциально триггерит инициализацию. Например, вот такой код кажется абсолютно мирным:
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderPrintService {
private final EntityManager entityManager;
public OrderPrintService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional(readOnly = true)
public void printItemCount(long orderId) {
// Сначала читаем заказ
PurchaseOrder order = entityManager.find(PurchaseOrder.class, orderId);
// Потом делаем "всего лишь size()", но это может триггерить загрузку items
System.out.println("items = " + order.getItems().size()); // items = 3 (и тут может быть SELECT)
}
}
С точки зрения Java это “печатка”. С точки зрения Hibernate это “доступ к ленивой коллекции”, и он честно делает работу: загружает элементы (или минимум информации о них), чтобы ответить на ваш вопрос.
Проблема не в том, что Hibernate плохой. Проблема в том, что мы иногда пишем код, который неявно формирует требования к данным. Hibernate эти требования выполняет, а мы удивляемся, что требования вообще были.
9. Persistent collection и отслеживание изменений
Хотя тема дня — lazy loading, полезно понимать вторую причину существования persistent collections. Hibernate не просто лениво грузит; он ещё и должен отслеживать изменения коллекции. Если вы внутри транзакции добавили позицию в заказ, Hibernate должен понять это и при flush() сделать нужный INSERT/UPDATE в таблице order_item.
Именно поэтому Hibernate не может оставить вам “обычный ArrayList”: обычный список ничего не знает о Session, не умеет аккуратно вести себя с отложенными операциями и не даёт Hibernate понятного места, где перехватить “коллекцию изменили”. Persistent collection — это как “умный провод” между вашим кодом и ORM: вы двигаете элементы, а провод фиксирует, что произошло.
В рамках этой лекции нам достаточно одной идеи: коллекция в entity — это часть механики ORM, а не просто удобный контейнер. Поэтому относиться к ней стоит с лёгким уважением, как к электричеству в розетке: пользоваться можно, но пальцы внутрь лучше не совать.
10. Типичные ошибки при работе с lazy-коллекциями
Ошибка №1: воспринимать order.getItems() как “готовые элементы”, а не как managed-обёртку.
Обычно это происходит из-за привычки к обычным Java-коллекциям. Разработчик видит List<OrderItem> и делает вывод: “Ну список же, значит он уже заполнен”. Hibernate в этот момент тихо держит в руках persistent wrapper и ждёт первого реального доступа. Ошибка всплывает позже, когда внезапно уходит SQL — и кажется, что он «из ниоткуда».
Ошибка №2: считать size() и isEmpty() “безобидными” и использовать их где угодно.
В чистой Java size() — это почти всегда простое поле, и мозг к этому привыкает. В ORM-коде size() может означать поход в базу, а иногда ещё и материализацию элементов. Поэтому привычка “проверю size в логике/логах/DTO-маппере” легко превращается в неожиданную SQL-активность.
Ошибка №3: диагностировать проблему по коду, а не по моменту SQL.
Когда начинающий разработчик видит в коде только findById(), он думает: “SQL был только здесь”. Но при lazy-коллекциях SQL часто появляется в другом месте: в обходе, в печати, в маппинге, в “маленьком helper-методе”. Если смотреть только на Java-строки, вы не увидите реальную причину. Если смотреть на SQL-лог и на момент инициализации — причина становится очевидной.
Ошибка №4: пытаться “просто заменить на new ArrayList<>()” и ожидать, что lazy исчезнет.
Иногда хочется сделать так: “Ладно, Hibernate, я понял, ты хитрый. Я тебе подложу свой ArrayList и всё будет честно”. На практике Hibernate всё равно будет управлять коллекцией в managed-состоянии, потому что ему нужно отслеживать изменения и поддерживать ленивую модель. В лучшем случае вы ничего не добьётесь, в худшем — получите несогласованное поведение изменения коллекции.
Ошибка №5: не отличать “пустая коллекция” от “неинициализированной коллекции”.
Пустая коллекция означает “элементов реально нет”. Неинициализированная означает “мы ещё не узнавали, есть ли элементы”. Это два разных состояния, и они по-разному влияют на SQL и на поведение кода. Если держать это различие в голове, становится проще понимать, почему один и тот же заказ “то делает запрос, то нет” — просто в одном месте коллекцию уже инициализировали, а в другом ещё нет.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ