· Michael Stöckler · Development  · 9 min read

Die Herausforderung der Skalierung

Ihre Java-Anwendung wächst und plötzlich brechen die Response-Zeiten ein? Was vor 25 Jahren als "C10k-Problem" die Entwicklerwelt beschäftigte, ist heute aktueller denn je. Erfahren Sie, warum der klassische Thread-pro-Request Ansatz an seine Grenzen stößt und wie moderne Architekturmuster Abhilfe schaffen.

Ihre Java-Anwendung wächst und plötzlich brechen die Response-Zeiten ein? Was vor 25 Jahren als "C10k-Problem" die Entwicklerwelt beschäftigte, ist heute aktueller denn je. Erfahren Sie, warum der klassische Thread-pro-Request Ansatz an seine Grenzen stößt und wie moderne Architekturmuster Abhilfe schaffen.

1.1 Die Herausforderung der Skalierung

Stellen Sie sich vor, Sie entwickeln eine Anwendung für ein erfolgreiches Startup. Alles läuft gut, die Nutzerzahlen steigen - und plötzlich wird Ihre Anwendung langsam. Die CPU-Auslastung steigt, der Speicherverbrauch explodiert, und die Antwortzeiten werden unvorhersehbar. Was ist passiert? Möglicherweise sind Sie an einem Punkt gekommen, wo Sie zu viele gleichzeitige Anfragen bearbeiten müssen und eine synchron programmierte Anwendung nicht mehr skaliert. Sie habe ein C10k Problem.

🚀 Source Code auf GitHub

Der vollständige Quellcode zu diesem Artikel ist jetzt verfügbar unter:
github.com/notizwerk/blog-async-programming
Star ⭐ das Repository für Updates!

Das C10k Problem: Ein alter Bekannter in modernem Gewand

📖 Begriffserklärung: C10k Problem

Das “C10k Problem” wurde 1999 von Dan Kegel beschrieben:

  • C: steht für “Concurrent” (gleichzeitig)
  • 10k: steht für 10.000 Verbindungen
  • Problem: beschreibt die Herausforderung der effizienten Verarbeitung

Historischer Kontext: Die Bezeichnung wurde zu einer Zeit gewählt, als 10.000
gleichzeitige Verbindungen als extreme Herausforderung galten.

Auch wenn diese Zahl heute bescheiden klingt - die zugrundeliegende Problematik ist aktueller denn je. Moderne Anwendungen stehen vor noch größeren Herausforderungen:

  • Microservices mit tausenden internen Verbindungen
  • Real-time Websocket Verbindungen für Live-Updates
  • IoT Geräte mit permanenten Verbindungen
  • Event-Streaming Systeme
  • Chat und Messaging Dienste

Heutzutage kann man für viele Probleme eher von einem C10m Problem sprechen.

Die traditionelle Herangehensweise: Ein Thread pro Request

Der naheliegendste Ansatz zum Beispiel einen Server zu implementieren ist, für jede Verbindung einen eigenen Thread zu verwenden. Schauen wir uns ein typisches Beispiel an:

package de.notizwerk.async.chapter1_1;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
/**
* Demonstriert das klassische Thread-per-Request Modell.
* Teil von Kapitel 1.1: "Die Herausforderung der Skalierung"
*/
public class SimpleHttpServer {
private static final int PORT = 8080;
private static final int SIMULATED_PROCESSING_TIME = 100;
public static void main(String[] args) throws IOException {
System.out.printf("Starting server on port %d...%n", PORT);
ServerSocket serverSocket = new ServerSocket(PORT);
// Server-Hauptschleife
while (true) {
Socket clientSocket = serverSocket.accept();
String clientAddress = clientSocket.getInetAddress().getHostAddress();
System.out.printf("New connection from %s - creating thread%n", clientAddress);
// Klassischer Thread-per-Request Ansatz
new Thread(() -> handleRequest(clientSocket),
"Client-" + clientAddress)
.start();
}
}
private static void handleRequest(Socket clientSocket) {
try {
// Simulation einer zeitintensiven Operation (z.B. Datenbankzugriff)
Thread.sleep(SIMULATED_PROCESSING_TIME);
// Hier würde normalerweise die eigentliche Request-Verarbeitung stattfinden
// z.B. HTTP-Header lesen, Datenbank abfragen, Antwort senden, etc.
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.err.println("Request processing interrupted");
} finally {
try {
clientSocket.close();
} catch (IOException e) {
System.err.println("Error closing client connection: " + e.getMessage());
}
}
}
}

Dieser Code zeigt den klassischen Ansatz: Für jeden eingehenden Request wird ein neuer Thread erstellt. Das erscheint zunächst logisch - jeder Client bekommt seinen eigenen Thread und damit seine eigenen Ressourcen. Doch dieser Ansatz hat mehrere entscheidende Nachteile.

Context Switching: Der versteckte Performance-Killer

📖 Begriffserklärung: Context Switching

Ein Context Switch (Kontextwechsel) tritt auf, wenn der Prozessor von einem Thread zu einem anderen wechselt. Dabei muss der komplette Zustand des aktuellen Threads gesichert und der Zustand des nächsten Threads geladen werden.

Der Thread-Kontext umfasst dabei:

  • Prozessor-Register
  • Programm Counter (aktuelle Position im Code)
  • Stack Pointer
  • Memory Maps
  • Cache-Zustände

Die Kosten eines Context Switch

Ein Context Switch ist ein teurer Vorgang, der mehrere Schritte umfasst:

  1. Sichern des aktuellen Thread-Zustands

    • Alle CPU Register müssen in den Speicher geschrieben werden
    • Der aktuelle Ausführungskontext muss gespeichert werden
  2. Laden des neuen Thread-Zustands

    • Register müssen mit den Werten des neuen Threads geladen werden
    • Memory Management Unit (MMU) muss aktualisiert werden
    • Translation Lookaside Buffer (TLB) muss möglicherweise geleert werden
  3. Cache-Auswirkungen

    • Cache-Lines des vorherigen Threads werden möglicherweise nicht mehr benötigt
    • Der neue Thread muss seine Daten erst in den Cache laden
    • Dies führt zu “Cache Misses” und damit zu Speicherzugriffen

Hier ein Beispiel, das die Kosten des Context Switching demonstriert:

package de.notizwerk.async.chapter1_1;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
/**
* Demonstriert die Auswirkungen von Context Switching auf die Performance in Java.
* Teil von Kapitel 1.1: "Die Herausforderung der Skalierung"
* */
public class ContextSwitchDemo {
// Konstanten für bessere Lesbarkeit und einfache Anpassung
private static final long TOTAL_ITERATIONS = 100_000_000L;
private static final int THREAD_MULTIPLIER = 2;
public static void main(String[] args) {
// Test mit einem Thread
long singleThreadTime = measureSingleThread();
// Test mit vielen Threads
long multiThreadTime = measureMultiThread();
// Ergebnisse ausgeben
System.out.printf("""
Berechnungszeit Vergleich: Single Thread: %d ms Multi Thread: %d ms Overhead: %.2fx%n""",
singleThreadTime, multiThreadTime,
(double) multiThreadTime / singleThreadTime);
}
private static long measureSingleThread() {
long start = System.nanoTime();
// Eine CPU-intensive Berechnung in einem Thread
long sum = 0;
for (long i = 0; i < TOTAL_ITERATIONS; i++) {
sum += i;
}
return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
}
private static long measureMultiThread() {
// Threads basierend auf CPU-Kernen erstellen
int threadCount = Runtime.getRuntime().availableProcessors() * THREAD_MULTIPLIER;
long iterationsPerThread = TOTAL_ITERATIONS / threadCount;
CountDownLatch latch = new CountDownLatch(threadCount);
long start = System.nanoTime();
// Gleiche Berechnung auf mehrere Threads aufteilen
for (int t = 0; t < threadCount; t++) {
new Thread(() -> {
long sum = 0;
for (long i = 0; i < iterationsPerThread; i++) {
sum += i;
}
latch.countDown();
}, "Calculator-" + t).start();
}
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
}
}

Warum Context Switching zum Problem wird

  1. Häufigkeit der Switches

    • Ein typisches System führt tausende Context Switches pro Sekunde durch
    • Jeder Switch kostet etwa 1-100 Mikrosekunden (abhängig von Hardware und OS)
    • Bei vielen Threads steigt die Anzahl der Switches quadratisch
  2. Cache-Effekte

    CPU Cache Hierarchie:
    L1 Cache: ~0.5 ns
    L2 Cache: ~7 ns
    L3 Cache: ~20 ns
    Hauptspeicher: ~100 ns

    Nach einem Context Switch sind die Daten des neuen Threads meist nicht im Cache,
    was zu deutlich langsameren Speicherzugriffen führt.

  3. Scheduling Overhead

    • Der OS Scheduler muss entscheiden, welcher Thread als nächstes laufen soll
    • Je mehr Threads, desto komplexer diese Entscheidung
    • “Ready Queue” wird länger, Scheduling-Algorithmen werden ineffizienter

Die Grenzen des Thread-per-Request Modells

Hardware-Limitierungen

Ein modernes System hat typischerweise 8-32 CPU-Kerne. Jeder Kern kann zu einem Zeitpunkt nur einen Thread ausführen. Nehmen wir ein typisches Server-System mit 16 Kernen und Hyperthreading:

  • 16 physische Kerne × 2 (Hyperthreading) = 32 Hardware-Threads
  • Zu jedem Zeitpunkt können maximal 32 Threads tatsächlich parallel ausgeführt werden
  • Alle weiteren Threads müssen warten

Zwar suggeriert Hyperthreading (Intel) oder SMT (Simultaneous Multithreading bei AMD) die Möglichkeit, zwei Threads parallel auf einem Kern auszuführen, jedoch teilen sich diese Threads die Ressourcen des Kerns und erreichen damit nicht die doppelte Performance.

Der verborgene Ressourcenhunger: Thread Stack-Größe

Ein oft übersehener Aspekt ist der Speicherverbrauch durch Thread Stacks. Jeder Thread benötigt seinen eigenen Stack-Speicher. Die Größe dieses Stacks wird durch den JVM-Parameter -Xss festgelegt und beträgt auf 64-bit Systemen standardmäßig 1MB.

Um den tatsächlichen Speicherverbrauch von Threads zu untersuchen, können wir ein praktisches Experiment durchführen:

package de.notizwerk.async.chapter1_1;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
/**
* Analyzes thread memory consumption using Native Memory Tracking. * Run with JVM argument: -XX:NativeMemoryTracking=summary */public class ThreadAnalyzer {
private static final int THREAD_GROUP_SIZE = 100;
private static final int NUM_GROUPS = 5;
public static void main(String[] args) throws Exception {
System.out.println("=== Native Memory Tracking Analysis ===\n");
printNMTStatus("Initial Status");
List<Thread> allThreads = new ArrayList<>();
// Create and analyze thread groups
for (int group = 1; group <= NUM_GROUPS; group++) {
System.out.printf("%n=== Thread Group %d (adding %d threads) ===%n",
group, THREAD_GROUP_SIZE);
long[] beforeMem = getThreadMemory();
List<Thread> newThreads = createThreads(THREAD_GROUP_SIZE);
allThreads.addAll(newThreads);
// Wait for GC to settle
Thread.sleep(1000);
System.gc();
Thread.sleep(100);
long[] afterMem = getThreadMemory();
printMemoryDelta(beforeMem, afterMem, THREAD_GROUP_SIZE);
printNMTStatus("After Group " + group);
}
// Cleanup
allThreads.forEach(Thread::interrupt);
for (Thread t : allThreads) {
t.join(100);
}
}
private static List<Thread> createThreads(int count) {
List<Thread> threads = new ArrayList<>();
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch readyLatch = new CountDownLatch(count);
for (int i = 0; i < count; i++) {
Thread t = new Thread(() -> {
readyLatch.countDown();
try {
startLatch.await();
Thread.sleep(Long.MAX_VALUE);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "WorkerThread-" + i);
t.start();
threads.add(t);
}
try {
readyLatch.await();
startLatch.countDown();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return threads;
}
private static void printNMTStatus(String phase) {
try {
Process p = new ProcessBuilder("jcmd",
String.valueOf(ProcessHandle.current().pid()),
"VM.native_memory",
"summary")
.redirectErrorStream(true)
.start();
String output = new String(p.getInputStream().readAllBytes());
p.waitFor();
System.out.println("\n=== " + phase + " ===");
output.lines()
.filter(l -> l.contains("Total:") || l.contains("Thread"))
.forEach(System.out::println);
} catch (Exception e) {
System.err.println("Error getting NMT status: " + e);
}
}
private static long[] getThreadMemory() throws Exception {
Process p = new ProcessBuilder("jcmd",
String.valueOf(ProcessHandle.current().pid()),
"VM.native_memory",
"summary")
.redirectErrorStream(true)
.start();
String output = new String(p.getInputStream().readAllBytes());
p.waitFor();
long[] memory = new long[2]; // [0] = reserved, [1] = committed
output.lines()
.filter(l -> l.contains("Thread (reserved="))
.findFirst()
.ifPresent(l -> {
memory[0] = extractKB(l, "reserved=");
memory[1] = extractKB(l, "committed=");
});
return memory;
}
private static long extractKB(String line, String marker) {
int start = line.indexOf(marker) + marker.length();
int end = line.indexOf("KB", start);
return Long.parseLong(line.substring(start, end).trim());
}
private static void printMemoryDelta(long[] before, long[] after, int threadCount) {
long reservedDelta = after[0] - before[0];
long committedDelta = after[1] - before[1];
System.out.printf("Memory change for %d threads:%n", threadCount);
System.out.printf("Reserved: %6d KB total, %6.1f KB per thread%n",
reservedDelta, (float)reservedDelta / threadCount);
System.out.printf("Committed: %6d KB total, %6.1f KB per thread%n",
committedDelta, (float)committedDelta / threadCount);
}
}

Analyse der Thread-Speichermessungen

Unsere Messungen mit dem ThreadAnalyzer zeigen sehr deutlich, warum das Thread-per-Request Modell bei hoher Last problematisch wird. Schauen wir uns die konkreten Zahlen an:

Speicherverbrauch pro Thread

MessungReserved MemoryCommitted Memory
Pro Thread~1.050 KB~70 KB
100 Threads~105 MB~7 MB
1.000 Threads~1.05 GB~70 MB
10.000 Threads~10.5 GB~700 MB

Der große Unterschied zwischen “reserved” und “committed” Memory zeigt, wie effizient das Betriebssystem mit Speicherreservierungen umgeht:

  • Reserved Memory: Der vom Betriebssystem reservierte Adressraum (~1MB pro Thread)
  • Committed Memory: Der tatsächlich allokierte physische Speicher (~70KB pro Thread)

Was bedeutet das für das C10k Problem?

Nehmen wir einen typischen Server mit 32GB RAM. Selbst wenn wir nur den committed Memory betrachten:

  • Bei 10.000 gleichzeitigen Verbindungen benötigen wir ~700 MB allein für Thread-Stacks
  • Bei 100.000 Verbindungen wären es theoretisch schon ~7 GB
  • Bei 1 Million Verbindungen würden ~70 GB benötigt

Dabei haben wir noch nicht einmal den Speicherbedarf der eigentlichen Anwendung berücksichtigt!

Die versteckten Kosten

Der Speicherverbrauch ist aber nur ein Teil des Problems:

  1. Context Switching:

    • Je mehr Threads aktiv sind, desto mehr Zeit verbringt das System mit Context Switches
    • Die CPU-Zeit für das Thread-Scheduling steigt quadratisch mit der Anzahl der Threads
  2. Garbage Collection:

    • Mehr Heap-Nutzung bedeutet längere GC-Pausen
    • Thread-Stacks müssen vom GC durchsucht werden
  3. Ressourcen-Limits:

    • File Descriptors
    • Kernel Limits
    • Stack Size Limits

Fazit

Die Messungen bestätigen eindeutig: Das Thread-per-Request Modell skaliert nicht zufriedenstellend mit der Last. Ab einer gewissen Anzahl von Verbindungen (typischerweise im Bereich von 1.000 bis 10.000) wird der Overhead für Threading zu groß.

Dies erklärt, warum moderne Hochlast-Systeme auf alternative Ansätze wie asynchrone Programmierung, Event-Loop-basierte Architekturen, Non-Blocking I/O oder zum Beispiel Virtual Threads setzen müssen. Diese Alternativen werden wir in den nächsten Kapiteln im Detail behandeln.

Back to Blog

Related Posts

View All Posts »
Blocking vs Non-Blocking I/O

Blocking vs Non-Blocking I/O

Nach unserem Blick auf die Grenzen des Thread-per-Request Modells tauchen wir tief in die Welt des I/O ein. Verstehen Sie den fundamentalen Unterschied zwischen blockierendem und nicht-blockierendem I/O und lernen Sie, wie moderne Java-Anwendungen mit NIO und Event Loops diese Konzepte nutzen.Mit praktischen Beispielen und Performance-Messungen zeigen wir, wie der Wechsel von blockierendem zu nicht-blockierendem I/O die in Teil 1.1 diskutierten Skalierungsprobleme adressiert.

Fundamentale Pattern der Asynchronen Programmierung

Fundamentale Pattern der Asynchronen Programmierung

Im dritten Teil unserer Serie betrachten wir die wichtigsten Entwurfsmuster, die asynchrone Programmierung erst praktikabel machen. Vom Future/Promise-Pattern bis zum Actor Model - wir zeigen, wie diese Patterns die Komplexität asynchroner Systeme beherrschbar machen und warum sie die Grundlage für moderne Frameworks wie RxJava, Kotlin Coroutinen und Vert.x bilden.

Flexible Konstruktorkörper in Java 24

Flexible Konstruktorkörper in Java 24

Java 24 bringt mit flexiblen Konstruktorkörpern eine lang erwartete Verbesserung in der Objekterstellung. Dieses Feature, nun in der dritten Preview, verspricht robustere und lesbarere Konstruktoren und steht möglicherweise kurz vor der Finalisierung.

Java 24: Primitive Types in Patterns, instanceof and switch

Java 24: Primitive Types in Patterns, instanceof and switch

Java 24 erweitert das Pattern Matching mit Unterstützung primitiver Typen und revolutioniert damit die Typisierung und Verarbeitung von Daten. Entwickler können nun Primitive in instanceof, switch und Patterns verwenden und gewinnen dadurch mehr Flexibilität und Lesbarkeit bei Typumwandlungen.