Wir feiern 30 Jahre Java. Zeit für einen Rückblick wie sich Java als Programmiersprache entwickelt hat. Wir werden sehen, wie Java verschiedene Paradigmen der Softwareentwicklung integriert hat, ohne seine klare Struktur zu verlieren. Es ist faszinierend, dass man trotz all dieser Veränderungen in Java immer noch genauso programmieren kann wie vor 30 Jahren.
Als Java Mitte der 90er Jahre aufkam, war die objektorientierte Programmierung das dominierende Paradigma. Dies spiegelt sich in Java wider, das von Anfang an eine „rein“ objektorientierte Sprache war. „Alles ist ein Objekt“ kann wörtlich genommen werden. Mit Ausnahme der Primitiven ist alles von der Basisklasse „Objekt“ abgeleitet. Anfangs hielt sich die Sprache strikt an dieses Paradigma. Dies änderte sich im Laufe der Zeit, als die Sprache immer mehr Elemente des funktionalen Programmierparadigmas übernahm. Diese Entwicklung lässt sich auch in anderen Programmiersprachen beobachten.
Dieser Artikel wird nicht jede einzelne Änderung beschreiben, die seit 1995 in Java vorgenommen wurde. Ich werde die Änderungen herausgreifen, die für mich und meine tägliche Arbeit am wichtigsten sind. Daher sind nicht alle wichtigen Änderungen der Sprache in diesem Artikel enthalten. Ich werde auch Verbesserungen in der Standardbibliothek von Java betrachten. Die Bibliothek ist ein wesentlicher Bestandteil und kann für mich nicht von der Sprache selbst getrennt werden.
Collections
Die Sprache selbst unterstützt das Konzept eines Arrays. Ein Array speichert Elemente, auf die über einen Index zugegriffen werden kann. Die Größe eines Arrays ist unveränderlich und wird bei der Erstellung des Arrays festgelegt. Arrays können zum Speichern von Primitiven und Objekten verwendet werden.
Um die Arbeit mit dynamischen Arrays zu ermöglichen die automatisch wachsen, wenn neue Elemente hinzugefügt werden, wurde mit JDK 1.0 die Klasse Vector eingeführt. Diese Klasse ist weiterhin verfügbar, sollte aber nicht in neuen Projekten verwendet werden. Sie wurde 1998 in das mit Java 1.2 eingeführte Collection-Framework integriert.
Man erstellt eine Instanz der Klasse Vector und fügt Elemente mit der Methode addElement hinzu. Um ein Element an einem bestimmten Index hinzuzufügen, kann die Methode insertElementAt verwenden. Diese Methode stellt sicher, dass das Element an dem angegebenen Index eingefügt wird und alle vorhandenen Elemente um eine Position verschoben werden.
Um alle Elemente eines Vektors zu durchlaufen, erhält man mit der Methode elements eine Enumeration. Diese Enumeration kann verwendet werden, um alle Elemente mithilfe einer while-Schleife zu durchlaufen. Mit der Methode hastMoreElements prüft man, ob noch nicht besuchte Elemente vorhanden sind. Mit der Methode nextElement der Enumeration gelangen wir zum nächsten Element.
package de.ithempel.java30;
import java.util.Enumeration;
import java.util.Vector;
public class VectorExample {
package de.ithempel.java30;
import java.util.Enumeration;
import java.util.Vector;
public class VectorExample {
public static void main(String[] args) {
Vector v = new Vector();
v.addElement("Duke");
v.addElement("JAVAPRO");
v.addElement("Java");
v.insertElementAt("Sebastian", 1);
v.remove(2);
Enumeration elements = v.elements();
while (elements.hasMoreElements()) {
System.out.println(elements.nextElement());
}
}
}
Neben der Vector-Klasse wurde mit JDK 1.0 auch die Dictionary-Klasse eingeführt, um einem Wert einen Schlüssel zuzuweisen. Dies ermöglicht den Zugriff auf das Element über einen Schlüssel. Die Klasse ermöglicht das Abrufen einer Enumeration aller Schlüssel oder aller Werte des Dictionarys. Die Wiederverwendung eines vorhandenen Schlüssels ersetzt den alten durch den neuen Wert. Es ist auch möglich, den Wert für den Schlüssel zu entfernen.
package de.ithempel.java30;
import java.util.Dictionary;
import java.util.Enumeration;
import java.util.Hashtable;
public class DictionaryExample {
public static void main(String[] args) {
Dictionary dict = new Hashtable();
dict.put("A", "Duke");
dict.put("B", "JAVAPRO");
dict.put("C", "Java");
System.out.println("Value of B: " + dict.get("B"));
String oldValue = (String) dict.put("C", "Sebastian");
System.out.println("Old Value of C: " + oldValue);
dict.remove("A");
Enumeration elements = dict.keys();
while (elements.hasMoreElements()) {
String key = (String) elements.nextElement();
System.out.println(dict.get(key));
}
}
}
JDK 1.2 führte 1998 ein völlig neues Framework für die Arbeit mit Collections ein. Das Framework unterstützt verschiedene Collection-Typen, wie beispielsweise List, Set oder Map.
Die Verwendung dieser neuen Collections unterscheidet sich nicht von der Verwendung von Vector oder Dictionary. Die Methodennamen sind kürzer. Anstelle der Enumeration verwenden wir nun einen Iterator, um alle Elemente einer Collection zu durchlaufen. Intern ist die Implementierung der Collections performanter. Die Vector-Klasse synchronisierte alle Operationen. Diese Funktion wurde mit der Umstellung auf das Collection-Framework abgeschafft.
package de.ithempel.java30;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
public class ListExample {
public static void main(String[] args) {
List list = new ArrayList();
list.add("Duke");
list.add("JAVAPRO");
list.add("Java");
list.add(1, "Sebastian");
list.remove(2);
Iterator iter = list.iterator();
while (iter.hasNext()) {
System.out.println(iter.next());
}
}
}
Generics
Wir müssen Elemente, die wir aus einer Collection abrufen, weiterhin vom generischen Typ „Object“ in den konkreten Typ umwandeln, den wir in der Collection speichern. Die Typumwandlung kann zur Laufzeit zu einer ClassCastException führen. Der Compiler kann beim Erstellen des Objektcodes nicht auf den korrekten Typ prüfen.
Java 5 führte 2005 das Konzept der Generics ein. Es ermöglicht die Erweiterung des Java-Typsystems. Das Collection-Framework unterstützt Generics, um den Typ der in einer Collection gespeicherten Objekte festzulegen. Mit Generics kann der Compiler den Typ des Elements zur Kompilierzeit prüfen.
Wir erhalten einen typsicheren Iterator, der den Typ des in einer Collection gespeicherten Elements zurückgibt.
package de.ithempel.java30;
import java.util.ArrayList;
import java.util.List;
public class ListGenericExample {
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
list.add("Duke");
list.add("JAVAPRO");
list.add("Java");
list.add(1, "Sebastian");
list.remove(2);
for (String elem : list) {
System.err.println(elem);
}
}
}
Java 5 brachte uns jedoch nicht nur Generics. Es führte auch einen neuen Typ der For-Schleife ein. Die For-Each-Schleife kann über jedes Element eines Arrays oder eines Iterable iterieren, das jede Standard-Collection-Klasse implementiert. Wir müssen weder die hasNext-Methode überprüfen noch die next-Methode verwenden, um das nächste Element zu erhalten. Zusammen mit der Iterable-Schnittstelle wirken Collections nun wie ein direktes Element der Sprache.
package de.ithempel.java30;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
public class ListForEachExample {
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
list.add("Duke");
list.add("JAVAPRO");
list.add("Java");
list.add(1, "Sebastian");
list.remove(2);
Iterator<String> iter = list.iterator();
while (iter.hasNext()) {
System.out.println(iter.next());
}
}
}
Java 7 aus dem Jahr 2011 verbesserte die Typinferenz für die Erstellung generischer Instanzen. Es handelt sich um eine kleine Erweiterung, die Duplikate im Code beseitigt. Der Typ des generischen Typs muss nur noch im Konstruktor definiert werden. Bei der Definition der Variablen kann der generische Typ weggelassen werden.
List<String> list = new ArrayList<>();
Arbeiten mit Datumswerten
Java unterstützt die Arbeit mit Datum und Uhrzeit von Anfang an. Die Klasse Date wurde in Java 1.0 eingeführt. Sie ist eine Art Wrapper für den Unix-Zeitwert und berücksichtigt auch Zeitzonen. Sie ist zudem die Klasse mit den seit Jahrzehnten am längsten veralteten Methoden. Alle Konstruktoren, Getter und Setter wurden zugunsten der neuen Klasse Calendar, die 1997 in Java 1.1 eingeführt wurde, verworfen.
package de.ithempel.java30;
import java.util.Calendar;
import java.util.Date;
public class DateTimeExample {
public static void main(String[] args) {
Date now = new Date();
Calendar cal = Calendar.getInstance();
cal.setTime(now);
int dayOfMonth = cal.get(Calendar.DAY_OF_MONTH);
int month = cal.get(Calendar.MONTH) + 1;
System.out.println("We have the " + dayOfMonth + " of the " + month + " month of the year");
}
}
Die Klasse Calendar kann sowohl mit Zeitzonen als auch mit dem Kalendersystem arbeiten. Nach dem Einstellen der Zeit können wir mit der Methode get Teile des Zeitstempels wie Monat oder Tag abrufen.
Wir mussten bis Java 8 (2014) warten, um eine komplett neue API für Datum und Uhrzeit zu erhalten. Java integriert die bisher verfügbare Joda-Time-Bibliothek in das JDK. Die API führt neue Klassen für die Verarbeitung von Datum und Uhrzeit in der aktuellen (lokalen) Zeitzone ein: LocalTime, LocalDate und LocalDateTime. Für die Arbeit mit Zeitzonen stehen uns nun die Klassen ZonedDateTime zur Verfügung. Die Klasse Instant ermöglicht die Arbeit mit exakten Zeitstempeln in Nanosekunden.
Die Klassen unterstützen Methoden zur Datumsberechnung und zur Berechnung von Differenzen zwischen Datum und Uhrzeit. Es gibt auch Methoden zum Abrufen oder Setzen bestimmter Felder für ein Datum oder eine Uhrzeit. Die Schnittstellen der neuen Klassen unterstützen die Verkettung mehrerer Operationen (Fluent Interface).
package de.ithempel.java30;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
public class DateTimeApiExample {
public static void main(String[] args) {
LocalDateTime now = LocalDateTime.now();
LocalDateTime tomorrow = now.plusDays(1);
LocalDateTime yesterday = now.minusDays(1)
.withHour(0).withMinute(0).withSecond(0);
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM);
System.out.println("Today: " + dateTimeFormatter.format(now) +
", yesterday: " + dateTimeFormatter.format(yesterday) +
", tomorrow: " + dateTimeFormatter.format(tomorrow));
}
}
Die Entwicklung von switch
Die Switch-Anweisung war von Anfang an vorhanden. Die erste Version konnte nur mit Integern verwendet werden. Sie trug dazu bei, Strukturen mit if-else if-else besser zu implementieren. Die Fallthrough-Funktionalität wurde von anderen Sprachen wie C übernommen.
package de.ithempel.java30;
public class SwitchExample {
public static void main(String[] args) {
int value = 5;
switch (value) {
case 1:
System.out.println("One");
break;
case 5:
System.out.println("five");
break;
default:
System.out.println("Unknown");
}
}
}
Die erste Änderung erfolgte 2011 in Java 7. Neben Integern können wir mit der Switch-Anweisung nun auch zwischen verschiedenen Strings und Enumerationswerten auswählen. Dies machte die Switch-Anweisung noch nützlicher.
Viele Verbesserungen der Switch-Anweisung und des nun möglichen Switch-Ausdrucks wurden 2020 in Java 14 eingeführt. Mehrere Case-Werte können in einem einzigen Case bereitgestellt werden. Die größte Änderung war die Möglichkeit, Switch als Ausdruck zu verwenden. Werte aus einem Switch-Block können auf verschiedene Arten zurückgeben werden. Es gibt eine neue Anweisung „yield“, um einen Wert zurückzugeben, oder man verwendet den Pfeiloperator, um eine Art „Mapping“ zu definieren.
package de.ithempel.java30;
public class SwitchExpressionExample {
public static void main(String[] args) {
String day = "Tuesday";
String typeOfDay = switch (day) {
case "Monday":
yield "Weekday";
case "Tuesday":
yield "Weekday";
case "Wednesday":
yield "Weekday";
case "Thursday":
yield "Weekday";
case "Friday":
yield "Weekday";
case "Saturday", "Sunday":
yield "Weekend";
default:
yield "Unknown";
};
System.out.println(typeOfDay);
}
}
Musterverarbeitung / Pattern Matching
Die Verarbeitung von Pattern wurde in mehreren Schritten eingeführt. Die Funktionalität wurde zunächst in funktionalen Programmiersprachen implementiert und bald auch in anderen Sprachen wie Scala oder F#. Für Java wurde die erste Implementierung einer Mustervergleichsart mit Java 14 im Jahr 2020 eingeführt.
Anstatt nur mit dem Operator „Instanceof“ auf einen bestimmten Typ zu prüfen, kann der gecastete Wert nun in einem Schritt einer neuen Variable zugewiesen werden.
package de.ithempel.java30;
public class InstanceofExample {
public static void main(String[] args) {
Object obj = "Duke";
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.length());
}
if (obj instanceof String s) {
System.out.println(s.length());
}
}
}
Ein wesentlich größerer Schritt war die Einführung des Mustervergleichs für Switch mit Java 21 im Jahr 2023. Er ermöglicht die Auswahl von Fällen basierend auf dem Typ des Arguments. Neben dem eigentlichen Vergleich steht der gecastete Wert als neue Variable zur Verfügung. Nach dem Vergleich mit einem Typ können zusätzliche Ausdrücke verwendet werden, um die Auswahl basierend auf anderen Bedingungen zu verfeinern.
package de.ithempel.java30;
public class PatternMatchingExample {
public static void main(String[] args) {
Object o = 42;
String formatted = switch (o) {
case null -> "Null";
case String s -> "String %s".formatted(s);
case Long l -> "long %d".formatted(l);
case Double d -> "double %f".formatted(d);
case Integer i when i > 0 // guarded pattern
-> "positive int %d".formatted(i);
case Integer i when i == 0
-> "zero int %d".formatted(i);
case Integer i when i < 0
-> "negative int %d".formatted(i);
default -> o.toString();
};
System.out.println(formatted);
}
}
Nicht nur beim Mustervergleich, sondern auch bei einer normalen Switch-Anweisung kann ein Fall für Null hinzugefügt werden. Dadurch kann eine NullPointerException umgangen werden, wenn einer Switch-Anweisung ein Nullwert zugewiesen wird.
neue Strukturen – Records
Java 16 (2021) ermöglicht die Definition unveränderlicher Datenklassen ohne umfangreichen Boilerplate-Code. Bei Records müssen lediglich Typ und Name der Felder definiert werden. Der restliche Code wie equals, hashCode, toString, Getter und öffentliche Konstruktoren werden vom Compiler erstellt.
Getter können wie normale Klassen verwendet werden. Der Unterschied ist das fehlende get vor jedem Feldnamen. Man schreibt einfach den Namen des Felds, auf das wir zugreifen möchten, z. B. name(), um das Namensfeld eines Records abzurufen. Records können durch statische Variablen und Methoden erweitert werden.
package de.ithempel.java30;
public class RecordsExample {
public record Person(String firstName, String lastName, String address) {
public String fullName() {
return "%s, %s".formatted(lastName, firstName);
}
}
public static void main(String[] args) {
Person duke = new Person("Duke", "Java", "Javaland");
System.out.println(duke.lastName() + ", " + duke.firstName());
System.out.println(duke.fullName());
}
}
Da Records unveränderlich sind – sie haben keine Setter – eignen sie sich ideal als Datenobjekte. In vielen aktuellen Quelltexten wird hierfür die Lombok-Bibliothek verwendet. In neueren Projekten versuche ich selbst, Lombok durch Records zu ersetzen.
Verarbeitung von Daten-Streams
Eine der größten Neuerungen in Java 8 aus dem Jahr 2014 war die Unterstützung von Lambda-Ausdrücken. Anstatt anonyme innere Klassen zu definieren, gibt es nun Funktionen als neuen Typ. Wir können Funktionen/Lambdas definieren und an andere Methoden übergeben, die sie aufrufen.
Lambdas nutzen funktionale Interfaces. Schnittstellen dieses Typs bestehen aus einer (abstrakten) Funktion. Das Paket java.util.function enthält viele vordefinierte funktionale Schnittstellen. Die Annotationen stellen sicher, dass beim Hinzufügen einer weiteren Funktion zu unserer Schnittstelle kein Kompilierungsfehler auftritt.
package de.ithempel.java30;
public class LambaExample {
@FunctionalInterface
public interface Foo {
public String method(String s);
}
public static void main(String[] args) {
Foo foo = parameter -> parameter + " from lambda";
String result = add("Duke", foo);
System.out.println(result);
}
private static String add(String s, Foo foo) {
return foo.method(s);
}
}
Die Java-Bibliothek selbst nutzt Lambdas, indem sie eine Streaming-API für die Verarbeitung von Datensequenzen bereitstellt. Streams sind vom Map-Reduce-Programmiermodell inspiriert, um große Datenmengen parallel und verteilt zu verarbeiten. Die von Streams bereitgestellten Operationen sind auch aus der funktionalen Programmierung bekannt. In Java bietet ein Stream verschiedene Operationen. Diese Operationen sind an eine Stream-Pipeline gekoppelt.
Die Streaming-API bietet Operationen wie Filter, um Elemente unter bestimmten Bedingungen auszuwählen. Mit der Map-Operation können wir ein Element transformieren. Finaloperationen können einfach über alle Elemente iterieren, wie die Operation „forEach“. Es gibt auch Collector-Funktionen, um alle Elemente eines Streams in einer Collection zu sammeln.
Eine Pipeline kann parallel an einem Stream arbeiten. Mit der Operation „parallel“ können wir die Arbeit der Pipeline auf mehrere Threads verteilen.
package de.ithempel.java30;
import java.util.stream.Stream;
public class StreamingExample {
public static void main(String[] args) {
Stream.of("a", "b", "c", "d", "Duke")
.filter(e -> e.length() == 1)
.map(e -> e.toUpperCase())
.forEach(e -> System.out.println(e));
}
}
Die Streaming-API unterstützt Zwischen- und Terminaloperationen. Terminaloperationen lösen die Ausführung einer Stream-Pipeline aus. Zwischenoperationen werden nur ausgeführt, wenn eine Terminaloperation ausgeführt wird. Dies optimiert die Ausführung einer Stream-Pipeline.
abschließende Worte
Dies war meine kurze und schnelle Beschreibung der Entwicklung von Java. Wie bereits erwähnt, habe ich nicht alle Änderungen der Sprache der letzten 30 Jahren berücksichtigt. Ein wichtiger Bereich, den ich nicht erwähnt habe, sind die verschiedenen Mechanismen zur Handhabung von mehreren Threads.
Beim Blick auf die aktuelle Entwicklung sehe ich eine vielversprechende und interessante Zukunft für Java. Es stehen viele Änderungen bevor. Seit der Umstellung auf einen Release Train sehen wir ständig Vorschauen auf neue Funktionen und Änderungen in der Sprache und der Bibliothek.