Was sind einige Tipps, um den GC-Overhead niedrig zu halten?
Mit der verzögerten Veröffentlichung von Java 9 wird der G1 („Garbage First“) Garbage Collector zum Standard-Garbage Collector für die virtuelle HotSpot-Maschine. Vom seriellen Garbage Collector bis zum CMS Collector hat JVM viele GC-Implementierungen erlebt und G1 wird der Garbage Collector der nächsten Generation sein.
Mit der Entwicklung von Garbage Collectors hat jede GC-Generation im Vergleich zur vorherigen Generation enorme Fortschritte und Verbesserungen gebracht. Im Vergleich zur seriellen GC ermöglicht die parallele GC dem Garbage Collector die Arbeit mit mehreren Threads und nutzt so die Rechenleistung von Multi-Core-Computern voll aus. Im Vergleich zur parallelen GC unterteilt der CMS-Kollektor („Concurrent Mark-Sweep“) den Recyclingprozess in mehrere Phasen, sodass bei laufendem Anwendungsthread die Sammlungsarbeit gleichzeitig abgeschlossen werden kann, was die häufige Ausführung von „Stop“ erheblich verbessert - die Welt“-Situation. G1 zeigt eine bessere Leistung für JVMs mit großen Mengen an Heap-Speicher und verfügt über einen vorhersehbareren und gleichmäßigeren Pausenprozess.
Tipp #1: Sagen Sie die Kapazität einer Sammlung voraus
Alle Standard-Java-Sammlungen, einschließlich benutzerdefinierter und erweiterter Implementierungen (wie Trove und Googles Guava), verwenden Arrays (native Datentypen). unten oder basierend auf dem Typ des Objekts). Da die Größe eines Arrays unveränderlich ist, sobald es zugewiesen ist, führt das Hinzufügen von Elementen zur Sammlung in den meisten Fällen dazu, dass ein neues Array mit großer Kapazität erneut beantragt werden muss, um das alte Array zu ersetzen (bezogen auf das von der Sammlung verwendete Array). zugrunde liegende Implementierung der Sammlung).
Auch wenn keine Größe für die Sammlungsinitialisierung bereitgestellt wird, versuchen die meisten Sammlungsimplementierungen, die Verarbeitung der Neuzuweisung des Arrays zu optimieren und den Overhead auf ein Minimum zu reduzieren. Die besten Ergebnisse können jedoch erzielt werden, wenn beim Aufbau der Sammlung die Größe angegeben wird.
Lassen Sie uns den folgenden Code als einfaches Beispiel analysieren:
public static List reverse(List & lt; ? extends T & gt; list) { List result = new ArrayList(); for (int i = list.size() - 1; i & gt; = 0; i--) { result.add(list.get(i)); } return result; }
Diese Methode weist ein neues Array zu und füllt es dann mit Elementen aus einer anderen Liste, nur in umgekehrter Reihenfolge Ordnet ein neues Array zu und füllt das Array dann mit Elementen aus einer anderen Liste. Lediglich die numerische Reihenfolge der Elemente ändert sich.
Diese Verarbeitungsmethode kann hohe Leistungseinbußen verursachen. Der Optimierungspunkt ist die Codezeile, die Elemente zu einer neuen Liste hinzufügt. Wenn jedes Element hinzugefügt wird, muss die Liste sicherstellen, dass das zugrunde liegende Array über genügend Platz für das neue Element verfügt. Ist ein freier Steckplatz vorhanden, wird das neue Element einfach im nächsten freien Steckplatz abgelegt. Wenn nicht, wird ein neues zugrunde liegendes Array zugewiesen, der alte Array-Inhalt wird in das neue Array kopiert und die neuen Elemente werden hinzugefügt. Dies führt dazu, dass das Array mehrmals zugewiesen wird und die verbleibenden alten Arrays schließlich vom GC zurückgefordert werden.
Wir können diese redundanten Zuweisungen vermeiden, indem wir dem zugrunde liegenden Array mitteilen, wie viele Elemente es beim Erstellen der Sammlung speichern wird.
public static List reverse(List & lt; ? extends T & gt; list) { List result = new ArrayList(list.size()); for (int i = list.size() - 1; i & gt; = 0; i--) { result.add(list.get(i)); } return result; }
Der obige Code gibt durch den Konstruktor von ArrayList genügend Platz an Wenn Sie list.size()-Elemente speichern, wird die Zuweisung während der Initialisierung abgeschlossen, was bedeutet, dass List während der Iteration keinen erneuten Speicher zuordnen muss.
Guavas Sammlungsklasse geht noch einen Schritt weiter und ermöglicht es Ihnen, beim Initialisieren der Sammlung explizit die Anzahl der erwarteten Elemente anzugeben oder einen vorhergesagten Wert anzugeben.
List result = Lists.newArrayListWithCapacity(list.size());List result = Lists.newArrayListWithExpectedSize(list.size());
Im obigen Code wird Ersteres verwendet, wenn wir bereits genau wissen, wie viele Elemente die Sammlung speichern wird, während Letzteres auf eine Weise zugewiesen wird, die falsche Schätzungen berücksichtigt.
Tipp Nr. 2: Datenströme direkt verarbeiten
Bei der Verarbeitung von Datenströmen, etwa beim Lesen von Daten aus einer Datei oder beim Herunterladen von Daten aus dem Netzwerk, kommt der folgende Code sehr häufig vor:
byte[] fileData = readFileToByteArray(new File("myfile.txt"));
Das resultierende Byte-Array kann als XML-Dokument, JSON-Objekt oder Protokollpuffernachricht sowie als einige allgemeine Optionen analysiert werden.
Der obige Ansatz ist unklug, wenn es um große Dateien oder Dateien mit unvorhersehbaren Größen geht, da OutOfMemoryErrors auftreten, wenn die JVM keinen Puffer für die Verarbeitung der echten Datei zuweisen kann.
Selbst wenn die Größe der Daten überschaubar ist, verursacht die Verwendung des oben genannten Musters dennoch einen enormen Mehraufwand bei der Speicherbereinigung, da dadurch ein sehr großer Bereich im Heap zum Speichern der Dateidaten zugewiesen wird.
Eine bessere Möglichkeit, damit umzugehen, besteht darin, einen geeigneten InputStream (wie in diesem Beispiel FileInputStream) zu verwenden, um ihn direkt an den Parser zu übergeben, anstatt die gesamte Datei auf einmal in ein Byte-Array einzulesen. Alle gängigen Open-Source-Bibliotheken bieten entsprechende APIs, um einen Eingabestream direkt zur Verarbeitung zu akzeptieren, wie zum Beispiel:
FileInputStream fis = new FileInputStream(fileName); MyProtoBufMessage msg = MyProtoBufMessage.parseFrom(fis);
Tipp Nr. 3: Verwenden Sie unveränderliche Objekte
Es gibt zu viele Vorteile der Unveränderlichkeit. Ich muss nicht einmal ins Detail gehen. Es gibt jedoch einen Vorteil, der sich auf die Garbage Collection auswirkt und der berücksichtigt werden sollte.
Die Attribute eines unveränderlichen Objekts können nach der Erstellung des Objekts nicht mehr geändert werden (das Beispiel hier verwendet Attribute von Referenzdatentypen), zum Beispiel:
public class ObjectPair { private final Object first; private final Object second; public ObjectPair(Object first, Object second) { this.first = first; this.second = second; } public Object getFirst() { return first; } public Object getSecond() { return second; } }
Ändern Sie das Obige nach einer Klasse Wird instanziiert, wird ein unveränderliches Objekt erzeugt – alle seine Eigenschaften werden mit final geändert und können nach Abschluss der Konstruktion nicht mehr geändert werden.
不可变性意味着所有被一个不可变容器所引用的对象,在容器构造完成前对象就已经被创建。就 GC 而言:这个容器年轻程度至少和其所持有的最年轻的引用一样。这意味着当在年轻代执行垃圾回收的过程中,GC 因为不可变对象处于老年代而跳过它们,直到确定这些不可变对象在老年代中不被任何对象所引用时,才完成对它们的回收。
更少的扫描对象意味着对内存页更少的扫描,越少的扫描内存页就意味着更短的 GC 生命周期,也意味着更短的 GC 暂停和更好的总吞吐量。
Tip #4: 小心字符串拼接
字符串可能是在所有基于 JVM 应用程序中最常用的非原生数据结构。然而,由于其隐式地开销负担和简便的使用,非常容易成为占用大量内存的罪归祸首。
这个问题很明显不在于字符串字面值,而是在运行时分配内存初始化产生的。让我们快速看一下动态构建字符串的例子:
public static String toString(T[] array) { String result = "["; for (int i = 0; i & lt; array.length; i++) { result += (array[i] == array ? "this" : array[i]); if (i & lt; array.length - 1) { result += ", "; } } result += "]"; return result; }
这是个看似不错的方法,接收一个字符数组然后返回一个字符串。但是这对于对象内存分配却是灾难性的。
很难看清这语法糖的背后,但是幕后的实际情况是这样的:
public static String toString(T[] array) { String result = "["; for (int i = 0; i & lt; array.length; i++) { StringBuilder sb1 = new StringBuilder(result); sb1.append(array[i] == array ? "this" : array[i]); result = sb1.toString(); if (i & lt; array.length - 1) { StringBuilder sb2 = new StringBuilder(result); sb2.append(", "); result = sb2.toString(); } } StringBuilder sb3 = new StringBuilder(result); sb3.append("]"); result = sb3.toString(); return result; }
字符串是不可变的,这意味着每发生一次拼接时,它们本身不会被修改,而是依次分配新的字符串。此外,编译器使用了标准的 StringBuilder 类来执行这些拼接操作。这就会有问题了,因为每一次迭代,既隐式地分配了一个临时字符串,又隐式分配了一个临时的 StringBuilder 对象来帮助构建最终的结果。
最佳的方式是避免上面的情况,使用 StringBuilder 和直接的追加,以取代本地拼接操作符(“+”)。下面是一个例子:
public static String toString(T[] array) { StringBuilder sb = new StringBuilder("["); for (int i = 0; i & lt; array.length; i++) { sb.append(array[i] == array ? "this" : array[i]); if (i & lt; array.length - 1) { sb.append(", "); } } sb.append("]"); return sb.toString(); }
这里,我们只在方法开始的时候分配了唯一的一个 StringBuilder。至此,所有的字符串和 list 中的元素都被追加到单独的一个StringBuilder中。最终使用 toString() 方法一次性将其转成成字符串返回。
Tip #5: 使用特定的原生类型的集合
Java 标准的集合库简单且支持泛型,允许在使用集合时对类型进行半静态地绑定。比如想要创建一个只存放字符串的 Set 或者存储 Map
TIntDoubleMap map = new TIntDoubleHashMap(); map.put(5, 7.0); map.put(-1, 9.999);...
Trove 的底层实现使用了原生类型的数组,所以当操作集合的时候不会发生元素的装箱(int->Integer)或者拆箱(Integer->int), 没有存储对象,因为底层使用原生数据类型存储。
最后
随着垃圾收集器持续的改进,以及运行时的优化和 JIT 编译器也变得越来越智能。我们作为开发者将会发现越来越少地考虑如何编写 GC 友好的代码。然而,就目前阶段,不论 G1 如何改进,我们仍然有很多可以做的事来帮 JVM 提升性能。