Wie messen wir die Speichereinsparungen durch kompakte Strings?
Wie Ihr vielleicht wisst, bin ich ein großer Fan von IntelliJ IDEA. Ich habe auf der letzten
Java Champions Conference sogar 45 Minuten lang über “IntelliJ Super Productivity”
gesprochen. Und irgendwann in meinem Leben habe ich vielleicht auch einmal eine abfällige
Bemerkung über Netbeans gemacht. Ich habe jedoch immer betont und werde dies auch
weiterhin tun, dass Netbeans, und insbesondere die Netbeans-Plattform, eine wichtige Rolle
im Java-Ökosystem einnimmt. Netbeans hat Fans, die sogar noch treuer sind als die
IntelliJ-Anhänger.
Einer der Vorzüge, die Netbeans mitbringt, ist, dass es mehr als nur eine IDE ist. Es ist eine
Plattform, auf der wir aufbauen und die wir erweitern können. In diesem Artikel werden wir
genau das tun.
Mittlerweile wissen wir hoffentlich alle über JEP 254 Bescheid, mit dem die Java-Strings ab
Java 9 komprimiert wurden. Anstatt zwei Bytes pro Zeichen zu verwenden, können Strings
auf ein einziges Byte reduziert werden, wenn alle Zeichen in den LATIN1-Zeichensatz fallen.
Dies kann je nach Länge der Strings zu erheblichen Platzeinsparungen führen. Das folgende
Diagramm veranschaulicht die Kosteneinsparungen in Abhängigkeit von der Länge der
Zeichenfolge, wobei davon ausgegangen wird, dass nur LATIN1-Zeichen verwendet werden.
Bis Länge=4 gibt es keine Unterschiede im Speicherverbrauch. Bei Länge=165 erreichen wir
45 % Einsparungen:
Eine Frage, die mich eine Weile beschäftigt hat, war, wie ich die Kosteneinsparungen
bei Strings zwischen Java 11+ und Java 8 berechnen kann. Gibt es eine Möglichkeit,
den Heap zu analysieren und alle String-Instanzen zu finden? Glücklicherweise fand ich
einen interessanten Vortrag von Ryan Cuprak, der mich auf eine einfache Lösung
hinwies. Man kann einfach das Netbeans Profiler API für die Analyse des Heaps
verwenden. Da das API auf Maven Central verfügbar ist, können wir eine Abhängigkeit
zu unserer pom.xml-Datei hinzufügen:
<dependency>
<groupId>org.netbeans.modules</groupId>
<artifactId>org-netbeans-lib-profiler</artifactId>
<version>RELEASE160</version>
</dependency>
Leider ist das Netbeans Profiler API nicht mit JPMS modularisiert, daher müssen wir uns
auf automatische Module verlassen. Das ist zwar nicht ideal, aber funktioniert –
zumindest für unsere jetzigen Zwecke. Hier ist meine module-info.java Datei:
module eu.javaspecialists.tjsn.issue306 {
requires org.netbeans.lib.profiler.RELEASE160;
}
Der Code, der den Heap-Dump analysiert, sieht wie folgt aus. Er ist sehr einfach: Wir
lesen den Heap-Dump in Netbeans mit HeapFactory ein. Dann filtern wir nach der
Klasse „java.lang.String“ und erstellen StringData-Instanzen, die den Codierer und die
Array-Länge enthalten. Optional können wir alle Strings ausdrucken, wenn wir mit dem
Parameter -verbose beginnen. Am Ende sehen wir die Gesamtzahl der Strings und
erkennen, wie viel Platz wir in Java 11 mit JEP 254 gegenüber Java 8 gespart haben.
package eu.javaspecialists.tjsn.issue306;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.function.Predicate;
import java.util.stream.Stream;
import org.netbeans.lib.profiler.heap.HeapFactory;
import org.netbeans.lib.profiler.heap.Instance;
import org.netbeans.lib.profiler.heap.PrimitiveArrayInstance;
import static java.nio.charset.StandardCharsets.ISO_8859_1;
import static java.nio.charset.StandardCharsets.UTF_16LE;
public class HeapAnalysis {
private enum Coder {LATIN1, UTF16}
private record StringData(Coder coder, int length) {}
public static void main(String... args) throws IOException {
if (args.length < 1 || args.length > 2 ||
args.length > 1 && !args[0].equals("-verbose")) {
System.err.println("Usage: java HeapAnalysis " +
"[-verbose] heapdump");
System.exit(1);
}
var verbose = args.length == 2;
var filename = args[args.length - 1];
System.out.println("Inspecting heap file " + filename);
var heap = HeapFactory.createHeap(new File(filename));
var stringClass = heap.getJavaClassByName(
"java.lang.String");
var instances = stringClass.getInstancesIterator();
var stats = extractStringData(instances, verbose);
printStatistics(stats);
}
private static List extractStringData(
Iterator<Instance> instances, boolean verbose) {
var result = new ArrayList<StringData>();
while (instances.hasNext()) {
Instance instance = instances.next();
Coder coder = getCoder(instance);
int length = getLength(instance, coder, verbose);
result.add(new StringData(coder, length));
}
return result;
}
private static Coder getCoder(Instance instance) {
Byte coder = (Byte) instance.getValueOfField("coder");
return switch (coder) {
case 0 -> Coder.LATIN1;
case 1 -> Coder.UTF16;
case null -> throw new IllegalStateException(
"Analysis for Java 11+ heap dumps only -"
+ " field coder not found in"
+ " java.lang.String");
default -> throw new IllegalStateException(
"Unknown coder: " + coder);
};
}
private static int getLength(Instance instance, Coder coder,
boolean verbose) {
var array = (PrimitiveArrayInstance)
instance.getValueOfField("value");
if (array == null)
throw new IllegalStateException(
"java.lang.String instances did not have a"
+ " value array field");
int length = array.getLength();
if (verbose) {
List<String> arrayValues = array.getValues();
byte[] bytes = new byte[length];
int i = 0;
for (String str : arrayValues)
bytes[i++] = Byte.parseByte(str);
System.out.println(switch (coder) {
case LATIN1 -> "LATIN1: "
+ new String(bytes, ISO_8859_1);
case UTF16 -> "UTF16: "
+ new String(bytes, UTF_16LE);
});
}
return length;
}
private static final Predicate<StringData> LATIN1_FILTER =
datum -> datum.coder() == Coder.LATIN1;
private static final Predicate<StringData> UTF16_FILTER =
datum -> datum.coder() == Coder.UTF16;
private static void printStatistics(List<StringData> data) {
long j8Memory = memoryUsed(data.stream(), 2);
long j11MemoryLatin1 =
memoryUsed(data.stream().filter(LATIN1_FILTER), 1);
long j11MemoryUTF16 =
memoryUsed(data.stream().filter(UTF16_FILTER), 2);
long j11Memory = j11MemoryLatin1 + j11MemoryUTF16;
var latin1Size = data.stream().filter(LATIN1_FILTER).count();
var utf16Size = data.stream().filter(UTF16_FILTER).count();
System.out.printf(Locale.US, """
Total number of String instances:
LATIN1 %,d
UTF16 %,d
Total %,d
""",
latin1Size, utf16Size, latin1Size + utf16Size);
System.out.printf(Locale.US, """
Java 8 memory used by String instances:
Total %,d bytes
""", j8Memory);
System.out.printf(Locale.US, """
Java 11+ memory used by String instances:
LATIN1 %,d bytes
UTF16 %,d bytes
Total %,d bytes
""", j11MemoryLatin1, j11MemoryUTF16, j11Memory);
System.out.printf(Locale.US, "Saving of %.2f%%%n", 100.0 *
(j8Memory - j11Memory) / j8Memory);
}
private static int memoryUsed(Stream<StringData> stats,
int bytesPerChar) {
return stats
.mapToInt(datum -> getStringSize(datum.length(),
bytesPerChar))
.sum();
}
private static int getStringSize(int length, int bytesPerChar) {
return 24 + 16 +
(int) (Math.ceil(length * bytesPerChar / 8.0) * 8);
}
}
Hier ist ein Beispiel für unseren Code, der gegen einen Heap-Dump von IntelliJ IDEA
läuft:
Inspecting heap file idea.hprof
Total number of String instances:
LATIN1 2,656,321
UTF16 47,231
Total 2,703,552
Java 8 memory used by String instances:
Total 275,473,456 bytes
Java 11+ memory used by String instances:
LATIN1 192,371,520 bytes
UTF16 8,066,152 bytes
Total 200,437,672 bytes
Saving of 27.24%
Und hier ist eine Analyse meines JavaSpecialists.eu Tomcat-Servers:
Inspecting heap file javaspecialists.hprof
Total number of String instances:
LATIN1 165,865
UTF16 174
Total 166,039
Java 8 memory used by String instances:
Total 15,145,800 bytes
Java 11+ memory used by String instances:
LATIN1 11,210,944 bytes
UTF16 111,600 bytes
Total 11,322,544 bytes
Saving of 25.24%
Was ich bisher noch nicht berücksichtigt habe, ist die Möglichkeit, dass die
Deduplizierung dieselben Array-Instanzen gemeinsam nutzen kann. Das wäre ein
Projekt für einen anderen verregneten Tag!
Happy Coding!