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 (керований)"] --> B["items: PersistentBag / PersistentSet — ще НЕ ініціалізована"]
B -->|перше реальне читання елементів| C["SELECT ... FROM order_item WHERE order_id=?"]
C --> D["Обʼєкти OrderItem у памʼяті"]
Важливо: саме виклик 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; // зазвичай повертає керовану обгортку, а не "справжній 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; // ведуча сторона зв’язку
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(...). Це не «правильний бізнес-код», а інструмент діагностики. У курсі з глибоким зануренням такі інструменти — майже обов’язкова частина мислення.
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; // повертається керована обгортка
}
}
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("Позицій = " + order.getItems().size()); // Позицій = 3 (і тут може бути SELECT)
}
}
З погляду Java це просто друк. З погляду Hibernate це доступ до lazy-колекції, і він чесно робить свою роботу: завантажує елементи (або мінімум інформації про них), щоб відповісти на ваше запитання.
Проблема не в тому, що 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 та на поведінку коду. Якщо тримати цю різницю в голові, стає простіше розуміти, чому одне й те саме замовлення «то робить запит, то ні» — просто в одному місці колекцію вже ініціалізували, а в іншому ще ні.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ