Java-Anwendungen optimieren

In diesem Leitfaden werden Optimierungen für Knative Serving-Dienste beschrieben, die in der Programmiersprache Java geschrieben sind. Außerdem finden Sie hier Hintergrundinformationen, damit Sie die Vor- und Nachteile einiger Optimierungen nachvollziehen können. Die Informationen auf dieser Seite ergänzen die allgemeinen Optimierungstipps, die auch für Java gelten.

Herkömmliche webbasierte Java-Anwendungen sind für die Bearbeitung von Anfragen mit hoher Parallelität und geringer Latenz ausgelegt und sind in der Regel Anwendungen mit langer Laufzeit. Die JVM selbst optimiert mit JIT auch den Ausführungscode im Laufe der Zeit, sodass Hot Paths optimiert werden und Anwendungen im Laufe der Zeit effizienter ausgeführt werden.

Viele der Best Practices und Optimierungen in dieser herkömmlichen webbasierten Java-Anwendung beziehen sich auf folgende Probleme:

  • Verarbeiten gleichzeitiger Anfragen (sowohl thread-basierter als auch nicht blockierender E/A-Vorgänge)
  • Reduzieren der Antwortlatenz durch Verbindungspooling und Stapelverarbeitung nicht kritischer Funktionen, wie z. B. das Senden von Traces und Messwerten an Hintergrundaufgaben.

Viele dieser traditionellen Optimierungen funktionieren gut bei Anwendungen mit langer Laufzeit. Sie funktionieren aber eventuell nicht optimal in einem Knative Serving-Dienst, der nur ausgeführt wird, wenn Anfragen aktiv bereitgestellt werden. Auf dieser Seite werden verschiedene Optimierungen und Kompromisse für Knative Serving erläutert, mit denen Sie die Startzeit und die Speichernutzung reduzieren können.

Container-Image optimieren

Durch die Optimierung des Container-Images können Sie die Lade- und Startzeiten reduzieren. Sie können das Image so optimieren:

  • Container-Image minimieren
  • Keine verschachtelten Bibliotheksarchiv-JARs verwenden
  • Jib verwenden

Container-Image minimieren

Weitere Informationen zu diesem Thema finden Sie auf der Seite mit allgemeinen Tipps zum Minimieren von Containern. Auf der Seite mit den allgemeinen Tipps wird empfohlen, den Inhalt von Container-Images nur auf die benötigten Elemente zu beschränken. In Ihrem Container-Image sollte beispielsweise nicht enthalten sein:

  • Quellcode
  • Maven-Build-Artefakte
  • Build-Tools
  • Git-Verzeichnisse
  • Nicht verwendete Binärprogramme/Dienstprogramme

Wenn Sie den Code in einem Dockerfile erstellen, sollten Sie einen mehrstufigen Build-Prozess in Docker verwenden, damit das endgültige Container-Image nur die JRE und die JAR-Datei der Anwendung selbst hat.

JARs für verschachtelte Bibliothekenarchive vermeiden

Einige beliebte Frameworks wie Spring Boot erstellen eine Anwendungsarchiv-Datei (JAR), die zusätzliche Bibliotheks-JAR-Dateien (verschachtelte JARs) enthält. Diese Dateien müssen während des Starts entpackt und dekomprimiert werden und können die Startgeschwindigkeit in Knative Serving erhöhen. Erstellen Sie nach Möglichkeit eine schlanke JAR-Datei mit externalisierten Bibliotheken. Dies kann mit Jib automatisiert werden, um Ihre Anwendung zu containerisieren.

Jib verwenden

Verwenden Sie das Jib-Plug-in, um einen minimalen Container zu erstellen und das Anwendungsarchiv automatisch zu vereinfachen. Jib funktioniert sowohl mit Maven als auch mit Gradle und ist mit standardmäßigen Spring Boot-Anwendungen kompatibel. Für einige Anwendungs-Frameworks sind möglicherweise weitere Jib-Konfigurationen erforderlich.

JVM-Optimierungen

Die Optimierung der JVM für einen Knative-Dienst kann zu einer besseren Leistung und Speichernutzung führen.

Containersensitive JVM-Versionen verwenden

In VMs und Maschinen erkennt die JVM für die CPU- und Arbeitsspeicherzuweisungen die CPU und den Arbeitsspeicher, die sie von bekannten Speicherorten aus verwenden kann, z. B. unter Linux, /proc/cpuinfo und /proc/meminfo. Bei der Ausführung in einem Container werden die CPU- und Speichereinschränkungen jedoch in /proc/cgroups/... gespeichert. Ältere Versionen des JDK verwenden weiterhin /proc anstelle von /proc/cgroups, was zu einer höheren CPU- und Speicherauslastung führen kann. Das kann folgende Gründe haben:

  • Eine übermäßige Anzahl von Threads, da die Größe des Threadpools von Runtime.availableProcessors() konfiguriert wird.
  • Ein standardmäßiger maximaler Heap, der das Container-Speicherlimit überschreitet. Die JVM verwendet den Speicher aggressiv, bevor der Speicher automatisch bereinigt wird. Dies kann dazu führen, dass der Container das Container-Speicherlimit überschreitet und den Status „OOMKilled“ erhält.

Verwenden Sie daher eine containersensitive JVM-Version. OpenJDK-Versionen größer oder gleich Version 8u192 sind standardmäßig containersensitiv.

Grundlegendes zur JVM-Speichernutzung

Die JVM-Speichernutzung setzt sich aus der nativen Speichernutzung und der Heap-Nutzung zusammen. Der Arbeitsspeicher der Anwendung befindet sich normalerweise im Heap. Die Größe des Heaps wird durch die Max-Heap-Konfiguration begrenzt. Mit einer Knative Serving 256 MB RAM-Instanz können Sie nicht alle 256 MB dem Max-Heap zuweisen, da die JVM und das Betriebssystem auch nativen Arbeitsspeicher benötigen, z. B. Thread-Stack, Code-Caches, Dateihandles, Puffer usw. Wenn Ihre Anwendung den Status „OOMKilled“ erhält und Sie die JVM-Speichernutzung (nativer Arbeitsspeicher + Heap) kennen müssen, aktivieren Sie Native Memory Tracking, um die Verwendung nach erfolgreichem Beenden der Anwendung anzuzeigen. Wenn Ihre Anwendung den Status „OOMKilled“ erhält, kann die Information nicht ausgegeben werden. Führen Sie in diesem Fall zuerst die Anwendung mit mehr Arbeitsspeicher aus, damit die Ausgabe erfolgreich generiert werden kann.

Natives Memory Tracking kann über die Umgebungsvariable JAVA_TOOL_OPTIONS nicht aktiviert werden. Sie müssen das Startargument für die Java-Befehlszeile zu Ihrem Container-Image-Einstiegspunkt hinzufügen, damit Ihre Anwendung mit den folgenden Argumenten gestartet wird:

java -XX:NativeMemoryTracking=summary \
  -XX:+UnlockDiagnosticVMOptions \
  -XX:+PrintNMTStatistics \
  ...

Die Nutzung des nativen Speichers kann anhand der Anzahl der zu ladenden Klassen geschätzt werden. Mit einem Open-Source-Java Memory Calculator können Sie die Speicheranforderungen schätzen.

Optimierungs-Compiler deaktivieren

Standardmäßig umfasst JVM mehrere Phasen der JIT-Kompilierung. Obwohl diese Phasen die Effizienz Ihrer Anwendung mit der Zeit verbessern, erhöhen sie auch die Arbeitsspeichernutzung und die Startzeit.

Bei kurz laufenden, serverlosen Anwendungen (z. B. Funktionen) sollten Sie die Optimierungsphasen deaktivieren, um die langfristige Effizienz gegen eine reduzierte Startzeit abzuwägen.

Konfigurieren Sie für einen Knative Serving-Dienst die Umgebungsvariable:

JAVA_TOOL_OPTIONS="-XX:+TieredCompilation -XX:TieredStopAtLevel=1"

Application Class Data Sharing verwenden

Wenn Sie die JIT-Zeit und die Speichernutzung weiter reduzieren möchten, sollten Sie Application Class Data Sharing (AppCDS) verwenden, um die vorab kompilierten Java-Klassen als Archiv freizugeben. Das AppCDS-Archiv kann beim Starten einer anderen Instanz derselben Java-Anwendung wiederverwendet werden. Die JVM kann die vorausberechneten Daten aus dem Archiv wiederverwenden, wodurch die Startzeit verkürzt wird.

Die folgenden Überlegungen gelten für die Verwendung von AppCDS:

  • Das zu verwendende AppCDS-Archiv muss mit derselben OpenJDK-Distribution, -Version und -Architektur reproduziert werden, mit der es ursprünglich erzeugt wurde.
  • Sie müssen Ihre Anwendung mindestens einmal ausführen, um die Liste der Klassen zu generieren, die freigegeben werden sollen. Anschließend können Sie diese Liste verwenden, um das AppCDS-Archiv zu generieren.
  • Die Abdeckung der Klassen hängt vom Codepfad ab, der während der Ausführung der Anwendung ausgeführt wird. Zur Erhöhung der Abdeckung lösen Sie programmatisch weitere Codepfade aus.
  • Die Anwendung muss beendet werden, um diese Klassenliste zu erstellen. Erwägen Sie die Implementierung eines Anwendungs-Flags, das die Generierung eines AppCDS-Archivs angibt, damit es sofort beendet werden kann.
  • Das AppCDS-Archiv kann nur dann wiederverwendet werden, wenn Sie neue Instanzen auf die gleiche Weise starten wie das Archiv generiert wurde.
  • Das AppCDS-Archiv funktioniert nur mit einem normalen JAR-Dateipaket. Sie können keine verschachtelten JARs verwenden.

Spring Boot-Beispiel mit einer Shading-JAR-Datei

Spring Boot-Anwendungen verwenden standardmäßig eine verschachtelte Uber-JAR-Datei, die für AppCDS nicht funktioniert. Wenn Sie also AppCDS verwenden, müssen Sie eine Shading-JAR-Datei erstellen. Hier ein Beispiel, wenn Sie Maven und das Maven-Shade-Plug-in verwenden:

<build>
  <finalName>helloworld</finalName>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-shade-plugin</artifactId>
      <configuration>
        <keepDependenciesWithProvidedScope>true</keepDependenciesWithProvidedScope>
        <createDependencyReducedPom>true</createDependencyReducedPom>
        <filters>
          <filter>
            <artifact>*:*</artifact>
            <excludes>
              <exclude>META-INF/*.SF</exclude>
              <exclude>META-INF/*.DSA</exclude>
              <exclude>META-INF/*.RSA</exclude>
            </excludes>
          </filter>
        </filters>
      </configuration>
      <executions>
        <execution>
          <phase>package</phase>
          <goals><goal>shade</goal></goals>
          <configuration>
            <transformers>
              <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                <resource>META-INF/spring.handlers</resource>
              </transformer>
              <transformer implementation="org.springframework.boot.maven.PropertiesMergingResourceTransformer">
                <resource>META-INF/spring.factories</resource>
              </transformer>
              <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                <resource>META-INF/spring.schemas</resource>
              </transformer>
              <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer" />
              <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                <mainClass>${mainClass}</mainClass>
              </transformer>
            </transformers>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

Wenn Ihre Shading-JAR-Datei alle Abhängigkeiten enthält, können Sie während des Container-Builds mit einem Dockerfile ein einfaches Archiv erstellen:

# Use Docker's multi-stage build
FROM adoptopenjdk:11-jre-hotspot as APPCDS

COPY target/helloworld.jar /helloworld.jar

# Run the application, but with a custom trigger that exits immediately.
# In this particular example, the application looks for the '--appcds' flag.
# You can implement a similar flag in your own application.
RUN java -XX:DumpLoadedClassList=classes.lst -jar helloworld.jar --appcds=true

# From the captured list of classes (based on execution coverage),
# generate the AppCDS archive file.
RUN java -Xshare:dump -XX:SharedClassListFile=classes.lst -XX:SharedArchiveFile=appcds.jsa --class-path helloworld.jar

FROM adoptopenjdk:11-jre-hotspot

# Copy both the JAR file and the AppCDS archive file to the runtime container.
COPY --from=APPCDS /helloworld.jar /helloworld.jar
COPY --from=APPCDS /appcds.jsa /appcds.jsa

# Enable Application Class-Data sharing
ENTRYPOINT java -Xshare:on -XX:SharedArchiveFile=appcds.jsa -jar helloworld.jar

Klassenprüfung deaktivieren

Wenn die JVM Klassen zur Ausführung in den Speicher lädt, bestätigt sie, dass die Klasse unmanipuliert ist und keine schädlichen Änderungen oder Beschädigungen hat. Wenn Ihre Softwarebereitstellungspipeline vertrauenswürdig ist (z. B. können Sie jede Ausgabe prüfen und validieren), wenn Sie dem Bytecode in Ihrem Container-Image vollständig vertrauen können und Ihre Anwendung keine Klassen aus beliebigen Remote-Quellen lädt, können Sie die Prüfung deaktivieren. Das Deaktivieren der Prüfung kann die Startgeschwindigkeit verbessern, wenn eine große Anzahl von Klassen beim Start geladen wird.

Konfigurieren Sie für einen Knative Serving-Dienst die Umgebungsvariable:

JAVA_TOOL_OPTIONS="-noverify"

Größe des Thread-Stacks reduzieren

Die meisten Java-Webanwendungen basieren auf Threads pro Verbindung. Jeder Java-Thread verbraucht nativen Arbeitsspeicher (nicht in Heap). Dies wird als Thread-Stack bezeichnet und ist standardmäßig auf 1 MB pro Thread eingestellt. Wenn Ihre Anwendung 80 gleichzeitige Anfragen verarbeitet, verfügt sie möglicherweise über mindestens 80 Threads, was 80 MB verwendetem Thread-Stack-Speicher entspricht. Der Arbeitsspeicher wird zusätzlich zur Heap-Größe angelegt. Die Standardeinstellung kann größer als erforderlich sein. Sie können die Größe des Thread-Stacks reduzieren.

Wenn Sie zu viel reduzieren, wird java.lang.StackOverflowError angezeigt. Sie können ein Profil für Ihre Anwendung erstellen und die optimale Thread-Stack-Größe ermitteln, die konfiguriert werden soll.

Konfigurieren Sie für einen Knative Serving-Dienst die Umgebungsvariable:

JAVA_TOOL_OPTIONS="-Xss256k"

Threads reduzieren

Sie können den Arbeitsspeicher optimieren, indem Sie die Anzahl der Threads reduzieren, indem Sie nicht blockierende reaktive Strategien verwenden und Hintergrundaktivitäten vermeiden.

Anzahl der Threads reduzieren

Jeder Java-Thread kann aufgrund des Thread-Stacks die Speichernutzung erhöhen. Knative Serving lässt maximal 80 Anfragen gleichzeitig zu. Beim Modell „Threads pro Verbindung“ benötigen Sie maximal 80 Threads, um alle gleichzeitigen Anfragen zu verarbeiten. Bei den meisten Webservern und Frameworks können Sie die maximale Anzahl von Threads und Verbindungen konfigurieren. In Spring Boot können Sie zum Beispiel die maximalen Verbindungen in der Datei applications.properties begrenzen:

server.tomcat.max-threads=80

Nicht blockierenden reaktiven Code zur Optimierung des Speichers und des Start-ups schreiben

Um die Anzahl der Threads wirklich zu reduzieren, sollten Sie ein nicht blockierendes reaktives Programmiermodell verwenden, damit die Anzahl der Threads erheblich reduziert werden kann, während mehr gleichzeitige Anfragen verarbeitet werden. Anwendungs-Frameworks wie Spring Boot mit Webflux, Micronaut und Quarkus unterstützen reaktive Webanwendungen.

Reaktive Frameworks wie Spring Boot mit Webflux, Micronaut, Quarkus haben in der Regel eine schnellere Startzeit.

Wenn Sie weiterhin blockierenden Code in einem nicht blockierenden Framework schreiben, sind die Durchsatz- und Fehlerraten in einem Knative Serving-Dienst erheblich schlechter. Dies liegt daran, dass nicht blockierende Frameworks nur wenige Threads enthalten, zum Beispiel 2 oder 4. Wenn Ihr Code blockiert, kann er nur sehr wenige gleichzeitige Anfragen verarbeiten.

Diese nicht blockierenden Frameworks können blockierenden Code auch in einen unbegrenzten Thread-Pool verlagern. Dies bedeutet, dass der blockierende Code zwar viele gleichzeitige Anfragen akzeptieren kann, jedoch in neuen Threads ausgeführt wird. Wenn sich Threads unbegrenzt ansammeln, erschöpfen Sie die CPU-Ressource und reagieren langsam nicht mehr. Die Latenz wird erheblich beeinträchtigt. Wenn Sie ein nicht blockierendes Framework verwenden, müssen Sie die Thread-Pool-Modelle verstehen und die Pools entsprechend binden.

Hintergrundaktivitäten vermeiden

Knative Serving drosselt eine Instanz-CPU, wenn diese Instanz keine Anfragen mehr erhält. Herkömmliche Arbeitslasten mit Hintergrundaufgaben müssen bei der Ausführung in Knative Serving besonders berücksichtigt werden.

Wenn Sie beispielsweise Anwendungsmesswerte erfassen und die Messwerte im Hintergrund in Batches aufteilen, um sie regelmäßig zu senden, werden diese Messwerte nicht gesendet, wenn die CPU gedrosselt wird. Wenn die Anwendung kontinuierlich Anfragen empfängt, treten möglicherweise weniger Probleme auf. Wenn Ihre Anwendung eine niedrige Rate von Abfragen pro Sekunde hat, wird die Hintergrundaufgabe möglicherweise nie ausgeführt.

Hier einige bekannte Muster, die Sie beachten sollten und die im Hintergrund ablaufen:

  • JDBC-Verbindungspools: Bereinigungen und Verbindungsprüfungen werden normalerweise im Hintergrund ausgeführt.
  • Sender für verteilte Traces: Verteilte Traces werden in der Regel zusammengefasst und in regelmäßigen Abständen oder wenn der Puffer voll ist im Hintergrund gesendet.
  • Messwertsender – Messwerte werden in der Regel zusammengefasst und in regelmäßigen Abständen im Hintergrund gesendet.
  • In Spring Boot alle mit @Async annotierten Methoden.
  • Timer: alle Timer-basierten Trigger (z. B. ScheduledThreadPoolExecutor, Quartz oder @Scheduled-Spring-Annotation) werden möglicherweise nicht ausgeführt, wenn CPUs gedrosselt sind.
  • Nachrichtenempfänger: Beispielsweise Pub/Sub-Streaming-Pull-Clients, JMS-Clients oder Kafka-Clients, Hintergrundthreads, die normalerweise ausgeführt werden, ohne Anfragen zu erfordern. Diese funktionieren nicht, wenn Ihre Anwendung keine Anfragen enthält. Das Empfangen von Nachrichten auf diese Weise wird in Knative Serving nicht empfohlen.

Anwendungsoptimierungen

In Ihrem Knative Serving-Dienstcode können Sie die Optimierung auch für schnellere Startzeiten und Speichernutzung optimieren.

Startaufgaben reduzieren

Herkömmliche webbasierte Java-Anwendungen können während des Startvorgangs viele Aufgaben ausführen, z. B. Daten vorab laden, Cache vorbereiten, Verbindungspools einrichten usw. Diese Aufgaben können, wenn sie nacheinander ausgeführt werden, langsam sein. Wenn sie jedoch parallel ausgeführt werden sollen, sollten Sie die Anzahl der CPU-Kerne erhöhen.

Knative Serving sendet derzeit eine echte Nutzeranfrage, um eine Kaltstartinstanz auszulösen. Bei Nutzern, denen eine Anfrage einer neu gestarteten Instanz zugewiesen wurde, kann es zu langen Verzögerungen kommen. Knative Serving verfügt derzeit über keine Prüfungsfunktion, um zu verhindern, dass Anfragen an nicht bereite Anwendungen gesendet werden.

Verbindungs-Pooling verwenden

Wenn Sie Verbindungspools verwenden, beachten Sie, dass Verbindungspools nicht benötigte Verbindungen in den Hintergrund verweisen können (siehe Hintergrundaufgaben vermeiden). Wenn Ihre Anwendung eine niedrige Abfragen pro Sekunde-Rate hat und eine hohe Latenz akzeptiert, sollten Sie Verbindungen pro Anfrage öffnen und schließen. Wenn Ihre Anwendung eine hohe Abfragen pro Sekunde-Rate hat, werden Hintergrundverweisungen möglicherweise weiterhin ausgeführt, solange es aktive Anfragen gibt.

In beiden Fällen wird der Datenbankzugriff der Anwendung durch die maximale Anzahl zulässiger Verbindungen beeinträchtigt, die die Datenbank erlaubt. Berechnen Sie die maximale Anzahl von Verbindungen, die Sie pro Knative Serving-Instanz herstellen können, und konfigurieren Sie die maximale Anzahl von Knative Serving-Instanzen so, dass die maximale Anzahl der Instanzen mal der Verbindungen pro Instanz kleiner als die maximale Anzahl zulässiger Verbindungen ist.

Spring Boot verwenden

Wenn Sie Spring Boot verwenden, müssen Sie die folgenden Optimierungen berücksichtigen:

Spring Boot Version 2.2 oder höher verwenden

Seit Version 2.2 wurde Spring Boot besonders für die Startgeschwindigkeit optimiert. Wenn Sie eine Spring Boot-Version vor 2.2 verwenden, erwägen Sie ein Upgrade oder wenden Sie einzelne Optimierungen manuell an.

Verzögerte Initialisierung verwenden

Es gibt ein globales Flag für verzögerte Initialisierung, das in Spring Boot 2.2 und höher aktiviert werden kann. Hierdurch wird die Startgeschwindigkeit verbessert, doch mit dem Kompromiss, dass die erste Anfrage mehr Latenz haben kann, weil die erste Initialisierung von Komponenten abgewartet werden muss.

Sie können die verzögerte Initialisierung in application.properties aktivieren:

spring.main.lazy-initialization=true

Oder Sie verwenden dafür eine Umgebungsvariable:

SPRING_MAIN_LAZY_INITIALIZATIION=true

Wenn Sie jedoch eine Mindestzahl an Instanzen verwenden, hilft die verzögerte Initialisierung nicht, da die Initialisierung beim Start der Mindestanzahl an Instanzen hätte stattfinden müssen.

Scannen von Klassen vermeiden

Durch das Scannen von Klassen werden in Knative Serving zusätzliche Lesevorgänge auf dem Laufwerk ausgelöst, da in Knative Serving der Laufwerkzugriff im Allgemeinen langsamer ist als eine reguläre Maschine. Der Komponentenscan sollte eingeschränkt oder vollständig vermieden werden. Ziehen Sie die Verwendung von Spring Context Indexer in Betracht, um einen Index vorab zu generieren. Ob Sie dadurch die Startgeschwindigkeit verbessern, hängt von der Anwendung ab.

Fügen Sie beispielsweise in Ihrer Maven-pom.xml-Datei die Indexierungsabhängigkeit ein (ein tatsächlicher Annotation Processor):

<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-context-indexer</artifactId>
  <optional>true</optional>
</dependency>

Spring Boot-Entwicklertools verwenden, die nicht in der Produktion sind

Wenn Sie das Spring Boot-Entwicklertool während der Entwicklung verwenden, achten Sie darauf, dass es nicht im Produktions-Container-Image verpackt ist. Dies kann auftreten, wenn Sie die Spring Boot-Anwendung ohne die Spring Boot-Build-Plug-ins erstellt haben (z. B. mit dem Shade-Plug-in oder mit Jib für die Containerisierung).

Achten Sie in diesen Fällen darauf, dass das Build-Tool das Spring Boot-Entwicklertool explizit ausschließt. Alternativ können Sie das Spring Boot-Entwicklertool explizit deaktivieren.

Nächste Schritte

Weitere Tipps finden Sie unter