Blog

Von Speicherfressern und viel leerem Nichts

16 Feb 2021

Laptop with pendrive, sd card, CD and portable hard drive. Concept of data storage.

Vor ein paar Wochen haben mein Kollege John Green und ich mit virtuellen Threads experimentiert (Projekt Loom). Unser Server empfing Textnachrichten, änderte ihren Case und sendete sie als Echo zurück. Unser Client simulierte jede Menge Benutzer.

Unser Experiment hatten wir auf 100 000 Sockets pro JVM hochgefahren, was einer Gesamtzahl von 200 000 virtuellen Threads entsprach. Sowohl die Server- als auch die Clientkomponenten liefen gut, aber wir bemerkten, dass der Speicherverbrauch auf dem Client um ein Vielfaches höher war. Aber warum? Der Server-Task sah aus, wie in Listing 1 gezeigt.

 

Listing 1
import java.io.*;
import java.net.*;
class TransmogrifyTask implements Runnable {
private final Socket socket;
public TransmogrifyTask(Socket socket) throws IOException {
this.socket = socket;
}
public void run() {
try (socket;
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream()
) {
while (true) {
int val = in.read();
if (Character.isLetter(val))
val ^= ' '; // change case of all letters
out.write(val);
}
} catch (IOException e) {
// connection closed
}
}
}}

 

Der clientseitige Task verwendete bequem PrintStream und BufferedReader zur Kommunikation mit dem Server (Listing 2).

 

Listing 2
import java.io.*;
import java.net.*;
import java.util.concurrent.*;
class ClientTaskWithIOStreams implements Runnable {
private final Socket socket;
private final boolean verbose;
public ClientTaskWithIOStreams(Socket socket, boolean verbose) {
this.socket = socket;
this.verbose = verbose;
}
private static final String message = "John 3:16";
public void run() {
try (socket;
BufferedReader in = new BufferedReader(
new InputStreamReader(
socket.getInputStream()));
PrintStream out = new PrintStream(
socket.getOutputStream(), true)
) {
while (true) {
out.println(message);
TimeUnit.SECONDS.sleep(2);
String reply = in.readLine();
if (verbose) System.out.println(reply);
TimeUnit.SECONDS.sleep(2);
}
} catch (Exception consumeAndExit) {}
}
}

 

Nachdem wir das Histogramm von jmap auf beiden JVMs ausgeführt hatten, stellten wir fest, dass der größte Speicherfresser der PrintStream war, gefolgt vom BufferedReader. Wir änderten daher den Client Task, um stattdessen einzelne Bytes zu senden und zu empfangen. Nicht alle Clients sind ausführlich, daher erstellen wir einen StringBuilder nur, wenn es erforderlich ist. Darüber hinaus verwendet standardmäßig jeder ClientTask dasselbe statische Appendable, das einen StringBuilder zurückgibt, wenn es sich um einen ausführlichen Client handelt (Listing 3).

 

Listing 3
import java.io.*;
import java.net.*;
import java.util.concurrent.*;
 
class ClientTask implements Runnable {
private final Socket socket;
private final boolean verbose;
 
public ClientTask(Socket socket, boolean verbose) {
this.socket = socket;
this.verbose = verbose;
}
 
private static final byte[] message = "John 3:16\n".getBytes();
 
private final static Appendable INITIAL = new Appendable() {
public Appendable append(CharSequence csq) {
return new StringBuilder().append(csq);
}
 
public Appendable append(CharSequence csq, int start, int end) {
return new StringBuilder().append(csq, start, end);
}
 
public Appendable append(char c) {
return new StringBuilder().append(c);
}
};
 
public void run() {
Appendable appendable = INITIAL;
try (socket;
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream()
) {
while (true) {
for (byte b : message) {
out.write(b);
}
out.flush();
TimeUnit.SECONDS.sleep(2);
 
for (int i = 0; i < message.length; i++) {
int b = in.read();
if (verbose) {
appendable = appendable.append((char) b);
}
}
if (verbose) {
System.out.print(appendable);
appendable = INITIAL;
}
TimeUnit.SECONDS.sleep(2);
}
} catch (Exception consumeAndExit) {}
}
}

 

Das funktionierte viel besser und der Speicherverbrauch auf dem Server und dem Client war ungefähr gleich. Wir ließen unser Experiment etwas länger laufen und hatten schließlich zwei Millionen Sockets auf der Server-JVM geöffnet, die von zwei Millionen virtuellen Threads bedient wurden, die wiederum von nur zwölf Träger-Threads bedient wurden. Unsere Clientsimulation hatte die gleiche Anzahl von Sockets und virtuellen Threads, mit insgesamt vier Millionen Sockets und Threads. Der Speicherverbrauch von all dem lag bei unter 3 GB pro JVM. Unglaubliche Technologie: Ich kann es nicht erwarten, bis sie in Java zum Mainstream wird.

Wir haben ein weiteres Experiment durchgeführt, um festzustellen, wie viel Speicher jeder der InputStreams und OutputStreams und der Reader und Writer verbraucht. Das geschah auf unserem eigenen Rechner, sodass eure Erfahrungen variieren können:

 

  • OutputStream
    • PrintStream 25064
    • BufferedOutputStream 8312
    • DataOutputStream 80
    • FileOutputStream 176
    • GZIPOutputStream 768
    • ObjectOutputStream 2264
  • InputStream
    • BufferedInputStream 8296
    • DataInputStream 328
    • FileInputStream 176
    • GZIPInputStream 1456
    • ObjectInputStream 2256
  • Writer
    • PrintWriter 80
    • BufferedWriter 16480
    • FileWriter 8608
    • OutputStreamWriter 8480
  • Reader
    • BufferedReader 16496
    • FileReader 8552
    • InputStreamReader 8424

 

So praktisch virtuelle Threads auch sind, wir werden unsere Art zu coden ändern müssen. Wer hätte gedacht, dass wir eines Tages in der Lage sein würden, Millionen von Threads in unseren JVMs zu erstellen? Selbst der Phaser hat ein maximales Limit von 65 535 Threads. Es ist zwar möglich, Phaser zusammenzusetzen, ich kann mir jedoch vorstellen, dass die Erfinder ursprünglich dachten, dass niemand jemals mehr als 64 000 Threads haben würde. Der ForkJoinPool hat eine ähnliche Begrenzung für die maximale Länge seiner Work Queues. Diese Zahlen sind vernünftig, wenn wir Tausende von Threads haben, aber nicht so sehr, wenn es Millionen Threads sind.

 

Mit freundlichen Grüßen aus Kreta

 

Heinz

 

PS: Die offensichtliche Frage, warum diese Objekte so viel Speicher verbrauchen, habe ich nicht beantwortet. Es liegt meist an leerem Space in Form von Puffern. Zum Beispiel hat der BufferedReader ein 8 k char[]. Da jedes char zwei Byte groß ist, ergibt das 16 kB. Der PrintStream enthält einen OutputStreamWriter (8 kB) und einen BufferedWriter (16 kB), sodass er etwa 25 kB groß ist. Einfach nur viel, viel leeres Nichts.

Keine Infos mehr verpassen!