Das folgende Java-Programm benötigt im Durchschnitt zwischen 0,50 und 0,55 Sekunden für die Ausführung:
public static void main(String[] args) {
long startTime = System.nanoTime();
int n = 0;
for (int i = 0; i < 1000000000; i++) {
n += 2 * (i * i);
}
System.out.println((double) (System.nanoTime() - startTime) / 1000000000 + " s");
System.out.println("n = " + n);
}
Wenn ich 2 * (i * i)
durch 2 * i * i
ersetze, dauert die Ausführung zwischen 0,60 und 0,65 Sekunden. Wie kommt das?
Ich habe jede Version des Programms 15 Mal ausgeführt, abwechselnd mit beiden. Hier sind die Ergebnisse:
2*(i*i) | 2*i*i
----------+----------
0.5183738 | 0.6246434
0.5298337 | 0.6049722
0.5308647 | 0.6603363
0.5133458 | 0.6243328
0.5003011 | 0.6541802
0.5366181 | 0.6312638
0.515149 | 0.6241105
0.5237389 | 0.627815
0.5249942 | 0.6114252
0.5641624 | 0.6781033
0.538412 | 0.6393969
0.5466744 | 0.6608845
0.531159 | 0.6201077
0.5048032 | 0.6511559
0.5232789 | 0.6544526
Der schnellste Durchlauf von "2 i i" dauerte länger als der langsamste Durchlauf von "2 (i i)". Wenn beide gleich effizient wären, wäre die Wahrscheinlichkeit, dass dies geschieht, kleiner als 1/2^15 * 100% = 0,00305%.
Byte-Codes: https://cs.nyu.edu/courses/fall00/V22.0201-001/jvm2.html Byte-Codes Betrachter: https://github.com/Konloch/bytecode-viewer
Auf meinem JDK (Windows 10 64 bit, 1.8.0_65-b17) kann ich es reproduzieren und erklären:
public static void main(String[] args) {
int repeat = 10;
long A = 0;
long B = 0;
for (int i = 0; i < repeat; i++) {
A += test();
B += testB();
}
System.out.println(A / repeat + " ms");
System.out.println(B / repeat + " ms");
}
private static long test() {
int n = 0;
for (int i = 0; i < 1000; i++) {
n += multi(i);
}
long startTime = System.currentTimeMillis();
for (int i = 0; i < 1000000000; i++) {
n += multi(i);
}
long ms = (System.currentTimeMillis() - startTime);
System.out.println(ms + " ms A " + n);
return ms;
}
private static long testB() {
int n = 0;
for (int i = 0; i < 1000; i++) {
n += multiB(i);
}
long startTime = System.currentTimeMillis();
for (int i = 0; i < 1000000000; i++) {
n += multiB(i);
}
long ms = (System.currentTimeMillis() - startTime);
System.out.println(ms + " ms B " + n);
return ms;
}
private static int multiB(int i) {
return 2 * (i * i);
}
private static int multi(int i) {
return 2 * i * i;
}
Ausgabe:
...
405 ms A 785527736
327 ms B 785527736
404 ms A 785527736
329 ms B 785527736
404 ms A 785527736
328 ms B 785527736
404 ms A 785527736
328 ms B 785527736
410 ms
333 ms
Und warum? Der Bytecode ist dieser:
private static multiB(int arg0) { // 2 * (i * i)
<localVar:index=0, name=i , desc=I, sig=null, start=L1, end=L2>
L1 {
iconst_2
iload0
iload0
imul
imul
ireturn
}
L2 {
}
}
private static multi(int arg0) { // 2 * i * i
<localVar:index=0, name=i , desc=I, sig=null, start=L1, end=L2>
L1 {
iconst_2
iload0
imul
iload0
imul
ireturn
}
L2 {
}
}
Der Unterschied ist:
Mit Klammern (2 * (i * i)
):
Ohne Klammern (2 * i * i
):
Alles auf den Stapel zu laden und dann wieder nach unten zu arbeiten, ist schneller als zwischen dem Auflegen auf den Stapel und dem Bearbeiten des Stapels zu wechseln.
Ich habe ähnliche Ergebnisse:
2 * (i * i): 0.458765943 s, n=119860736
2 * i * i: 0.580255126 s, n=119860736
Ich erhielt die SAME Ergebnisse, wenn beide Schleifen im selben Programm waren, oder jede in einer separaten .java-Datei/.class, die in einem separaten Lauf ausgeführt wurde.
Abschließend ist hier ein javap -c -v <.java>
Dekompilieren von beiden:
3: ldc #3 // String 2 * (i * i):
5: invokevirtual #4 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
8: invokestatic #5 // Method java/lang/System.nanoTime:()J
8: invokestatic #5 // Method java/lang/System.nanoTime:()J
11: lstore_1
12: iconst_0
13: istore_3
14: iconst_0
15: istore 4
17: iload 4
19: ldc #6 // int 1000000000
21: if_icmpge 40
24: iload_3
25: iconst_2
26: iload 4
28: iload 4
30: imul
31: imul
32: iadd
33: istore_3
34: iinc 4, 1
37: goto 17
vs.
3: ldc #3 // String 2 * i * i:
5: invokevirtual #4 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
8: invokestatic #5 // Method java/lang/System.nanoTime:()J
11: lstore_1
12: iconst_0
13: istore_3
14: iconst_0
15: istore 4
17: iload 4
19: ldc #6 // int 1000000000
21: if_icmpge 40
24: iload_3
25: iconst_2
26: iload 4
28: imul
29: iload 4
31: imul
32: iadd
33: istore_3
34: iinc 4, 1
37: goto 17
ZU DEINER INFORMATION -
java -version
java version "1.8.0_121"
Java(TM) SE Runtime Environment (build 1.8.0_121-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, mixed mode)
Die beiden Methoden des Hinzufügens erzeugen leicht unterschiedlichen Bytecode:
17: iconst_2
18: iload 4
20: iload 4
22: imul
23: imul
24: iadd
Für 2 * (i * i)
vs:
17: iconst_2
18: iload 4
20: imul
21: iload 4
23: imul
24: iadd
Für "2 i i".
Und bei Verwendung eines JMH Benchmarks wie diesem:
@Warmup(iterations = 5, batchSize = 1)
@Measurement(iterations = 5, batchSize = 1)
@Fork(1)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
public class MyBenchmark {
@Benchmark
public int noBrackets() {
int n = 0;
for (int i = 0; i < 1000000000; i++) {
n += 2 * i * i;
}
return n;
}
@Benchmark
public int brackets() {
int n = 0;
for (int i = 0; i < 1000000000; i++) {
n += 2 * (i * i);
}
return n;
}
}
Der Unterschied ist deutlich:
# JMH version: 1.21
# VM version: JDK 11, Java HotSpot(TM) 64-Bit Server VM, 11+28
# VM options: <none>
Benchmark (n) Mode Cnt Score Error Units
MyBenchmark.brackets 1000000000 avgt 5 380.889 ± 58.011 ms/op
MyBenchmark.noBrackets 1000000000 avgt 5 512.464 ± 11.098 ms/op
Was Sie beobachten, ist korrekt und nicht nur eine Anomalie Ihres Benchmarking-Stils (d.h. kein Warmup, siehe Wie schreibe ich einen korrekten Mikro-Benchmark in Java?)
Erneut mit Graal ausgeführt:
# JMH version: 1.21
# VM version: JDK 11, Java HotSpot(TM) 64-Bit Server VM, 11+28
# VM options: -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler
Benchmark (n) Mode Cnt Score Error Units
MyBenchmark.brackets 1000000000 avgt 5 335.100 ± 23.085 ms/op
MyBenchmark.noBrackets 1000000000 avgt 5 331.163 ± 50.670 ms/op
Sie sehen, dass die Ergebnisse viel näher beieinander liegen, was Sinn macht, da Graal ein insgesamt leistungsfähigerer, modernerer Compiler ist.
Es hängt also wirklich nur davon ab, wie gut der JIT-Compiler in der Lage ist, einen bestimmten Teil des Codes zu optimieren, und hat nicht unbedingt einen logischen Grund dafür.