Compiler Optimierungen für "final" Variablen

Ich fand es interessant dass der final Modifier bei lokalen Variablen eine so große Rolle spielt.
Nur Mal ein paar Beispiele:

public static void foo(){
    final int a=5;
    final int b=7;
    int c=a+b;
}

Ergebnis:

public static void foo();
Code:
   0: bipush        12
   2: istore_2
   3: return

diese Optimierung ist nützlich, lässt man beide final Modifier weg wird daraus:

public static void foo();
Code:
   0: iconst_5
   1: istore_0
   2: bipush        7
   4: istore_1
   5: iload_0
   6: iload_1
   7: iadd
   8: istore_2
   9: return

also obwohl beide Varianten nichts bewirken führt das weglassen von final zu weniger Optimierungen. Ich finde das interessant; ich meine beide Varianten haben keine Nebeneffekte, ausser dass die Methode ohne final mehr Speicher auf dem Stack benötigt. Schreibt man im obigen Beispiel bei allen Variablen einen final Modifier wird der gesamte Code wegoptimiert zu „return“. Also daraus schliesse ich für mich: um einen möglichst effizienten Code zu schreiben soll man woimmer möglich die lokalen Variablen als final deklarieren. Mag sein, dass die JVM sowas auch optimieren kann, aber es stört ja nicht, wenn die Methoden von vorne hinein schon etwas weniger bytes an Speicher verbrauchen.

hier noch ein für mich überraschendes Beispiel:

public static void bar(){
        final String a="foo";
        final String b="bar";
        String c=a+b;
}

das wird optimiert zu:

public static void bar();
Code:
   0: ldc           #2                  // String foobar
   2: astore_2
   3: return

lässt man dagegen die final Modifier weg, wird die Methode sehr aufwendig:

public static void bar();
Code:
   0: ldc           #2                  // String foo
   2: astore_0
   3: ldc           #3                  // String bar
   5: astore_1
   6: new           #4                  // class java/lang/StringBuilder
   9: dup
  10: invokespecial #5                  // Method java/lang/StringBuilder."<init>":()V
  13: aload_0
  14: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  17: aload_1
  18: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  21: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
  24: astore_2
  25: return

Hab schon für Firmen gearbeitet, bei denen man alle nicht veränderlichen, lokalen Variablen final machen musste… Die Sch*** aus der QS haben darauf geachtet (natürlich haben die dafür Tools drüberlaufenlassen). Damals verstand ich nicht, wieso, aber dank Deines Beitrags ist mir das nun klar. Vielen Dank.

1 „Gefällt mir“

Variablen final zu machen kann sinnvoll sein.

Fields sollten wann immer möglich final sein. (Also private final ist der default, und jede Abweichung davon muss gut gerechtfertigt sein - POJOs/Beans natürlich außen vor gelassen).

Methodenparameter sollten nicht verändert werden. Das könnte man erzwingen, indem man sie final macht, aber das ist IMHO häßlich - da sollte man eher die Warnings in der IDE passend einstellen (wenn man nicht die „Disziplin“ hat, die man braucht, um das sowieso einzuhalten).

Lokale Variablen kann man mal final machen. Das hat aber eher den Charakter von „Dokumentation“ - eben dass man weiß, dass die Variable nicht mehr verändert wird.


Der JVM ist das aber wurscht. Es gibt praktisch keinen Fall, wo ein final für Variablen wirklich einen Unterschied macht. Man sieht zwar einen Unterschied im bytecode (wie @neoexpert ja gepostet hat). Aber … der Bytecode hat mit dem, was die Maschine macht, nicht viel zu tun.

Wenn man mal folgendes Programm anschaut…

public class FinalAgain
{
	public static void main(String args[])
	{
		runTest();
	}

	private static void runTest()
	{
		int sumA = 0;
		int sumB = 0;
		for (int i=0; i<10000; i++)
		{
			sumA += fooWithFinal();
			sumB += fooWithoutFinal();
		}
		System.out.println(sumA);
		System.out.println(sumB);
	}


    public static int fooWithFinal()
    {
        final int a=5;
        final int b=7;
        int c=a+b;
        return c;
    }

    public static int fooWithoutFinal()
    {
        int a=5;
        int b=7;
        int c=a+b;
        return c;
    }

}

und sich mit javap -c FinalAgain.class den passenden bytecode ansieht

Compiled from "FinalAgain.java"
public class FinalAgain {
  public FinalAgain();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: invokestatic  #2                  // Method runTest:()V
       3: return

  public static int fooWithFinal();
    Code:
       0: bipush        12
       2: istore_2
       3: iload_2
       4: ireturn

  public static int fooWithoutFinal();
    Code:
       0: iconst_5
       1: istore_0
       2: bipush        7
       4: istore_1
       5: iload_0
       6: iload_1
       7: iadd
       8: istore_2
       9: iload_2
      10: ireturn
}

sieht man einen Unterschied zwischen den Methoden.

Wenn man das ganze aber mal in einer Hotspot-Disassembler-VM mit java -server -XX:+UnlockDiagnosticVMOptions -XX:+TraceClassLoading -XX:+LogCompilation -XX:+PrintAssembly -XX:+PrintInlining FinalAgain startet und den ganzen Kram durchnudeln läßt, kommen folgende Ergebnisse für die beiden Methoden raus:

Für fooWithFinal:

Decoding compiled method 0x000001a59b7e1610:
Code:
[Entry Point]
[Verified Entry Point]
[Constants]
  # {method} {0x000001a5bd960468} &apos;fooWithFinal&apos; &apos;()I&apos; in &apos;FinalAgain&apos;
  #           [sp+0x40]  (sp of caller)
  0x000001a59b7e1760: mov    dword ptr [rsp-0x6000],eax
  0x000001a59b7e1767: push   rbp
  0x000001a59b7e1768: sub    rsp,0x30
  0x000001a59b7e176c: movabs rax,0x1a5bd9606a8  ;   {metadata(method data for {method} {0x000001a5bd960468} &apos;fooWithFinal&apos; &apos;()I&apos; in &apos;FinalAgain&apos;)}
  0x000001a59b7e1776: mov    esi,dword ptr [rax+0xdc]
  0x000001a59b7e177c: add    esi,0x8
  0x000001a59b7e177f: mov    dword ptr [rax+0xdc],esi
  0x000001a59b7e1785: movabs rax,0x1a5bd960460  ;   {metadata({method} {0x000001a5bd960468} &apos;fooWithFinal&apos; &apos;()I&apos; in &apos;FinalAgain&apos;)}
  0x000001a59b7e178f: and    esi,0x1ff8
  0x000001a59b7e1795: cmp    esi,0x0
  0x000001a59b7e1798: je     0x000001a59b7e17af  ;*bipush
                                                ; - FinalAgain::fooWithFinal@0 (line 27)

  0x000001a59b7e179e: mov    eax,0xc
  0x000001a59b7e17a3: add    rsp,0x30
  0x000001a59b7e17a7: pop    rbp
  0x000001a59b7e17a8: test   dword ptr [rip+0xfffffffffe70e952],eax        # 0x000001a599ef0100
                                                ;   {poll_return}
  0x000001a59b7e17ae: ret    
  0x000001a59b7e17af: mov    qword ptr [rsp+0x8],rax
  0x000001a59b7e17b4: mov    qword ptr [rsp],0xffffffffffffffff
  0x000001a59b7e17bc: call   0x000001a59b7d05a0  ; OopMap{off=97}
                                                ;*synchronization entry
                                                ; - FinalAgain::fooWithFinal@-1 (line 27)
                                                ;   {runtime_call}
  0x000001a59b7e17c1: jmp    0x000001a59b7e179e
  0x000001a59b7e17c3: nop
  0x000001a59b7e17c4: nop
  0x000001a59b7e17c5: mov    rax,qword ptr [r15+0x2a8]
  0x000001a59b7e17cc: movabs r10,0x0
  0x000001a59b7e17d6: mov    qword ptr [r15+0x2a8],r10
  0x000001a59b7e17dd: movabs r10,0x0
  0x000001a59b7e17e7: mov    qword ptr [r15+0x2b0],r10
  0x000001a59b7e17ee: add    rsp,0x30
  0x000001a59b7e17f2: pop    rbp
  0x000001a59b7e17f3: jmp    0x000001a59b740620  ;   {runtime_call}

Für fooWithoutFinal:

Decoding compiled method 0x000001a59b7e1950:
Code:
[Entry Point]
[Verified Entry Point]
[Constants]
  # {method} {0x000001a5bd960508} &apos;fooWithoutFinal&apos; &apos;()I&apos; in &apos;FinalAgain&apos;
  #           [sp+0x40]  (sp of caller)
  0x000001a59b7e1aa0: mov    dword ptr [rsp-0x6000],eax
  0x000001a59b7e1aa7: push   rbp
  0x000001a59b7e1aa8: sub    rsp,0x30
  0x000001a59b7e1aac: movabs rax,0x1a5bd960688
  0x000001a59b7e1ab6: mov    esi,dword ptr [rax+0x8]
  0x000001a59b7e1ab9: add    esi,0x8
  0x000001a59b7e1abc: mov    dword ptr [rax+0x8],esi
  0x000001a59b7e1abf: movabs rax,0x1a5bd960500  ;   {metadata({method} {0x000001a5bd960508} &apos;fooWithoutFinal&apos; &apos;()I&apos; in &apos;FinalAgain&apos;)}
  0x000001a59b7e1ac9: and    esi,0x3ff8
  0x000001a59b7e1acf: cmp    esi,0x0
  0x000001a59b7e1ad2: je     0x000001a59b7e1ae9  ;*iconst_5
                                                ; - FinalAgain::fooWithoutFinal@0 (line 33)

  0x000001a59b7e1ad8: mov    eax,0xc
  0x000001a59b7e1add: add    rsp,0x30
  0x000001a59b7e1ae1: pop    rbp
  0x000001a59b7e1ae2: test   dword ptr [rip+0xfffffffffe70e618],eax        # 0x000001a599ef0100
                                                ;   {poll_return}
  0x000001a59b7e1ae8: ret    
  0x000001a59b7e1ae9: mov    qword ptr [rsp+0x8],rax
  0x000001a59b7e1aee: mov    qword ptr [rsp],0xffffffffffffffff
  0x000001a59b7e1af6: call   0x000001a59b7d05a0  ; OopMap{off=91}
                                                ;*synchronization entry
                                                ; - FinalAgain::fooWithoutFinal@-1 (line 33)
                                                ;   {runtime_call}
  0x000001a59b7e1afb: jmp    0x000001a59b7e1ad8
  0x000001a59b7e1afd: nop
  0x000001a59b7e1afe: nop
  0x000001a59b7e1aff: mov    rax,qword ptr [r15+0x2a8]
  0x000001a59b7e1b06: movabs r10,0x0
  0x000001a59b7e1b10: mov    qword ptr [r15+0x2a8],r10
  0x000001a59b7e1b17: movabs r10,0x0
  0x000001a59b7e1b21: mov    qword ptr [r15+0x2b0],r10
  0x000001a59b7e1b28: add    rsp,0x30
  0x000001a59b7e1b2c: pop    rbp
  0x000001a59b7e1b2d: jmp    0x000001a59b740620  ;   {runtime_call}

Ja: Die sind gleich.

Mag sein, dass die JVM sowas auch optimieren kann, aber es stört ja nicht, wenn die Methoden von vorne hinein schon etwas weniger bytes an Speicher verbrauchen.

Das stimmt bis zu einem geissen Grad. In den tiefsten Innereien der Concurrency-Klassen wird teilweise ein „obskur“ erscheinender Stil verwendet. Dort werden z.B. Fields auch nochmal in (finalen) lokalen Variablen gespeichert. Also z.B. nicht

class HashMap {
    Entry table[];

    void doSomething() {
        ...
        this.table[i].key = k;
        this.table[i].value = v;
    }
}

sondern

class HashMap {
    Entry table[];

    void doSomething() {
        final Entry t[] = this.table;
        ...
        t[i].key = k;
        t[i].value = v;
    }
}

(Doug Lea ist da „bekannt“ dafür…). Aber das bezieht sich auf riesige, performance-kritischSTe Kern-Klassen, wo die Bytecode-Größe vielleicht eine Rolle spielt.

In „normalem“ Code sollte man das machen, was am besten lesbar und nachvollziehbar ist. Da kann auch ein final für lokale Variablen dazugehören, aber … man sollte nicht sagen „Ich mach’ das wegen der höheren Performance“ - das Argument ist einfach zu dünn, wenn man sich’s genau anschaut.

1 „Gefällt mir“

Premature optimization …

Lesbarkeit / Verständlichkeit / Wartbarkeit sollte vorgehen, es sei denn man weiß, dass es an dieser Stelle wirklich zeitlich hakt.

Aber schön zu sehen, dass der Unterschied im Bytecode keinen im wirklich erzeugten Code macht.

zu

Methodenparameter sollten nicht verändert werden. Das könnte man erzwingen, indem man sie final macht, aber das ist IMHO häßlich - da sollte man eher die Warnings in der IDE passend einstellen (wenn man nicht die „Disziplin“ hat, die man braucht, um das sowieso einzuhalten).

Diese Warnung hab ich auch eingestellt und eigentlich hätte ich auch behauptet, die Disziplin zu haben, sie ohne das einzuhalten, vor ein paar Tagen hatte ich aber den Fall, ein Stück Code aus einer Methode in eine eigene auszulagern. Mit Refactoring-Tools der IDE geht das ja sehr einfach. Und da wurden dann plötzlich die vormals lokalen und nun nun Methodenparameter geändert. Daher ist die Warnung in meine Augen schon nützlich, ich weiß nicht, ob mir das sonst bei dieser (recht trivialen Methode) von allein ins Auge gefallen wäre.

Ich finde diese automatische Optimisationen spannend im Bezug auf meine JavaScript JVM implementation. Da bin ich mittlerweile so Weit, dass so Ausdrucke wie

Math.sin(2.0);

vereinfacht werden auf eine Konstante. Und neuerdings habe ich erfahren, dass ein Thread nicht unbedingt ein Stacktrace haben muss, also so Methoden getStackTrace von einem Thread oder Throwable könnten leeres Stacktrace liefern. Das gibt eine größere Freiheit bei der Optimierung: man kann z.B. Methoden einfacher inlinen ohne sich um korrekte Stacktraces zu kümmern oder vielleicht noch schlimmer: der Code könnte eine ganz andere Form haben als man gewohnt ist, man muss halt nur garantieren dass es korrekt abläuft. Und das finde ich echt cool.

Die JVM verwendet sogar eine ziemlich anspruchsvolle Inlining Laufzeit-Optimierung. Am Ende soll möglichst prozeduraler C Code herauskommen.
Der Android bytecode ist zumindest voll mit Metainformationen aus denen ein Stacktrace aufgebaut werden kann und/oder ein Debugger stoppen kann, auch wenn der Code so gar nicht mehr existiert.

1 „Gefällt mir“

Jetzt wo du Metainformationen erwähnst ist mir klar wie die JVM trotz des Inlinings das richtige Stacktrace generieren kann:
Man kann beim Inlinen eine Tabelle ähnlich dem ExceptionTable-Attribute aufbauen mit programmcounter-Bereichen die den inlinten Methoden entsprechen. Woraus dann die getStackTrace Methode eine Art virtuelle Frames auf dem Stack sehen kann.