JavaRush /Курси /Hibernate deep-dive /Lazy loading: колекції to-many

Lazy loading: колекції to-many

Hibernate deep-dive
Рівень 6 , Лекція 1
Відкрита

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 та на поведінку коду. Якщо тримати цю різницю в голові, стає простіше розуміти, чому одне й те саме замовлення «то робить запит, то ні» — просто в одному місці колекцію вже ініціалізували, а в іншому ще ні.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ