CodeGym /Kurse /Hibernate deep-dive /LazyInitializationException und Session

LazyInitializationException und Session

Hibernate deep-dive
Level 6, Lektion 2
Verfügbar

1. Daten werden benötigt, aber der Kontext ist weg

Wenn Sie zum ersten Mal auf LazyInitializationException stoßen, fühlt es sich ungefähr so an: „Ich habe einfach einen Getter aufgerufen – warum werde ich mit einer Exception bestraft? Ich bin doch ein guter Mensch.“ Das ist eine normale Reaktion. Das Problem ist, dass ein Getter in ORM manchmal nicht „gib das Feld zurück“ bedeutet, sondern „geh bei Bedarf in die Datenbank und gib das Feld zurück“. Und wenn Hibernate nicht korrekt in die Datenbank gehen kann, fällt es lieber sofort aus, als so zu tun, als ob alles in Ordnung wäre.

Meistens sieht die Meldung ungefähr so aus (variiert je nach Version, aber der Sinn ist derselbe):

org.hibernate.LazyInitializationException:
could not initialize proxy [com.example.commerce.customer.entity.Customer#5] - no Session

Beachten Sie die beiden Teile. Der erste Teil sagt, dass Hibernate versucht hat, ein Proxy zu initialisieren (also einen echten Ladevorgang durchzuführen). Der zweite Teil erklärt direkt den Grund: no Session. Nicht „Problem mit dem fetch type“, nicht „JPA ist kaputt“, nicht „PostgreSQL ist beleidigt“, sondern „das Proxy hat keine aktive Hibernate Session mehr, über die die Daten geladen werden könnten“.

Es ist sehr hilfreich, diese Exception nicht als Bug, sondern als diagnostisches Signal zu betrachten. Sie zeigt, dass Sie versucht haben, Daten außerhalb einer korrekten Ladegrenze zu lesen. Im Übungsprojekt Commerce Persistence Lab ist diese Exception besonders wertvoll: Sie zwingt Sie dazu, die Stelle zu identifizieren, an der Sie die Transaktion „verloren“ haben – anstatt versehentlich weiterzuarbeiten und sich dann über unnötige Abfragen an unerwarteten Stellen zu wundern.

An diesem Punkt haben wir bereits beide Details der Mechanik: to-one lazy lebt in einem Proxy, to-many in einer persistent collection. Jetzt müssen wir das nur noch mit dem kombinieren, was wir über managed und detached wissen: Solange diese lazy-Hülle eine lebendige Session hat, kann sie die Daten nachladen; wenn die Session verschwunden ist, wird derselbe Getter zu einer LazyInitializationException.

2. Was eine lazy-Assoziation zum „Nachladen“ braucht: ein kleines Modell aus 4 Teilen

Damit die LazyInitializationException kein Mysterium mehr ist, müssen Sie nur ein einfaches Schema im Kopf haben: wer wen anruft, wenn Sie auf eine lazy-Assoziation zugreifen. Die schlechte Nachricht: „nur ein Getter“ ist nicht immer einfach. Die gute Nachricht: Das Modell ist sehr kurz und schnell zu merken, weil es an eine alltägliche Situation erinnert – Sie versuchen, eine Tür mit einem Schlüssel zu öffnen, aber der Schlüssel steckt in einer anderen Jacke.

Stellen Sie sich vor, Sie haben eine PurchaseOrder order, und sie hat order.getCustomer(), wobei customer lazy ist. Solange customer nicht initialisiert ist, speichert Hibernate dort kein vollwertiges Objekt, sondern einen „Stellvertreter“. Dieser Stellvertreter kennt die ID des benötigten Datensatzes und weiß: „Wenn mich jemand bittet, echte Daten zu liefern, muss ich über die Session in die Datenbank gehen“.

Als kleine Kette sieht das so aus:

sequenceDiagram
    participant Code as Ihr Code
    participant Proxy as "Proxy/Sammlung (lazy)"
    participant Session as "Hibernate Session (persistence context)"
    participant DB as PostgreSQL

    Code->>Proxy: "Datenanfrage (getter/size/iteration)"
    Proxy->>Session: "initialisiere mich"
    Session->>DB: SELECT ...
    DB-->>Session: Ergebniszeilen
    Session-->>Proxy: Daten geladen
    Proxy-->>Code: gebe Werte zurück

Und hier liegt die ganze „Magie“: Proxies und persistent collections gehen nicht selbst in die DB. Sie tun dies immer über die Session/den persistence context. Wenn die Session bereits geschlossen oder vom Objekt gelöst ist, kann das Proxy nicht korrekt weiterarbeiten, und anstelle von „naja, irgendwie“ erhalten wir ein ehrliches „no Session“.

3. Die Grenze von @Transactional und das Ende der unit of work

Wenn es um die Session geht, gibt es bei Anfängern zwei klassische Verwirrungen. Die erste ist, die Hibernate Session mit der HTTP-Session (HttpSession) zu verwechseln. Die zweite ist zu denken, dass die Session „etwas Ewiges“ sei, das wie eine Katze neben der Anwendung lebt. In Wahrheit lebt die Hibernate Session (oder JPA EntityManager) in einer typischen Spring-Anwendung um eine Transaktion herum und ist normalerweise für die Dauer dieser Transaktion an den Ausführungsthread gebunden.

Vereinfacht gesagt passiert in unserer Anwendung Commerce Persistence Lab beim Aufruf einer Servicemethode mit @Transactional ungefähr Folgendes: Spring öffnet über AOP eine Transaktion, bindet den EntityManager an den aktuellen Thread, führt Ihren Code aus und macht dann am Ende einen Commit/Rollback, gibt JDBC-Ressourcen frei und beendet die Arbeit des EntityManager für diese unit of work. In diesem Moment haben Ihre Entitäten keinen aktiven persistence context mehr.

Es ist hilfreich, sich das wie einen „kurzen Korridor“ vorzustellen. Im Korridor gibt es Licht (Session), und in diesem Licht kann das Proxy Ihnen die Daten bringen. Sie verlassen den Korridor – kein Licht mehr, und das Proxy sagt ehrlich: „Kumpel, ich würde gerne, aber ich bin jetzt nur ein Stück Plastik.“

Anschauliches Diagramm:

sequenceDiagram
    participant Caller as "Aufrufender Code (Runner/Controller)"
    participant Tx as "Spring Tx (AOP)"
    participant EM as "EntityManager/Session"
    participant DB as PostgreSQL

    Caller->>Tx: service.loadOrder(10) aufrufen
    Tx->>EM: Session öffnen/binden
    Tx->>DB: BEGIN
    Tx->>EM: entityManager.find(...)
    EM->>DB: SELECT ...
    DB-->>EM: row
    EM-->>Caller: managed-Entity zurückgeben
    Tx->>DB: COMMIT
    Tx->>EM: Session loslösen/schließen
    Caller->>Caller: "order.getCustomer().getEmail() (BOOM)"

Beachten Sie den wichtigen Punkt: Die Entität wird dem aufrufenden Code „als Java-Objekt“ zurückgegeben. Java weiß nicht, dass das Objekt „lazy-Versprechen“ enthält. Hibernate weiß es – und stürzt ab, wenn das Versprechen nicht eingelöst werden kann.

4. Detached: „Das Objekt lebt, aber es ist nicht mehr managed“

Bevor wir uns konkrete Beispiele ansehen, müssen wir einen Gedanken zu 100% akzeptieren: Ein detached-Objekt ist kein „kaputtes Objekt“. Es ist nicht kaputt, nicht beschädigt, nicht von einem Schamanen verflucht. Es ist nur nicht mehr mit dem persistence context verbunden. Hibernate hat seine unit of work beendet und ist nicht mehr verpflichtet (und oft physisch nicht in der Lage), für dieses Objekt zusätzliche Arbeit zu leisten.

Um Verwirrung zu vermeiden, ist es praktisch, eine kleine Tabelle zu haben. Sie handelt nicht von „Annotationen“, sondern von der tatsächlichen Fähigkeit Hibernates, lazy loading durchzuführen:

Entitätsstatus Kann die Assoziation lazy nachgeladen werden? Warum
managed ja es gibt eine Session, es gibt einen persistence context, es gibt einen Ort, an den das Geladene „angehängt“ werden kann
detached normalerweise nein das Proxy/ die Sammlung hat keine aktive Session (no Session)
transient nein das Objekt ist nicht mit der DB verbunden, hat oft keine ID, es gibt nichts, was „per Referenz“ geladen werden kann
removed nein (und auch nicht nötig) das Objekt wird gerade gelöscht, lazy-Nachladen würde die Situation nur verschlimmern

Mit detached-Daten sind Sie bereits durch merge()-Szenarien vertraut: Daten könnten in DTOs gewandert sein, eine Serialisierung überlebt haben, eine andere Anwendungsschicht durchlaufen haben – und detached geworden sein. Heute fügen wir dieser Liste noch eine häufigste Quelle für detached hinzu: Das Ende einer Transaktion.

5. Beispiel Nr. 1: PurchaseOrder → Customer

Jetzt erstellen wir ein extrem robustes to-one-Beispiel aus unserem Projekt. Die Beziehung Bestellung → Kunde haben wir bereits mit der lazy-Mechanik gesehen, und genau bei ManyToOne(fetch = LAZY) ist es am einfachsten, den Moment zu erwischen, in dem ein Getter zu SQL wird. Mit Product.details ist das gleiche Problem auch möglich, aber für das grundlegende Modell ist es hier nützlicher, sich an das vorhersagbarere PurchaseOrder.customer zu halten.

Ein minimaler Mapping-Ausschnitt könnte so aussehen:

import jakarta.persistence.*;

@Entity
public class PurchaseOrder {
    @Id
    private Long id;

    // LAZY: customer kann ein Proxy sein, die eigentlichen Felder des Kunden werden bei Bedarf nachgeladen
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")
    private Customer customer;

    public Customer getCustomer() {
        return customer;
    }
}

Stellen Sie sich nun einen Service vor, der einfach eine Bestellung zurückgibt (eine typische „naive“ Leseimplementierung):

import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderLookupService {
    private final EntityManager entityManager;

    public OrderLookupService(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Transactional(readOnly = true)
    public PurchaseOrder loadOrder(Long id) {
        // In diesem Schritt erhalten wir eine managed-Entität innerhalb der Transaktion
        return entityManager.find(PurchaseOrder.class, id);
        // Nach Verlassen der Methode endet die Transaktion und die Entität wird detached
    }
}

Und jetzt der aufrufende Code. Das kann ein Controller, Runner, Test, Mapper sein – egal. Wichtig ist, dass der Aufruf von loadOrder() beendet ist und die Transaktion geschlossen wurde:

PurchaseOrder order = orderLookupService.loadOrder(10L); // Transaktion bereits beendet

// Hier greifen wir auf die lazy-Assoziation bereits außerhalb der unit of work zu
System.out.println(order.getCustomer().getEmail());
// org.hibernate.LazyInitializationException: ... - no Session

Genau hier fangen Sie die klassische LazyInitializationException ein. Von außen sieht es „ungerecht“ aus: order ist ein echtes Objekt, es ist nicht null. Aber order.getCustomer() gibt eine lazy-Referenz zurück, und wenn Sie getEmail() anfordern, will Hibernate ein SELECT auf den Kunden ausführen. Aber die Session ist bereits geschlossen.

Wenn Sie das SQL-trace-Profil aktiviert haben, sehen Sie, dass ein SELECT auf PurchaseOrder stattfand, aber ein SELECT auf Customer nicht mehr abgesetzt werden konnte: Es hätte später kommen sollen, aber „später“ war bereits außerhalb der unit of work.

6. Beispiel Nr. 2: PurchaseOrder.items und der Auslöser size()

Das zweite Beispiel ist psychologisch meist noch unangenehmer, weil es oft durch einen sehr einfachen Aufruf von size() oder einer for-each entsteht. In Commerce Persistence Lab sind das PurchaseOrder und seine Positionen OrderItem.

Der Ausschnitt der Bestell-Entität könnte so sein:

import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class PurchaseOrder {
    @Id
    private Long id;

    // Wichtig: Dies ist eine LAZY-Sammlung - innen wird es keine "gewöhnliche Liste",
    // sondern einen Hibernate-Wrapper geben, der über die Session nachladen kann
    @OneToMany(fetch = FetchType.LAZY, mappedBy = "order")
    private List<OrderItem> items = new ArrayList<>();

    public List<OrderItem> getItems() { return items; }
}

Und wieder ein „naiver“ Service, der die Bestellung zurückgibt:

import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderQueryService {
    private final EntityManager entityManager;

    public OrderQueryService(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Transactional(readOnly = true)
    public PurchaseOrder loadOrder(Long id) {
        // Innerhalb der Transaktion ist order managed
        return entityManager.find(PurchaseOrder.class, id);
        // Außerhalb der Transaktion wird order detached und lazy-Sammlungen können nicht nachgeladen werden
    }
}

Und nun der aufrufende Code:

PurchaseOrder order = orderQueryService.loadOrder(10L); // Transaktion bereits beendet

// size() sieht "sicher" aus, aber für eine LAZY-Sammlung kann das eine Abfrage an die DB bedeuten
int count = order.getItems().size(); // LazyInitializationException
System.out.println("items = " + count);

Warum stürzt das ab? Weil order.getItems() keine „normale ArrayList aus der DB“ zurückgibt, sondern einen verwalteten Hibernate-Wrapper. Solange dieser nicht initialisiert ist, weiß Hibernate nicht, wie viele Elemente darin sind, und um size() ehrlich beantworten zu können, müsste es die Elemente laden oder zumindest eine Abfrage durchführen, die die Größe berechnet. Aber wieder: no Session.

Für Anfänger ist das sehr überraschend, weil size() „sicher“ aussieht. In der ORM-Welt ist das nicht immer der Fall. In der ORM-Welt ist nicht die Methode sicher, sondern der Kontext, in dem Sie sie aufrufen.

7. Nachladen ohne Session: Was genau fehlt

Irgendwann kommt einem Studenten der natürliche Gedanke: „Na gut, die Session ist geschlossen … aber ich habe doch die ID. Warum öffnet Hibernate nicht einfach eine Verbindung, macht ein SELECT und gibt die Daten zurück?“ Und genau hier liegt ein wichtiger technischer Grund, warum die LazyInitializationException überhaupt existiert.

Hibernate Session ist nicht einfach nur ein „Kabel zur Datenbank“. Die Session ist ein Arbeitskontext, in dem die identity map, Snapshots, Synchronisationsregeln, die Aktionswarteschlange für den Flush, die Bindung an die Transaktion und eine Menge interner Garantien leben. Wenn Hibernate anfangen würde, „aus sich heraus“ zu beliebigen Zeitpunkten Verbindungen zu öffnen und Daten nachzuladen, würde es die Vorhersagbarkeit der unit of work zerstören. Sie könnten eine Situation bekommen, in der die Hälfte der Daten in einer Transaktion gelesen wurde und die andere Hälfte bereits in einer anderen, mit einer anderen Isolationsstufe, einer anderen Zeit und anderen parallelen Änderungen. Das wäre nicht „bequem“, sondern „leise falsch“.

Außerdem, selbst wenn man sich vorstellt, Hibernate würde irgendwie Daten ohne Session laden, stellt sich die Frage: Wo sollen die geladenen Objekte angehängt werden? Sind sie managed oder detached? Wie stellt man sicher, dass im Speicher keine Dubletten derselben Zeile entstehen (Verletzung der identity map)? Wie synchronisiert man das mit bereits vorhandenen Objekten? Ohne Session wird das zu einer „selbstgemachten ORM in der ORM“, und das ist, gelinde gesagt, der Weg zu seltsamen Bugs.

Deshalb wählt Hibernate eine ehrliche Strategie: Wenn der lazy-Graph nachgeladen werden muss, aber kein Kontext vorhanden ist – besser abstürzen. Das ist keine Grausamkeit, sondern ein Schutz vor „leisen Halbwahrheiten“, die in der Regel teurer sind als jede Exception.

8. Wie man das Problem diagnostiziert

Wenn Sie eine LazyInitializationException sehen, ist Aufgabe Nr. 1 nicht „schnell FetchType.LAZY entfernen“. Aufgabe Nr. 1 ist, die erste Stelle zu finden, an der Sie tatsächlich die Daten angefordert haben. Denn oft taucht der Fehler „nicht dort auf, wo Sie denken“. Sie könnten z. B. eine „harmlose“ Formatierungsmethode aufgerufen haben, die intern order.getItems().toString() oder eine Sammlungsiteration durchführt, und genau dort fand der Zugriff statt.

Das praktischste Werkzeug zu Beginn ist der Stack-Trace: Er zeigt die Aufrufkette, die zum Initialisierungsversuch geführt hat. Darin ist fast immer der „Berührungspunkt“ zu sehen: Getter, size(), Iteration, Logging, Serialisierung. Für einen Anfänger ist das wie die Suche nach dem, der den Keks gegessen hat, anhand der Krümel: mühsam, aber es funktioniert.

Manchmal ist es nützlich, Hibernate direkt im Code zu fragen, ob die Assoziation initialisiert ist oder nicht. Ja, das ist Hibernate-spezifisch, aber für die Diagnose im Labor ist es in Ordnung:

import org.hibernate.Hibernate;

PurchaseOrder order = orderQueryService.loadOrder(10L);

// false bedeutet: Die Sammlung wurde noch nicht geladen, und beim Zugriff wird SQL benötigt (also eine Session)
System.out.println(Hibernate.isInitialized(order.getItems())); // false
// ...nach dem Zugriff auf items innerhalb der Transaktion wäre es true

Wenn Sie false sehen, wurde die Sammlung noch nicht aus der Datenbank gelesen, und jeder Zugriff auf Elemente (oder auf die Größe) wird SQL erfordern. Wenn Sie true sehen, ist die Sammlung bereits initialisiert (d. h. SQL wurde bereits im Rahmen einer aktiven Session gesendet), und ein erneuter Zugriff sollte nicht wegen lazy fehlschlagen.

Dieser Trick behebt das Problem nicht, aber er macht es sichtbar. In einem solchen Thema ist Sichtbarkeit die halbe Miete. Die andere Hälfte ist, nicht mehr zu erwarten, dass die ORM Ihre Architektur errät.

9. Typische Fehler beim Umgang mit LazyInitializationException

Fehler Nr. 1: Die Exception mit einer Annotation behandeln, ohne herauszufinden, wo die Transaktion verloren ging.
Es ist sehr verlockend, eine LazyInitializationException zu sehen und sofort fetch = EAGER zu setzen, „damit es einfach funktioniert“. Manchmal beseitigt das tatsächlich das Symptom, aber die Ursache bleibt: Der Code liest immer noch Daten außerhalb einer korrekten Operation. Dadurch verschieben Sie das Problem nur an eine andere Stelle und fügen eine unvorhersehbare Ladebelastung hinzu.

Fehler Nr. 2: Entitäten nach außen zurückgeben und erwarten, dass die lazy-Assoziationen „später nachgeladen“ werden.
Ein Java-Objekt überlebt das Ende einer Transaktion leicht, aber der Hibernate-Kontext nicht. Wenn ein Service eine Entität zurückgibt, ist das kein „Versprechen“, dass ihr Graph unendlich expandiert werden kann. Es ist nur die Rückgabe eines Objekts, von dem ein Teil der Daten lazy sein kann. Wenn Sie später den lazy-Teil in einer anderen Schicht anfassen, bitten Sie die ORM selbst, ohne Arbeitskontext zu arbeiten.

Fehler Nr. 3: Die Hibernate Session mit der HTTP Session verwechseln.
Manchmal hört ein Entwickler das Wort „session“ und beginnt, Cookies, Header, Benutzerlogin und andere Web-Dinge zu überprüfen. Die Hibernate Session hat mit dem persistence context und der Verbindung zur Transaktion zu tun. Die HTTP-Session hat damit überhaupt nichts zu tun. Es sind verschiedene „Sessions“, wie die „Maus“ in der Zoologie und die „Maus“ auf dem Schreibtisch des Programmierers.

Fehler Nr. 4: Das Problem mit try/catch verstecken oder „es soll null sein“.
Eine LazyInitializationException abzufangen und weiterzuarbeiten ist, als würde man die „Check Engine“-Lampe mit Isolierband abkleben. Ja, die Lampe stört nicht mehr, aber der Motor ist immer noch in Schwierigkeiten. Diese Exception ist fast immer ein Signal für einen architektonischen Fehler an der Ladegrenze, nicht für einen „gewöhnlichen Ausfall, den man ignorieren kann“.

Fehler Nr. 5: Nicht zu bemerken, dass der Punkt des lazy-Zugriffs in einem „Hilfs“-Code liegt.
Sehr oft tritt die Exception nicht in der „Geschäftslogik“ auf, sondern in der Formatierung, im Logging, in der Debug-Ausgabe, beim Mapping in DTOs oder sogar im IDE-Debugger, der beschlossen hat, Ihnen den Inhalt einer Sammlung zu zeigen. Daher ist es wichtig, nicht nur auf die Zeile zu schauen, in der der Fehler auftrat, sondern auch zu verstehen: Was genau in dieser Zeile hat die Notwendigkeit ausgelöst, Daten aus der Datenbank nachzuladen?

1
Aufgabe
Hibernate deep-dive, Level 6, Lektion 2
Gesperrt
Ausnahme bei einer Lazy-Kollektion von Nachrichten
Ausnahme bei einer Lazy-Kollektion von Nachrichten
1
Aufgabe
Hibernate deep-dive, Level 6, Lektion 2
Gesperrt
Ausnahme bei lazy to-one nach Transaktionsende
Ausnahme bei lazy to-one nach Transaktionsende
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION