Das Deklarieren mehrerer Arrays mit 64 Elementen ist 1000-mal schneller als das Deklarieren eines Arrays mit 65 Elementen

91

Kürzlich habe ich festgestellt, dass das Deklarieren eines Arrays mit 64 Elementen viel schneller (> 1000-fach) ist als das Deklarieren des gleichen Array-Typs mit 65 Elementen.

Hier ist der Code, mit dem ich das getestet habe:

public class Tests{
    public static void main(String args[]){
        double start = System.nanoTime();
        int job = 100000000;//100 million
        for(int i = 0; i < job; i++){
            double[] test = new double[64];
        }
        double end = System.nanoTime();
        System.out.println("Total runtime = " + (end-start)/1000000 + " ms");
    }
}

Dies läuft in etwa 6 ms, wenn ich ersetzen new double[64]mit new double[65]ihm ca. 7 Sekunden dauert. Dieses Problem wird exponentiell schwerwiegender, wenn der Job auf immer mehr Threads verteilt wird, von denen mein Problem stammt.

Dieses Problem tritt auch bei verschiedenen Arten von Arrays wie int[65]oder auf String[65]. Dieses Problem tritt bei großen Zeichenfolgen nicht auf: String test = "many characters";tritt jedoch auf, wenn dies in geändert wirdString test = i + "";

Ich habe mich gefragt, warum dies der Fall ist und ob es möglich ist, dieses Problem zu umgehen.

Sipko
quelle
3
Off-Note: System.nanoTime()sollte System.currentTimeMillis()für das Benchmarking vorgezogen werden .
Rocketboy
4
Ich bin nur neugierig ? Bist du unter Linux? Ändert sich das Verhalten mit dem Betriebssystem?
BSD
9
Wie um alles in der Welt hat diese Frage eine Abwertung bekommen?
Rohit Jain
2
FWIW, ich sehe ähnliche Leistungsunterschiede, wenn ich diesen Code mit byteanstelle von ausführe double.
Oliver Charlesworth
3
@ThomasJungblut: Was erklärt also die Diskrepanz im OP-Experiment?
Oliver Charlesworth

Antworten:

88

Sie beobachten ein Verhalten, das durch die vom JIT-Compiler Ihrer Java-VM vorgenommenen Optimierungen verursacht wird . Dieses Verhalten ist reproduzierbar und wird mit skalaren Arrays mit bis zu 64 Elementen ausgelöst. Es wird nicht mit Arrays ausgelöst, die größer als 64 sind.

Bevor wir auf Details eingehen, schauen wir uns den Körper der Schleife genauer an:

double[] test = new double[64];

Der Körper hat keine Wirkung (beobachtbares Verhalten) . Das heißt, es macht außerhalb der Programmausführung keinen Unterschied, ob diese Anweisung ausgeführt wird oder nicht. Gleiches gilt für die gesamte Schleife. Es kann also vorkommen, dass der Code-Optimierer die Schleife in etwas (oder nichts) mit derselben Funktion und unterschiedlichem Timing-Verhalten übersetzt.

Für Benchmarks sollten Sie mindestens die folgenden zwei Richtlinien einhalten. Wenn Sie dies getan hätten, wäre der Unterschied erheblich geringer gewesen.

  • Erwärmen Sie den JIT-Compiler (und den Optimierer), indem Sie den Benchmark mehrmals ausführen.
  • Verwenden Sie das Ergebnis jedes Ausdrucks und drucken Sie es am Ende des Benchmarks aus.

Gehen wir nun auf Details ein. Es überrascht nicht, dass eine Optimierung für skalare Arrays ausgelöst wird, die nicht größer als 64 Elemente sind. Die Optimierung ist Teil der Escape-Analyse . Es legt kleine Objekte und kleine Arrays auf den Stapel, anstatt sie auf dem Heap zuzuweisen - oder optimiert sie noch besser vollständig. Einige Informationen dazu finden Sie in dem folgenden Artikel von Brian Goetz aus dem Jahr 2005:

Die Optimierung kann mit der Befehlszeilenoption deaktiviert werden -XX:-DoEscapeAnalysis. Der magische Wert 64 für skalare Arrays kann auch in der Befehlszeile geändert werden. Wenn Sie Ihr Programm wie folgt ausführen, gibt es keinen Unterschied zwischen Arrays mit 64 und 65 Elementen:

java -XX:EliminateAllocationArraySizeLimit=65 Tests

Trotzdem rate ich dringend davon ab, solche Befehlszeilenoptionen zu verwenden. Ich bezweifle, dass dies bei einer realistischen Anwendung einen großen Unterschied macht. Ich würde es nur verwenden, wenn ich von der Notwendigkeit absolut überzeugt wäre - und nicht auf den Ergebnissen einiger Pseudo-Benchmarks.

nosid
quelle
9
Aber warum erkennt der Optimierer, dass das Array der Größe 64 entfernbar ist, aber nicht 65
ug_
10
@nosid: Obwohl der Code des OP möglicherweise nicht realistisch ist, löst er eindeutig ein interessantes / unerwartetes Verhalten in der JVM aus, das Auswirkungen auf andere Situationen haben kann. Ich denke, es ist gültig zu fragen, warum dies geschieht.
Oliver Charlesworth
1
@ThomasJungblut Ich glaube nicht, dass die Schleife entfernt wird. Sie können "int total" außerhalb der Schleife hinzufügen und "total + = test [0];" zum obigen Beispiel. Wenn Sie dann das Ergebnis drucken, sehen Sie, dass insgesamt = 100 Millionen und es stull läuft in weniger als einer Sekunde.
Sipko
1
Beim Ersetzen des Stapels geht es darum, interpretierten Code durch spontan kompilierten Code zu ersetzen, anstatt die Heap-Zuordnung durch die Stapelzuweisung zu ersetzen. EliminateAllocationArraySizeLimit ist die Grenzgröße von Arrays, die in der Escape-Analyse als skalar ersetzbar angesehen werden. Der Hauptpunkt, dass der Effekt auf die Compileroptimierung zurückzuführen ist, ist also korrekt, aber nicht auf die Stapelzuweisung, sondern darauf, dass die Escape-Analysephase nicht bemerkt, dass die Zuweisung nicht erforderlich ist.
Kiheru
2
@Sipko: Sie schreiben, dass die Anwendung nicht mit der Anzahl der Threads skaliert. Dies ist ein Hinweis darauf, dass das Problem nicht mit den von Ihnen gewünschten Mikrooptimierungen zusammenhängt. Ich empfehle, das große Bild anstelle der kleinen Teile zu betrachten.
Nosid
2

Es gibt eine Reihe von Möglichkeiten, wie es je nach Größe eines Objekts zu Unterschieden kommen kann.

Wie nosid angegeben hat, kann der JITC (höchstwahrscheinlich) kleine "lokale" Objekte auf dem Stapel zuweisen, und der Größengrenzwert für "kleine" Arrays kann bei 64 Elementen liegen.

Das Zuweisen auf dem Stapel ist erheblich schneller als das Zuweisen auf dem Heap, und genauer gesagt, der Stapel muss nicht durch Müll gesammelt werden, sodass der GC-Overhead erheblich reduziert wird. (Und für diesen Testfall beträgt der GC-Overhead wahrscheinlich 80-90% der gesamten Ausführungszeit.)

Sobald der Wert dem Stapel zugewiesen ist, kann die JITC eine "Eliminierung des toten Codes" durchführen, feststellen, dass das Ergebnis von newniemals irgendwo verwendet wird, und, nachdem sichergestellt wurde, dass keine Nebenwirkungen verloren gehen, den gesamten newVorgang eliminieren . und dann die (jetzt leere) Schleife selbst.

Selbst wenn die JITC keine Stapelzuweisung durchführt, ist es durchaus möglich, dass Objekte, die kleiner als eine bestimmte Größe sind, in einem Heap anders zugewiesen werden (z. B. aus einem anderen "Raum") als größere Objekte. (Normalerweise würde dies jedoch nicht zu so dramatischen Zeitunterschieden führen.)

Hot Licks
quelle
Spät zu diesem Thread. Warum ist die Zuweisung auf dem Stapel schneller als die Zuweisung auf dem Heap? Nach wenigen Artikeln sind für die Zuordnung auf dem Heap ~ 12 Anweisungen erforderlich. Es gibt nicht viel Raum für Verbesserungen.
Vortex
@Vortex - Das Zuweisen zum Stapel erfordert 1-2 Anweisungen. Aber das ist, um einen ganzen Stapelrahmen zuzuweisen. Der Stapelrahmen muss ohnehin zugewiesen werden, um einen Register-Speicherbereich für die Routine zu haben, sodass alle anderen gleichzeitig zugewiesenen Variablen "frei" sind. Und wie gesagt, der Stack benötigt keinen GC. Der GC-Overhead für ein Heap-Element ist weitaus höher als die Kosten für die Heap-Zuweisungsoperation.
Hot Licks