Unbekannte Besonderheiten von Java

Was? Das geht mit Java?

So gut wie jede Programmiersprache, die ich kenne, besitzt Dinge, die so erstaunlich sind, dass ich oft denke „Was? Das geht?“. In dieser Kolumne wollen wir uns acht solche Besonderheiten von Java anschauen und verstehen, wieso diese funktionieren und wozu sie gut sein können.

Als ich vor ein paar Jahren den Lightning Talk „Wat“ von Gary Bernhardt gesehen habe, war ich erheitert und abgeschreckt davon, wie sich JavaScript an der einen oder anderen Stelle verhält. Mein unerfahreneres Ich dachte, dass so etwas in Java nicht möglich ist. Über die Jahre habe ich dann allerdings an Erfahrung und Einsicht gewonnen. Auch in Java gibt es das ein oder andere überraschende Feature oder Verhalten. Genau solche Stellen wollen wir uns in dieser Kolumne anschauen.

Veränderliche Strings

Ich erinnere mich noch gut daran, dass ich bereits früh gelernt habe, dass ein String in Java unveränderlich ist. Ich kann seinen Inhalt also nicht ändern, sondern muss eine neue Zuweisung tätigen, um den alten Wert einer Variablen zu ändern.

Auf diese Eigenschaft zielen auch viele Fragen in Java-Quizzen und Bewerbungsgesprächen ab. Und obwohl diese Aussage natürlich stimmt, können wir trotzdem mithilfe von ein wenig Reflection Strings verändern, wie in Listing 1 zu sehen ist.

public class NonFinalString {

    private static final void replaceWith(
            String target, String replacement) throws Exception {
        Field value = String.class.getDeclaredField("value");
        value.setAccessible(true);
        value.set(target, replacement.getBytes());
    }

    public static void main(String[] args) throws Exception {
        String javaSpektrum = "JavaSpektrum";

        replaceWith(javaSpektrum, "ObjektSpektrum");

        System.out.println(javaSpektrum);
        System.out.println("JavaSpektrum");
    }
}
Listing 1: Magische Änderung eines Strings

Wir machen uns hierzu die Eigenheit zunutze, dass Java für Strings als Optimierung einen Pool nutzt. Manipulieren wir direkt das Feld value eines im Pool vorhandenen Strings, ändert sich der Wert an allen Stellen, die auf diesen String verweisen. Typischerweise sind das mindestens alle Stellen, an denen wir den String direkt als Literal verwenden. Es gibt allerdings, wie im Code aus Listing 2 zu sehen, eine Ausnahme.

public static void main(String[] args) throws Exception {
    String javaSpektrum = "JavaSpektrum";
    String javaSpektrum2 = new String(javaSpektrum);

    replaceWith(javaSpektrum, "ObjektSpektrum");

    System.out.println(javaSpektrum);
    System.out.println("JavaSpektrum");
    System.out.println(javaSpektrum2);
}
Listing 2: Aber nicht alle Strings werden geändert

Obwohl mir meine Entwicklungsumgebung, mittels SonarQube, anzeigt, dass die Verwendung von new String() hier redundant sei, macht diese einen deutlichen Unterschied. Mit dieser Verwendung gibt die Konsole als dritte Ausgabe nämlich nach wie vor JavaSpektrum aus. Ohne würde auch die Variable javaSpektrum2 auf den Wert ObjektSpektrum geändert.

Nützlich ist dieser Trick in der Realität nicht. Ganz im Gegenteil. Wenn überhaupt könnte dies ein Einfallstor für ungewollte Manipulationen sein. Stellen wir uns vor, dass ab jetzt alle unsere HTTP-Calls an den Server eines Angreifers gehen, weil dieser Strings manipuliert hat. Verhindern lässt sich dies zum Beispiel über den Einsatz einer entsprechenden Security Manager Policy, die den Einsatz von Reflection massiv einschränkt.

Ist das noch ein Enum?

Enums sind bereits seit Version 1.5 Bestandteil von Java. Häufig verwenden wir diese wie statische Konstanten mit dem Vorteil, dass diese typsicherer sind und der Compiler uns somit an vielen Stellen hilft.

In der Regel zwar bekannt, aber meiner Erfahrung nach selten genutzt, ist die Möglichkeit, dass ein Enum auch Methoden und damit Fachlogik implementieren kann. Eher unbekannt hingegen ist, dass ein Enum auch ohne eine einzige Konstante definiert werden kann (s. Listing 3).

public enum EmptyEnum {
    ;

    public static int square(int i) {
        return i * i;
    }
}
Listing 3: Enum ohne Konstante

Auf den ersten Blick erscheint es nutzlos, ein Enum ohne Konstante zu definieren. Für Hilfsklassen, die nur statische Methoden enthalten, ist diese Art der Deklaration allerdings die kompakteste Variante. Enums sind nämlich standardmäßig final und haben auch implizit einen privaten Konstruktor, der verhindert, dass eine Instanz erzeugt werden kann.

In der Praxis kommt diese Art der Verwendung trotzdem nicht vor. Vermutlich liegt dies an zwei Gründen. Zum einen lässt sich darüber streiten, ob ein Enum, eine Aufzählung, ohne Konstanten semantisch noch ein Enum ist. Zudem ist der Gewinn zur klassischen Variante der Hilfsklasse nur sehr gering. Die Überraschung über dieses unbekannte Konstrukt allerdings hoch.

Ein großes Nichts

Java hat sich bereits zu Beginn dafür entschieden, neben Objekten für Basistypen wie Zahlen oder Wahrheitswerte primitive Typen anzubieten. Allerdings lassen sich diese nicht an allen Stellen verwenden. Dies zeigt sich vor allem bei der Nutzung von Generics. Deshalb gibt es zu jedem primitiven Typ eine passende Klasse, die sogenannten Wrapper-Klassen. Gäbe es diese nicht, müsste es beispielsweise für jeden primitiven Typ eine spezifische Listenimplementierung geben.

Vielfach unbekannt ist, dass es neben den Wrapper-Klassen für die Zahlenwerte und Boolean mit Void auch eine Wrapper-Klasse für void gibt. In Listing 4 ist zu sehen, wie die Interfaces Supplier und Consumer aussähen, wenn wir beide als besondere Art von Function ansehen. Am Beispielcode können wir allerdings auch bereits sehen, wieso anstatt der Nutzung von Void in der Regel doch spezialisierte Klassen zum Einsatz kommen.

import java.util.function.Function;

public class VoidWrapper {

    public interface VoidSupplier<T>
        extends Function<Void, T> {}

    public interface VoidConsumer<T>
        extends Function<T, Void> {}

    public static void main(String[] args) {
        VoidSupplier supplier = v -> "JavaSpektrum";
        VoidConsumer consumer = value -> {
            System.out.println(value);
            return null;
        };
        consumer.apply(supplier.apply(null));
    }
}
Listing 4: Einsatz von Void als Wrapper-Typ

Wir müssen für jeden Void-Parameter ein Argument übergeben und auch für Void als Rückgabetyp müssen wir innerhalb unserer Methode etwas zurückgeben. Der so resultierende Code sieht somit komplizierter aus und auch die Nutzung ist nicht so praktisch wie mit void.

Ist this möglich

Bei der nächsten Besonderheit geht es um die Nutzung von this. Mit this lässt sich der Aufruf von Klassenvariablen und -methoden qualifizieren. Dies benötigen wir vor allem, wenn beispielsweise ein Parameter einer Methode denselben Namen wie eine Klassenvariable hat und wir auf die Variable zugreifen wollen. Auch innerhalb von Konstruktoren benötigen wir this, um einen anderen Konstruktor unserer Klasse aufzurufen.

Weniger bekannt ist jedoch die Nutzung von this als sogenannter Receiver-Parameter, wie in Listing 5 zu sehen. Ja, richtig gesehen. In der Methode print gibt es einen this benannten Parameter vom Typ unserer Klasse This. Trotzdem müssen wir diesen Parameter beim Aufruf innerhalb der main-Methode nicht übergeben und auch die Ausgabe auf der Konsole ergibt wie erwartet This[42] 13.

public class This {

    private final int i;

    public This(int i) {
        this.i = i;
    }

    public void print(This this, int that) {
        System.out.println(this + " " + that);
    }

    @Override
    public String toString() {
        return "This[" + i + "]";
    }

    public static void main(String[] args) {
        new This(42).print(13);
    }
}
Listing 5: this als Parameter

Die Möglichkeit, Receiver-Parameter einzusetzen, gibt es bereits seit Java 8 und ist in der Java Language Specification in Kapitel 8.4.1 definiert.

Diese Art der Nutzung von this ermöglicht es uns, den Parameter mit einer Annotation zu versehen. Somit könnten wir beispielsweise prüfen, dass gewisse Methoden nur auf dem Client oder dem Server aufgerufen werden. Der Blog-Post „Explicit receiver parameters“ von Stephen Colebourne deutet darüber hinaus noch Möglichkeiten im Rahmen von Value Types an.

Diese Exception muss nicht gefangen werden

Ähnlich wie die Unveränderlichkeit von String habe ich bereits früh gelernt, dass Java zwei Arten von Exceptions besitzt: Checked und Runtime Exceptions.

Checked Exceptions erweitern durch Vererbung direkt oder durch eine Subklasse java.lang.Exception. Werfen wir eine solche Exception, müssen wir unsere Methode zwangsläufig mit einer throws-Klausel versehen. Dadurch wird bei jeder Nutzung dieser Methode erzwungen, dass die geworfene Exception entweder gefangen oder weitergeworfen wird.

Runtime Exceptions hingegen erweitern java.lang.RuntimeException. Im Gegensatz zu Checked Exceptions muss beim Werfen von diesen keine throws-Klausel definiert werden und auch ein Fangen oder Weiterwerfen wird vom Compiler nicht erzwungen.

Das Konzept von Checked Exceptions existiert allerdings nur innerhalb der Sprache Java. Im Bytecode und damit in der Java Virtual Machine hingegen gibt es diese nicht. Sämtliche Prüfungen werden nur vom Compiler während der Kompilierung geprüft. Mit einem kleinen Trick (s. Listing 6) lässt sich der Compiler jedoch überlisten. Anschließend können wir in unserem Java-Code eine Checked Exception werfen, ohne dass wir dazu gezwungen werden, diese zu deklarieren und zu fangen. Die Ausgabe auf der Konsole (s. Listing 7) zeigt uns dabei, dass wir wirklich nur eine java.lang.Exception werfen und diese auch genauso ankommt.

public class NonCatchableException {

    static <T extends Exception> void throwUnchecked(
            Exception exception) throws T {
        throw (T) exception;
    }

    public static void throwsCheckedException() {
        throwUnchecked(new Exception());
    }

    public static void main(String[] args) {
        throwsCheckedException();
    }
}
Listing 6: Checked Exception ohne throws
Exception in thread "main" java.lang.Exception
  at ....NCE.throwsCheckedException(NCE.java:10)
  at ....NCE.main(NCE.java:14)
Listing 7: Ausgabe der Checked Exception ohne throws

Mittlerweile hat sich für diesen Trick der Begriff Sneaky Throws etabliert. Das liegt auch daran, dass es im Project Lombok genau für diesen Trick die Annotation @SneakyThrows gibt. Ich persönlich reduziere den Einsatz dieses Tricks, egal ob mit oder ohne Lombok, auf ein Minimum. Er produziert Code, der für viele Menschen unerwartet ist, und auch der Nutzen ist in der Regel begrenzt. Aber gerade bei der Nutzung von Methoden als java.util.function.Function kann es Stellen geben, bei denen uns dieser Trick hilft.

Klein oder groß

Häufig sind wir fasziniert von Kleinstoptimierungen und Performanz. Oft kommt deswegen die Fragestellung auf, ob nun toLowerCase oder toUpperCase bei einem String schneller ist.

Um solche Fragestellungen beantworten zu können, bietet sich für Java das Tool JMH an. Mit diesem können wir Benchmarks schreiben. Für die obige Fragestellung habe ich den Benchmark aus Listing 8 geschrieben. Natürlich hängt das Ergebnis eines solchen Benchmarks immer vom konkreten Rechner ab. Und auch der Wert des hier genutzten zufälligen Strings hat einen Einfluss auf das Ergebnis.

@BenchmarkMode(AverageTime)
@OutputTimeUnit(NANOSECONDS)
@Fork(1)
@Warmup(iterations = 1, time = 1)
@Measurement(iterations = 5, time = 5)
@Threads(5)
@State(Thread)
public class ToUpperOrToLowerCase {

    public static void main(String[] args) throws Exception {
        final Options opt = new OptionsBuilder()
            .include(ToUpperOrToLowerCase.class.getSimpleName())
            .build();
        new Runner(opt).run();
    }

    private String string = RandomStringUtils.randomAlphabetic(32);

    @Benchmark
    public String toLowerCase() {
        return string.toLowerCase();
    }

    @Benchmark
    public String toUpperCase() {
        return string.toUpperCase();
    }
}
Listing 8: JMH-Benchmark für toUpper- gegen toLowerCase

Bei mir (s. Listing 9) liegen die Ergebnisse jedoch auch nach mehreren Ausführungen so nah beieinander, dass sich daraus erst mal nichts Konkretes ableiten lässt. Spannend ist allerdings, dass SonarQube mich darauf hinweist, doch bitte bei beiden Methoden die überladene Methode zu nutzen, die als Argument ein java.util.Locale entgegennimmt.

Benchmark                        Mode Cnt Score    Error  Units
ToUpperOrToLowerCase.toLowerCase avgt 5   64,782 ±  6,612 ns/op
ToUpperOrToLowerCase.toUpperCase avgt 5   70,929 ± 20,561 ns/op
Listing 9: JMH-Benchmark-Ergebnis für toUpper- gegen toLowerCase

Ergänzen wir nun den Benchmark um zwei weitere Benchmarks (s. Listing 10), sieht das Ergebnis plötzlich anders aus (s. Listing 11). Zwar bleibt auch hier der Unterschied zwischen toUpper- und toLowerCase gering, allerdings zeigt sich, dass zwischen der Variante ohne explizite Angabe einer Locale und der mit der Türkischen Locale ein riesiger Unterschied existiert. Dies liegt daran, dass im Türkischen der Kleinbuchstabe für ein großes I ein kleines I ohne Punkt ist. Für diese „Sonderregel“ greifen beide Methoden auf eine HashTable zurück. Ähnliche Fälle gibt es neben Türkisch auch für Aserbaidschanisch und Litauisch.

...

private static final Locale TR = Locale.forLanguageTag("tr");

@Benchmark
public String toLowerCaseTr() {
    return string.toLowerCase(TR);
}

@Benchmark
public String toUpperCaseTr() {
    return string.toUpperCase(TR);
}

...
Listing 10: JMH-Benchmark-Erweiterung um Locale
Benchmark                          Mode Cnt Score       Error    Units
ToUpperOrToLowerCase.toLowerCase   avgt 5      63,171 ±    5,005 ns/op
ToUpperOrToLowerCase.toLowerCaseTr avgt 5   18148,602 ± 5726,237 ns/op
ToUpperOrToLowerCase.toUpperCase   avgt 5      59,448 ±   15,815 ns/op
ToUpperOrToLowerCase.toUpperCaseTr avgt 5   18368,514 ± 3520,108 ns/op
Listing 11: JMH-Benchmark-Ergebnis mit Erweiterung um Locale

Wir sollten uns also bei der Nutzung der beiden Methoden Gedanken machen, ob diese an einer für die Performanz kritischen Stelle genutzt werden, und idealerweise nicht die Variante ohne Locale wählen.

Literale für URLs

An vielen Stellen in Java ist es möglich, eine URL direkt im Code zu verwenden. Listing 12 zeigt ein Beispiel für diese Möglichkeit. Natürlich sind Literale für URLs kein Feature von Java, sondern wir machen uns zwei Eigenschaften von Java in Kombination zunutze.

public class MagicComment {

    static void method() {
        http://javaspektrum.de
        System.out.println("JavaSpektrum");
    }

    public static void main(String[] args) {
        method();
    }
}
Listing 12: Ein magischer Kommentar

Die erste Eigenschaft sind Kommentare. Kommentare werden in Java entweder nach // als einzeiliger Kommentar oder zwischen /* und */ für mehrzeilige Kommentare geschrieben. Kombinieren wir nun einen einzeiligen Kommentar noch mit einem Label, entstehen diese, auf den ersten Blick „magischen“, URL-Literale.

Dieser Trick bringt keinen Vorteil, außer Leser zu irritieren. Eingesetzt habe ich diesen deswegen noch nie.

Was für ein Type

Seit Java 10 müssen wir für lokale Variablen innerhalb einer Methode in vielen Fällen den Typ nicht mehr selbst deklarieren. Durch die Verwendung von var leitet der Compiler den konkreten Typ selbstständig ab. Listing 13 zeigt die Zuweisung einer anonymen Instanz von java.lang.Object an eine mit var deklarierte lokale Variable.

public class NonAssignable {

    static void method() {
        var object = new Object() {};

        object = new Object();
    }
}
Listing 13: Neuzuweisung einer var-Variable

Auf den ersten Blick könnten wir nun denken, der Compiler würde für die Variable object als Typ java.lang.Object wählen. Das wäre schließlich auch der Typ, den wir vor Java 10 selbst deklariert hätten. Diese Erwartung ist jedoch falsch. Der Compiler leitet den speziellsten ihm möglichen Typ ab. Im Falle einer anonymen Instanz erzeugt er einen Typ speziell für diese eine Instanz und Stelle. Eine erneute Zuweisung ist also nie wieder möglich, da er genau diesen Typ nie wieder ableiten wird.

Noch deutlicher wird dies durch den Code aus Listing 14. Wir erweitern unsere anonyme Instanz um eine Methode print. Dadurch, dass der Compiler einen speziellen Typ nur für diese Zuweisung ableitet, sind wir in der Lage, diese Methode auch aufzurufen. Ohne var und mit Zuweisung zu java.lang.Object funktioniert dies nicht.

public class NonAssignable {

    static void method2() {
        var object = new Object() {
            public void print() {
                System.out.println("JavaSpektrum");
            }
        };

        object.print();
    }
}
Listing 14: var-Zuweisung mit Methode innerhalb der anonymen Instanz

Dieses Verhalten wirkt auf den ersten Blick vollkommen konstruiert. Allerdings können wir diese Eigenschaft nutzen und einen Typ mit speziellen Methoden ad hoc erzeugen, um diesen in einem Stream zu nutzen. Hier ist dann eine zweite Zuweisung allerdings eher wieder unüblich.

Fazit

Wir haben in diesem Artikel acht Dinge gesehen, die auf den ersten Blick in Java unmöglich oder seltsam erscheinen. Dabei handelt es sich zwar um Besonderheiten, die uns nur selten über den Weg laufen werden und die zum Großteil keinen, oder nur sehr geringen, Nutzen haben. Trotzdem hoffe ich, dass zumindest einige der Besonderheiten dem einen oder anderen Leser noch nicht bekannt war und somit etwas Neues gelernt wurde oder dass zumindest das Lesen kurzweilig war.

Den Quelltext aller Listings gibt es wie gewohnt auf GitHub unter https://github.com/mvitz/javaspektrum-java-wat.

TAGS

Comments

Please accept our cookie agreement to see full comments functionality. Read more