Wie man nicht in Java wegwirft

Es gibt ein weit verbreitetes Missverständnis, dass Sie, wenn Ihnen die Garbage Collection nicht gefällt, nicht in Java, sondern in C / C ++ schreiben müssen. In den letzten drei Jahren habe ich Java-Code mit geringer Latenz für den Devisenhandel geschrieben und musste vermeiden, unnötige Objekte in jeder Hinsicht zu erstellen. Als Ergebnis formulierte ich ein paar einfache Regeln für mich selbst, wie man Zuweisungen in Java, wenn nicht auf Null, dann auf ein vernünftiges Minimum reduziert, ohne auf manuelle Speicherverwaltung zurückzugreifen. Vielleicht ist es auch für jemanden aus der Community nützlich.


Warum überhaupt Müll vermeiden?


Über das, was GC ist und wie man sie konfiguriert, wurde viel gesagt und geschrieben. Aber letztendlich funktioniert der Code, egal wie Sie den GC einrichten, suboptimal. Es gibt immer einen Kompromiss zwischen Durchsatz und Latenz. Es wird unmöglich, eines zu verbessern, ohne das andere zu verschlechtern. In der Regel wird der GC-Overhead durch Studieren der Protokolle gemessen. Sie können anhand dieser Protokolle nachvollziehen, zu welchen Zeitpunkten Pausen aufgetreten sind und wie viel Zeit sie in Anspruch genommen haben. Die GC-Protokolle enthalten jedoch nicht alle Informationen zu diesem Overhead. Das vom Thread erstellte Objekt wird automatisch in den L1-Cache des Prozessorkerns gestellt, auf dem der Thread ausgeführt wird. Dies führt dazu, dass andere potenziell nützliche Daten verdrängt werden. Mit einer großen Anzahl von Zuordnungen können nützliche Daten auch aus dem L3-Cache verschoben werden. Wenn der Thread das nächste Mal auf diese Daten zugreift, tritt ein Fehlcache auf, der zu Verzögerungen bei der Ausführung des Programms führt. Da der L3-Cache für alle Kerne innerhalb desselben Prozessors gleich ist, werden durch einen Garbage Stream Daten und andere Threads / Anwendungen aus dem L3-Cache übertragen, und es treten bereits besonders teure Cache-Fehler auf, selbst wenn sie in Bare C geschrieben sind und keinen Müll anlegen. Keine Einstellungen, keine Garbage Collectors (weder C4 noch ZGC) helfen, dieses Problem zu lösen. Die einzige Möglichkeit, die Situation insgesamt zu verbessern, besteht darin, nicht unnötig unnötige Objekte zu erstellen. Java verfügt im Gegensatz zu C ++ nicht über ein umfangreiches Arsenal an Mechanismen für die Arbeit mit Speicher, es gibt jedoch eine Reihe von Möglichkeiten, die Zuweisungen zu minimieren. Sie werden diskutiert.


Lyrischer Exkurs

Natürlich müssen Sie nicht den gesamten müllfreien Code schreiben. Die Sache mit der Java-Sprache ist, dass Sie Ihr Leben erheblich vereinfachen können, indem Sie nur die Hauptmüllquellen entfernen. Sie können sich auch nicht mit der sicheren Speicherwiederherstellung befassen, wenn Sie sperrfreie Algorithmen schreiben. Wenn ein Code beim Start der Anwendung nur einmal ausgeführt wird, kann er so viel zuweisen, wie Sie möchten, und das ist keine große Sache. Das wichtigste Arbeitsinstrument, um überschüssigen Müll loszuwerden, ist natürlich der Zuordnungsprofiler.


Primitive Typen verwenden


In vielen Fällen ist es am einfachsten, primitive Typen anstelle von Objekttypen zu verwenden. Die JVM verfügt über eine Reihe von Optimierungen, um den Overhead von Objekttypen zu minimieren, z. B. das Zwischenspeichern kleiner Werte von Ganzzahltypen und das Inlinen einfacher Klassen. Es lohnt sich jedoch nicht immer, sich auf diese Optimierungen zu verlassen, da sie möglicherweise nicht funktionieren: Der ganzzahlige Wert wird möglicherweise nicht zwischengespeichert, und Inlining tritt möglicherweise nicht auf. Wenn wir mit einer bedingten Ganzzahl arbeiten, müssen wir außerdem dem Link folgen, was möglicherweise zu einem Cache-Fehler führt. Außerdem verfügen alle Objekte über Header, die zusätzlichen Speicherplatz im Cache beanspruchen und andere Daten von dort verdrängen. Nehmen wir an: Ein primitives int benötigt 4 Bytes. Object Integer belegt 16 Bytes + Linkgröße für dieses Minimum von 4 Bytes (im Fall von komprimierten Oops). Insgesamt stellt sich heraus, dass Integer fünf (!) Mal mehr Platz einnimmt als int . Daher ist es besser, primitive Typen selbst zu verwenden. Ich werde einige Beispiele geben.


Beispiel 1. Konventionelle Berechnungen


Nehmen wir an, wir haben eine reguläre Funktion, die nur etwas zählt.


 Integer getValue(Integer a, Integer b, Integer c) { return (a + b) / c; } 

Ein solcher Code wird wahrscheinlich inline (sowohl die Methode als auch die Klassen) und führt nicht zu unnötigen Zuweisungen, aber Sie können sich dessen nicht sicher sein. Selbst wenn dies passiert, gibt es ein Problem mit der Tatsache, dass eine NullPointerException hier NullPointerException könnte. Auf die eine oder andere Weise muss die JVM entweder Nullprüfungen unter die Haube einfügen oder aus dem Kontext heraus verstehen, dass null kein Argument sein kann. Auf jeden Fall ist es besser, nur den gleichen Code auf Grundelemente zu schreiben.


 int getValue(int a, int b, int c) { return (a + b) / c; } 

Beispiel 2. Lambdas


Manchmal werden Objekte ohne unser Wissen erstellt. Zum Beispiel, wenn wir primitive Typen an Orte übergeben, an denen Objekttypen erwartet werden. Dies passiert häufig bei der Verwendung von Lambda-Ausdrücken.
Stellen Sie sich vor, wir haben diesen Code:


 void calculate(Consumer<Integer> calculator) { int x = System.currentTimeMillis(); calculator.accept(x); } 

Trotz der Tatsache, dass die Variable x ein Grundelement ist, wird ein Objekt vom Typ Integer erstellt, das an den Taschenrechner übergeben wird. Um dies zu vermeiden, verwenden Sie IntConsumer anstelle von Consumer<Integer> :


 void calculate(IntConsumer calculator) { int x = System.currentTimeMillis(); calculator.accept(x); } 

Ein solcher Code führt nicht mehr zur Erstellung eines zusätzlichen Objekts. Java.util.function verfügt über eine ganze Reihe von Standardschnittstellen, die für die Verwendung primitiver Typen angepasst sind: DoubleSupplier , LongFunction usw. Wenn etwas fehlt, können Sie jederzeit die gewünschte Schnittstelle mit Grundelementen hinzufügen. Beispielsweise können Sie anstelle von BiConsumer<Integer, Double> eine selbst erstellte Schnittstelle verwenden.


 interface IntDoubleConsumer { void accept(int x, double y); } 

Beispiel 3. Sammlungen


Die Verwendung eines primitiven Typs kann schwierig sein, da sich eine Variable dieses Typs in einer Sammlung befindet. Angenommen, wir haben eine List<Integer> und möchten herausfinden, welche Zahlen darin enthalten sind, und berechnen, wie oft jede der Zahlen wiederholt wird. Dafür verwenden wir HashMap<Integer, Integer> . Der Code sieht folgendermaßen aus:


 List<Integer> numbers = new ArrayList<>(); // fill numbers somehow Map<Integer, Integer> counters = new HashMap<>(); for (Integer x : numbers) { counters.compute(x, (k, v) -> v == null ? 1 : v + 1); } 

Dieser Code ist in mehrfacher Hinsicht gleichzeitig schlecht. Erstens wird eine Zwischendatenstruktur verwendet, auf die wahrscheinlich verzichtet werden könnte. Okay, der Einfachheit halber nehmen wir an, dass diese Liste später benötigt wird, d. H. Sie können es nicht vollständig entfernen. Zweitens wird das Objekt Integer an beiden Stellen anstelle des primitiven int . Drittens gibt es viele Zuordnungen in der compute . Viertens wird ein Iterator zugewiesen. Diese Zuweisung wird wahrscheinlich inline, aber dennoch. Wie verwandle ich diesen Code in müllfreien Code? Sie müssen die Sammlung nur für die Grundelemente einer Bibliothek eines Drittanbieters verwenden. Es gibt eine Reihe von Bibliotheken, die solche Sammlungen enthalten. Der folgende Code verwendet die Agrona- Bibliothek.


 IntArrayList numbers = new IntArrayList(); // fill numbers somehow Int2IntCounterMap counters = new Int2IntCounterMap(0); for (int i = 0; i < numbers.size(); i++) { counters.incrementAndGet(numbers.getInt(i)); } 

Die hier erstellten Objekte sind zwei Sammlungen und zwei int[] , die sich in diesen Sammlungen befinden. Beide Sammlungen können durch Aufrufen der clear() -Methode wiederverwendet werden. Durch die Verwendung von Sammlungen für Grundelemente haben wir unseren Code nicht kompliziert (und sogar vereinfacht, indem wir die Berechnungsmethode mit einem komplexen Lambda darin entfernt haben) und im Vergleich zur Verwendung von Standardsammlungen die folgenden zusätzlichen Boni erhalten:


  1. Fast völliges Fehlen von Zuweisungen. Wenn die Sammlungen wiederverwendet werden, gibt es überhaupt keine Zuordnungen.
  2. Erhebliche Speicherersparnis ( IntArrayList benötigt etwa fünfmal weniger Speicherplatz als ArrayList<Integer> . Wie bereits erwähnt, IntArrayList wir IntArrayList die wirtschaftliche Verwendung von Prozessor-Caches und nicht von RAM.
  3. Serieller Zugriff auf den Speicher. Es wurde viel darüber geschrieben, warum dies wichtig ist, deshalb werde ich hier nicht aufhören. Hier einige Artikel: Martin Thompson und Ulrich Drepper .

Ein weiterer kleiner Kommentar zu Sammlungen. Es kann sich herausstellen, dass die Sammlung Werte unterschiedlichen Typs enthält und es daher nicht möglich ist, sie durch eine Sammlung mit Grundelementen zu ersetzen. Meiner Meinung nach ist dies ein Zeichen für ein schlechtes Design der Datenstruktur oder des Algorithmus als Ganzes. In diesem Fall ist die Zuweisung zusätzlicher Objekte höchstwahrscheinlich nicht das Hauptproblem.


Veränderbare Objekte


Was aber, wenn auf Primitive nicht verzichtet werden kann? Zum Beispiel für den Fall, dass die von uns benötigte Methode mehrere Werte zurückgeben sollte. Die Antwort ist einfach: Verwenden Sie veränderbare Objekte.


Kleiner Exkurs

Einige Sprachen betonen die Verwendung unveränderlicher Objekte, beispielsweise in Scala. Das Hauptargument für sie ist, dass das Schreiben von Multithread-Code stark vereinfacht wird. Es gibt jedoch auch Gemeinkosten, die mit einer übermäßigen Zuweisung von Müll verbunden sind. Wenn wir sie vermeiden wollen, sollten wir keine kurzlebigen unveränderlichen Objekte erstellen.


Wie sieht es in der Praxis aus? Angenommen, wir müssen den Quotienten und den Rest der Division berechnen. Und dafür verwenden wir den folgenden Code.


 class IntPair { int x; int y; } IntPair divide(int value, int divisor) { IntPair result = new IntPair(); result.x = value / divisor; result.y = value % divisor; return result; } 

Wie kann man in diesem Fall die Zuordnung loswerden? IntPair , übergeben Sie IntPair als Argument und schreiben Sie das Ergebnis dort. In diesem Fall müssen Sie ein detailliertes Javadoc schreiben und noch besser eine Konvention für Variablennamen verwenden, in die das Ergebnis geschrieben wird. Sie können beispielsweise mit dem Präfix out beginnen. Müllfreier Code sieht in diesem Fall folgendermaßen aus:


 void divide(int value, int divisor, IntPair outResult) { outResult.x = value / divisor; outResult.y = value % divisor; } 

Ich möchte darauf hinweisen, dass die divide Methode keinen Link speichern sollte, um ihn irgendwo zu koppeln, oder ihn an Methoden übergeben sollte, die dies können, da wir sonst große Probleme haben könnten. Wie wir sehen können, sind veränderbare Objekte schwieriger zu verwenden als primitive Typen. Wenn Sie also primitive Objekte verwenden können, ist es besser, dies zu tun. In unserem Beispiel haben wir das Problem der Zuordnung von innerhalb der Teilungsmethode nach außen übertragen. An allen Stellen, an denen wir diese Methode aufrufen, benötigen wir einen IntPair Dummy, den wir zum divide . Oft genug, um diesen Dummy im final Feld des Objekts zu speichern, von wo aus wir die divide Methode aufrufen. Lassen Sie mich ein weit hergeholtes Beispiel geben: Nehmen wir an, unser Programm behandelt nur den Empfang eines Zahlenstroms über das Netzwerk, teilt diese auf und sendet das Ergebnis an denselben Socket.


 class SocketListener { private final IntPair pair = new IntPair(); private final BufferedReader in; private final PrintWriter out; SocketListener(final Socket socket) throws IOException { in = new BufferedReader(new InputStreamReader(socket.getInputStream())); out = new PrintWriter(socket.getOutputStream(), true); } void listenSocket() throws IOException { while (true) { int value = in.read(); int divisor = in.read(); divide(value, divisor, pair); out.print(pair.x); out.print(pair.y); } } } 

Der Kürze halber habe ich keinen zusätzlichen Code für die Fehlerbehandlung, die korrekte Programmbeendigung usw. geschrieben. Die Hauptidee dieses Codes ist, dass das IntPair uns IntPair Objekt einmal erstellt und im final Feld gespeichert wird.


Objektpools


Wenn wir veränderbare Objekte verwenden, müssen wir zuerst ein leeres Objekt von irgendwoher nehmen, dann die benötigten Daten in das Objekt schreiben, es irgendwo verwenden und dann das Objekt "an Ort und Stelle" zurückgeben. Im obigen Beispiel war das Objekt immer "an Ort und Stelle", d.h. im final Feld. Leider ist dies nicht immer auf einfache Weise möglich. Beispielsweise wissen wir möglicherweise nicht im Voraus genau, wie viele Objekte wir benötigen. In diesem Fall helfen uns Objektpools. Wenn wir ein leeres Objekt benötigen, erhalten wir es aus dem Objektpool, und wenn es nicht mehr benötigt wird, geben wir es dorthin zurück. Befindet sich kein freies Objekt im Pool, erstellt der Pool ein neues Objekt. Dies ist in der Tat eine manuelle Speicherverwaltung mit allen sich daraus ergebenden Konsequenzen. Es ist ratsam, nicht auf diese Methode zurückzugreifen, wenn die vorherigen Methoden verwendet werden können. Was könnte schief gehen?


  • Wir können vergessen, das Objekt in den Pool zurückzugeben, und dann wird Müll ("Speicherverlust") erzeugt. Dies ist ein kleines Problem - die Leistung wird leicht abnehmen, aber der GC wird funktionieren und das Programm wird weiter funktionieren.
  • Wir können das Objekt in den Pool zurückgeben, aber den Link dazu irgendwo speichern. Dann wird jemand anderes das Objekt aus dem Pool holen, und zu diesem Zeitpunkt in unserem Programm gibt es bereits zwei Links zu demselben Objekt. Dies ist ein klassisches Problem nach dem Gebrauch. Es ist schwer zu debütieren, weil Im Gegensatz zu C ++ stürzt das Programm nicht ab und funktioniert weiterhin fehlerhaft .

Um die Wahrscheinlichkeit zu verringern, dass die oben genannten Fehler auftreten, können Sie das Standardkonstrukt "Try-with-Resources" verwenden. Es kann so aussehen:


 public interface Storage<T> { T get(); void dispose(T object); } class IntPair implements AutoCloseable { private static final Storage<IntPair> STORAGE = new StorageImpl(IntPair::new); int x; int y; private IntPair() {} public static IntPair create() { return STORAGE.get(); } @Override public void close() { STORAGE.dispose(this); } } 

Die Divide-Methode könnte folgendermaßen aussehen:


 IntPair divide(int value, int divisor) { IntPair result = IntPair.create(); result.x = value / divisor; result.y = value % divisor; return result; } 

Und die listenSocket Methode listenSocket :


 void listenSocket() throws IOException { while (true) { int value = in.read(); int divisor = in.read(); try (IntPair pair = divide(value, divisor)) { out.print(pair.x); out.print(pair.y); } } } 

In der IDE können Sie normalerweise die Hervorhebung aller Fälle konfigurieren, in denen AutoCloseable Objekte außerhalb des Try-with-Resources-Blocks verwendet werden. Dies ist jedoch keine absolute Option, da Das Hervorheben in der IDE kann einfach deaktiviert werden. Daher gibt es eine andere Möglichkeit, die Rückgabe des Objekts an die Pool-Control-Inversion zu gewährleisten. Ich werde ein Beispiel geben:


 class IntPair implements AutoCloseable { private static final Storage<IntPair> STORAGE = new StorageImpl(IntPair::new); int x; int y; private IntPair() {} private static void apply(Consumer<IntPair> consumer) { try(IntPair pair = STORAGE.get()) { consumer.accept(pair); } } @Override public void close() { STORAGE.dispose(this); } } 

In diesem Fall können wir grundsätzlich nicht IntPair außen auf das Objekt der IntPair Klasse IntPair . Leider funktioniert diese Methode auch nicht immer. Beispielsweise funktioniert es nicht, wenn ein Thread Objekte aus dem Pool abruft und in eine Warteschlange stellt und ein anderer Thread sie aus der Warteschlange entfernt und in den Pool zurückgibt.


Wenn wir keine generischen Objekte im Pool speichern, sondern einige Bibliotheksobjekte, die AutoCloseable nicht implementieren, AutoCloseable die Option "Mit Ressourcen versuchen" AutoCloseable auch nicht.


Ein weiteres Problem ist hier das Multithreading. Die Implementierung des Objektpools muss sehr schnell erfolgen, was sehr schwer zu erreichen ist. Ein langsamer Pool kann der Leistung mehr schaden als nützen. Die Zuweisung neuer Objekte in TLAB ist wiederum sehr schnell, viel schneller als bei malloc in C. Das Schreiben eines schnellen Objektpools ist ein separates Thema, das ich jetzt nicht entwickeln möchte. Ich kann nur sagen, dass ich keine guten „vorgefertigten“ Implementierungen gesehen habe.


Anstelle einer Schlussfolgerung


Kurz gesagt, die Wiederverwendung von Objekten mit Objektpools ist eine schwerwiegende Hämorrhoiden. Glücklicherweise kann man fast immer darauf verzichten. Meine persönliche Erfahrung ist, dass eine übermäßige Verwendung von Objektpools Probleme mit der Anwendungsarchitektur signalisiert. In der Regel reicht uns eine Instanz des im final Feld zwischengespeicherten Objekts aus. Aber auch das ist übertrieben, wenn es möglich ist, primitive Typen zu verwenden.


Update:


Ja, ich erinnerte mich an einen anderen Weg für diejenigen, die keine Angst vor bitweisen Verschiebungen haben: mehrere kleine primitive Typen in einen großen zu packen. Angenommen, wir müssen zwei int . In diesem speziellen Fall können Sie das IntPair Objekt nicht verwenden, sondern ein long Objekt zurückgeben, wobei die ersten 4 Bytes dem ersten int 'y und die zweiten 4 Bytes dem zweiten entsprechen. Der Code könnte folgendermaßen aussehen:


 long combine(int left, int right) { return ((long)left << Integer.SIZE) | (long)right & 0xFFFFFFFFL; } int getLeft(long value) { return (int)(value >>> Integer.SIZE); } int getRight(long value) { return (int)value; } long divide(int value, int divisor) { int x = value / divisor; int y = value % divisor; return combine(left, right); } void listenSocket() throws IOException { while (true) { int value = in.read(); int divisor = in.read(); long xy = divide(value, divisor); out.print(getLeft(xy)); out.print(getRight(xy)); } } 

Solche Methoden müssen natürlich gründlich getestet werden, da es ziemlich einfach ist, sie aufzuschreiben. Aber dann benutze es einfach.

Source: https://habr.com/ru/post/de436024/


All Articles