1. Zwei Grenzen: Containerspeicher und JVM-Speicher
Bei broken config, broken host oder broken profile ist alles ziemlich ehrlich: Der Dienst startet entweder nicht oder schreibt direkt, was nicht stimmt. Bei resource limits ist es kniffliger. Dasselbe Image und dieselbe Konfiguration können sich unterschiedlich verhalten, nur weil dem Container ein anderes Speicher- oder CPU-Budget gegeben wurde. Daher werden wir zuerst diese beiden Achsen trennen, und beginnen mit dem Speicher: Hier dreht sich die meiste Verwirrung fast immer um --memory und -Xmx.
Wenn ein Java-Entwickler zum ersten Mal vom „Container-Speicherlimit“ hört, versucht das Gehirn zu vereinfachen: „Ach, das ist einfach wie -Xmx, nur von Docker gesetzt.“ Und genau hier lauert die erste Falle des Tages. Tatsächlich haben wir zwei verschiedene Grenzen: die äußere Grenze des Containers und die interne Organisation des JVM-Speichers. Wenn man ihre Rollen verwechselt, behandelt man Symptome an der falschen Stelle.
Das Container-Speicherlimit ist das, was Docker (bzw. die Runtime) dem gesamten Prozess im Container erlaubt. Die JVM versucht innerhalb dieses Budgets den Speicher nach ihren Bedürfnissen zu verteilen: Heap (wo Java-Objekte leben), Dienstbereiche, Thread-Stacks, Puffer und so weiter. Daher beantworten --memory und -Xmx unterschiedliche Fragen, auch wenn beide „über Speicher“ sind.
Um es ganz anschaulich zu machen, stellen wir es uns als Matroschka vor:
flowchart TB A["Docker container memory limit
(--memory)"] --> B["Java process (JVM)"] B --> C["Heap (Java objects)
-Xmx"] B --> D["Other memory
(JVM-Dienstspeicher, Stacks, Puffer usw.)"]
Der Sinn des Diagramms ist einfach: Der Heap ist ein Teil des Prozessspeichers, und der Container begrenzt den gesamten Prozess.
2. --memory in Docker: Was wird begrenzt
Wenn wir sagen „Setzen wir dem Container ein Speicherlimit“, tun wir der JVM keinen Gefallen und „konfigurieren Java nicht“. Wir sagen Docker: „Dieser Container darf nicht mehr als N Speicher verbrauchen.“ Das ist eine externe Begrenzung, sie hängt nicht davon ab, ob dort Spring Boot oder ein Taschenrechner in Bash läuft (ja, Leute schreiben so etwas).
In der einfachsten Form wird das Limit beim Start des Containers gesetzt. Docker hat das Flag --memory (Kurzform -m). Ein Beispiel für unseren Dienst:
# Container mit Speicherlimit für den gesamten Prozess im Container starten
docker run --rm --name catalog-service \
--memory=512m \
-p 8080:8080 \
docker-java-catalog-service
Hier ist wichtig, eine Sache zu verstehen: --memory=512m sagt nicht „Der Heap wird 512m sein“. Es sagt: „Der Container darf ~512 Megabyte Gesamtspeicherverbrauch nicht überschreiten.“ Und dann beginnt das Leben: Die JVM im Container wird versuchen, in dieses Budget zu passen. Wenn es passt – gut. Wenn nicht – hat die Umgebung das Recht (und die technische Möglichkeit), den Prozess so zu beenden, dass Sie nicht einmal höflich „Entschuldigung“ sagen können.
Wenn kein Limit gesetzt ist, bedeutet das nicht „unendlich“. Es bedeutet „innerhalb dessen, was dem System/der VM zur Verfügung steht, auf der Docker läuft“. Auf Docker Desktop (macOS/Windows) gibt es zusätzlich ein Speicherlimit der Docker-Desktop-VM selbst, sodass „unbegrenzt“ dort auch ziemlich bedingt ist. Aber für uns heute ist die Hauptaussage: `--memory` ist die externe Obergrenze für den gesamten Prozess.
3. -Xmx: Maximaler Heap
Kommen wir nun zurück zur vertrauten Welt der JVM. Das Flag -Xmx legt die maximale Größe des Heaps fest. Der Heap ist der Ort, an dem Ihre gewöhnlichen Java-Objekte leben: Strings, Listen, DTOs, Entities, Maps, alles, was Sie mit new erstellen (oder indirekt über Frameworks, die auch new mögen, aber ohne Ihr Wissen).
Zur schnellen Überprüfung „Wie groß ist mein maximaler Heap gerade“ kann man den Wert von Runtime.().maxMemory() ansehen. In Java ist das der maximal verfügbare Heap, den die JVM bereit ist zu nutzen (in Bytes). Ein Minibeispiel:
// maxMemory() gibt den Wert in Bytes zurück – umgerechnet in Megabyte für bessere Lesbarkeit
long maxHeapMb = Runtime.getRuntime().maxMemory() / 1024 / 1024;
// Dies ist genau die "Obergrenze des Heaps", nicht das Container-Limit
System.out.println("maxHeapMb=" + maxHeapMb); // maxHeapMb=256 (Beispiel)
Dieser Wert ist sehr nützlich, aber es ist wichtig, seine Bedeutung nicht zu überschätzen. Er zeigt die interne Grenze des Heaps, nicht „wie viel Speicher der Container hat“. Die JVM kann maxHeapMb=256 haben, während der Container ein Limit von 384 MB oder 1 GB haben kann – das sind unterschiedliche Ebenen.
Warum kann man nicht annehmen, dass der Heap der gesamte Speicher ist? Weil die JVM nicht nur aus dem Heap besteht. Sie braucht noch etwas zum Leben. Vorerst reicht es, sich eine einfache Regel zu merken: Der Heap ist ein wichtiger, aber nicht der einzige Teil des Speichers eines Java-Prozesses.
4. Budget und Reserve: --memory vs -Xmx
Jetzt fassen wir die Hauptidee der Vorlesung in einem verständlichen Bild zusammen. Sie haben ein externes Budget des Containers, und darin muss die JVM den Heap und alles andere unterbringen. Wenn Sie -Xmx fast gleich dem Container-Limit setzen, lassen Sie der JVM „kein Wechselgeld“. Und die JVM beginnt wie jeder lebende Organismus ohne Wechselgeld nervös zu werden und stürzt dann ab. Manchmal – sehr abrupt.
Vergleichen wir die beiden Einschränkungen in einer Tabelle, um Verwirrung zu vermeiden:
| Einschränkung | Wo festgelegt | Was wird begrenzt | Typischer Anfängerfehler |
|---|---|---|---|
| --memory=512m | Docker-Runtime (docker run, Compose) | Den gesamten Container-Speicher, also den Speicher des gesamten Prozesses | Zu glauben, das sei die „Heap-Größe“ |
| -Xmx256m | JVM (über args/env) | Nur den Heap, in dem Java-Objekte leben | Zu glauben, das reiche aus, um kein Memory-Problem zu bekommen |
Nun zum praktischen „Puffer“. Stellen Sie sich vor, der Container sei eine Wohnung mit einer strengen Flächenbegrenzung, und der Heap sei Ihr Schreibtisch. Man kann den Tisch bündig an die Wände stellen, sodass die Schranktür nicht geöffnet werden kann. Formal passt der Tisch. Tatsächlich ist das Wohnen unbequem: Sie werden sich jedes Mal die Knie stoßen, wenn Sie versuchen durchzukommen. In der JVM bedeutet „Knie stoßen“ normalerweise unangenehme Memory-Szenarien und Instabilität.
Die Arbeitsregel auf dem Niveau von Junior-Gesundem-Menschenverstand lautet: Zuerst wählen wir das Memory-Limit des Containers als Gesamtbudget, dann entscheiden wir, welchen Teil des Budgets wir dem Heap geben, und lassen einen Puffer für das „restliche Leben des Prozesses“. Noch zerlegen wir dieses „restliche Leben“ nicht im Detail. Wichtig ist jetzt, das Prinzip zu sehen: zwei Grenzen, externes Budget und interne Verteilung.
5. Loggen von maxHeapMb beim Start
Die Theorie wird viel ruhiger, wenn ein beobachtbares Signal direkt in den Logs unserer Anwendung erscheint. Wir wollen nicht raten „Wie viel Heap hat die JVM?“, wir wollen das beim Start des Containers genauso natürlich sehen, wie wir es gewohnt sind, aktive Profile und Ports zu sehen.
Im Container-Ready Catalog Service können wir ein kleines „Betriebs-Signal“ im Paket ops hinzufügen: Beim Start der Anwendung den maximalen Heap in Megabyte ausgeben. Das ist kein „Tuning“, keine „Magie“, sondern einfach eine verständliche Diagnose, die hilft, JAVA_TOOL_OPTIONS und das Verhalten der JVM zu verknüpfen. Diese kleine Konfiguration lässt sich später bequem mit neuen Signalen erweitern, anstatt für jede Metrik eine separate Startup-Klasse anzulegen.
Ein Beispiel für eine Konfiguration, die man leicht im Projekt behalten (und noch leichter später löschen kann, falls nötig), sieht so aus:
package com.example.catalog.ops;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration // Spring-Konfigurationsklasse: Infrastruktur-Bean registrieren
public class RuntimeSnapshotConfig {
private static final Logger log = LoggerFactory.getLogger(RuntimeSnapshotConfig.class);
@Bean
ApplicationRunner logRuntimeSnapshot() {
// ApplicationRunner wird einmal beim Start der Anwendung ausgeführt
return args -> {
// maxMemory() – das ist die Heap-Obergrenze, die die JVM bereit ist zuzuweisen (Wert in Bytes)
long maxHeapMb = Runtime.getRuntime().maxMemory() / 1024 / 1024;
// Loggen der Metrik als 'Betriebs-Signal', um die Auswirkung von -Xmx zur Laufzeit zu sehen
log.info("Runtime snapshot: maxHeapMb={}", maxHeapMb);
};
}
}
Hier ist eines wichtig: Wir loggen nur das, was die JVM ehrlich über den Heap mitteilt. Wir versuchen in dieser Vorlesung nicht, das Container-Limit von innen aus Java zu ermitteln – das ist eine separate Welt (cgroups, Systemdateien usw.), und es ist uns jetzt wichtiger, die Dinge nicht zu verkomplizieren. Das Container-Limit lesen wir von außen mit Docker-Befehlen, die interne Heap-Grenze aus Java.
Danach haben wir eine wunderbare Verknüpfung: Container starten, docker logs ansehen – wir sehen maxHeapMb. JAVA_TOOL_OPTIONS ändern – wir sehen, wie sich maxHeapMb ändert. Und das alles ohne Neubau des Images. Wunderbar. Fast wie im Märchen, nur ohne Einhörner und mit Logs.
6. Übergeben von -Xmx über JAVA_TOOL_OPTIONS
Jetzt ein wichtiger praktischer Trick, der gleichzeitig einfach und im Geiste sehr „containerartig“ ist. Wir wollen das Image nicht nur neu bauen, nur weil wir den Heap ändern wollen. Außerdem widerspricht das unserer grundlegenden Kursidee: same image, different runtime config.
Für die JVM gibt es einen Standardmechanismus: die Umgebungsvariable JAVA_TOOL_OPTIONS. Wenn sie gesetzt ist, übernimmt die JVM beim Start automatisch Argumente daraus. Das bedeutet, wir können dasselbe Image ausführen, aber den Heap so ändern:
# Container im Speicher begrenzen und separat die Heap-Grenze für die JVM setzen
docker run --rm --name catalog-service \
--memory=512m \
-e JAVA_TOOL_OPTIONS="-Xmx256m" \
-p 8080:8080 \
docker-java-catalog-service
Was passiert hier schichtweise (und warum ist es wichtig, dies im Kopf zu behalten)?
Das Container-Limit --memory=512m setzt das externe Budget. Die Umgebungsvariable JAVA_TOOL_OPTIONS="-Xmx256m" setzt die interne Heap-Grenze. Das sind keine konkurrierenden Einstellungen, sondern Einstellungen auf verschiedenen Ebenen. Deshalb trennen wir sie heute so beharrlich nach ihren Rollen.
Und ja, eine kleine alltägliche Warnung: Anführungszeichen und Leerzeichen. In der Unix-ähnlichen Shell ist alles recht gewohnt. In PowerShell und CMD unter Windows kann es Nuancen beim Escaping geben. Im Rahmen des Kurses gehen wir nicht auf den OS-spezifischen Track ein, aber wenn bei Ihnen plötzlich „-Xmx nicht angewendet wurde“, prüfen Sie zuerst, ob die Umgebungsvariable tatsächlich den Container erreicht hat (über docker inspect), und ob die Zeichenkette nicht aufgrund der Anführungszeichen zerbrochen ist.
7. Start--Xmx: Ein sicherer Ausgangspunkt
Jetzt möchte man eine „Zauberformel“ bekommen. Zum Beispiel: „Setze den Heap auf genau 70% des Limits, und das Glück kommt.“ Ich verstehe den Wunsch. Er ist sehr menschlich. Aber es gibt keine universelle Zahl: Es hängt zu stark davon ab, was die Anwendung tut, wie viele Threads, welche Bibliotheken eingebunden sind und welche Puffer verwendet werden.
Dennoch ist ein Startpunkt notwendig. Die Arbeitsregel auf dieser Ebene ist einfach: Setzen Sie -Xmx nicht direkt an das Container-Limit. Wenn der Container --memory=384m bekommen hat, ist es normalerweise viel sicherer, mit -Xmx256m zu beginnen als mit -Xmx384m.
Der Sinn liegt nicht in der magischen Zahl 256. Der Sinn liegt im Puffer. Sobald Metaspace, Thread-Stacks und Puffer ins Budget fallen, wird schnell klar, warum „das gesamte Limit dem Heap geben“ eine schlechte Idee ist. Wenn man ein ganz alltägliches Bild möchte: Das Container-Limit ist der gesamte Geldbeutel, und -Xmx ist nur eine Ausgabenposition. Den gesamten Geldbeutel für eine Position auszugeben, ist leicht, danach zu leben – schwer.
Für den ersten Start reicht diese Regel. Genaue Bereiche ergeben sich erst, wenn Sie nicht nur den Heap, sondern auch den restlichen Speicher des Prozesses berücksichtigen.
8. Überprüfen des Container-Limits
Ein sehr typisches Szenario in der Praxis sieht so aus: Jemand schreibt einen Befehl mit --memory, schaut dann auf die Anwendung und sagt: „Na ja, scheint zu funktionieren.“ Und dann stellt sich heraus, dass das Limit nicht angewendet wurde (oder an der falschen Stelle), und die Schlussfolgerungen über das Verhalten der JVM waren „nach Augenmaß“.
Um dies zu vermeiden, ist es nützlich, zwei schnelle Methoden im Kopf zu haben, um das Limit von außen zu überprüfen, ohne in die Tiefe zu gehen. Die erste Methode ist docker stats, sie zeigt Speicherverbrauch und Limit nebeneinander an einem Ort. Beispiel:
docker stats catalog-service
In der Ausgabe gibt es eine Spalte wie MEM USAGE / LIMIT. Wenn Sie --memory=512m gesetzt haben, sollten Sie ein Limit von etwa 512MiB sehen (das Format hängt von der Plattform ab, aber der Sinn ist derselbe). Das ist eine einfache Möglichkeit, in 10 Sekunden zu verstehen: „Habe ich den Container wirklich so gestartet, wie ich denke?“
Die zweite Methode ist docker inspect. Sie gibt Ihnen die „Wahrheit in JSON“. Für die Lerngeschwindigkeit kann man nur einen Teil des Zustands extrahieren:
# Container-Speicherlimit aus HostConfig extrahieren (Wert in Bytes)
docker inspect --format '{{json .HostConfig.Memory}}' catalog-service
Hier ist es wichtig, sich nicht in den Einheiten zu verheddern. Docker speichert viele Werte in Bytes. Wenn Sie eine Zahl wie 536870912 sehen, erschrecken Sie nicht: Das ist genau 512 * 1024 * 1024. Ihr Limit ist also tatsächlich aufgezeichnet.
Und wenn Sie dann beide Signale haben – docker stats/inspect von außen und maxHeapMb in den Logs drinnen – erhalten Sie das, wofür wir das tun: ein beobachtbares System, keine Sammlung von Vermutungen.
9. Typische Fehler bei der Arbeit mit --memory und -Xmx
Fehler Nr. 1: --memory mit -Xmx gleichsetzen.
Das ist die häufigste Verwirrung: „Ich habe dem Container 512m gegeben, also ist der Heap auch 512m.“ Nein. --memory begrenzt den gesamten Prozess, -Xmx nur den Heap. Wie vermeiden: Immer die „zwei Grenzen“ im Kopf behalten und mit zwei Methoden prüfen – Limit über Docker, Heap über maxMemory().
Fehler Nr. 2: -Xmx fast direkt an das Container-Limit setzen.
Eine Kombination wie --memory=512m und -Xmx512m ist ein fast garantierter Weg, sich ein instabiles Leben zu bereiten, besonders bei einem Spring Boot-Dienst, wo es neben Ihren Objekten noch Infrastrukturcode, Klassen, Threads und vieles mehr gibt. Wie vermeiden: Einen deutlichen Puffer lassen, besonders bei kleinen Limits.
Fehler Nr. 3: Gleichzeitig Container-Limit und JVM-Einstellungen ändern – und versuchen zu verstehen, was genau geholfen hat.
Wenn Sie gleichzeitig --memory, -Xmx, das Spring Boot-Profil und noch „ein bisschen“ die Image-Version geändert haben, werden Sie später nicht ehrlich sagen können, was genau gewirkt hat. Wie vermeiden: Immer nur eine Ebene auf einmal ändern. Heute – nur Runtime-Limits und JVM-Parameter, Image und Code unverändert lassen.
Fehler Nr. 4: Nur auf Java-Logs schauen und den Container-Startbefehl ignorieren.
Manchmal sieht man in den Logs „alles gut“, aber das Container-Limit wurde eigentlich nicht angewendet, oder ein anderes wurde angewendet. Wie vermeiden: Nach dem Start des Containers schnell mit docker stats oder docker inspect prüfen, um keine Schlussfolgerungen auf falscher Grundlage zu ziehen.
Fehler Nr. 5: Jedes Memory-Symptom durch Erhöhen des Heaps behandeln.
Das ist ein Reflex des Java-Entwicklers: „Nicht genug Speicher – erhöhen wir den Heap.“ Aber wenn das Problem darin besteht, dass das Container-Budget klein ist, kann eine Erhöhung des Heaps die Kollision mit dem Limit nur beschleunigen. Wie vermeiden: Zuerst das externe Budget (--memory) und die interne Heap-Grenze (-Xmx) abgleichen, und erst dann Entscheidungen treffen.
GO TO FULL VERSION