Schleifen in Java - gibt es eine beste Variante?

vorhergehende Artikel in: Java
06.12.2025

Ich habe mich wieder einmal hingesetzt und die Art und Weise, wie Java Bytecode erstellt und verarbeitet, untersucht

Die Untersuchung soll sich zunächst einmal auf die Größe des Bytecode konzentrieren. Danach wird diskutiert, wie sich die einzelnen Varianten in Hinblick auf die Abarbeitungsgeschwindigkeit verhalten.

Ich möchte hier gleich betonen: es geht um Bytecode - was der Just-in-time-Compiler dann daraus macht und wie der Bytecode auf tatsächliche Maschineninstruktionen abgebildet wird, wird nicht Teil dieser Analyse sein!

Nun kann man sich fragen: "Schleifen gibts denn da solche Unterschiede?" Ich habe mich von einem Artikel im schönen Internet inspirieren lassen - der zeigte für low-Level-Sprachen, dass es durchaus überlegenswerte Abwäghungen gibt, die einen messbaren Einföuss auf beide genannten Messgrößen haben können. Daher wollte ich das auch für Java Bytecode untersuchen.

Außerdem sind da noch die verknöcherten Ansichten eines alten weißen Mannes, der vor vielen Jahrzehnten einmal gelernt hat, dass sich die Performanz von Postfix- und Prefix-Operatoren unterscheidet und der seither seine for Schleifen wie folgt schreibt:

for(int i=0;i<5;++i)

statt

for(int i=0;i<5;i++)

Das sollte gleich das erste Ziel der Untersuchung sein - also die Unterschiede zwischen diesen beiden Varianten:

public void call1()
{
    long akku=0;
    for(int i=0;i<getIterationCount();++i)
    {
        akku+=System.currentTimeMillis();
    }
}
public void call2()
{
    long akku=0;
    for(int i=0;i<getIterationCount();i++)
    {
        akku+=System.currentTimeMillis();
    }
}

Der Bytecode dafür sieht wie folgt aus:

  public void call1();
    Code:
       0: lconst_0
       1: lstore_1
       2: iconst_0
       3: istore_3
       4: iload_3
       5: aload_0
       6: invokevirtual #119                // Method getIterationCount:()I
       9: if_icmpge     24
      12: lload_1
      13: invokestatic  #122                // Method java/lang/System.currentTimeMillis:()J
      16: ladd
      17: lstore_1
      18: iinc          3, 1
      21: goto          4
      24: return

public void call2(); Code: 0: lconst_0 1: lstore_1 2: iconst_0 3: istore_3 4: iload_3 5: aload_0 6: invokevirtual #119 // Method getIterationCount:()I 9: if_icmpge 24 12: lload_1 13: invokestatic #122 // Method java/lang/System.currentTimeMillis:()J 16: ladd 17: lstore_1 18: iinc 3, 1 21: goto 4 24: return

Wie man sieht - überhaupt kein Unterschied! - Das liegt daran, dass die Variable i in der Schleife nicht verwendet wird, wie man weiter unten in den Beispielen mit den repeat Schleifen und dem zugehörigen Quellcode erkennen kann (und wir merken uns mal - die Länge des Bytecodes für die eigentliche Schleife beträgt hier 17).

Aber zunächst zu einem weiteren Test, der sich nahe an den verlinkten Artikel schmiegt: Wir haben oben mit einer beliebigen Zahl als Abbruchkriterium verglichen - ändert sich etwas an der Gestalt und/oder Länge des Bytecode wenn das Abbruchkriterium immer 0 oder -1 ist?

public void call3()
{
    long akku=0;
    for(int i=getIterationCount()-1;i!=0;--i)
    {
        akku+=System.currentTimeMillis();
    }
}
public void call4()
{
    long akku=0;
    for(int i=getIterationCount()-1;i>-1;--i)
    {
        akku+=System.currentTimeMillis();
    }
}

Der zugehörige Bytecode:

  public void call3();
    Code:
       0: lconst_0
       1: lstore_1
       2: aload_0
       3: invokevirtual #119                // Method getIterationCount:()I
       6: iconst_1
       7: isub
       8: istore_3
       9: iload_3
      10: ifeq          25
      13: lload_1
      14: invokestatic  #122                // Method java/lang/System.currentTimeMillis:()J
      17: ladd
      18: lstore_1
      19: iinc          3, -1
      22: goto          9
      25: return

public void call4(); Code: 0: lconst_0 1: lstore_1 2: aload_0 3: invokevirtual #119 // Method getIterationCount:()I 6: iconst_1 7: isub 8: istore_3 9: iload_3 10: iconst_m1 11: if_icmple 26 14: lload_1 15: invokestatic #122 // Method java/lang/System.currentTimeMillis:()J 18: ladd 19: lstore_1 20: iinc 3, -1 23: goto 9 26: return

Und ja - es ist ein Unterschied zu entdecken: Die Variante, die auf 0 testet ist zunächst einmal deutlich kürzer: in Bytecode gemessen ist die Länge der eigentlichen Schleife nur noch 13 bzw. 14 bei der Variante mit Test auf -1. Der Unterschied zum vorangegangenen Test ist hier hauptsächlich auf die Tatsache zurückzuführen, dass die Methode zur Bestimmung der Anzahl der Iterationen nicht mehr in jedem Schleifendurchlauf aufgerufen wird, sondern nur noch zu Beginn.

Bisher haben wir uns auf for Schleifen konzentriert. Sieht es eventuell mit anderen syntaktischen Konstrukten anders aus? Was ist mit while Schleifen?

public void call6()
{
    int a=getIterationCount();
    long akku=0;
    while(a--!=0)
    {
        akku+=System.currentTimeMillis();
    }
}
  public void call6();
    Code:
       0: aload_0
       1: invokevirtual #119                // Method getIterationCount:()I
       4: istore_1
       5: lconst_0
       6: lstore_2
       7: iload_1
       8: iinc          1, -1
      11: ifeq          23
      14: lload_2
      15: invokestatic  #122                // Method java/lang/System.currentTimeMillis:()J
      18: ladd
      19: lstore_2
      20: goto          7
      23: return

Die Länge beträgt hier analog zum vorhergehenden Beipiel wieder 13 (Vergleich und Abbruchbedingung wieder 0).

Nun zur Frage, ob sich mittels repeat Schleifen vielleicht noch etwas machen lässt?

public void call7()
{
    int a=getIterationCount()-1;
    long akku=0;
    if(a>0)
    {
        do
        {
            akku+=System.currentTimeMillis();
        }
        while(--a>=0);
    }
}
public void call8()
{
    int a=getIterationCount()-1;
    if(a>0)
    {
        long akku=0;
        do
        {
            akku+=System.currentTimeMillis();
        }
        while(a--!=0);
    }
}

Hier wurde wieder auch der Vergleich zwischen Prefix- und Postfix-Operator gezogen und es existiert ein Unterschied:

  public void call7();
    Code:
       0: aload_0
       1: invokevirtual #119                // Method getIterationCount:()I
       4: iconst_1
       5: isub
       6: istore_1
       7: lconst_0
       8: lstore_2
       9: iload_1
      10: ifle          26
      13: lload_2
      14: invokestatic  #122                // Method java/lang/System.currentTimeMillis:()J
      17: ladd
      18: lstore_2
      19: iinc          1, -1
      22: iload_1
      23: ifge          13
      26: return

public void call8(); Code: 0: aload_0 1: invokevirtual #119 // Method getIterationCount:()I 4: iconst_1 5: isub 6: istore_1 7: iload_1 8: ifle 26 11: lconst_0 12: lstore_2 13: lload_2 14: invokestatic #122 // Method java/lang/System.currentTimeMillis:()J 17: ladd 18: lstore_2 19: iload_1 20: iinc 1, -1 23: ifne 13 26: return

Die Länge der eigentlichen Schleife beträgt nur noch lediglich 10! (und man sieht hier auch schön den Unterschied zwischen Prefix- und Postfix-Operator, erkennt aber auch, dass das heutzutage und in Hochsprachen herzlich egal ist!).

Nun stellt sich natürlich die Frage: wenn wir einen solch drastischen Unterschied in der Größe sehen - wirkt sich das irgendwie auf die Laufzeit aus? Ich habe dazu 4000 Mal jede der Schleifen laufen lassen und dabei jeweils 500000 Mal iteriert - vorher habe ich durch 5-maligen Durchlauf durch diese 500000 Iterationen dafür gesorgt, dass sich der JIT eingeschwungen hat und das Ergebnis nur noch die reine Ausführungszeit des eigentlichen Maschinencodes misst. Die Ergebnisse wurden über die 40 Läufe gemittelt und es stellt sich heraus, dass die naive Version, in der jedesmal die Funktion zur Bestimmung der Abbruchbedingung aufgerufen wird für die 500000 Durchläufe (auf meinem Testsystem: Intel) genauso viel Zeit benötigt, wie die auf dem Papier kürzeste call8. Man sieht wieder einmal - premature optimization is the root of all evil!

Aus Neugierde habe ich diesen Test auch noch einmal auf einem ARM (Rock64 - Rockchip RK3328 quad-core ARM Cortex A53 64-Bit Processor) durchgeführt, wo sich die vom JIT-Compiler erzeugten Instruktionen ja prinzipiell deutlich unterscheiden sollten und vom Ergebnis her ein wenig abwichen: Die Variante call1 schnitt deutlich besser ab als alle anderen - allerdings möchte ich nochmals zu bedenken geben, dass die Art meines Tests und die geringe Stichprobenanzahl nicht das Umstellen aller Schleifen in einer Codebasis rechtfertigt!

Alle Artikel rss Wochenübersicht Monatsübersicht Codeberg Repositories Mastodon Über mich home xmpp


Vor 5 Jahren hier im Blog

  • Links - Verschiedenes II

    02.12.2020

    Heute wieder eine Sammlung wild durch den Kräutergarten - diesmal hauptächlich zum Thema Kubernetes.

    Weiterlesen

Neueste Artikel

  • Asymmetrische Kryptographie

    Ich habe mich mit der Idee schon länger getragen: Nochmal einen Rundumschlag zu asymmetrischer Kryptographie zu machen. Dabei werde ich mich auf Demonstrationen der einzelnen Konzepte und Operationen mit Beispielcode konzentrieren und zu jedem der vorgestellten Konzepte mehr oder weniger ausführlich bezüglich der Einsatzszenarien und Vor- und Nachteile Stellung beziehen

    Weiterlesen
  • PKI Renewal 2025

    Es war wieder einmal an der Zeit, meine PKI zu warten...

    Weiterlesen
  • sQLshell Version 7.9pre1 build 11189

    Eine neue Version der sQLshell ist verfügbar!

    Weiterlesen

Manche nennen es Blog, manche Web-Seite - ich schreibe hier hin und wieder über meine Erlebnisse, Rückschläge und Erleuchtungen bei meinen Hobbies.

Wer daran teilhaben und eventuell sogar davon profitieren möchte, muss damit leben, daß ich hin und wieder kleine Ausflüge in Bereiche mache, die nichts mit IT, Administration oder Softwareentwicklung zu tun haben.

Ich wünsche allen Lesern viel Spaß und hin und wieder einen kleinen AHA!-Effekt...

PS: Meine öffentlichen Codeberg-Repositories findet man hier.