Java17
Java17
Einführung in das
Programmieren mit
Java 17
Die aktuelle, um ca. 200 Seiten erweiterte
Version dieses Manuskripts (inklusive Daten-
bankprogrammierung mit JDBC und JPA)
wird hier angeboten:
https://bebagoe.de/java/
Im Java-Kurs geht es nicht um Kochrezepte zur schnellen Erstellung effektvoller Programme, son-
dern um die systematische Einführung in das Programmieren. Dabei werden wichtige Konzepte und
Methoden der objektorientierte Software-Entwicklung vorgestellt.
Trier und Bruchsal, im Juni 2022 Bernhard Baltes-Götz und Johannes Götz
1
Zur Vermeidung von sprachlichen Umständlichkeiten wird in diesem Manuskript meist die männliche Form ver-
wendet. Die „Teilnehmenden“ sind stilistisch durchaus akzeptabel. Im nächsten Satz stünden aber die umständliche
Formulierung „Leser und Leserinnen“ oder die zumindest ungewohnte Formulierung „Lesende“ zur Wahl. Trotz
großer Sympathie für das Ziel einer geschlechtsneutralen Sprache scheint uns gegenwärtig die männliche Form das
kleinere Übel zu sein.
2
Für zahlreiche Hinweise auf mittlerweile behobene Fehler möchten wir uns bei Paul Frischknecht, Andreas
Hanemann, Peter Krumm, Michael Lehnen, Lukas Nießen, Rolf Schwung und Jens Weber herzlich bedanken.
Inhaltsverzeichnis
VORWORT III
1 EINLEITUNG 1
1.1 Beispiel für die objektorientierte Software-Entwicklung mit Java 1
1.1.1 Objektorientierte Analyse und Modellierung 1
1.1.2 Objektorientierte Programmierung 7
1.1.3 Algorithmen 8
1.1.4 Startklasse und main() - Methode 10
1.1.5 Zusammenfassung zum Abschnitt 1.1 12
2.4.3 Quellcode-Editor 55
2.4.3.1 Syntaxerweiterung 55
2.4.3.2 Code-Inspektion und Quick-Fixes 57
2.4.3.3 Live Templates 58
2.4.3.4 Orientierungshilfen 59
2.4.3.5 Refaktorieren 61
2.4.3.6 Sonstige Hinweise 61
2.4.4 Übersetzen und Ausführen 61
2.4.5 Sichern und Wiederherstellen 63
2.4.6 Konfiguration 64
2.4.6.1 SDKs einrichten 64
2.4.6.2 Struktur des aktuellen Projekts 66
2.4.6.3 Einstellungen für IntelliJ oder das aktuelle Projekt 67
2.4.6.4 Einstellungen für neue Projekte 70
2.4.7 Übungsprojekte zum Kurs verwenden 71
3 ELEMENTARE SPRACHELEMENTE 77
3.1 Einstieg 77
3.1.1 Aufbau eines Java-Programms 77
3.1.2 Projektrahmen zum Üben von elementaren Sprachelementen 78
3.1.3 Syntaxdiagramme 80
3.1.3.1 Klassendefinition 82
3.1.3.2 Methodendefinition 83
3.1.4 Hinweise zur Gestaltung des Quellcodes 84
3.1.5 Kommentare 86
3.1.5.1 Zeilenrestkommentar 86
3.1.5.2 Mehrzeilenkommentar 86
3.1.5.3 Dokumentationskommentar 87
3.1.6 Namen 89
3.1.7 Vollständige Klassennamen und Import-Deklaration 90
9 INTERFACES 447
9.1 Überblick 447
9.1.1 Beispiel 447
9.1.2 Primäre Funktion 448
9.1.3 Mögliche Bestandteile 450
11 AUSNAHMEBEHANDLUNG 529
11.1 Prävention und Beispielprogramm 530
14.7 Daten lesen und schreiben über die NIO.2 - Klasse Files 752
14.7.1 Öffnungsoptionen 752
14.7.2 Lesen und Schreiben von kleinen Dateien 753
14.7.3 Datenstrom zu einem Path-Objekt erstellen 754
14.7.4 MIME-Type einer Datei ermitteln 755
14.7.5 Stream<String> mit den Zeilen einer Textdatei erstellen 755
15 MULTITHREADING 765
15.1 Start und Ende eines Threads 767
15.1.1 Die Klasse Thread 767
15.1.2 Das Interface Runnable 772
16 NETZWERKPROGRAMMIERUNG 863
16.1 Elementare Konzepte der Netzwerktechnologie 864
16.1.1 Das OSI-Modell 864
16.1.2 Zur Funktionsweise von Protokollstapeln 869
16.1.3 Optionen zur Netzwerkprogrammierung in Java 870
ANHANG 907
A. Operatorentabelle 907
LITERATUR 929
INDEX 933
1 Einleitung
Im ersten Kapitel geht es zunächst um die Denk- und Arbeitsweise der objektorientierten Program-
mierung. Danach wird Java als Software-Technologie vorgestellt.
Ausbau des Programms zu einem Bruchrechnungstrainer kommen jedoch weitere Klassen hinzu
(z. B. Aufgabe, Schüler).
Eine Klasse ist gekennzeichnet durch:
• Eigenschaften bzw. Zustände
Die Objekte bzw. Instanzen der Klasse und auch die Klasse selbst besitzen jeweils einen
Zustand, der durch Eigenschaften gekennzeichnet ist. Im Beispiel der Klasse Bruch ...
o besitzt ein Objekt die Eigenschaften Zähler und Nenner,
o gehört zu den Eigenschaften der Klasse die z. B. Anzahl der bei einem Pro-
grammeinsatz bereits erzeugten Brüche.
Im letztlich entstehenden Programm landet jede Eigenschaft in einer sogenannten Variab-
len. Darunter versteht man einen benannten Speicherplatz, der Werte eines bestimmten Typs
(z. B. ganze Zahlen, Zeichenfolgen) aufnehmen kann. Variablen zum Speichern der Eigen-
schaften von Objekten oder Klassen werden in Java meist als Felder bezeichnet.
• Handlungskompetenzen
Analog zu den Eigenschaften sind auch die Handlungskompetenzen entweder individuellen
Objekten bzw. Instanzen oder der Klasse selbst zugeordnet. Im Beispiel der Klasse Bruch ...
o hat ein Objekt z. B. die Fähigkeit zum Kürzen von Zähler und Nenner,
o kann die Klasse z. B. über die Anzahl der bereits erzeugten Brüche informieren.
Im letztlich entstehenden Programm sind die Handlungskompetenzen durch sogenannte Me-
thoden repräsentiert. Diese ausführbaren Programmbestandteile enthalten die oben ange-
sprochenen Algorithmen. Die Kommunikation zwischen Klassen und Objekten besteht da-
rin, ein Objekt oder eine Klasse aufzufordern, eine bestimmte Methode auszuführen.
Eine Klasse …
• beinhaltet meist einen Bauplan für konkrete Objekte, die im Programmablauf nach Bedarf
erzeugt und mit der Ausführung bestimmter Methoden beauftragt werden,
• andererseits aber auch Akteur sein (Methoden ausführen und aufrufen).
Bei einer nur einfach zu besetzenden Rolle kann eine Klasse zum Einsatz kommen, die nicht zum
Instanziieren (Erzeugen von Objekten) gedacht ist, aber als Akteur mitwirkt.
Dass Zähler und Nenner die zentralen Eigenschaften eines Bruch-Objekts sind, bedarf keiner nä-
heren Erläuterung. Sie werden in der Klassendefinition durch Felder zum Speichern von ganzen
Zahlen (Java-Datentyp int) mit den folgenden Namen repräsentiert:
• zaehler
• nenner
Eine wichtige, auf den ersten Blick leicht zu übersehende Entscheidung der Modellierungsphase
besteht darin, beim Zähler und beim Nenner eines Bruchs auch negative ganze Zahlen zu erlauben.1
Alternativ könnte man ...
• beim Nenner negative Werte verbieten, um folgende Beispiele auszuschließen:
2 −2
,
−3 −3
• beim Zähler und beim Nenner negative Werte verbieten, weil ein Bruch als Anteil aufgefasst
und daher stets größer oder gleich null sein sollte.
Auf die oben als Möglichkeit genannte klassenbezogene Eigenschaft mit der Anzahl bereits erzeug-
ter Brüche wird vorläufig verzichtet.
1
Auf die Möglichkeit, alternative Bruch-Definitionen in Erwägung zu ziehen, hat Paul Frischknecht hingewiesen.
Abschnitt 1.1 Beispiel für die objektorientierte Software-Entwicklung mit Java 3
Im objektorientierten Paradigma ist jede Klasse für die Manipulation ihrer Eigenschaften selbst ver-
antwortlich. Diese sollten eingekapselt und so vor dem direkten Zugriff durch fremde Klassen ge-
schützt sein. So kann sichergestellt werden, dass nur sinnvolle Änderungen der Eigenschaften auf-
treten. Außerdem wird aus später zu erläuternden Gründen die Produktivität der Software-Entwick-
lung durch die Datenkapselung gefördert.
Demgegenüber sind die Handlungskompetenzen (Methoden) einer Klasse in der Regel von ande-
ren Akteuren (Klassen, Objekten) ansprechbar, wobei es aber auch private Methoden für den aus-
schließlich internen Gebrauch gibt. Die öffentlichen Methoden einer Klasse bilden ihre Schnittstel-
le zur Kommunikation mit anderen Klassen. Man spricht auch vom API (Application Programming
Interface) einer Klasse.
Die folgende, an Goll & Heinisch (2016) angelehnte Abbildung zeigt für eine Klasse ...
• im gekapselten Bereich ihre Felder sowie eine private Methode
• die Kommunikationsschnittstelle mit den öffentlichen Methoden
de
tho
Methode
Me
Me
e
Merkmal
od
Me
al
th
Feld
th
rkm
od
rk
Me
ma
e
Me
FeldKlasse AFeld
l
l
Me
a
rkm
Me
e
rkm
od
priv. Methode
Me
th
th
al
od
Merkmal
Me
e
de
Methode
tho
Me
Für die Objekte einer Klasse wird in der objektorientierten Analyse und Modellierung die die Befä-
higung eingeplant, auf eine Reihe von Nachrichten mit einem bestimmten Verhalten zu reagieren.
In unserem Beispiel sollten die Objekte der Klasse Bruch z. B. eine Methode zum Kürzen besitzen.
Dann kann einem konkreten Bruch-Objekt durch Aufrufen dieser Methode die Nachricht zugestellt
werden, Zähler und Nenner zu kürzen.
Sich unter einem Bruch ein Objekt vorzustellen, das Nachrichten empfängt und mit einem passen-
den Verhalten beantwortet, ist etwas gewöhnungsbedürftig. In der realen Welt sind Brüche, die sich
selbst auf ein Signal hin kürzen, nicht unbedingt alltäglich, wenngleich möglich (z. B. als didakti-
sches Spielzeug). Das objektorientierte Modellieren eines Anwendungsbereichs ist nicht unbedingt
eine direkte Abbildung, sondern eine Rekonstruktion. Einerseits soll der Anwendungsbereich im
Modell gut repräsentiert sein, andererseits soll eine möglichst stabile, gut erweiterbare und wieder-
verwendbare Software entstehen.
Um (Objekten aus) fremden Klassen trotz Datenkapselung die Veränderung einer Eigenschaft zu
erlauben, müssen entsprechende Methoden (mit geeigneten Kontrollmechanismen) angeboten wer-
den. Unsere Bruch-Klasse sollte also über Methoden zum Verändern von Zähler und Nenner ver-
fügen (z. B. mit den Namen setzeZaehler() und setzeNenner()). Bei einer gekapselten Ei-
genschaft ist auch der direkte Lesezugriff ausgeschlossen, sodass im Bruch-Beispiel auch noch Me-
thoden zum Ermitteln von Zähler und Nenner erforderlich sind (z. B. mit den Namen gib-
4 Kapitel 1 Einleitung
Zaehler() und gibNenner()). Eine konsequente Umsetzung der Datenkapselung erzwingt also
eventuell eine ganze Serie von Methoden zum Lesen und Setzen von Eigenschaftswerten.
Mit diesem Aufwand werden aber gravierende Vorteile realisiert:
• Stabilität
Die Eigenschaften sind vor unsinnigen und gefährlichen Zugriffen geschützt, wenn Verän-
derungen nur über die vom Klassendesigner sorgfältig entworfenen Methoden möglich sind.
Treten trotzdem Fehler auf, sind diese relativ leicht zu identifizieren, weil nur wenige Me-
thoden verantwortlich sein können. Gelegentlich kann es auch wichtig sein, dass Eigen-
schaftsausprägungen von anderen Klassen nicht ermittelt werden können.
• Produktivität
Durch Datenkapselung wird die Modularisierung unterstützt, sodass große Softwaresyste-
me beherrschbar werden und zahlreiche Programmierer möglichst reibungslos zusammenar-
beiten können. Der Klassendesigner trägt die Verantwortung dafür, dass die von ihm ent-
worfenen Methoden korrekt arbeiten. Andere Programmierer müssen beim Verwenden einer
Klasse lediglich die Methoden der Schnittstelle kennen. Das Innenleben einer Klasse kann
vom Designer nach Bedarf geändert werden, ohne dass andere Programmbestandteile ange-
passt werden müssen. Bei einer sorgfältig entworfenen Klasse stehen die Chancen gut, dass
sie in mehreren Software-Projekten genutzt werden kann (Wiederverwendbarkeit). Beson-
ders günstig ist die Recycling-Quote bei den Klassen der Java-Standardbibliothek (siehe
Abschnitt 1.3.3), von denen alle Java-Programmierer regen Gebrauch machen. Auch die
Klasse Bruch aus dem Beispielprojekt besitzt einiges Potential zur Wiederverwendung.
Nach obigen Überlegungen sollten die Objekte der Klasse Bruch folgende Methoden beherrschen:
• setzeZaehler(int z), setzeNenner(int n)
Ein Objekt wird beauftragt, seinen zaehler bzw. nenner auf einen bestimmten Wert zu
setzen. Ein direkter Zugriff auf die Eigenschaften soll fremden Klassen nicht erlaubt sein
(Datenkapselung). Bei dieser Vorgehensweise kann das Objekt z. B. verhindern, dass sein
Nenner auf null gesetzt wird.
Wie die Beispiele zeigen, wird dem Namen einer Methode eine in runden Klammern einge-
schlossene, eventuell leere Parameterliste angehängt. Methodenparameter, mit denen wir
uns noch ausführlich beschäftigen werden, haben einen Namen (bei setzeNenner() z. B.
n) und einen Datentyp. Im Beispiel erlaubt der Datentyp int ganze Zahlen als Werte.
• gibZaehler(), gibNenner()
Ein Bruch-Objekt wird beauftragt, den Wert seiner Zähler- bzw. Nenner-Eigenschaft mitzu-
teilen. Diese Methoden sind im Beispiel erforderlich, weil bei gekapselten Eigenschaften
weder schreibende noch lesende Direktzugriffe möglich sind.
• kuerze()
Ein Objekt wird beauftragt, zaehler und nenner zu kürzen. Welcher Algorithmus dazu
benutzt wird, bleibt dem Klassendesigner überlassen.
• addiere(Bruch b)
Ein Objekt wird beauftragt, den als Parameter übergebenen Bruch zum eigenen Wert zu ad-
dieren. Wir werden uns noch ausführlich damit beschäftigen, wie man beim Aufruf einer
Methode ihr Verhalten durch die Übergabe von Parametern (Argumenten) steuert.
• frage()
Ein Objekt wird beauftragt, zaehler und nenner beim Anwender via Konsole (Einga-
beaufforderung) zu erfragen.
• zeige()
Ein Objekt wird beauftragt, zaehler und nenner auf der Konsole anzuzeigen.
Abschnitt 1.1 Beispiel für die objektorientierte Software-Entwicklung mit Java 5
In realen (komplexeren) Programmen wird keinesfalls jedes gekapselte Feld über ein Methodenpaar
zum Lesen und geschützten Schreiben für die Außenwelt zugänglich gemacht.
Beim Eigenschaftsbegriff ist eine (ungefährliche) Zweideutigkeit festzustellen, die je nach Anwen-
dungsbeispiel mehr oder weniger spürbar wird (beim Bruchrechnungsbeispiel überhaupt nicht).
Man kann unterscheiden:
• real definierte, meist gekapselte Felder
Diese sind für die Außenwelt (für andere Klassen) irrelevant und unbekannt. In diesem Sinn
wurde der Begriff oben eingeführt.
• nach außen dargestellte Eigenschaften
Eine solche Eigenschaft ist über Methoden zum Lesen und/oder Schreiben zugänglich und
nicht unbedingt durch ein einzelnes Feld realisiert.
Wir sprechen im Manuskript meist über Felder und Methoden, wobei keinerlei Mehrdeutigkeit be-
steht.
Man verwendet für die in einer Klasse definierten Bestandteile oft die Bezeichnung Member, gele-
gentlich auch die deutsche Übersetzung Mitglieder. Unsere Klasse Bruch hat folgende Mitglieder:
• Felder
zaehler, nenner
• Methoden
setzeZaehler(), setzeNenner(), gibZaehler(), gibNenner(),
kuerze(), addiere(), frage() und zeige()
Von kommunizierenden Objekten und Klassen mit Handlungskompetenzen zu sprechen, mag als
übertriebener Anthropomorphismus (als Vermenschlichung) erscheinen. Bei der Ausführung von
Methoden sind Objekte und Klassen selbstverständlich streng determiniert, während Menschen bei
Kommunikation und Handlungsplanung ihren freien Willen einbringen, Spontanität, Kreativität und
auch Emotionen besitzen. Fußball spielende Roboter (als besonders anschauliche Objekte aufge-
fasst) zeigen allerdings mittlerweile schon recht weitsichtige und auch überraschende Spielzüge.
Was sie noch zu lernen haben, sind vielleicht Strafraumschwalben, absichtliches Handspiel etc.
Nach diesen Randbemerkungen kehren wir zum Programmierkurs zurück, um möglichst bald
freundliche und kompetente Objekte definieren zu können.
Um die durch objektorientierte Analyse gewonnene Modellierung eines Anwendungsbereichs stan-
dardisiert und übersichtlich zu beschreiben, wurde die Unified Modeling Language (UML) entwi-
ckelt, die bevorzugt mit Diagrammen arbeitet.1 Hier wird eine Klasse durch ein Rechteck mit drei
Abschnitten dargestellt:
• Oben steht der Name der Klasse.
• In der Mitte stehen die Eigenschaften (Felder).
Hinter dem Namen einer Eigenschaft gibt man ihren Datentyp an (z. B. int für ganze Zah-
len).
• Unten stehen die Handlungskompetenzen (Methoden).
In Anlehnung an eine in vielen Programmiersprachen (wie z. B. Java) übliche Syntax zur
Methodendefinition gibt man für die Argumente eines Methodenaufrufs sowie für den
Rückgabewert (falls vorhanden) den Datentyp an. Was mit dem letzten Satz genau gemeint
ist, werden Sie bald erfahren.
1
Während die UML im akademischen Bereich nachdrücklich empfohlen wird, ist ihre Verwendung in der Software-
Branche allerdings noch entwicklungsfähig, wie empirische Studien gezeigt haben (siehe z. B. Baltes & Diehl 2014,
Petre 2013).
6 Kapitel 1 Einleitung
Bruch
zaehler: int
nenner: int
setzeZaehler(int zpar)
setzeNenner(int npar):boolean
gibZaehler():int
gibNenner():int
kuerze()
addiere(Bruch b)
frage()
zeige()
Sind bei einer Anwendung mehrere Klassen beteiligt, dann sind auch die Beziehungen zwischen
den Klassen wesentliche Bestandteile des Modells. In einem UML-Klassendiagramm können u. a.
die folgenden Beziehungen zwischen Klassen (bzw. zwischen den Objekten von Klassen) darge-
stellt werden:
• Spezialisierung bzw. Vererbung („Ist-ein - Beziehung“)
Beispiel: Ein Lieferwagen ist ein spezielles Auto.
• Komposition („Hat - Beziehung“)
Beispiel: Ein Auto hat einen Motor.
• Assoziation („Kennt - Beziehung“)
Beispiel: Ein (intelligentes, autonomes) Auto kennt eine Liste von Parkplätzen.
Nach der sorgfältigen Modellierung per UML muss übrigens die Programmierung nicht am Punkt
null beginnen, weil UML-Entwicklungswerkzeuge üblicherweise Teile des Quellcodes automatisch
aus dem Modell erzeugen können. Die kostenpflichtige Ultimate-Version der im Kurs bevorzugte
Java-Entwicklungsumgebung IntelliJ IDEA (siehe Abschnitt 2.4) unterstützt die UML-
Modellierung und erstellt automatisch den Quellcode zu einem UML-Diagramm. Das mit IntelliJ
Ultimate erstellte Diagramm der Klasse Bruch zeigt für die Klassen-Member auch die Art (Feld
bzw. Methode) sowie den Zugriffsschutz an (privat bzw. öffentlich):
Die fehlende UML-Unterstützung der Community Edition von IntelliJ wird sich im Kurs nicht
nachteilig auswirken.
Abschnitt 1.1 Beispiel für die objektorientierte Software-Entwicklung mit Java 7
1
Bei der Zugriffsberechtigung spielen in Java die Pakete (und ab Java 9 zusätzlich die Module) eine wichtige Rolle.
Jede Klasse gehört zu einem Paket, und per Voreinstellung (ohne Vergabe eines Zugriffsmodifikators) haben die
anderen Klassen im selben Paket Zugriff auf eine Klasse und ihre Member (Felder und Methoden). In der Regel
sollten die Felder einer Klasse auch vor dem Zugriff durch andere Klassen im selben Paket geschützt sein.
8 Kapitel 1 Einleitung
1.1.3 Algorithmen
Am Anfang von Abschnitt 1.1 wurden mit der Modellierung des Anwendungsbereichs und der Rea-
lisierung von Algorithmen zwei wichtige Aufgaben der Software-Entwicklung genannt, von denen
die letztgenannte bisher kaum zur Sprache kam. Auch im weiteren Verlauf des Manuskripts wird
die explizite Diskussion von Algorithmen (z. B. hinsichtlich Korrektheit, Terminierung und Auf-
wand) keinen großen Raum einnehmen. Wir werden uns intensiv mit der Programmiersprache Java
sowie der zugehörigen Standardbibliothek beschäftigen und dabei mit möglichst einfachen Bei-
spielprogrammen (Algorithmen) arbeiten. Damit die Beschäftigung mit Algorithmen im Kurs nicht
Abschnitt 1.1 Beispiel für die objektorientierte Software-Entwicklung mit Java 9
ganz fehlt, werden wir im Rahmen des Bruchrechnungsbeispiels alternative Verfahren zum Kürzen
von Brüchen betrachten.
Unser Einführungsbeispiel verwendet in der Methode kuerze() den bekannten und nicht gänzlich
trivialen euklidischen Algorithmus, um den größten gemeinsamen Teiler (GGT) von Zähler und
Nenner eines Bruchs zu bestimmen, durch den zum optimalen Kürzen beide Zahlen zu dividieren
sind. Im euklidischen Algorithmus wird die leicht zu beweisende Aussage genutzt, dass für zwei
natürliche Zahlen (1, 2, 3, …) u und v (u > v > 0) der GGT gleich dem GGT von v und (u - v) ist:
Ist t ein Teiler von u und v, dann gibt es natürliche Zahlen tu und tv mit tu > tv und
u = tut sowie v = tvt
Folglich ist t auch ein Teiler von (u - v), denn:
u - v = (tu - tv)t
Ist andererseits t ein Teiler von u und (u – v), dann gibt es natürliche Zahlen tu und td mit tu > td
und
u = tut sowie (u – v) = tdt
Folglich ist t auch ein Teiler von v:
u – (u – v) = v = (tu - td)t
Weil die Paare (u, v) und (v, u - v) dieselben Mengen gemeinsamer Teiler besitzen, sind auch die
größten gemeinsamen Teiler identisch.
Beim Übergang von
(u, v) mit u > v > 0
zu
(v, u - v) mit v > 0 und u - v > 0
wird die größere von den beiden Zahlen durch eine echt kleinere Zahl ersetzt, während der GGT
identisch bleibt.
Wenn v und (u - v) in einem Prozessschritt identisch werden, ist der GGT gefunden. Das muss nach
endlich vielen Schritten passieren, denn:
• Solange die beiden Zahlen im aktuellen Schritt k noch verschieden sind, resultieren im
nächsten Schritt k+1 zwei neue Zahlen mit einem echt kleineren Maximum.
• Alle Zahlen bleiben > 0.
• Das Verfahren endet in endlich vielen Schritten, eventuell mit v = u - v = 1.
Weil die Zahl 1 als trivialer Teiler zugelassen ist, existiert zu zwei natürlichen Zahlen immer ein
größter gemeinsamer Teiler, der eventuell gleich 1 ist.
Diese Ergebnisse werden in der Methode Kuerze() folgendermaßen ausgenutzt:
Es wird geprüft, ob Zähler und Nenner identisch sind. Trifft dies zu, ist der GGT gefunden (iden-
tisch mit Zähler und Nenner). Anderenfalls wird die größere der beiden Zahlen durch deren Dif-
ferenz ersetzt, und mit diesem vereinfachten Problem startet das Verfahren neu.
Man erhält auf jeden Fall in endlich vielen Schritten zwei identische Zahlen und damit den GGT.
Der beschriebene Algorithmus eignet sich dank seiner Einfachheit gut für das Einführungsbeispiel,
ist aber in Bezug auf den erforderlichen Berechnungsaufwand nicht optimal. In einer Übungs-
aufgabe zu Abschnitt 3.7 sollen Sie eine erheblich effizientere Variante implementieren.
10 Kapitel 1 Einleitung
Nachdem der euklidische Algorithmus mit seiner Schrittfolge und seinen Bedingungen vorgestellt
wurde, sind Sie vielleicht doch daran interessiert, wie der Algorithmus in der Methode kuerze()
der Klasse Bruch realisiert wird:
public void kuerze() {
// Größten gemeinsamen Teiler mit dem euklidischen Algorithmus bestimmen
if (zaehler != 0) {
int az = Math.abs(zaehler);
int an = Math.abs(nenner);
while (az != an)
if (az > an)
az = az - an;
else
an = an - az;
zaehler = zaehler / az;
nenner = nenner / az;
} else
nenner = 1;
}
In der Methode werden lokale Variablen deklariert (z.B. az zur Aufbewahrung des Betrags des
Zählers), die von den Feldern der Klasse zu unterscheiden sind, und Anweisungen ausgeführt. Der
Begriff einer Java-Anweisung wird im Manuskript erst nach ca. 200 Seiten vollständig entwickelt
sein. Trotzdem sind die folgenden Hinweise wohl schon jetzt zu verdauen. Einige Anweisungen
enthalten einfache Wertzuweisungen an Variablen, z. B.:
az = az - an;
Zwei Anweisungen sorgen in der Methode kuerze() für eine situationsadäquate und prägnante
Beschreibung des Lösungswegs:
• Verzweigung
Die if-Anweisung enthält eine Bedingung sowie ...
o einen Blockanweisung, die bei erfüllter Bedingung ausgeführt wird
o eine Anweisung, die bei nicht erfüllter Bedingung ausgeführt wird.
• Wiederholung
Die while-Schleife enthält eine Bedingung und eine Anweisung, die wiederholt ausgeführt
wird, solange die jeweils vor dem nächsten potentiellen Schleifendurchgang geprüfte Be-
dingung erfüllt ist.
Mit diesen beiden Anweisungen zur Ablaufsteuerung werden wir uns später noch ausführlich be-
schäftigen.
Es bietet sich an, die oben angedachte Anweisungssequenz des Bruchadditionsprogramms in der
obligatorischen main() - Methode der Startklasse unterzubringen.
Obwohl prinzipiell möglich, ist es nicht sinnvoll, die auf Wiederverwendbarkeit hin konzipierte
Klasse Bruch mit der Startmethode für eine sehr spezielle Anwendung zu belasten. Daher definie-
ren wir eine zusätzliche Klasse namens Bruchaddition, die nicht als Bauplan für Objekte dienen
soll und auch kaum Recycling-Chancen besitzt. Ihr Handlungsrepertoire kann sich auf die Klas-
senmethode main() zur Ablaufsteuerung im Bruchadditionsprogramm beschränken. Indem wir eine
neue Klasse definieren und dort Bruch-Objekte verwenden, wird u. a. gleich demonstriert, wie
leicht das Hauptergebnis unserer bisherigen Arbeit (die Klasse Bruch) für verschiedene Projekte
genutzt werden kann.
In der Bruchaddition - Methode main() werden zwei Objekte (Instanzen) aus der Klasse Bruch
erzeugt und mit der Ausführung verschiedener Methoden beauftragt. Beim Erzeugen der Objekte
mit Hilfe des new-Operators ist mit dem sogenannten Konstruktor (siehe unten) eine spezielle
Methode der Klasse Bruch beteiligt, die den Namen der Klasse trägt:
Eingabe (grün, kursiv) und
Quellcode
Ausgabe
class Bruchaddition { 1. Bruch
public static void main(String[] args) { Zähler: 20
Bruch b1 = new Bruch(), b2 = new Bruch(); Nenner: 84
5
System.out.println("1. Bruch"); -----
b1.frage(); 21
b1.kuerze();
b1.zeige();
2. Bruch
System.out.println("\n2. Bruch"); Zähler: 12
b2.frage(); Nenner: 36
b2.kuerze(); 1
b2.zeige(); -----
3
System.out.println("\nSumme");
b1.addiere(b2);
b1.zeige(); Summe
} 4
} -----
7
Wir haben zur Lösung der Aufgabe, ein Programm für die Addition von zwei Brüchen zu erstellen,
zwei Klassen mit der folgenden Rollenverteilung definiert:
• Die Klasse Bruch enthält den Bauplan für die wesentlichen Akteure im Aufgabenbereich.
Dort alle Eigenschaften und Handlungskompetenzen von Brüchen zu konzentrieren, hat fol-
gende Vorteile:
o Die Klasse kann in verschiedenen Programmen eingesetzt werden (Wiederverwend-
barkeit). Dies fällt vor allem deshalb so leicht, weil die Objekte sowohl Handlungs-
kompetenzen (Methoden) als auch die erforderlichen Eigenschaften (Felder) besit-
zen.
Wir müssen bei der Definition dieser Klasse ihre allgemeine Verfügbarkeit explizit
mit dem Zugriffsmodifikator public genehmigen. Per Voreinstellung ist eine Klasse
nur im eigenen Paket verfügbar (siehe Kapitel 6).
12 Kapitel 1 Einleitung
o Beim Umgang mit den Bruch-Objekten sind wenige Probleme zu erwarten, weil nur
klasseneigene Methoden direkten Zugang zu kritischen Eigenschaften haben (Daten-
kapselung). Sollten doch Fehler auftreten, sind die Ursachen in der Regel schnell ge-
funden.
• Die Klasse Bruchaddition dient nicht als Bauplan für Objekte, sondern enthält eine Klas-
senmethode main(), die beim Programmstart automatisch aufgerufen wird und dann für ei-
nen speziellen Einsatz von Bruch-Objekten sorgt. Mit einer Wiederverwendung des
Bruchaddition-Quellcodes in anderen Projekten ist kaum zu rechnen.
In der Regel bringt man den Quellcode jeder Klasse in einer eigenen Datei unter, die den Namen
der Klasse trägt, ergänzt um die Namenserweiterung .java, sodass im Beispielsprojekt die Quell-
codedateien Bruch.java und Bruchaddition.java entstehen. Weil die Klasse Bruch mit dem Zu-
griffsmodifikator public definiert wurde, muss ihr Quellcode in einer Datei mit dem Namen
Bruch.java gespeichert werden (siehe unten). Es wäre erlaubt, aber nicht sinnvoll, den Quellcode
der Klasse Bruchaddition ebenfalls in der Datei Bruch.java unterzubringen.
Wie aus den beiden vorgestellten Klassen bzw. Quellcodedateien ein ausführbares Programm ent-
steht, erfahren Sie im Abschnitt 2.2.
Die Firma Oracle liefert seit ihrer Änderung ihrer Lizenzpolitik im Jahr 2019 keine langfristig durch
Updates unterstützte und frei verwendbare JVM mehr aus (siehe Abschnitt 1.3.5). Glücklicherweise
sind einige IT-Firmen in die Presche gesprungen. Dabei wird stets das umfassende Java Develop-
ment Kit (JDK) geliefert, das neben einer JVM z. B. auch den Quellcode der Java-
Standardbibliothek und einen Java-Compiler enthält. Damit geht es geht über den Bedarf eines An-
wenders von Java-Software hinaus, was aber bis auf ca. 100 MB verschwendeten Massenspeicher-
platz keine Nachteile hat.
Um eine Java-Laufzeitumgebung bequem und ohne Lizenzunsicherheit auf einen Windows-
Rechner zu befördern, kommt z. B. die vom Open Source - Projekt ojdkbuild auf der Webseite1
https://github.com/ojdkbuild/ojdkbuild
angebotene OpenJDK-Distribution von Java 8 (alias 1.8) in Frage:2
java-1.8.0-openjdk-1.8.0.302-1.b08.ojdkbuild.windows.x86_64.msi
Diese Distribution bietet folgende Vorteile.
• Keine lizenzrechtlichen Einschränkungen
• Long Term Support (LTS) bis Mai 2026
• Das im Kurs als Bibliothek für Programme mit grafischer Bedienoberfläche verwendete Ja-
vaFX (alias OpenJFX) ist eine Option im Installationsprogramm, also ohne separaten
Download bequem verfügbar.
1
Das Open Source - Projekt ojdkbuild wird von der Firma Red Hat gesponsert.
2
Eine in der Java-Szene seit Jahrzehnten gepflegte Marotte besteht in zwei parallelen Versionierungen. Für die Ver-
sion 8 erscheint in vielen Dateinamen die Version 1.8. Diese Spiel begann mit Java 2 (alias 1.2) im Jahr 1998. Mit
der Version 9 wurde die Doppel-Versionierung aufgegeben.
14 Kapitel 1 Einleitung
• Eine weitere Option im Installationsprogramm ist ein Hilfsprogramm, das nötigenfalls zum
Update auffordert. Unter Windows taucht im Infobereich das folgende Symbol auf, wenn
ein OpenJDK-Update ansteht. Nach einem Mausklick auf das Symbol erscheint ein Fenster
mit Download-Link, z. B.:
Die seit dem 14.9.2021 bei Oracle verfügbare LTS-Version JDK 17 wird vermutlich in Kürze auch
von Red Hat angeboten.
Obwohl das Alter gegen Java 8 (alias 1.8) spricht und jüngere LTS-Versionen verfügbar sind, be-
stehen Argumente für die oben beschriebene JDK 8 - Distribution:
• Java 8 ist nach einer aktuellen Umfrage der Firma JetBrains (Hersteller der im Kurs bevor-
zugten Entwicklungsumgebung IntelliJ IDEA) unter Entwicklern die Java-Version mit der
größten Verbreitung.1 Das liegt eventuell auch daran, dass in Java 9 mit dem Modulsystem
(siehe Kapitel 6) eine wesentlich Architekturveränderung Einzug gehalten hat, die von vie-
len Entwicklern und Anwendern noch mit Zurückhaltung betrachtet wird. Bei der Software-
Entwicklung kommt für uns das JDK 8 als Laufzeitumgebung dann in Frage, wenn wir uns
auf minimale Voraussetzungen auf der Kundenseite beschränken und keine JVM mit unserer
Anwendung ausliefern wollen. Um Neuerungen der Java-Technik (Programmiersprache,
Standardbibliothek, Laufzeitumgebung) nutzen zu können, werden wir aber auch die LTS -
Java-Version 17 verwenden (siehe Abschnitt 2.1).
1
https://www.jetbrains.com/lp/devecosystem-2021/java/
Abschnitt 1.2 Java-Programme ausführen 15
• In der aktuell ebenfalls verfügbaren ojdkbuild-Distribution mit JDK 11 LTS fehlen die GUI-
Bibliothek JavaFX und ein Programm, das automatisch auf anstehende Updates hinweist.
Diese Komponenten sind zwar grundsätzlich nachrüst- bzw. verzichtbar, doch das OpenJDK
8 - Paket aus dem ojdkbuild-Projekt macht den Einstieg in die Java-Technik besonders be-
quem.
• Die Firma Red Hat unterstützt das OpenJDK 8 LTS bis Mai 2026, das OpenJDK 11 LTS
hingegen nur bis Oktober 2024.
Nachdem die OpenJDK 8 - Installation per Doppelklick auf die heruntergeladene MSI-Datei gestar-
tet worden ist, meldet unter Windows 10 eventuell die SmartScreen-Funktion Bedenken gegen das
Installationsprogramm an:
Vorsichtige Menschen lassen in dieser Situation die Datei zunächst von einem Virenschutzpro-
gramm überprüfen. Weil es sich um ein großes Archiv mit ca. 70.000 Dateien handelt, nimmt die
Prüfung einige Zeit in Anspruch. Nach bestandenem Test klickt man zunächst auf den Link Weite-
re Informationen und dann auf den Schalter Trotzdem ausführen:
Die vorgelegte
GNU GPL, Version 1991, mit CLASSPATH-Ausnahme
ist beim OpenJDK üblich und erlaubt eine liberale, auch kommerzielle Nutzung.1
Im Dialog Custom Setup sollten die OpenJFX Runtime und der Update Notifier aktiviert
werden:
1
https://github.com/ojdkbuild/ojdkbuild/blob/master/LICENSE, https://openjdk.java.net/legal/gplv2+ce.html
Abschnitt 1.2 Java-Programme ausführen 17
Außerdem kann nach einem Klick auf Browse der Installationsordner eingestellt werden. Es
spricht nichts dagegen, die Voreinstellung zu verwenden:
C:\Program Files\ojdkbuild\java-1.8.0-openjdk-1.8.0.302-1\
Nach Mausklicks auf Next und Install sowie einer positiven Antwort auf die Nachfrage der Benut-
zerkontensteuerung von Windows (UAC) ist die Installation schnell erledigt:
einfachung der Konsoleneingabe (Simple Input) für den Kurs entworfene Klasse Simput in eigenen
Programmen einsetzen sollen, wird sie näher vorgestellt. Im Abschnitt 2.2.4 lernen Sie eine Mög-
lichkeit kennen, die in mehreren Projekten benötigten class-Dateien zentral abzulegen und durch
eine passende Definition der Windows-Umgebungsvariablen CLASSPATH allgemein verfügbar zu
machen. Dann muss die Datei Simput.class nicht mehr in den Ordner eines Projekts kopiert wer-
den, um sie dort nutzen zu können.
Gehen Sie folgendermaßen vor, um die Klasse Bruchaddition zu starten:
• Öffnen Sie ein Konsolenfenster (auch Eingabeaufforderung genannt), z. B. so:
o Tastenkombination Windows + R
o Befehl cmd eintragen und mit OK ausführen lassen:
o
• Wechseln Sie zum Ordner mit den class-Dateien, z. B.:
>u:
>cd \Eigene Dateien\Java\BspUeb\Einleitung\Bruchaddition\Konsole
• Starten Sie die Java Runtime Environment über das Programm java.exe, und geben Sie als
Kommandozeilenargument die Startklasse an, wobei die Groß/Kleinschreibung zu beachten
ist:
>java Bruchaddition
Damit das zur Java-Laufzeitumgebung gehörende Programm java.exe wie im Beispiel ohne
Angabe des Installationsordners aufgerufen werden kann, muss der bin-Unterordner der O-
penJDK-Installation
C:\Program Files\ojdkbuild\java-1.8.0-openjdk-1.8.0.302-1\bin
in die Windows-Umgebungsvariable PATH eingetragen worden sein, was bei der im Ab-
schnitt 1.2.1 beschriebenen Installation automatisch geschieht. Wie man nötigenfalls einen
fehlenden PATH-Eintrag manuell vornehmen kann, wird im Abschnitt 2.2.2 beschrieben.
Ab jetzt sind Bruchadditionen kein Problem mehr:
Abschnitt 1.2 Java-Programme ausführen 19
Mit dem Quellcode zur Gestaltung der grafischen Bedienoberfläche könnten Sie im Moment noch
nicht allzu viel anfangen. Am Ende des Kurses bzw. nach der Lektüre des Manuskripts werden Sie
derartige Anwendungen aber mit Leichtigkeit entwickeln, zumal die Erstellung grafischer Bedien-
oberflächen durch die GUI-Technologie JavaFX (alias OpenJFX) und den Fensterdesigner Scene
Builder erleichtert wird (siehe Abschnitt 2.5).
Zum Ausprobieren des Programms startet man mit Hilfe einer Java-Laufzeitumgebung mit Open-
JFX-Unterstützung (vgl. Abschnitt 1.2.1 zur Installation) aus dem Ordner
...\BspUeb\Einleitung\Bruchaddition\JavaFX\out\production\Bruchaddition
die Klasse Bruchaddition:
Um das Programm unter Windows per Doppelklick starten zu können, legt man eine Verknüpfung
zum konsolenfreien JVM-Startprogramm javaw.exe an, z. B. über das Kontextmenü zu einem
Fenster des Windows-Explorers (Befehl Neu > Verknüpfung):
Weil das Programm keine Konsole benötigt, sondern ein Fenster als Bedienoberfläche anbietet,
verwendet man bei der Verknüpfungsdefinition als JVM-Startprogramm die Variante javaw.exe
(mit einem w am Ende des Namens). Bei Verwendung von java.exe als JVM-Startprogramm würde
zusätzlich zum grafischen Bruchadditionsprogramm ein leeres Konsolenfenster erscheinen:
20 Kapitel 1 Einleitung
Während das Konsolenfenster beim normalen Programmablauf leer bleibt, erscheinen dort bei einen
Laufzeitfehler hilfreiche diagnostische Ausgaben. Daher ist ein Programmstart mit Konsolenfenster
(per java.exe) bei der Fehlersuche durchaus sinnvoll.
Im nächsten Dialog des Assistenten für neue Verknüpfungen trägt man den gewünschten Namen
der Link-Datei ein:
Nun genügt zum Starten des Programms ein Doppelklick auf die Verknüpfung:
Beim eben beschriebenen Verfahren muss die verwendete JVM eine JavaFX-Unterstützung enthal-
ten, was z. B. nach den im Abschnitt 1.2.1 beschriebenen Installation der OpenJDK-Distribution 8
aus dem ojdkbuild-Projekt der Fall ist. Ist z. B. die ohne JavaFX (alias OpenJFX) ausgelieferte
OpenJDK-Distribution 17.0.1 der Firma Oracle gemäß Abschnitt 2.1 im Ordner
C:\Program Files\Java\OpenJDK-17
und die hier
https://gluonhq.com/products/javafx/
frei verfügbare JavaFX-Distribution 17.0.1 der Firma Gluon gemäß Abschnitt 2.5 im Ordner1
C:\Program Files\Java\OpenJFX-SDK-17
installiert, dann taugt das folgende Kommando zum Starten des Programms
>"C:\Program Files\Java\OpenJDK-17\bin\javaw.exe" --module-path "C:\Program
Files\Java\OpenJFX-SDK-17\lib" --add-modules javafx.controls,javafx.fxml
Bruchaddition
aus einer Konsole, die auf den Ordner mit der Datei Bruchaddition.class positioniert ist. Den An-
wendern sollte dieses Kommando z. B. mit Hilfe einer Verknüpfung erspart werden.
Professionelle Java-Programme werden oft als Java-Archivdatei (mit der Namenserweiterung .jar,
siehe Abschnitte 6.1.3 und 6.2.6) ausgeliefert und sind unter Windows nach einer korrekten JVM-
Installation über einen Doppelklick auf diese Datei zu starten. Im Ordner
...\BspUeb\Einleitung\Bruchaddition\JavaFX\out\artifacts\Bruchaddition
finden Sie die (von IntelliJ erstellte) Datei Bruchaddition.jar mit dem grafischen Bruchadditions-
programm. Es kann auf einem Windows-Rechner per Doppelklick gestartet werden, wenn z. B. ...
• die im Abschnitt 1.2.1 beschriebene Installation der OpenJDK 8 - Distribution aus dem
ojdkbuild-Projekt ausgeführt wurde (inklusive OpenJFX),
• in der Windows-Registry ...
o der Schlüssel
HKEY_LOCAL_MACHINE\SOFTWARE\Classes\.jar
im Standardwert den Eintrag jarfile besitzt,
o der Schlüssel
HKEY_LOCAL_MACHINE\SOFTWARE\Classes\jarfile\shell\open\command
im Standardwert den folgenden Eintrag besitzt:
"C:\Program Files\ojdkbuild\java-1.8.0-openjdk-1.8.0.302-1\bin\javaw.exe" -jar "%1" %*
Professionelle Java-Programme bringen oft eine JVM mit (z. B. basierend auf dem OpenJDK) und
sind damit nicht darauf angewiesen, dass sich auf dem Kundenrechner eine JVM befindet. Damit
bürden sich die Anbieter professioneller Java-Programme aber die Pflicht auf, Sicherheitsupdates
für die integrierte JVM zu liefern. Seit Java 9 ermöglicht das JPMS (siehe Abschnitt 6.2) die Erstel-
lung einer angepassten modularen Laufzeitumgebung, die ausschließlich vom Programm benötigte
Module enthält (siehe Abschnitt 6.2.8). Das spart viel Platz und reduziert die Gefahr, von einer ent-
deckten Sicherheitslücke betroffen zu sein. Für kleinere Programme bleibt es aber eine sinnvolle
Option, dem Anwender der (meist kostenlosen) Software die Installation und Wartung einer JVM
zu überlassen (siehe Abschnitt 1.2.1). Dann entfällt für den Programmanbieter die Verpflichtung,
1
Im Manuskript werden die Bezeichnungen JavaFX und OpenJFX synonym verwendet.
22 Kapitel 1 Einleitung
Mittlerweile hat sich Java als sehr vielseitig einsetzbare Programmiersprache etabliert, die als Stan-
dard für die plattformunabhängige Entwicklung gelten kann und einen hohen Verbreitungsgrad be-
sitzt. Laut der aktuellen Entwicklerbefragung durch die Firma JetBrains ist Java in Deutschland die
meistbenutzte Programmiersprache. Auch in anderen Rankings belegt Java stets vordere Plätze:
• Populäre Programmiersprachen in Deutschland (2021, Java-Rangplatz: 1)
https://www.jetbrains.com/lp/devecosystem-2021/
• Nachfrage nach Programmiersprachen auf dem deutschen Arbeitsmarkt (2021, Platz 1)
https://www.get-in-it.de/magazin/bewerbung/it-skills/welche-programmiersprache-lernen
• TIOBE Programming Community Index (Oktober 2021, Java-Rangplatz: 3)
http://www.tiobe.com/index.php/content/paperinfo/tpci/index.html
• PYPL PopularitY of Programming Language (Oktober 2021, Java-Rangplatz: 2)
http://pypl.github.io/PYPL.html
• RedMonk Programming Language Rankings (Juni 2021, Java-Rangplatz: 3)
https://redmonk.com/sogrady/2021/08/05/language-rankings-6-21/
• IEEE Spectrum: The Top Programming Languages (2021, Java-Rangplatz: 2)
https://spectrum.ieee.org/top-programming-languages/
Außerdem ist Java relativ leicht zu erlernen und daher für den Einstieg in die professionelle Pro-
grammierung eine gute Wahl.
Die Java-Designer haben sich stark an den Programmiersprachen C und C++ orientiert, sodass sich
Umsteiger von diesen sowohl im Windows- als auch im Linux/UNIX - Bereich verbreiteten Spra-
chen schnell in Java einarbeiten können. Wesentliche Ziele bei der Weiterentwicklung waren Ein-
fachheit, Robustheit, Sicherheit und Portabilität.
1
https://de.wikipedia.org/wiki/Instruktionen_pro_Sekunde
https://en.wikipedia.org/wiki/Instructions_per_second
24 Kapitel 1 Einleitung
1
http://en.wikipedia.org/wiki/Jazelle
Abschnitt 1.3 Architektur und Eigenschaften von Java-Software 25
ligte Software, also sozusagen für die Emulation des Java-Prozessors in Software. Man benötigt
also für jede reale Maschine eine partiell vom jeweiligen Betriebssystem und von der konkreten
CPU abhängige JVM, um den Java-Bytecode auszuführen. Diese Software wird meist in der Pro-
grammiersprache C++ realisiert.
Für viele Desktop-Betriebssysteme (Linux, macOS, Solaris, Windows) ist eine Java-
Laufzeitumgebung kostenlos verfügbar. Über eine lange Zeit haben die Firma Sun und der Aufkäu-
fer Oracle ein zur Ausführung, aber nicht zur Entwicklung von Java-Programmen geeignetes Soft-
warepaket namens Java Runtime Environment (JRE) kostenlos zur Verfügung gestellt und durch
Updates unterstützt. Für Entwickler wurde das Java Development Kit (inklusive Compiler) kos-
tenlos angeboten. Mit Java 8 endet die JRE-Verteilung durch Oracle, und seit April 2019 darf die
JRE 8 der Firma Oracle nur noch für private Zwecke kostenlos genutzt werden. Beginnend mit der
Java-Version 9 ist nur noch das JDK-Paket verfügbar, das aber auch eine Java-Laufzeitumgebung
enthält. Java-Entwickler sind mit dem JDK gut bedient, und Java-Anwender müssen lediglich einen
irrelevanten Massenspeicher-Mehrverbrauch hinnehmen. Außerdem liefert die Firma Oracle nur
noch 6 Monate lang (bis zum Erscheinen der nächsten Hauptversion) kostenlose Updates für ein
JDK. Einige Java-Versionen erhalten aber über die 6 Monate hinaus eine mindestens 5 Jahre dau-
ernde Langzeitunterstützung (LTS), also eine Versorgung durch Updates. Das gilt aktuell für die
Versionen 8, 11 und 17. Während bei Oracle der LTS auf die private Nutzung beschränkt ist, ge-
währen andere Firmen den LTS auch für kommerziell genutzte JDK-Installationen (siehe Abschnitt
1.3.5).
Die wichtigsten Komponenten der Java-Laufzeitumgebung sind:
• JVM
Neben der Bytecode-Übersetzung erledigt die JVM bei der Ausführung eines Java-
Programms noch weitere Aufgaben, z. B.:
o Der Klassenlader befördert die vom Programm benötigten Klassen in den Speicher
und nimmt dabei eine Bytecode-Verifikation vor, um potentiell gefährliche Aktionen
zu verhindern.
o Die Speicherverwaltung entfernt automatisch die im Programmablauf überflüssig
gewordenen Objekte (Garbage Collection).
• Java-Standardbibliothek mit Klassen für (fast) alle Routineaufgaben (siehe Abschnitt 1.3.3)
Wie Sie bereits aus dem Abschnitt 1.2 wissen, startet man unter Windows mit java.exe bzw. ja-
vaw.exe die Ausführungsumgebung für ein Java-Programm (mit Konsolen- bzw. Fensterbedienung)
und gibt als Parameter die Startklasse des Programms an.
Mittlerweile kommen bei der Ausführung von Java-Programmen leistungssteigernde Techniken
(Just-in-Time - Compiler, HotSpot - Compiler mit Analyse des Laufzeitverhaltens) zum Einsatz,
die die Bezeichnung Interpreter fraglich erscheinen lassen. Allerdings ändert sich nichts an der
Aufgabe, aus dem plattformunabhängigen Bytecode den zur aktuellen CPU passenden Maschinen-
code zu erzeugen. So wird wohl keine Verwirrung gestiftet, wenn in diesem Manuskript weiterhin
vom Interpreter die Rede ist.
In der folgenden Abbildung sind die beiden Übersetzungen auf dem Weg vom Quell- zum Maschi-
nencode durch den Compiler javac.exe und den Interpreter java.exe, die beide im JDK enthalten
sind, am Beispiel des Bruchrechnungsprojekts (vgl. Abschnitt 1.1) im Überblick zu sehen:
26 Kapitel 1 Einleitung
Bibliotheken
(z.B. Java-API)
Dank der Plattformunabhängigkeit von Java lässt sich der Bytecode unter verschiedenen Betriebs-
systemen ausführen:
Interpreter ARM
für Linux Maschinencode
Compiler,
Interpreter M1
Quellcode z.B. Bytecode
für macOS Maschinencode
javac.exe
Interpreter x86
für Windows Maschinencode
Es wäre nicht grob falsch, auch das Smartphone-Betriebssystem Android in die Abbildung aufzu-
nehmen, wenngleich dort eine andere Java-Klassenbibliothek (siehe Abschnitt 1.3.3) und eine alter-
native Bytecode-Technik zum Einsatz kommen (siehe z. B. Baltes-Götz 2018). Für die verwendete
Bytecode-Technik muss sich ein Anwendungsprogrammierer kaum interessieren. Die Besonderhei-
ten eines Mobil-Betriebssystems wirken sich allerdings auf die Klassenbibliothek und damit auf den
Quellcode aus.
1.3.3 Standardklassenbibliothek
Damit die Programmierer nicht das Rad (und ähnliche Dinge) ständig neu erfinden müssen, bietet
die Java-Plattform eine Standardbibliothek mit fertigen Klassen für nahezu alle Routineaufgaben,
die oft als API (Application Program Interface) bezeichnet wird. Im Manuskript werden Sie zahl-
reiche API-Klassen kennenlernen; eine vollständige Behandlung ist wegen des enormen Umfangs
unmöglich und auch nicht erforderlich.
Bevor man selbst eine Klasse oder Methode entwickelt, sollte man unbedingt die Standardbiblio-
thek auf die Existenz einer Lösung untersuchen, denn die Lösungen in der Standardbibliothek ...
• sind leistungsoptimiert und sorgfältig getestet,
• werden ständig weiterentwickelt.
Durch die Verwendung der Standardbibliothek steigert man in der Regel die Qualität der entstehen-
den Software, spart viel Zeit und verbessert auch noch die Lesbarkeit des Quellcodes, weil die Lö-
sungen der Standardbibliothek vielen Entwicklern vertraut sind (Bloch 2018, S. 267ff).
Abschnitt 1.3 Architektur und Eigenschaften von Java-Software 27
Wir halten fest, dass die Java-Technologie einerseits auf einer Programmiersprache basiert, dass
andererseits aber die Funktionalität im Wesentlichen von einer umfangreichen Standardbibliothek
beigesteuert wird, deren Klassen in jeder virtuellen Java-Maschine zur Verfügung stehen.
Die Java-Designer waren bestrebt, sich auf möglichst wenige, elementare Sprachelemente zu be-
schränken und alle damit formulierbaren Konstrukte in der Standardbibliothek unterzubringen. Es
resultierte eine sehr kompakte Sprache (siehe Gosling et al. 2021), die nach ihrer Veröffentlichung
im Jahr 1995 lange Zeit nahezu unverändert blieb.
Neue Funktionalitäten werden in der Regel durch eine Erweiterung der Java-Klassenbibliothek rea-
lisiert, sodass hier erhebliche Änderungen stattfinden. Einige Klassen sind mittlerweile als depre-
cated (überholt, nicht mehr zu benutzen) eingestuft worden. Gelegentlich stehen für eine Aufgabe
verschiedene Lösungen aus unterschiedlichen Entwicklungsstadien zur Verfügung (z. B. bei den
Multithreading-Lösungen für die parallele Programmausführung).
Mit der 2004 erschienenen Version 5 (alias 1.5) hat auch die Programmiersprache Java substantielle
Veränderungen erfahren (z. B. generische Typen, Auto-Boxing). Auch die Version 8 (alias 1.8) hat
mit der funktionalen Programmierung (den Lambda-Ausdrücken) eine wesentliche Erweiterung der
Programmiersprache Java gebracht.
In Kurs bzw. Manuskript steht zunächst die Programmiersprache Java im Vordergrund. Mit wach-
sender Kapitelnummer geht es aber auch darum, wichtige Pakete der Standardbibliothek mit Lö-
sungen für Routineaufgaben kennenzulernen, z. B.:
• Kollektionen zur Verwaltung von Listen, Mengen oder (Schlüssel-Wert) - Tabellen
• Lesen und Schreiben von Dateien
• Multithreading
Neben der sehr umfangreichen Standardbibliothek, die integraler Bestandteil der Java-Plattform ist,
sind aus anderen Quellen unzählige Java-Klassen für diverse Aufgaben verfügbar.
1
https://jakarta.ee/
2
https://de.wikipedia.org/wiki/Spring_(Framework)
28 Kapitel 1 Einleitung
1
https://www.oracle.com/java/technologies/javame-embedded/javame-embedded-getstarted.html
2
Die in Smartphone-CPUs mit ARM-Design vorhandene reale Java-Maschine namens Jazelle DBX wird von Andro-
id ignoriert und von der Prozessor-Schmiede ARM mittlerweile als veraltet und überflüssig betrachtet (Langbridge
2014, S. 48). Aktuelle ARM-Prozessoren setzen auf einen Befehlssatz namens ThumbEE, der sich gut für die JIT -
Übersetzung von Bytecode in Maschinencode eignet.
3
https://www.statista.com/statistics/272698/global-market-share-held-by-mobile-operating-systems-since-2009/
4
https://gs.statcounter.com/os-market-share/tablet/worldwide
Abschnitt 1.3 Architektur und Eigenschaften von Java-Software 29
1
https://jaxenter.de/red-hat-openjdk-java-82758
Red Hat wurde mittlerweile von IBM übernommen.
2
https://developer.ibm.com/blogs/ibm-and-java-looking-forward-to-the-future/
3
https://blogs.microsoft.com/blog/2019/08/19/microsoft-acquires-jclarity-to-help-optimize-java-workloads-on-azure/
https://www.theserverside.com/opinion/Microsoft-vs-IBM-A-major-shift-in-Java-support
4
https://openjdk.java.net/legal/gplv2+ce.html
5
https://jaxenter.de/java-jdk-release-zyklus-75402
30 Kapitel 1 Einleitung
kaum Merkmale der objektorientierten Programmierung aufweisen. Hier wird die gesamt Funktio-
nalität in die main() - Methode der Startklasse und eventuell in weitere statische Methoden der
Startklasse gezwängt. Später werden auch wir solche pseudo-objektorientierten (POO-) Pro-
gramme benutzen, um elementare Java-Sprachelemente in möglichst einfacher Umgebung kennen-
zulernen. Aus den letzten Ausführungen ergibt sich u. a., dass Java zwar eine objektorientierte Pro-
grammierweise nahelegen und unterstützen, aber nicht erzwingen kann.
Nachdem das objektorientierte Paradigma die Software-Entwicklung über Jahrzehnte dominiert hat,
gewinnt das ältere, aber lange Zeit auf akademische Diskurse beschränkte funktionale Paradigma
in den letzten Jahren an Bedeutung. Ein wesentlicher Grund ist seine gute Eignung für die zur opti-
malen Nutzung moderner Mehrkern-CPUs erforderliche nebenläufige Programmierung (Horstmann
2014b). Seit der Version 8 unterstützt Java wichtige Techniken bzw. Prinzipien der funktionalen
Programmierung (z. B. Lambda-Ausdrücke).
1.3.6.2 Portabilität
Die im Abschnitt 1.3.2 beschriebene Übersetzungsprozedur führt zusammen mit der Tatsache, dass
sich Bytecode-Interpreter für aktuelle IT-Plattformen relativ leicht implementieren lassen, zur guten
Portabilität von Java. Man mag einwenden, dass sich der Quellcode vieler Programmiersprachen
(z. B. C++) ebenfalls auf verschiedenen Rechnerplattformen kompilieren lässt. Diese Quellcode-
Portabilität aufgrund weitgehend genormter Sprachdefinitionen und verfügbarer Compiler ist je-
doch auf einfache Anwendungen mit textorientierter Benutzerschnittstelle beschränkt und stößt
selbst dort auf manche Detailprobleme (z. B. durch verschiedenen Zeichensätze). C++ wird zwar
auf vielen verschiedenen Plattformen eingesetzt, doch kommen dabei in der Regel plattformab-
hängige Funktions- bzw. Klassenbibliotheken zum Einsatz (z. B. GTK unter Linux, MFC unter
Windows).1 Bei Java besitzt hingegen bereits die zuverlässig in jeder JVM verfügbare Standardbib-
liothek mit ihren insgesamt ca. 4000 Klassen weitreichende Fähigkeiten für die Gestaltung grafi-
scher Bedienoberflächen, für Datenbank- und Netzwerkzugriffe usw., sodass sich plattformunab-
hängige Anwendungen mit modernem Funktionsumfang und Design realisieren lassen.
Weil der von einem Java-Compiler erzeugte Bytecode von jeder JVM (mit passender Version) aus-
geführt werden kann, bietet Java nicht nur Quellcode- sondern auch Binärportabilität. Ein Pro-
gramm ist also ohne erneute Übersetzung auf verschiedenen Plattformen einsetzbar.
Microsoft strebt mit seiner .NET - Plattform dasselbe Ziel an und verspricht für die im Herbst 2021
erscheinende Version 6 sogar eine Multiplattform - GUI-Lösung namens MAUI (siehe z. B. Baltes-
Götz 2021). Während Linux generell von .NET unterstützt wird, bleibt dieses Betriebssystem bei
MAUI aber außen vor.
1.3.6.3 Sicherheit
Beim Design der Java-Technologie wurde das Thema Sicherheit gebührend berücksichtigt. Weil ein
als Bytecode übergebenes Programm durch die beim Empfänger installierte virtuelle Maschine vor
der Ausführung auf unerwünschte Aktivitäten geprüft wird, können viele Schadwirkungen verhin-
dert werden.
Leider hat sich die Sicherheitstechnik der Java-Laufzeitumgebung im Jahr 2013 mehrfach als löch-
rig erwiesen. Von den Risiken, die oft voreilig und unreflektiert auf die gesamte Java-Technik be-
zogen wurden, waren allerdings überwiegend die von Webservern bezogenen Applets betroffen, die
1
Dass es grundsätzlich möglich ist, eine C++ - Klassenbibliothek mit umfassender Funktionalität (z. B. auch für die
Gestaltung grafischer Bedienoberflächen) für verschiedene Plattformen herzustellen und so für Quellcode-
Portabilität bei modernen, kompletten Anwendungen zu sorgen, beweist die Firma Trolltech mit ihrem Produkt Qt.
Abschnitt 1.3 Architektur und Eigenschaften von Java-Software 31
im Internet-Browser - Kontext mit Hilfe von Plugins ausgeführt wurden. Diese Java-Applets sind
strikt von lokal installierten Java-Anwendungen für Desktop-Rechner zu unterscheiden. Noch weni-
ger als Java-Anwendungen für Desktop-Rechner waren die außerordentlich wichtigen Server-
Anwendungen (z. B. erstellt mit der Java Enterprise Edition oder alternativen Frameworks wie
Spring) von der damaligen Sicherheitsmisere betroffen. Moderne Browser unterstützen keine
Plugins mehr, sodass Java-Applets (wie auch Anwendungen für Flash und Silverlight) keine Rolle
mehr spielen. Oracle hat die Applet-Technik seit dem JDK 9 als veraltet (engl. deprecated) gekenn-
zeichnet und seit der Version 11 aus dem JDK entfernt.1
Generell ist natürlich auch Java-Software nicht frei von Sicherheitsproblemen und muss (wie das
Betriebssystem, die Browser, der Virenschutz und viele andere Programme) stets aktuell gehalten
werden. Das gilt insbesondere für die JVM-Installationen.
Offenbar hat die Firma Oracle aus den ärgerlichen und peinlichen Problemen des Jahres 2013 ge-
lernt. Das Bundesamt für Sicherheit in der Informationstechnik stellt in seinem Jahresbericht 2018
zur IT-Sicherheit in Deutschland bei der Java-Laufzeitumgebung (Oracle JRE) relativ wenige kriti-
sche Schwachstellen (kritische CVE-Einträge) bei stark sinkender Tendenz fest:2
1.3.6.4 Robustheit
In diesem Abschnitt werden Gründe für die hohe Robustheit (Stabilität) von Java-Software genannt,
wobei auch die anschließend noch separat behandelte Einfachheit eine große Rolle spielt.
Die Programmiersprache Java verzichtet auf Merkmale von C++, die erfahrungsgemäß zu Fehlern
verleiten, z. B.:
• Pointer-Arithmetik
• Benutzerdefiniertes Überladen von Operatoren
• Mehrfachvererbung
1
https://www.oracle.com/technetwork/java/javase/javaclientroadmapupdatev2020may-6548840.pdf
2
https://www.bmi.bund.de/SharedDocs/downloads/DE/publikationen/themen/it-digitalpolitik/bsi-lagebericht-
2018.pdf?__blob=publicationFile&v=3
32 Kapitel 1 Einleitung
Außerdem werden die Programmierer zu einer systematischen Behandlung der bei einem Metho-
denaufruf potentiell zu erwartenden Ausnahmefehler gezwungen.
Schließlich leistet die hohe Qualität der Java-Standardbibliothek einen Beitrag zur Stabilität der
Software.
1.3.6.5 Einfachheit
Schon im Zusammenhang mit der Robustheit wurden einige komplizierte und damit fehleranfällige
C++ - Bestandteile erwähnt, auf die Java bewusst verzichtet. Zur Vereinfachung trägt auch bei, dass
Java keine Header-Dateien benötigt, weil die Bytecode-Datei einer Klasse alle erforderlichen Me-
tadaten enthält. Weiterhin kommt Java ohne Präprozessor-Anweisungen aus, die in C++ den
Quellcode vor der Übersetzung modifizieren oder die Arbeitsweise des Compilers beeinflussen
können.1
Wenn man dem Programmierer eine Aufgabe komplett abnimmt, kann er dabei keine Fehler ma-
chen. In diesem Sinn wurde in Java der sogenannte Garbage Collector (dt.: Müllsammler) imple-
mentiert, der den Speicher nicht mehr benötigter Objekte automatisch freigibt. Im Unterschied zu
C++, wo die Freigabe durch den Programmierer zu erfolgen hat, sind damit typische Fehler bei der
Speicherverwaltung ausgeschlossen:
• Ressourcenverschwendung durch überflüssige Objekte (Speicherlöcher)
• Programmabstürze beim Zugriff auf voreilig entsorgte Objekte
Insgesamt ist Java im Vergleich zu C/C++ deutlich einfacher zu beherrschen und damit für Einstei-
ger eher zu empfehlen.
Es existieren mehrere hochwertige Java-Entwicklungsumgebungen (z. B. Eclipse, IntelliJ IDEA,
NetBeans), die das Erstellen des Quellcodes erleichtern (z. B. durch Vorschläge zur Code-
Erweiterung, Refaktorierung), Beiträge zur Qualitätssteigerung leisten (z. B. durch Code-Analyse,
Testunterstützung) und meist kostenlos verfügbar sind.
1.3.6.6 Multithreading
Java bietet eine gute Unterstützung für Anwendungen mit mehreren, parallel laufenden Ausfüh-
rungsfäden (Threads). Solche Anwendungen bringen erhebliche Vorteile für den Benutzer, der z. B.
mit einem Programm interagieren kann, während es im Hintergrund aufwändige Berechnungen aus-
führt oder auf die Antwort eines Netzwerk-Servers wartet. Andererseits kommt es vor, dass zwar
nur eine Aufgabe ansteht (z. B. Transkodieren eines Films, Überprüfung vieler Dateien auf Schäd-
linge), dabei jedoch durch Beteiligung mehrerer Threads eine erhebliche Beschleunigung im Ver-
gleich zum traditionellen Single-Thread-Betrieb erzielt werden kann. Weil mittlerweile Mehrkern-
bzw. Mehrprozessor-Systeme üblich sind, wird für Programmierer die Multithreading-
Beherrschung immer wichtiger.
Die zur Erstellung nebenläufiger Programme attraktive funktionale Programmierung wird in Java
seit der Version 8 unterstützt.
1
Der Gerüchten zufolge im früher verbreiteten Textverarbeitungsprogramm StarOffice (Vorläufer der Open Source
Programme OpenOffice und LibreOffice) über eine Präprozessor-Anweisung realisierte Unfug, im Quellcode den
Zugriffsmodifikator private vor der Übergabe an den Compiler durch die schutzlose Alternative public zu ersetzen,
ist also in Java ausgeschlossen.
Abschnitt 1.4 Übungsaufgaben zum Kapitel 1 33
1.3.6.7 Performanz
Der durch Sicherheit (z. B. Bytecode-Verifikation), Stabilität (z. B. Garbage Collector) und Portabi-
lität verursachte Performanznachteil von Java-Programmen (z. B. gegenüber C++) ist durch die
Entwicklung leistungsfähiger virtueller Java-Maschinen mittlerweile weitgehend irrelevant gewor-
den, wenn es nicht gerade um performanzkritische Anwendungen (z. B. Spiele) geht. Mit unserer
Entwicklungsumgebung IntelliJ IDEA werden Sie eine komplett in Java erstellte, sehr komplexe
und dabei flott agierende Anwendung kennenlernen.
1.3.6.8 Beschränkungen
Wie beim Designziel der Plattformunabhängigkeit nicht anders zu erwarten, lassen sich in Java-
Programmen sehr spezielle Eigenschaften eines Betriebssystems schlecht verwenden (z. B. die
Windows-Registrierungsdatenbank). Wegen der Einschränkungen beim freien Speicher- bzw.
Hardware-Zugriff eignet sich Java außerdem kaum zur Entwicklung von Treiber-Software (z. B. für
eine Grafikkarte). Für System- bzw. Hardware-nahe Programme ist z. B. C (bzw. C++) besser ge-
eignet.
1
https://jaxenter.de/java-jdk-release-zyklus-75402
36 Kapitel 2 Werkzeuge zum Entwickeln von Java-Programmen
Eine OpenJDK-Distribution (von Oracle oder von einem alternativen Anbieter, siehe Abschnitt
2.1.2) enthält u. a. (im bin-Unterordner) …
• den Java-Compiler javac.exe, der Java-Quellcode in Java-Bytecode übersetzt
• den Java-Interpreter java.exe, der Java-Programme ausführt (Bytecode in Maschinencode
übersetzt)
• zahlreiche Werkzeuge (z. B. den Dokumentationsgenerator javadoc.exe und den Archivge-
nerator jar.exe)
Abschnitt 2.2 Java-Entwicklung mit dem JDK und einem Texteditor 37
2.2.1 Editieren
Um das Erstellen, Übersetzen und Ausführen von Java-Programmen ohne großen Aufwand üben zu
können, erstellen wir das unvermeidliche Hallo-Programm, das vom bereits erwähnten POO-Typ
ist (pseudo-objektorientiert):
Quellcode Ausgabe
class Hallo { Hallo allerseits!
public static void main(String[] args) {
System.out.println("Hallo allerseits!");
}
}
Im Unterschied zu hybriden Programmiersprachen wie C++ und Delphi, die neben der objektorien-
tierten auch die strukturierte Programmierung (vgl. Abschnitt 4.1.2) erlauben, verlangt Java auch
für solche Trivialprogramme eine Klassendefinition. Im Beispiel genügt eine einzige Klasse, die
den Namen Hallo erhält. Es muss eine startfähige Klasse sein, weil eine solche in jedem Java-
Programm benötigt wird. In der somit erforderlichen Methode main() erzeugt die Klasse Hallo
aber keine Objekte, wie es die Startklasse Bruchaddition im Einstiegsbeispiel tat, sondern be-
schränkt sich auf eine Bildschirmausgabe.
38 Kapitel 2 Werkzeuge zum Entwickeln von Java-Programmen
Immerhin kommt dabei ein vordefiniertes Objekt (System.out) zum Einsatz, das durch Aufruf sei-
ner println() - Methode mit der Ausgabe beauftragt wird. Durch einen Parameter vom Zeichenfol-
gentyp wird der Auftrag beschrieben.
Das POO-Programm eignet sich aufgrund seiner Kürze zum Erläutern wichtiger Regeln, an die Sie
sich so langsam gewöhnen sollten. Alle Themen werden aber später noch einmal systematisch und
ausführlich behandelt:
• Nach dem Schlüsselwort class folgt der frei wählbare Klassenname. Hier ist wie bei allen
Bezeichnern zu beachten, dass Java streng zwischen Groß- und Kleinbuchstaben unterschei-
det. Nach einer weitgehend eingehaltenen Konvention beginnt in Java ein Klassenname mit
einem Großbuchstaben.
Weil bei den Klassen der POO-Übungsprogramme im Unterschied zur eingangs vorgestell-
ten Bruch-Klasse eine Nutzung durch andere Klassen nicht in Frage kommt, wird in der
Klassendefinition auf den Modifikator public verzichtet. Manche Autoren von Java-
Beschreibungen entscheiden sich für die systematische Verwendung des public-
Modifikators, z. B.:
public class Hallo {
public static void main(String[] args) {
System.out.println("Hallo allerseits!");
}
}
Das vorliegende Manuskript orientiert sich am Verhalten der Java-Urheber: Gosling et al.
(2021) lassen bei Startklassen, die nur von der JVM angesprochen werden, den Modifikator
public systematisch weg. Später werden klare und unvermeidbare Gründe für die Verwen-
dung des Klassen-Modifikators public beschrieben.
• Dem Kopf der Klassendefinition folgt der mit geschweiften Klammern eingerahmte Rumpf.
• Weil die Klasse Hallo startfähig sein soll, muss sie eine Methode namens main() besitzen.
Diese wird von der JVM beim Programmstart ausgeführt und dient bei „echten“ OOP-
Programmen (direkt oder indirekt) dazu, Objekte zu erzeugen (siehe die Klasse Bruchad-
dition im Abschnitt 1.1.4).
• Die Definition der Methode main() wird von drei obligatorischen Schlüsselwörtern einge-
leitet, deren Bedeutung Sie auch jetzt schon (zumindest teilweise) verstehen können:
o public
Wie eben erwähnt, wird die Methode main() beim Programmstart von der JVM ge-
sucht und ausgeführt. Sie muss den Zugriffsmodifikator public erhalten. Anderen-
falls reagiert die JVM auf den Startversuch mit einer Fehlermeldung:
o static
Mit diesem Modifikator wird main() als statische, d .h. der Klasse zugeordnete Me-
thode gekennzeichnet. Im Unterschied zu den Instanzmethoden der Objekte werden
die statischen Methoden von der Klasse selbst ausgeführt. Die beim Programmstart
automatisch ausgeführte main() - Methode der Startklasse muss auf jeden Fall durch
den Modifikator static als Klassenmethode gekennzeichnet werden.
Abschnitt 2.2 Java-Entwicklung mit dem JDK und einem Texteditor 39
o void
Die Methode main() hat den Rückgabetyp void, weil sie keinen Rückgabewert lie-
fert.1
Die beiden Modifikatoren public und static stehen in beliebiger Reihenfolge am Anfang der
Methodendefinition. Ihnen folgt der Rückgabetyp, der unmittelbar vor dem Methodennamen
stehen muss.
• Die Namen von Methoden starten in Java nach einer weitgehend eingehaltenen Konvention
mit einem Kleinbuchstaben.
• Auf den Namen einer Methode folgt durch runde Klammern begrenzt die Parameterliste
mit Daten und Informationen zur Steuerung der Ausführung. Wir werden uns später aus-
führlich mit diesem wichtigen Thema beschäftigen und beschränken uns hier auf zwei Hin-
weise:
o Für Neugierige und/oder Vorgebildete
Der main() - Methode werden über einen Array mit String-Elementen die Spezifika-
tionen übergeben, die der Anwender in der Kommandozeile beim Programmstart an-
gegeben hat. In unserem Beispiel kümmert sich die Methode main() allerdings nicht
um solche Anwenderwünsche.
o Für Alle
Bei einer main() - Methode ist die im Beispiel verwendete Parameterliste obligato-
risch, weil die JVM ansonsten die Methode beim Programmstart nicht erkennt und
mit derselben Fehlermeldung reagiert wie bei einem fehlenden public-Modifikator
(siehe oben). Den Parameternamen (im Beispiel: args) darf man allerdings beliebig
wählen.
• Dem Kopf einer Methodendefinition folgt der mit geschweiften Klammern eingerahmte
Rumpf mit Variablendeklarationen und sonstigen Anweisungen. Das minimalistische Bei-
spielprogramm beschränkt sich auf eine einzige Anweisung, die einen Methodenaufruf ent-
hält.
• In der main() - Methode unserer Hallo-Klasse wird die println() - Methode des vordefi-
nierten Objekts System.out dazu benutzt, einen Text an die Standardausgabe zu senden. Der
Auftrag geht an das statische Objekt out in der Klasse System. Zwischen der Objektbe-
zeichnung System.out und dem Methodennamen println() steht ein Punkt. Bei einem Me-
thodenaufruf handelt es sich um eine Anweisung, die folglich mit einem Semikolon abzu-
schließen ist.
Es dient der Übersichtlichkeit, zusammengehörige Programmteile durch eine gemeinsame Ein-
rücktiefe zu kennzeichnen. Man realisiert die Einrückungen am einfachsten mit der Tabulatortaste,
aber auch Leerzeichen sind erlaubt. Für den Compiler sind die Einrückungen irrelevant.
Schreiben Sie den Quellcode mit einem beliebigen Texteditor, unter Windows z. B. mit Notepad,
und speichern Sie Ihr Quellprogramm unter dem Namen Hallo.java in einem geeigneten Verzeich-
nis, z. B. in
U:\Eigene Dateien\Java\BspUeb\Einleitung\Hallo\JDK
Beachten Sie bitte:
1
Die Programmiersprachen C, C++ und C# besitzen ebenfalls eine Funktion bzw. Methode namens main() bzw.
Main(), und dort wird (optional) der Rückgabetype int verwendet, der beim Verlassen des Programms die Übergabe
eines Returncodes an das Betriebssystem erlaubt. In Java hat die main() - Methode obligatorisch den Rückgabetyp
void, doch kann z. B. mit der statischen Methode exit() der Klasse System ein Returncode an das Betriebssystem
übergeben werden (siehe Abschnitt 11.2).
40 Kapitel 2 Werkzeuge zum Entwickeln von Java-Programmen
2.2.2 Übersetzen
Öffnen Sie ein Konsolenfenster, und wechseln Sie in das Verzeichnis mit dem neu erstellten Quell-
programm Hallo.java. Lassen Sie das Programm vom Compiler javac.exe im OpenJDK 17 über-
setzen, das wir im Abschnitt 2.1.1 installiert haben, z. B.:
>"C:\Program Files\Java\OpenJDK-17\bin\javac" Hallo.java
Falls beim Übersetzen keine Probleme auftreten, dann meldet sich der Rechner nach kurzer Ar-
beitszeit mit einem neuer Kommandoaufforderung zurück, und die Quellcodedatei Hallo.java er-
hält Gesellschaft durch die Bytecode-Datei Hallo.class, z. B.:
1
Bei der Software-Entwicklung mit IntelliJ IDEA wirkt sich ein Verzicht auf den PATH-Eintrag nicht aus.
Abschnitt 2.2 Java-Entwicklung mit dem JDK und einem Texteditor 41
Die Quellcodedatei Bruch.java mit den im Programm falsch dargestellten Umlauten verwendet die
(in Windows 10 voreingestellte) UTF-8 - Codierung. Per Voreinstellung geht der Java-Compiler im
OpenJDK aber von der traditionell in Windows voreingestellten ANSI-Codierung aus (korrekte
Bezeichnung: Windows-1252). Durch die Compiler-Option -encoding kann der Compiler über die
tatsächlich verwendete UTF-8 - Codierung informiert werden:
>javac -encoding utf8 *.java
Nach dieser Übersetzung ist das Problem behoben:
42 Kapitel 2 Werkzeuge zum Entwickeln von Java-Programmen
Eine weitere Beschäftigung mit der Syntax von javac.exe ist nicht erforderlich, weil wir komplex-
ere Projekte mit Hilfe unserer Entwicklungsumgebung IntelliJ erstellen lassen. Wenn dabei in den
Erstellungsprozess eingegriffen werden soll, dann bietet sich die Verwendung eines Erstellungs-
werkzeugs wie Ant, Gradle oder Maven an. Im Manuskript werden diese Werkzeuge aber nicht
behandelt.
2.2.3 Ausführen
Wie Sie bereits wissen, wird zum Ausführen von Java-Programmen eine Java Virtual Machine
(JVM) mit dem Interpreter java.exe und der Standardklassenbibliothek benötigt. Aufgrund der
2019 von der Firma Oracle geänderten Lizenzpolitik ist oft auf dem Rechner eines Anwenders eine
OpenJDK-Distribution installiert. Diese enthält auch eine JVM, sodass aus Anwendersicht eine
OpenJDK-Installation äquivalent ist zur früher üblichen Installation einer puren Ausführungsumge-
bung (JRE).
Lassen Sie das Programm (bzw. die Klasse) Hallo.class von der JVM ausführen. Der Aufruf
>java Hallo
sollte zum folgenden Ergebnis führen:
• Weil beim Programmstart der Klassenname anzugeben ist, muss die Groß-/Kleinschreibung
mit der Klassendeklaration übereinstimmen (auch unter Windows!). Java-Klassennamen be-
ginnen meist mit großem Anfangsbuchstaben, und genau so müssen die Namen auch beim
Programmstart geschrieben werden.
Wird java.exe ohne Pfad angesprochen, hängt es von der Windows-Umgebungsvariablen PATH ab,
• ob java.exe gefunden wird,
• welche Version zum Zug kommt, wenn mehrere Versionen von java.exe vorhanden sind.
Abschnitt 2.2 Java-Entwicklung mit dem JDK und einem Texteditor 43
Wie man unter Windows für einen korrekten PATH-Eintrag sorgt, wird im Abschnitt 2.2.2 be-
schrieben.
Seit Java 11 kann der Interpreter aus einer Datei bestehende Programme als Quellcode entgegen-
nehmen, im Hauptspeicher übersetzen und dann ausführen, z. B.:
Diese Option hat aber keine allzu große Bedeutung, weil Java-Programme in der Regel aus vielen
Quellcode-Dateien bestehen.
Bei einer Verzeichnisangabe sind Unterverzeichnisse nicht einbezogen. Sollten sich z. B. für einen
Compiler- oder Interpreter-Aufruf benötigte Dateien im Ordner U:\Eigene Dateien\Java\lib\sub
befinden, werden sie aufgrund der CLASSPATH-Definition in obiger Dialogbox nicht gefunden.
Wie man unter Windows 10 eine Umgebungsvariable setzen kann, wird im Abschnitt 2.2.2 be-
schrieben.
Befinden sich alle benötigten Klassen entweder in der Standardbibliothek (vgl. Abschnitt 1.3.3)
oder im aktuellen Verzeichnis, dann wird keine CLASSPATH-Umgebungsvariable benötigt. Ist sie
jedoch vorhanden (z. B. von irgendeinem Installationsprogramm unbemerkt angelegt), dann werden
außer der Standardbibliothek nur die Pfade in der CLASSTATH-Definition berücksichtigt. Dies
führt zu Problemen, wenn in der CLASSPATH-Definition das aktuelle Verzeichnis nicht enthalten
ist, z. B.:
In diesem Fall muss das aktuelle Verzeichnis (z. B. dargestellt durch einen einzelnen Punkt, s.o.) in
die CLASSPATH-Pfadliste aufgenommen werden, z. B.:
44 Kapitel 2 Werkzeuge zum Entwickeln von Java-Programmen
In vielen konsolenorientierten Beispielprogrammen des Manuskripts kommt die nicht zum Java-
API gehörige Klasse Simput in der Bytecode-Datei Simput.class (siehe unten) zum Einsatz. Über
die Umgebungsvariable CLASSPATH kann man dafür sorgen, dass der JDK-Compiler und der
Interpreter die Klasse Simput finden. Dies gelingt z. B. unter Windows 10 mit der oben abgebilde-
ten Dialogbox Neue Benutzervariable, wenn Sie die Datei
...\BspUeb\Simput\Standardpaket\Simput.class
in den Ordner U:\Eigene Dateien\Java\lib kopiert haben:
Achten Sie in der Dialogbox Neue Benutzervariable unbedingt darauf, den aktuellen Pfad über
einen Punkt in die CLASSPATH-Definition aufzunehmen.
Unsere Entwicklungsumgebung IntelliJ IDEA ignoriert die CLASSPATH-Umgebungsvariable,
bietet aber eine alternative Möglichkeit zur Definition des Klassenpfads für ein Projekt (siehe Ab-
schnitt 3.4.2).
Wenn sich nicht alle bei einem Compiler- oder Interpreter-Aufruf benötigten class-Dateien im aktu-
ellen Verzeichnis befinden und auch nicht auf die CLASSPATH-Variable vertraut werden soll,
dann können die nach class-Dateien zu durchsuchenden Pfade auch in den Startkommandos über
die Option -classpath (abzukürzen durch -cp) angegeben werden, z. B.:
>javac -cp ".;U:\Eigene Dateien\java\lib" Bruchaddition.java
>java -cp ".;U:\Eigene Dateien\java\lib" Bruchaddition
Auch hier muss das aktuelle Verzeichnis ausdrücklich (z. B. durch einen Punkt) aufgelistet werden,
wenn es in die Suche einbezogen werden soll.
Ein Vorteil der Option -cp gegenüber der Umgebungsvariablen CLASSPATH besteht darin, dass
für jede Anwendung eine eigene Suchliste eingestellt werden kann. Bei Verwendung der Option -cp
wird eine eventuell vorhandene CLASSPATH-Umgebungsvariable für den gestarteten Compiler-
oder Interpreter-Einsatz deaktiviert.
Die eben beschriebene Klassenpfadtechnik ist seit der ersten Java-Version im Einsatz, wird auch in
Java-Versionen mit Modulsystem (ab Version 9) noch unterstützt und genügt unseren vorläufig sehr
bescheidenen Ansprüchen beim Zugriff auf Bibliotheksklassen. Langfristig wird der Klassenpfad
vermutlich durch den mit Java 9 eingeführten Modulpfad ersetzt (siehe Abschnitt 6.2.4).
Abschnitt 2.2 Java-Entwicklung mit dem JDK und einem Texteditor 45
Weil sich der Compiler bereits unmittelbar hinter dem betroffenen Wort sicher ist, dass ein Fehler
vorliegt, kann er die Schadstelle genau lokalisieren:
• In der ersten Fehlermeldungszeile liefert der Compiler den Namen der betroffenen Quell-
codedatei, die Zeilennummer und eine Fehlerbeschreibung.
• Anschließend protokolliert der Compiler die betroffene Zeile und markiert die Stelle, an der
die Übersetzung abgebrochen wurde.
Manchmal wird dem Compiler aber erst in einiger Distanz zur Schadstelle klar, dass ein Regelver-
stoß vorliegt, sodass statt der kritisierten Stelle eine frühere Passage zu korrigieren ist.
Im Beispiel fällt die Fehlerbeschreibung brauchbar aus, obwohl der Compiler (vermutlich aufgrund
des Kleinbuchstabens am Namensanfang) falsch vermutet, dass mit dem verunglückten Bezeichner
ein Paket gemeint sei (vgl. Abschnitt 6.1).
Weil sich in das simple Hallo-Beispielprogramm kaum ein Logikfehler einbauen lässt, betrachten
wir die im Abschnitt 1.1 vorgestellte Klasse Bruch. Wird z. B. in der Methode setzeNenner()
bei der Absicherung gegen Nullwerte das Ungleich-Operatorzeichen (!=) durch sein Gegenteil (==)
ersetzt, dann ist keine Java-Syntaxregel verletzt:
46 Kapitel 2 Werkzeuge zum Entwickeln von Java-Programmen
Ein derart außer Kontrolle geratenes Konsolenprogramm kann man unter Windows z. B. mit der
Tastenkombination Strg+C beenden.
Während der mit IntelliJ 2021.2.3 gestarteten Arbeit an diesem Manuskript werden vermutlich eini-
ge IntelliJ-Updates erscheinen. Auf wesentliche Abhängigkeiten von der IntelliJ-Version wird ggf.
im Manuskript hingewiesen.
Die Systemvoraussetzungen für IntelliJ unter Windows dürfte praktisch jeder Rechner erfüllen:
• 64-Bit-Version von Windows 10 oder 8
• Mindestens 2 GB RAM
• 3,5 GB freier Festplattenspeicher für IntelliJ
• Minimale Display-Auflösung: 1024 x 768
Über einem Klick auf den Download-Schalter zur Community-Edition erhält man unter Windows
einen Installationsassistenten als ausführbares Programm (am 23.10.2021: ideaIC-2021.2.3.exe).
Nach einem Klick auf den daneben stehenden EXE-Schalter erlaubt ein Menü die Wahl zwischen
einem ausführbaren Programm und einem ZIP-Archiv, wobei die erste Variante etwas mehr Be-
quemlichkeit und die zweite Variante etwas mehr Kontrolle bietet.
Nach dem Einstieg über einen Doppelklick auf die heruntergeladene Programmdatei ideaIC-
2021.2.3.exe und einer positiven Antwort auf die UAC-Nachfrage (User Account Control) von
Windows startet der Installationsassistent:
liefern die zu erwartenden Updates. Lässt man diese Updates von IntelliJ durchführen (siehe unten),
dann wird die Installation im vorhandenen Ordner aktualisiert, sodass der voreingestellte Ordner-
name nicht mehr zur Version passt. Daher wird unter Windows der folgende Installationsordner
empfohlen:
48 Kapitel 2 Werkzeuge zum Entwickeln von Java-Programmen
Im Effekt lässt sich per Windows-Explorer ein IntelliJ-Projekt über das Item
Open Folder as IntelliJ IDEA Community Edition Project
im Kontextmenü zu seinem Ordner öffnen.
Nach der wenig relevanten Entscheidung über den Startmenüordner
Zur späteren Kontrolle auf ein eventuell anstehendes Update wählt man in IntelliJ den Menübefehl
Help > Check for Updates
Ggg. erscheint ein Info-Fenster unten rechts, z. B.:
Nach einem Klick auf den Link Update kann man sich über das Update informieren
und seiner Installation über den Schalter Update and Restart zustimmen. Mit dem folgenden
Info-Fenster signalisiert IntelliJ seine Bereitschaft für das Update, das nun mit einem Klick auf den
Link Restart veranlasst werden kann:
Anschließend muss noch auf Nachfrage durch die UAC von Windows eine Änderung des Systems
durch das Programm elevator.exe erlaubt werden.
50 Kapitel 2 Werkzeuge zum Entwickeln von Java-Programmen
Dann bittet JetBrains um die Erlaubnis, diagnostische Informationen zur Verwendung von IntelliJ
anonym übertragen zu dürfen:
Ggf. wird die Übernahme von Einstellungen einer früheren Version angeboten:
Im Manuskript wird das Farbschema IntelliJ Light verwendet, das nach einem Klick auf Cus-
tomize gewählt werden kann:
52 Kapitel 2 Werkzeuge zum Entwickeln von Java-Programmen
Wir klicken auf der Projects-Seite des Welcome-Dialogs auf den Schalter New Project, um mit
dem ersten IntelliJ-Projekt zu beginnen.
Ein Projekt benötigt ein SDK (Software Development Kit), das die Standardbibliothek, den Compi-
ler und die JVM zur Ausführung des Projekts innerhalb der Entwicklungsumgebung bereitstellt.
Weil wir im Abschnitt 1.2.1 die OpenJDK 8 - Distribution der Firma Red Hat und im Abschnitt 2.1
die OpenJDK 17 - Distribution der Firma Oracle installiert haben, stehen uns per Drop-Down-Liste
zwei SDKs zur Verfügung:
• OpenJDK 8 (alias 1.8, mit maximaler Kompatibilität)
• OpenJDK 17 (mit maximaler Aktualität)
Beim ersten Projekt entscheiden wir uns für das OpenJDK 8 (alias 1.8).
Abschnitt 2.4 Java-Entwicklung mit IntelliJ IDEA 53
Bei Groovy und Kotlin handelt es sich um alternative Programmiersprachen für die JVM, an denen
wir in diesem Kurs nicht interessiert sind, sodass wir im Bereich Additional Libraries and
Frameworks keine Markierungen vornehmen.
Im nächsten Dialog markieren wir das Kontrollkästchen Create project from template und ak-
zeptieren die einzige Option (Command Line App) per Next:
Die Wahl dieser Projektvorlage hat zur Folge, dass im entstehenden Projekt automatisch eine zu
unserer Zielsetzung passende Java-Klasse samt main() - Methode angelegt wird, sodass wir an-
schließend etwas Aufwand sparen.
Wir wählen einen Projektnamen, übernehmen den resultierenden Projektordner und verzichten auf
ein Basispaket, z. B.:1
Nach einem Klick auf Finish erscheint die Entwicklungsumgebung, ist aber noch ein Weilchen mit
Projektvorbereitungsarbeiten beschäftigt (siehe Fortschrittsbalken zum Indizieren in der Statuszei-
le):
1
Durch den Verzicht auf ein Basispaket ergibt sich eine einfache Lernumgebung. Soll ein Programm veröffentlicht
werden, ist ein Basispaket sehr zu empfehlen (siehe Kapitel 6).
54 Kapitel 2 Werkzeuge zum Entwickeln von Java-Programmen
Schließlich ist IntelliJ einsatzbereit und präsentiert im Editor den basierend auf der Vorlagendefini-
tion (Command Line App) erstellten Quellcode der Klasse Main mit bereits vorhandener Start-
methode main():
1
In IntelliJ IDEA konnten Projekte schon immer mehrere Module enthalten, wobei diese Module im Sinne der Ent-
wicklungsumgebung nicht verwechselt werden dürfen mit den seit Java 9 vorhandenen Modulen der Programmier-
sprache (siehe Abschnitt 6.2). Letztere ergänzen die Pakete durch eine zusätzliche Ebene zur Zusammenfassung und
Abschottung von Java-Typen.
Abschnitt 2.4 Java-Entwicklung mit IntelliJ IDEA 55
o .idea
Dieser Ordner enthält die projekt-bezogene Einstellungen in mehreren XML-
Dateien.
o src
In diesem Ordner befinden sich die Quellcodedateien des primären Moduls.
o HalloIntelliJ.iml
In dieser Datei befindet sich die Konfiguration des primären Moduls.
• External Libraries
Als externe Bibliothek verwendet unser Projekt nur die Standardbibliothek aus dem einge-
stellten SDK.
Weitere Projekte lassen sich entweder über den IntelliJ-Startdialog oder über den folgenden Menü-
befehl anlegen:
Start > New > Project
2.4.3 Quellcode-Editor
Um das von IntelliJ erstellte Programm zu vollenden, müssen wir im Editor noch die Ausgabean-
weisung
System.out.println("Hallo allerseits!");
verfassen (vgl. Abschnitt 2.2.1).
2.4.3.1 Syntaxerweiterung
Dabei ist die Syntaxerweiterung von IntelliJ eine große Hilfe. Wir löschen den aktuellen Inhalt der
Zeile 4 (einen Kommentar), nehmen durch zwei Tabulatorzeichen (Taste ) eine Einrückung
1
vor und beginnen, den Klassennamen System zu schreiben. IntelliJ IDEA erkennt unsere Ab-
sicht und präsentiert eine Liste möglicher Erweiterungen, in der die am besten passende Erweite-
rung hervorgehoben und folglich per Enter-Taste wählbar ist:
Sobald wir einen Punkt hinter den Klassennamen System setzen, erscheint eine neue Liste mit allen
zulässigen Fortsetzungen, wobei wir uns im Beispiel für die Klassenvariable out entscheiden, die
auf ein Objekt der Klasse PrintStream zeigt:2
1
Bei Bedarf lassen sich die Zeilennummern folgendermaßen einschalten:
File > Settings > Editor > General > Appearance > Show line numbers
2
In der ersten Vorschlagsliste mit Bestandteilen der Klasse System erscheint die von uns häufig benötigte Klassenva-
riable out noch nicht an der bequemen ersten Position, doch passt sich IntelliJ schnell an unsere Gewohnheiten an.
56 Kapitel 2 Werkzeuge zum Entwickeln von Java-Programmen
Wir übernehmen das Ausgabeobjekt per Enter-Taste oder Doppelklick und setzen einen Punkt hin-
ter seinen Namen (out). Jetzt werden u. a. die Instanzmethoden der Klasse PrintStream aufgelistet,
und wir wählen die Variante (spätere Bezeichnung: Überladung) der Methode println() mit einem
Parameter vom Typ String, die sich zur Ausgabe einer Zeichenfolge eignet:
Ein durch doppelte Hochkommata begrenzter Text komplettiert den println() - Methodenaufruf,
den wir objektorientiert als Nachricht an das Objekt System.out auffassen.
Die Syntaxerweiterung von IntelliJ macht Vorschläge für Variablen, Typen, Methoden usw. Sollte
sie nicht spontan tätig werden, kann sie mit der folgenden Tastenkombination angefordert werden:
Strg + Leertaste
Soll mit Hilfe der Syntaxerweiterung eine Anweisung nicht fortgesetzt, sondern geändert werden,
dann quittiert man einen Vorschlag nicht per Enter-Taste oder Doppelklick, sondern per Tabulator-
taste ( ). Auf diese Weise wird z. B. ein Methodenname ersetzt, in dem sich die Einfügemarke
gerade befindet, statt durch Einfügen des neuen Namens ein fehlerhaftes Gebilde zu erzeugen.
Mit der Tastenkombination
Strg + Umschalt + Enter
fordert man die Vervollständigung einer Anweisung an, z. B.:
Abschnitt 2.4 Java-Entwicklung mit IntelliJ IDEA 57
vorher nachher
Obwohl das vervollständigte Programm auf der rechten Seite fehlerfrei ist, unterschlängelt IntelliJ
das Wort „allerseits“. Kommentare, Klassennamen etc. werden per Voreinstellung von der Entwick-
lungsumgebung auf die Einhaltung der englischen Rechtschreibung überprüft. Wir schreiben Deng-
lisch (mal deutsch, mal englisch) und kümmern uns nicht um die Kritik an unserer Orthographie.
Zeigt die Maus auf die Birne, kann ein Drop-Down - Menü mit Korrekturvorschlägen geöffnet wer-
den, z. B.:
Statt das Drop-Down - Menü zur gelben Birne zu öffnen, kann man auch die Einfügemarke auf den
markierten Syntaxbestandteil setzen und die Tastenkombination Alt + Enter betätigen, um dieselbe
Vorschlagsliste zu erhalten. Im Beispiel muss der Name der Methode main() klein geschrieben
werden, damit sie als Startmethode akzeptiert wird.
Wenn IntelliJ IDEA einen Syntaxfehler findet, dann erscheint eine rote Birne links neben der be-
troffenen, durch rote Schrift markierten Stelle, z. B.:
Zeigt die Maus auf die Birne, kann ein Drop-Down - Menü mit Korrekturvorschlägen geöffnet wer-
den, z. B.:
58 Kapitel 2 Werkzeuge zum Entwickeln von Java-Programmen
Statt das Drop-Down - Menü zur roten Birne zu öffnen, kann man auch die Einfügemarke auf den
rot gefärbten Syntaxbestandteil setzen und die Tastenkombination Alt + Enter betätigen, um die-
selbe Vorschlagsliste zu erhalten. Im Beispiel muss der Name der Klassenvariablen out korrekt
geschrieben werden.
die Alternative sout per Enter-Taste, per Doppelklick oder per Tabulatortaste ( ). Daraufhin
erstellt IntelliJ einen Methodenaufruf, den Sie nur noch um die auszugebende Zeichenfolge erwei-
tern müssen:
Wenn Sie die Vorlagenbezeichnung sout komplett eintippen, präsentiert IntelliJ eine Vorschlagslis-
te mit dem passenden Element in führender Position:
Abschnitt 2.4 Java-Entwicklung mit IntelliJ IDEA 59
Um die Anweisung
System.out.println();
zu erstellen, müssen Sie also nur sout schreiben und den markierten Vorschlag per Enter-Taste, per
Doppelklick oder per Tabulatortaste ( ) übernehmen.
Nach
File > Settings > Editor > Live Templates
kann man im folgenden Dialog
die vorhandenen Java-Vorlagen einsehen und konfigurieren sowie neue Vorlagen erstellen.
2.4.3.4 Orientierungshilfen
Zeigt man bei gedrückter Strg-Taste mit dem Mauszeiger auf eine Methode, dann erscheint der
Definitionskopf, z. B.:
60 Kapitel 2 Werkzeuge zum Entwickeln von Java-Programmen
Wenn für das Projekt-SDK ein Pfad zur Online-Dokumentation eingetragen wurde (siehe Abschnitt
2.4.6.1), dann kann man die Dokumentation zu einem API-Bestandteil (z.B. zu einer Klasse oder
Methode) folgendermaßen in einem Browser-Fenster öffnen:
• Einfügemarke auf den interessierenden API-Bestandteil setzen
• Tastenkombination Umschalt + F1
Setzt man bei gedrückter Strg-Taste einen Mausklick auf einen Bezeichner (z. B. Klasse, Variable,
Methode), dann springt IntelliJ zur Implementierung des angefragten Syntaxbestandteils. Nötigen-
falls wird der Quellcode der zugehörigen Klasse in ein neues Registerblatt des Editors geladen,
z. B.:
Um den Quellcode einer beliebigen Klasse aus dem Projekt-SDK anzufordern, trägt man ihren
Namen(sanfang) nach der Tastenkombination Strg + N in das Suchfeld des folgenden Dialogs ein
und wählt (z. B. per Doppelklick) ein Element aus der Liste mit kompatiblen Namen.
2.4.3.5 Refaktorieren
Um z. B. einen Variablen- oder Klassennamen an allen Auftrittsstellen im Projekt über das soge-
nannte Refaktorieren zu ändern, setzt man die Einfügemarke auf ein Vorkommen des Namens,
drückt die Tastenkombination Umschalt + F6, ändert den Namen und quittiert mit der Eingabetas-
te. Im Menüsystem ist die Refaktorierungsfunktion hier zu finden:
Refactor > Rename
Die im Refactor-Menü zahlreich vorhandenen weiteren IntelliJ-Kompetenzen zur Quellcode-
Umgestaltung werden wir im Kurs nicht benötigen.
Zum Starten klicken wir auf den grünen Run-Schalter neben der Konfiguration oder verwenden die
Tastenkombination
Umschalt + F10
IntelliJ verwendet per Voreinstellung den Compiler im Projekt-SDK, um im Beispiel aus der Quell-
codedatei Main.java die Bytecode-Datei Main.class zu erstellen:
62 Kapitel 2 Werkzeuge zum Entwickeln von Java-Programmen
Nach der Übersetzung, die im Rahmen einer Projekterstellung stattfindet, erscheint das Run - Fens-
ter von IntelliJ:
Es zeigt in der Statuszeile das Erstellungsergebnis mit Zeitaufwand und im Inhaltsbereich ...
• die ausführende Laufzeitumgebung (JVM),1
• die Ausgabe des Programms
• und den Exit-Code, wobei die 0 für eine fehlerfreie Ausführung steht.
Der beim Erstellen erzeugte Ausgabeordner wird im Project-Fenster angezeigt:
1
Das zum Starten des Programms verwendete Kommando verrät, dass ein sogenannter Java-Agent im Spiel ist, wenn
das Programm innerhalb der Entwicklungsumgebung ausgeführt wird:
Er kann Daten über das Programm sammeln (z. B. zum Speicherbedarf von Objekten) und der Entwicklungsumge-
bung zur Verfügung stellen. Darum müssen wir uns im Augenblick nicht kümmern.
Abschnitt 2.4 Java-Entwicklung mit IntelliJ IDEA 63
Öffnet man das Build-Fenster per Mausklick auf die gleichnamige Schaltfläche über der Statuszei-
le, dann erfährt man u. a., dass IntelliJ den Compiler javac.exe aus dem OpenJDK benutzt hat:
Bei der Ausführung einer unveränderten Quelle ist keine Übersetzung erforderlich, und im Build
Output wird dementsprechend keine Compiler-Version angezeigt. Um in dieser Lage eine Doku-
mentation des Compilers im Build Output zu erhalten, kann man ...
• vor der nächsten Ausführung den Quellcode modifizieren
• oder mit dem Menübefehl Build > Rebuild Project eine Übersetzung erzwingen.
Um zu einem vorherigen Zustand zurückzukehren, wählt man aus seinem Kontextmenü das Item
Revert.
64 Kapitel 2 Werkzeuge zum Entwickeln von Java-Programmen
2.4.6 Konfiguration
Von den zahlreichen Einstellungsmöglichkeiten in unserer Entwicklungsumgebung wird anschlie-
ßend nur eine kleine Auswahl beschrieben.
Über diesen Dialog können aber auch SDKs konfiguriert oder ergänzt werden. Es ist z. B. sinnvoll,
die vorhandenen SDKs so zu benennen (siehe Bildschirmfoto), dass die Kursbeispiele problemlos
geöffnet werden können (vgl. Abschnitt 2.4.7).
Außerdem sollte zu jedem SDK eine Internet-Adresse mit der offiziellen API-Beschreibung als
Documentation Path eingetragen werden. Klickt man bei aktiver Registerkarte Documenta-
tion Paths auf den Schalter mit Plussymbol und Weltkugel, dann erscheint ein Fenster mit ei-
nem Textfeld für die Dokumentationsadresse. Beim OpenJDK 17 bewährt sich z. B. der folgende
Eintrag:1
1
Als Text für die Übernahme per Copy & Paste:
OpenJDK 8: https://docs.oracle.com/javase/8/docs/api/
OpenJDK 11: https://docs.oracle.com/en/java/javase/11/docs/api/
OpenJDK 17: https://docs.oracle.com/en/java/javase/17/docs/api/
Abschnitt 2.4 Java-Entwicklung mit IntelliJ IDEA 65
kann IntelliJ bei der Arbeit mit dem Quellcode-Editor nach der Tastenkombination Umschalt +
F1 zu dem die Einfügemarke enthaltenen Java-Bezeichner die API-Dokumentation in einem exter-
nen Browser-Fenster liefern.
Wir verwenden im Kurs meist ...
• das gemäß Abschnitt 1.2.1 installierte OpenJDK 8, wenn minimale Voraussetzungen bzgl.
der Laufzeitumgebung erwünscht sind,
• das gemäß Abschnitt 2.1.1 installierte OpenJDK 17, wenn alle aktuellen Java-
Sprachmerkmale genutzt werden sollen.
Die in IntelliJ 2021.2 enthaltene OpenJDK-Version 11.0.12 mit dem Startverzeichnis
C:\Program Files\JetBrains\IntelliJ IDEA Community Edition 2021.2\jbr
kann ebenfalls als SDK für Projekte verwendet werden. Damit stehen uns die drei momentan ver-
fügbaren LTS-Version von Java (8, 11 und 17) als SDKs zur Verfügung. Wenn Sie in Ihrer IntelliJ-
Installation SDKs mit diesen Hauptversionen und mit den Namen OpenJDK8, OpenJDK11 sowie
OpenJDK17 einrichten, dann sollten Sie alle im Kurs angebotenen Beispielprojekte problemlos in
IntelliJ öffnen können.
Um das in IntelliJ enthaltene OpenJDK 11 als SDK - Option für neue Projekte zu vereinbaren, kli-
cken wir auf das - Symbol am oberen Fensterrand und wählen den Typ JDK:
Wir vereinbaren den SDK-Namen OpenJDK 11 und nötigenfalls den folgenden Documentation
Path
https://docs.oracle.com/en/java/javase/11/docs/api/
Hier lassen sich diverse Einstellungen modifizieren, die sich entweder auf die Entwicklungsumge-
bung oder auf das aktuelle Projekt beziehen. In der Abteilung Editor gehört z. B. die Schriftart
(Font) zu den IDE-Einstellungen und die Dateicodierung (File Encodings) zu den Projekt-
Einstellungen, die am Symbol zu erkennen sind:
IntelliJ verwendet unter Windows für Java-Quellcodedateien per Voreinstellung die UTF-8 - Codie-
rung (ohne Byte Order Mark, BOM), sodass bei der Übertragung der Dateien auf einen Entwick-
lungsrechner mit einem anderen Betriebssystem (macOS, Linux oder UNIX) keine Codierungsin-
kompatibilität stört.
Nach
File > Settings > Editor > File Encodings
wird bei einem neuen Projekt windows-1252 als Project Encoding angezeigt:
Für die im neuen Projekt entstehenden Java-Quellcodedateien wird aber trotzdem die UTF-8 - Co-
dierung verwendet. Damit bei neuen Projekten UTF-8 als Project Encoding erscheint,
68 Kapitel 2 Werkzeuge zum Entwickeln von Java-Programmen
muss nach
File > New Projects Setup > Settings for New Projects > Editor > File Encodings
die gewünschte Einstellung bei Project Encoding vorgenommen werden:
Nach
File > Settings > Build, Execution, Deployment > Compiler > Java-Compiler
kann im folgenden Dialog z. B. für das aktuelle Projekt der voreingestellte Compiler javac.exe aus
dem Projekt-SDK durch den Compiler aus der Open Source - Entwicklungsumgebung Eclipse er-
setzt werden:
Abschnitt 2.4 Java-Entwicklung mit IntelliJ IDEA 69
Die IDE-Konfiguration zu IntelliJ 2021.2.x für den Windows-Benutzer otto landet im folgenden
Ordner:
C:\Users\otto\AppData\Roaming\JetBrains
Weitere benutzer-bezogene Einstellungen befinden sich im Ordner:
C:\Users\otto\AppData\Local\JetBrains\IdeaIC2021.2
Die Einstellungen zu einem Projekt befinden sich im .idea - Unterordner des Projekts, also z. B. in:
C:\Users\otto\IdeaProjects\Hallo\.idea
Sollte die IDE-Konfiguration einmal außer Kontrolle geraten, kann man über den Menübefehl
File > Manage IDE Settings > Restore Default Settings
den Ausgangszustand wiederherstellen:
Wird in dieser Situation nach dem Beenden von IntelliJ der Ordner
...\Appdata\Local\JetBrains
gelöscht, dann lässt sich das neue Projekt mit dem gewünschten alten Namen fehlerfrei anlegen.
Man verliert dabei die von IntelliJ benötigten und bei Bedarf automatisch erstellten Indizes zu den
in Projekten verwendeten JDKs, sodass für jeden neu zu erstellenden Index ca. eine Minute Warte-
zeit anfällt.
In diesem Zusammenhang ist das IntelliJ-Angebot zur Beschleunigung der Indizierung durch das
Herunterladen gemeinsamer Indizes zu erwähnen:
70 Kapitel 2 Werkzeuge zum Entwickeln von Java-Programmen
In der Community Edition von IntelliJ können die pre-built shared indices allerdings nur 30
Tage lang genutzt werden.1 Weil ein in mehreren Projekten verwendetes JDK nur einmal indiziert
werden muss, können wir problemlos auf die pre-built shared indices verzichten.
Diese Einstellung bleibt allerdings ohne Effekt, wenn IntelliJ in einer bereits vorhandenen Datei
durch Leerzeichen realisierte Einrückungen antrifft. Soll die Tabulatortaste auch dort ein Tabulator-
zeichen produzieren, muss im Abschnitt
Editor > Code Style
des Settings-Dialogs das Kontrollkästchen bei Detect and use existing file indents for edit-
ing entfernt werden:
1
https://www.jetbrains.com/help/idea/shared-indexes.html#plugin-note
Abschnitt 2.5 OpenJFX und Scene Builder installieren 71
Gerade wurden Einstellungen zur Tabulatorbehandlung bei neuen Projekten beschrieben. Für vor-
handene Projekte sind nach
File > Settings > Editor > Code Style
analoge Einstellungen möglich.
1
Im Manuskript werden die Bezeichnungen JavaFX und OpenJFX synonym verwendet.
72 Kapitel 2 Werkzeuge zum Entwickeln von Java-Programmen
Wie es der Bestandteil windows im Namen der heruntergeladenen Datei vermuten lässt, sind die
mit diesem SDK erstellten JavaFX-Programme wegen der enthaltenen nativen Bibliotheken (DLL-
Dateien) nur unter Windows zu verwenden. Um eine Multi-Plattform - JavaFX-Anwendung zu er-
stellen, muss man auch die SDK-Varianten für Linux und macOS herunterladen.
Weil die gemäß Abschnitt 1.2.1 installierte OpenJDK 8 - Distribution aus dem Open Source - Pro-
jekt ojdkbuild ein OpenJFX-SDK enthält, können wir JavaFX-Anwendungen für die LTS-
Versionen Java 8 und 17 erstellen.
Beim Einsatz der JavaFX-Technik wird die Bedienoberfläche in der Regel in einer FXML-Datei
deklariert. Deren Gestaltung wird erheblich erleichtert durch das unter der BSD-Lizenz stehende
Programm Scene Builder, das von der Firma Oracle entwickelt wurde und mittlerweile von der
Firma Gluon gepflegte wird. Es steht auf der folgenden Webseite zur Verfügung:
https://gluonhq.com/products/scene-builder/
Aktuell (im Oktober 2021) werden die Version 8.5.0 (für Java 8) sowie die Version 17.0.0 (für Java
ab Version 11) angeboten. Wir beschränken uns auf die Version 17.0.0, wählen das Format
Windows Installer und erhalten somit die Datei SceneBuilder-17.0.0.msi.
Wir starten die Installation per Doppelklick auf diese MSI-Datei und akzeptieren die Lizenzbedin-
gungen:
Es wird eine Installation im Windows-Profil des angemeldeten Benutzers (also mit vorhandenen
Schreibrechten) vorgeschlagen. Das ist akzeptabel, sofern nicht mehrere Personen mit dem Pro-
gramm arbeiten sollen:
Damit IntelliJ IDEA mit dem Scene Builder kooperieren kann, muss nach dem Menübefehl
File > Settings > Languages & Frameworks > JavaFX
der Pfad zum ausführbaren Programm bekanntgegeben werden, z. B.:1
Eine erste Verwendung des Scene Builders werden Sie im Abschnitt 4.9 erleben. Ein Blick auf die
Arbeitsoberfläche des GUI-Designers mit dem geöffneten Fenster des im Abschnitt 1.2.3 vorge-
stellten Bruchadditionsprogramms lässt erkennen, dass wir zur Entwicklung attraktiver Programme
ein modernes Werkzeug zur Verfügung haben:
1
Von Rolf Schwung stimmt der Tipp, bei der Installation und Konfiguration von JavaFX in IntelliJ unter Linux die
Anleitung auf der folgenden Webseite von Michael Kofler zu beachten:
https://kofler.info/java-11-javafx-intellij-idea-und-linux/
74 Kapitel 2 Werkzeuge zum Entwickeln von Java-Programmen
2) Experimentieren Sie mit dem Hallo-Beispielprogramm aus dem Abschnitt 2.2.1, z. B. indem Sie
weitere Ausgabeanweisungen ergänzen.
3.1 Einstieg
1
Unsere Entwicklungsumgebung IntelliJ verwendet unter Windows für Quellcodedateien per Voreinstellung die
UTF-8 - Codierung (ohne Byte Order Mark, BOM), sodass bei der Übertragung der Dateien auf einen Entwick-
lungsrechner mit einem anderen Betriebssystem (macOS, Linux oder UNIX) keine Codierungsinkompatibilität stört.
Eine Änderung der Codierung ist möglich über:
File > Settings > Editor > File Encodings
78 Kapitel 3 Elementare Sprachelemente
Wäre die Klasse Prog nicht in einer Datei namens Prog.java untergebracht, dann würde das Pro-
ject-Fenster beide Namen anzeigen, z. B.:
Das Symbol zur Klasse Prog enthält übrigens ein grünes Dreieck in der rechten oberen Ecke ( ),
weil diese Klasse startfähig ist.
Zum Üben elementarer Sprachelemente werden wir im Rumpf der main() - Methode passende An-
weisungen einfügen, z. B.:
Über das Symbol oder die Tastenkombination Umschalt + F10 lassen wir das Programm über-
setzen und ausführen:
80 Kapitel 3 Elementare Sprachelemente
Dabei wird die vorgegebene Ausführungskonfiguration verwendet. Wenn wir das Drop-Down -
Menü zur Ausführungskonfiguration öffnen und das Item Edit Configuration
wählen, dann stellt sich heraus, dass IntelliJ beim Refaktorieren auch die Start- bzw. Hauptklasse
(mit der Methode main()) angepasst hat. Man kann die Ausführungskonfiguration umbenennen
oder weitere Konfigurationen anlegen (z. B. mit Kommandozeilenargumenten):
3.1.3 Syntaxdiagramme
Um für Java-Sprachbestandteile (z. B. Definitionen oder Anweisungen) die Bildungsvorschriften
kompakt und genau zu beschreiben, werden wir im Manuskript u. a. sogenannte Syntaxdiagramme
einsetzen, für die folgende Vereinbarungen gelten:
Abschnitt 3.1 Einstieg 81
• Man bewegt sich in Pfeilrichtung durch das Syntaxdiagramm und gelangt dabei zu Recht-
ecken, die die an der jeweiligen Stelle zulässigen Sprachbestandteile angeben, wie z. B. im
folgenden Syntaxdiagramm zum Kopf einer Klassendefinition:
class Name
Modifikator
• Bei einer Verzweigung kann man sich für eine Richtung entscheiden, wenn nicht per Pfeil
eine Bewegungsrichtung vorgeschrieben ist. Zulässige Realisationen zum obigen Segment
sind also z. B.:
o class Bruchaddition
o public class Bruch
Verboten sind hingegen z. B. die folgenden Sequenzen:
o class public Bruchaddition
o Bruchaddition public class
• Für konstante (terminale) Sprachbestandteile, die aus einem Rechteck exakt in der angege-
benen Form in konkreten Quellcode zu übernehmen sind, wird fette Schrift verwendet.
• Platzhalter sind an kursiver Schrift zu erkennen. Im konkreten Quellcode muss anstelle des
Platzhalters eine zulässige Realisation stehen, und die zugehörigen Bildungsregeln sind an
anderer Stelle (z. B. in einem anderen Syntaxdiagramm) erklärt.
• Als Klassenmodifikator ist uns bisher nur der Zugriffsmodifikator public begegnet, der für
die allgemeine Verfügbarkeit einer Klasse sorgt. Später werden wir noch weitere Klassen-
modifikatoren kennenlernen. Sicher kommt niemand auf die Idee, z. B. den Modifikator
public mehrfach zu vergeben und damit gegen eine Java-Syntaxregel zu verstoßen. Das obi-
ge (möglichst einfach gehaltene) Syntaxdiagrammsegment lässt diese offenbar sinnlose Pra-
xis zu. Es bieten sich zwei Lösungen an:
o Das Syntaxdiagramm mit einem gesteigerten Aufwand präzisieren
o Durch eine generelle Regel die Mehrfachverwendung eines Modifikators verbieten
Im Manuskript wird die zweite Lösung verwendet.
Als Beispiele betrachten wir anschließend die Syntaxdiagramme zur Definition von Klassen und
Methoden. Aus didaktischen Gründen zeigen die Diagramme nur solche Sprachbestandteile, die im
Beispielprogramm von Abschnitt 1.1 (mit der Klasse Bruch) verwendet wurden. Durch den engen
Bezug zum Beispiel sollte es in diesem Abschnitt gelingen, …
• Syntaxdiagramme als metasprachliche Hilfsmittel einzuführen
• und gleichzeitig zur allmählichen Klärung der wichtigen Begriffe Klasse und Methode bei-
zutragen.
82 Kapitel 3 Elementare Sprachelemente
3.1.3.1 Klassendefinition
Wir arbeiten vorerst mit dem folgenden, leicht vereinfachten Klassenbegriff:
Klassendefinition
class Name { }
Modifikator
Felddeklaration
Methodendefinition
Solange man sich auf zulässigen Pfaden bewegt (immer in Pfeilrichtung, eventuell auch in Schlei-
fen), an den Stationen (Rechtecken) entweder den konstanten Sprachbestandteil exakt übernimmt
oder den Platzhalter auf zulässige (an anderer Stelle erläuterte) Weise ersetzt, entsteht eine syntak-
tisch korrekte Klassendefinition.
Als Beispiel betrachten wir die Klasse Bruch aus dem Abschnitt 1.1:
Modifikator Name
}
Abschnitt 3.1 Einstieg 83
3.1.3.2 Methodendefinition
Weil ein Syntaxdiagramm für die komplette Methodendefinition etwas unübersichtlich wäre, be-
trachten wir separate Diagramme für die Begriffe Methodenkopf und Methodenrumpf:
Methodendefinition
Methodenkopf Methodenrumpf
Methodenkopf
Methodenrumpf
{ }
Anweisung
Zur Erläuterung des Begriffs Parameterdeklaration beschränken wir uns vorläufig auf das Beispiel
in der addiere() - Definition. Es enthält einen Datentyp (Klasse Bruch) und einen Parameterna-
men (b).
In vielen Methoden werden sogenannte lokale Variablen (vgl. Abschnitt 3.3.4) deklariert, z. B. in
der Bruch-Methode kuerze():
public void kuerze() {
if (zaehler != 0) {
int az = Math.abs(zaehler);
int an = Math.abs(nenner);
. . .
zaehler = zaehler / az;
nenner = nenner / az;
} else
nenner = 1;
}
84 Kapitel 3 Elementare Sprachelemente
Weil wir bald u. a. die Variablendeklarationsanweisung kennenlernen werden, benötigt das Syn-
taxdiagramm zum Methodenrumpf jedoch (im Unterschied zum Klassendefinitionsdiagramm) kein
separates Rechteck für die Variablendeklaration.
Unsere Entwicklungsumgebung verwendet per Voreinstellung die linke Variante, kann aber mit
Gültigkeit für das aktuelle Projekt nach
File > Settings > Editor > Code Style > Java> Scheme=Project
bzw. mit Gültigkeit für neue Projekte nach
File > Settings > Editor > Code Style > Java > Scheme=Default
umgestimmt werden, z. B.:
Abschnitt 3.1 Einstieg 85
Damit die (geänderten) Projekteinstellungen für eine vorhandene Quellcodedatei realisiert werden,
muss bei aktivem Editorfenster die folgende akrobatische Tastenkombination
Umschalt + Strg + Alt + L
betätigt und anschließend die folgende Dialogbox mit Run quittiert werden:
IntelliJ unterstützt die Einhaltung der Layout-Regeln nicht dadurch, dass beim Editieren Abwei-
chungen verhindert werden, sondern ...
• durch die beschriebene Möglichkeit zur automatisierten Layout-Anpassung
• und durch das Verhalten von Assistenten, die Quellcode erstellen.
Die im Manuskript verwendete Syntaxgestaltung durch Farben und Textattribute stammt von Intel-
liJ, wobei nach
File > Settings > Editor > Color Scheme > General
das Schema Classic Light gewählt wurde:
86 Kapitel 3 Elementare Sprachelemente
3.1.5 Kommentare
Kommentare unterstützen die spätere Verwendung (z. B. Weiterentwicklung) des Quellcodes und
werden vom Compiler ignoriert. Java bietet drei Möglichkeiten, den Quellcode zu kommentieren:
3.1.5.1 Zeilenrestkommentar
Alle Zeichen vom ersten doppelten Schrägstrich (//) bis zum Ende der Zeile gelten als Kommentar,
z. B.:
private int zaehler; // wird automatisch mit 0 initialisiert
Im Beispiel wird eine Variablendeklarationsanweisung in derselben Zeile kommentiert.
Um in IntelliJ einen markierten Zeilenblock als Kommentar zu deklarieren, wählt man den Menübe-
fehl
Code > Comment with Line Comment
oder drückt die Strg-Taste zusammen mit der Divisionstaste im numerischen Ziffernblock:1
Strg +
Anschließend werden doppelte Schrägstriche vor jede Zeile des Blocks gesetzt. Bei Anwendung des
Menü- bzw. Tastenbefehls auf einen zuvor mit Doppelschrägstrichen auskommentierten Block ent-
fernt IntelliJ die Kommentar-Schrägstriche.
3.1.5.2 Mehrzeilenkommentar
Ein durch /* eingeleiteter Kommentar muss explizit durch */ terminiert werden. In der Regel wird
diese Syntax für einen ausführlichen Kommentar verwendet, der sich über mehrere Zeilen erstreckt,
z. B.:
1
Die laut IntelliJ-Dokumentation zu verwendende Tastenkombination Strg + / klappt nur mit einem US-
Tastaturlayout.
Abschnitt 3.1 Einstieg 87
/*
Ein Bruch-Objekt verhindert, dass sein Nenner auf 0
gesetzt wird, und hat daher stets einen definierten Wert.
*/
public boolean setzeNenner(int n) {
if (n != 0) {
nenner = n;
return true;
} else
return false;
}
Ein mehrzeiliger Kommentar eignet sich auch dazu, einen Programmteil (vorübergehend) zu deak-
tivieren, ohne ihn löschen zu müssen.
Weil der explizit terminierte Kommentar (jedenfalls ohne farbliche Hervorhebung der auskommen-
tierten Passage) unübersichtlich ist, wird er selten verwendet.
3.1.5.3 Dokumentationskommentar
Vor der Definition bzw. Deklaration von Klassen, Interfaces (siehe unten), Methoden oder Variab-
len darf ein Dokumentationskommentar stehen, eingeleitet mit /** und beendet mit */. Im Quell-
code der API-Klasse System befindet sich z. B. der folgende Dokumentationskommentar zum Aus-
gabeobjekt out, das Sie schon kennengelernt haben:1
/**
* The "standard" output stream. This stream is already
* open and ready to accept output data. Typically this stream
* corresponds to display output or another output destination
* specified by the host environment or user. The encoding used
* in the conversion from characters to bytes is equivalent to
* {@link Console#charset()} if the {@code Console} exists,
* {@link Charset#defaultCharset()} otherwise.
. . .
* @see Console#charset()
* @see Charset#defaultCharset()
*/
public static final PrintStream out = null;
Ein Dokumentationskommentar kann mit dem JDK-Werkzeug javadoc in eine HTML-Datei extra-
hiert werden. Die strukturierte Dokumentation wird über Markierungen für Methodenparameter,
Rückgabewerte usw. unterstützt.2 So sieht die von javadoc aus dem Dokumentationskommentar zu
System.out erstellte HTML-Passage aus:
1
Die Quellcodedatei System.java steckt im API-Quellcodearchiv src.zip. Wo diese Archivdatei bei der Installation
landet, wird gleich beschrieben. Die Klasse System.java befindet sich im Paket java.lang. Ab Java 9 muss man zu-
sätzlich wissen, dass das Paket java.lang zum Modul java.base gehört. Der im Text wiedergegebene Dokumentati-
onskommentar stammt aus dem OpenJDK 17.
2
Siehe z. B.: https://docs.oracle.com/en/java/javase/17/docs/specs/man/javadoc.html
88 Kapitel 3 Elementare Sprachelemente
Eine solche API-Dokumentation kann aus IntelliJ aufgerufen werden, sofern sich die Einfügemarke
des Editors in dem interessierenden Bezeichner (im Beispiel: out) befindet, und dann eine von den
folgenden Tastenkombinationen gedrückt wird:
• Umschalt + F1
Nach dieser Tastenkombination (oder nach dem Menübefehl View > External Documen-
tation) versucht IntelliJ, die HTML-Datei mit der Dokumentation in einem externen Brow-
ser-Fenster zu öffnen und den Fokus passend zu setzen (siehe obiges Bildschirmfoto). Damit
dies gelingt, muss ein Documentation Path in der SDK-Konfiguration gesetzt sein (siehe
Abschnitt 2.4.6.2).
• Strg + Q
Über diese Tastenkombination (oder den Menübefehl View > Quick Documentation)
erhält man in IntelliJ ein PopUp-Fenster mit Informationen zum interessierenden API-
Bestandteil, z. B.:
Dieses PopUp-Fenster erscheint auch, wenn bei aktivem IntelliJ-Fenster der Mauszeiger ei-
ne kurze Zeitspanne über dem interessierenden API-Bestandteil verharrt. IntelliJ kann die
Informationen über den Documentation Path in der SDK-Konfiguration beschaffen (sie-
he Abschnitt 2.4.6.2) oder die per Voreinstellung als JDK-Bestandteil installierte Datei
Abschnitt 3.1 Einstieg 89
src.zip mit dem Quellcode der Standardbibliothek auswerten. Bei der im Abschnitt 1.2.1 be-
schriebenen OpenJDK 8 - Installation landet die Datei src.zip im Installationsordner. Bei
der im Abschnitt 2.1.1 beschriebenen OpenJDK 17 - Installation landet sie im Unterordner
lib.
Während vielleicht noch einige Zeit vergeht, bis Sie den ersten Dokumentationskommentar zu einer
eigenen Klasse schreiben, sind die Techniken zum Zugriff auf die Dokumentation der API-Klassen
von Beginn an im Alltag der Software-Entwicklung unverzichtbar.
3.1.6 Namen
Für Klassen, Methoden, Felder, Parameter und sonstige Elemente eines Java-Programms benötigen
wir Namen, wobei folgende Regeln gelten:
• Die Länge eines Namens ist nicht begrenzt.
Zwar fördern kurze Namen die Übersicht im Quellcode, doch ist die Verständlichkeit eines
Namens noch wichtiger als die Kürze.
• Das erste Zeichen muss ein Buchstabe, Unterstrich oder Dollar-Zeichen sein, danach dürfen
außerdem auch Ziffern auftreten.
• Damit sind insbesondere das Leerzeichen sowie Zeichen mit spezieller syntaktischer Bedeu-
tung (z. B. -, (, *) als Namensbestandteile verboten.
• Java-Programme werden intern im Unicode-Zeichensatz dargestellt. Daher erlaubt Java in
Namen auch Umlaute oder sonstige nationale Sonderzeichen, die als Buchstaben gelten,
z. B.:
public static void main(String[] args) {
int möglich = 13;
System.out.println(möglich);
}
• Die Groß-/Kleinschreibung ist signifikant. Für den Java-Compiler sind also z. B.
Anz anz ANZ
grundverschiedene Namen.
• Die folgenden reservierten Schlüsselwörter dürfen nicht als Namen verwendet werden:
abstract assert boolean break byte case catch
char class const continue default do double
else enum extends false final finally float
for goto if implements import instanceof int
interface long native new null package private
protected public return short static strictfp super
switch synchronized this throw throws transient true
try void volatile while
Die Schlüsselwörter const und goto sind reserviert, werden aber derzeit nicht verwendet.
• In der letzten Zeit (vor allem in den Java-Versionen 9 und 17) sind kontextabhängige
Schlüsselwörter dazugekommen, die in einem bestimmten Umfeld als Namen verboten
sind:
exports module non-sealed open opens permits provides
requires sealed to transitive uses var with
yield
• Seit Java 9 ist ein isolierter Unterstrich ("_") nicht mehr als Name erlaubt.
• Namen müssen innerhalb ihres Kontexts (siehe unten) eindeutig sein.
90 Kapitel 3 Elementare Sprachelemente
Um alle Klassen eines Pakets zu importieren, gibt man einen Stern an Stelle des Klassennamens an,
z. B.:
import java.util.*;
Unterpakete (siehe Kapitel 6) sind dabei nicht einbezogen.
Zur Erläuterung der Import-Deklaration hätten die beiden Beispiele eigentlich genügt, und das fol-
gende Syntaxdiagramm ist ziemlich überflüssig:
1
Ab Java 9 befindet sich das Paket java.util im Modul java.base. Das gilt bis auf wenige Ausnahmen für alle im
Manuskript verwendeten Pakete, sodass der Hinweis auf die Modulzugehörigkeit bald nur noch in den Ausnahme-
fällen erscheint.
Abschnitt 3.2 Ausgabe bei Konsolenanwendungen 91
Import-Deklaration
Klasse
import Paket . ;
*
1
Für eine genauere Erläuterung reichen unsere bisherigen OOP-Kenntnisse noch nicht ganz aus. Wer aus anderen
Quellen Vorkenntnisse besitzt, kann die folgenden Sätze vielleicht jetzt schon verdauen: Wir benutzen bei der Kon-
solenausgabe die im Paket java.lang definierte und damit automatisch in jedem Java-Programm verfügbare Klasse
System. Unter den Membern dieser Klasse befindet sich das statische (klassenbezogene) Feld out, das als Referenz-
variable auf ein Objekt aus der Klasse PrintStream zeigt. Dieses Objekt beherrscht u. a. die Methoden print() und
println(), die jeweils ein einziges Argument von beliebigem Datentyp erwarten und zur Standardausgabe befördern.
92 Kapitel 3 Elementare Sprachelemente
Als erster Parameter wird an printf() eine Zeichenfolge übergeben, die Formatierungsangaben für
die restlichen Parameter enthält. Für die Formatierungsangabe zu einem Ausgabeparameter ist die
folgende Syntax zu verwenden, wobei Leerzeichen zwischen ihren Bestandteilen verboten sind:
Platzhalter für die formatierte Ausgabe
Darin bedeuten:
1
Alternativ kann die äquivalente PrintStream-Methode format() benutzt werden.
Abschnitt 3.2 Ausgabe bei Konsolenanwendungen 93
Wie print() produziert auch printf() keinen automatischen Zeilenwechsel nach der Ausgabe. Im
obigen Beispielprogramm wird daher in der Formatierungszeichenfolge des ersten printf() - Auf-
rufs durch die Formatspezifikation %n für einen Zeilenwechsel gesorgt.
Im Unterschied zu print() und println() gibt printf() das landesübliche Dezimaltrennzeichen aus,
z. B.:
94 Kapitel 3 Elementare Sprachelemente
Quellcode Ausgabe
class Prog { 3.141592653589793
public static void main(String[] args) { 3,1415927
System.out.println(Math.PI);
System.out.printf("%-12.7f", Math.PI);
}
}
Eben wurde eine kleine Teilmenge der Syntax einer Java-Formatierungszeichenfolge vorgestellt.
Die komplette Information findet sich in der API-Dokumentation zur Klasse Formatter (Paket ja-
va.util, ab Java 9 im Modul java.base).1 Zur Online-Version dieser Dokumentation gelangen Sie
z. B. auf dem folgenden Weg:
• Öffnen Sie z. B. die HTML-Startseite der API-Dokumentation zu Java 17 über die Adresse
https://docs.oracle.com/en/java/javase/17/docs/api/index.html
• Tragen Sie den Klassennamen Formatter in das SEARCH-Feld ein (oben rechts), und
wählen Sie aus der Trefferliste den Typ java.util.Formatter.
Noch bequemer klappt es mit Hilfe von IntelliJ z. B. so:2
• Im Quellcodeeditor die Einfügemarke auf den Namen der Methode printf() setzen
• Tastenbefehl Umschalt + F1
• Im auftauchenden Browser-Fenster Klick auf den Link Format string syntax
1
Mit den Modulen und Paketen der Standardklassenbibliothek werden wir uns später ausführlich beschäftigen. An
dieser Stelle dient die Angabe der Modul- und Paketzugehörigkeit dazu, eine Klasse eindeutig zu identifizieren und
die Standardklassenbibliothek allmählich kennenzulernen.
2
Wird mit IntelliJ 2021.2 in einem Projekt mit dem JDK 17 als SDK über den Tastenbefehl Umschalt + F1 die
API-Dokumentation zu einer Methode angefordert, denn werden im folgenden Fenster neben dem korrekten Link
auch überflüssige Duplikate und defekte Links angeboten:
Abschnitt 3.3 Variablen und Datentypen 95
class Prog {
public static void main(String[] args) {
int ivar = 4711; //schreibender Zugriff auf ivar
System.out.println(ivar); //lesender Zugriff auf ivar
}
}
Nach einem Mausklick an derselben Stelle geht Ihnen ein rotes Licht auf. Durch einen Klick
auf die rote Glühbirne oder mit der Tastenkombination Alt + Enter erhalten Sie in dieser
Situation eine Liste mit Reparaturvorschlägen:
96 Kapitel 3 Elementare Sprachelemente
Nach der Wahl des ersten Vorschlags nimmt IntelliJ im Beispiel die fehlende Variablende-
klaration vor und empfiehlt dabei einen Datentyp:
1
Halten Sie bitte die eben erläuterte statische Typisierung (im Sinn von unveränderlicher Typfestlegung) in begriffli-
cher Distanz zu den bereits erwähnten statischen Variablen (im Sinn von klassenbezogenen Variablen). Das Wort
statisch ist eingeführter Bestandteil bei beiden Begriffen, sodass es mir nicht sinnvoll erschien, eine andere Be-
zeichnung vorzunehmen, um die Doppelbedeutung zu vermeiden.
Abschnitt 3.3 Variablen und Datentypen 97
wird die Variable ivar vom Typ int deklariert, der sich für ganze Zahlen im Bereich
von -2147483648 bis 2147483647 eignet.
Im Unterschied zu vielen Skriptsprachen arbeitet Java mit einer statischen Typisierung, so-
dass der einer Variablen zugewiesene Typ nicht mehr geändert werden kann.
In der obigen Anweisung erhält die Variable ivar beim Deklarieren gleich den Initialisierungs-
wert 4711. Auf diese oder andere Weise müssen Sie jeder lokalen, d .h. innerhalb einer Methode
deklarierten Variablen einen Wert zuweisen, bevor Sie zum ersten Mal lesend darauf zugreifen (vgl.
Abschnitt 3.3.8). Weil die zu einem Objekt oder zu einer Klasse gehörenden Variablen (siehe un-
ten) automatisch initialisiert werden, hat in Java jede Variable beim Lesezugriff stets einen definier-
ten Wert.
Seit der Version 10 können Java-Compiler den Typ von lokalen (in Methoden definierten) Variab-
len aus einem zugewiesenen Initialisierungswert erschließen (Typinferenz), und man darf in der
Variablendeklaration die Typangabe durch das Schlüsselwort var ersetzen, z. B.:
Im Beispiel gelingt die Inferenz, weil die zugewiesene Zahl 4711, die wir später als Ganzzahlliteral
bezeichnen werden, den Typ int besitzt (vgl. Abschnitt 3.3.11.1). Wie man bei gedrückter Strg-
Taste für die in der Nähe des Mauszeigers befindliche Variable erfährt, kennt der Compiler (bzw.
die Entwicklungsumgebung) den Datentyp:
Das Schlüsselwort var ist in vielen Situationen bequem, doch sollte die Lesbarkeit des Quellcodes
nicht leiden.
3.3.2 Variablennamen
Es sind beliebige Bezeichner gemäß Abschnitt 3.1.6 erlaubt. Eine Beachtung der folgenden Kon-
ventionen verbessert aber die Lesbarkeit des Quellcodes, insbesondere auch für andere Program-
mierer (vgl. Bloch 2018, S. 289ff; Gosling et al. 2021, Abschnitt 6.1):
98 Kapitel 3 Elementare Sprachelemente
• Variablennamen mit einem einzigen Buchstaben sollten nur in speziellen Fällen verwendet
werden (z. B. als Laufvariable von Wiederholungsanweisungen, siehe unten).
0 1
Zur Realisation der Datenkapselung erhalten die beiden Felder den Zugriffsmodifikator pri-
vate.
In der Bruch-Methode kuerze() tritt u. a. die lokale Variable az auf, die ebenfalls den
primitiven Typ int besitzt:
int az = Math.abs(zaehler);
Bei den lokalen Variablen einer Methode ist eine Datenkapselung weder erforderlich, noch
möglich. Somit ist hier der Modifikator private überflüssig und verboten. Im Abschnitt
3.3.6 werden zahlreiche weitere primitive Datentypen vorgestellt.
Abschnitt 3.3 Variablen und Datentypen 99
• Referenztypen
Besitzt eine Variable einen Referenztyp, dann kann ihr Speicherplatz die Adresse eines Ob-
jekts aus einer bestimmten Klasse aufnehmen. Sobald ein solches Objekt erzeugt und seine
Adresse der Referenzvariablen zugewiesen worden ist, kann das Objekt über die Referenz-
variable angesprochen werden. Von den Variablen mit primitivem Typ unterscheidet sich
eine Referenzvariable also …
o durch ihren speziellen Inhalt (eine Objektadresse)
o und durch ihre Rolle bei der Kommunikation mit Objekten.
Man kann jede Klasse (aus dem Java-API oder selbst definiert) als Referenzdatentyp ver-
wenden, also Referenzvariablen dieses Typs deklarieren. In der main() - Methode der Klas-
se Bruchaddition (siehe Abschnitt 1.1.4) werden z. B. die Referenzvariablen b1 und b2
vom Datentyp Bruch deklariert:
Bruch b1 = new Bruch(), b2 = new Bruch();
Sie erhalten als Initialisierungswert jeweils eine Referenz auf ein (per new-Operator, siehe
Abschnitt 4.4) neu erzeugtes Bruch-Objekt. Daraus resultiert im Arbeitsspeicher die fol-
gende Situation:
Bruch-Objekt
zaehler nenner
b1
0 1
Bruch@87a5cc
b2 Bruch-Objekt
0 1
Das von b1 referenzierte Bruch-Objekt wurde bei einem konkreten Programmlauf von der
JVM an der Speicheradresse 0x87a5cc (ganze Zahl, ausgedrückt im Hexadezimalsystem)
untergebracht. Wir müssen diese Adresse nicht kennen, sondern sprechen das dort abgelegte
Objekt über die Referenzvariable an, z. B. in der folgenden Anweisung aus der main() - Me-
thode der Klasse Bruchaddition:
b1.frage();
Jedes Bruch-Objekt besitzt die Felder (Instanzvariablen) zaehler und nenner vom primi-
tiven Datentyp int.
Zur Beziehung der Begriffe Objekt und Variable halten wir fest:
• Ein Objekt enthält im Allgemeinen mehrere Instanzvariablen (Felder) von beliebigem Da-
tentyp. So enthält z. B. ein Bruch-Objekt die Felder zaehler und nenner vom primitiven
Typ int (zur Aufnahme einer Ganzzahl). Bei einer späteren Erweiterung der Bruch-
Klassendefinition werden ihre Objekte auch eine Instanzvariable mit Referenztyp erhalten.
• Eine Referenzvariable dient zur Aufnahme einer Objektadresse. So kann z. B. eine Variable
vom Datentyp Bruch die Adresse eines Bruch-Objekts aufnehmen und zur Kommunikation
mit diesem Objekt dienen. Es ist ohne weiteres möglich und oft sinnvoll, dass mehrere Refe-
renzvariable die Adresse desselben Objekts enthalten. Das Objekt existiert unabhängig vom
Schicksal einer konkreten Referenzvariablen, wird jedoch überflüssig (und damit zum po-
tentiellen Opfer des Garbage Collectors der JVM), wenn im gesamten Programm keine ein-
zige Referenz (Kommunikationsmöglichkeit) mehr vorhanden ist.
100 Kapitel 3 Elementare Sprachelemente
Wir werden im Kapitel 3 überwiegend mit Variablen von primitivem Typ arbeiten, können und
wollen dabei aber den Referenzvariablen (z. B. zur Ansprache des Objekts System.out aus der
Klasse PrintStream bei der Konsolenausgabe, siehe Abschnitt 3.2) nicht aus dem Weg gehen.
1
Die statischen Felder out (aus der API-Klasse System) und PI (aus der API-Klasse Math) sind finalisiert (siehe
Abschnitt 4.5.1), können also nicht geändert werden. Außerdem kommt keine Änderung des Datentyps in Betracht.
In dieser Situation ist eine Ausnahme vom Prinzip der Datenkapselung sinnvoll, um den Zugriff zu vereinfachen.
Die Deklaration der Referenzvariablen out kennen Sie schon aus dem Abschnitt 3.1.5
public static final PrintStream out = null;
Hier ist die Deklaration der Variablen PI (mit dem primitiven Datentyp double) zu sehen:
public static final double PI = 3.14159265358979323846;
Abschnitt 3.3 Variablen und Datentypen 101
Stack Heap
Bruch@87a5cc 0 1
b2 Bruch-Objekt
0 1
Die lokalen Referenzvariablen b1 und b2 der Methode main() befinden sich im Stack-Bereich des
programmeigenen Arbeitsspeichers und enthalten jeweils die Adresse eines Bruch-Objekts. Jedes
Bruch-Objekt besitzt die Felder (Instanzvariablen) zaehler und nenner vom primitiven Typ int
und befindet sich im Heap-Bereich des programmeigenen Arbeitsspeichers.
Auf Instanz- und Klassenvariablen kann in allen Methoden der eigenen Klasse zugegriffen werden.
Wenn (als gut begründete Ausnahme vom Prinzip der Datenkapselung) entsprechende Rechte ein-
geräumt wurden, ist dies auch in Methoden fremder Klassen möglich.
Im Kapitel 3 werden wir überwiegend mit lokalen Variablen arbeiten, aber z. B. auch das statische
Feld out der Klasse System benutzen, das auf ein Objekt der Klasse PrintStream zeigt. Im Zu-
sammenhang mit der systematischen Behandlung der objektorientierten Programmierung werden
die Instanz- und Klassenvariablen ausführlich erläutert.
Im Unterschied zu anderen Programmiersprachen (z. B. C++) ist es in Java nicht möglich, soge-
nannte globale Variablen außerhalb von Klassen zu definieren.
• Aktueller Wert
Im folgenden Beispiel taucht eine lokale Variable namens ivar auf, die zur Methode
main() gehört, vom primitiven Typ int ist und den Wert 5 besitzt:
public class Prog {
public static void main(String[] args) {
int ivar = 5;
}
}
Speicher-
Typ Beschreibung Werte
bedarf in Bits
byte -128 … 127 8
Diese Variablentypen speichern ganze
short Zahlen. -32768 … 32767 16
int Beispiel: -2147483648 ... 2147483647 32
int alter = 31;
long -9223372036854775808 … 64
9223372036854775807
float Variablen vom Typ float speichern Minimum: 32
Gleitkommazahlen nach der Norm -3,40282351038
IEEE-754 mit einer Genauigkeit von Maximum: 1 für das Vorz.,
8 für den Expon.,
mindestens 7 Dezimalstellen in der 3,40282351038 23 für die Mantisse
Mantisse. Kleinster positiver Betrag:
Beispiel: 1.4012984610-45
float pi = 3.141593f;
float-Literale (siehe Beispiel) benöti-
gen das Suffix f (oder F).
double Variablen vom Typ double speichern Minimum: 64
Gleitkommazahlen nach der Norm -1,797693134862315710308
IEEE-754 (64 Bit) mit einer Genauig- Maximum: 1 für das Vorz.,
11 für den Expon.,
keit von mindestens 15 signifikanten 1,797693134862315710308 52 für die Mantisse
Dezimalstellen in der Mantisse. Kleinster positiver Betrag:
Beispiel: 4,940656458412465410-324
double ph = 1.57079632679490;
Abschnitt 3.3 Variablen und Datentypen 103
Speicher-
Typ Beschreibung Werte
bedarf in Bits
char Variablen vom Typ char speichern Unicode-Zeichen 16
eine Unicode-Zeichen. Im Speicher Tabellen mit allen Unicode-
landet aber nicht die Gestalt des Zei- Zeichen sind z. B. auf der
chens, sondern seine Nummer im Webseite des Unicode-
Unicode-Zeichensatz. Daher zählt char Konsortiums verfügbar:
zu den integralen (ganzzahligen) Da- http://www.unicode.org/charts/
tentypen.
Beispiel:
char zeichen = 'j';
char-Literale sind durch einfache An-
führungszeichen zu begrenzen (siehe
Beispiel).
boolean Variablen vom Typ boolean können true, false 8
Wahrheitswerte aufnehmen.
Beispiel:
boolean cond = true;
Eine Variable mit einem integralen Datentyp (z. B. int oder byte) speichert eine ganze Zahl (z. B.
4711) exakt, sofern es nicht durch eine Wertebereichsüberschreitung zu einem Überlauf und damit
zu einem sinnlosen Speicherinhalt kommt (siehe Abschnitt 3.6).
Eine Variable zur Aufnahme einer Gleitkommazahl (synonym: Gleitpunkt- oder Fließkommazahl,
englisch: floating point number) dient zur approximativen Darstellung einer reellen Zahl. Dabei
werden drei Bestandteile separat gespeichert: Vorzeichen, Mantisse und Exponent. Diese ergeben
nach folgender Formel den dargestellten Wert, wobei b für die Basis eines Zahlensystems steht
(meist verwendet: 2 oder 10):
Wert = Vorzeichen Mantisse bExponent
Bei dieser von Konrad Zuse entwickelten Darstellungstechnik1 resultiert im Vergleich zur Fest-
kommadarstellung bei gleichem Speicherplatzbedarf ein erheblich größerer Wertebereich. Während
die Mantisse für die Genauigkeit sorgt, speichert der Exponent die Größenordnung, z. B.:
-0,0000001252612 = (-1) 1,252612 10-7
1252612000000000 = (1) 1,252612 1015
Durch eine Änderung des Exponenten könnte man das Dezimalkomma durch die Mantisse „gleiten“
lassen. Allerdings wird in der Regel durch eine Restriktion der Mantisse (z. B. auf das Intervall
[1; 2)) für eine eindeutige Darstellung gesorgt.
Weil der verfügbare Speicher für Mantisse und Exponent begrenzt ist (siehe obige Tabelle), bilden
die Gleitkommazahlen nur eine endliche (aber für die meisten praktischen Zwecke ausreichende)
Teilmenge der reellen Zahlen. Nähere Informationen über die Darstellung von Gleitkommazahlen
im Arbeitsspeicher eines Computers folgen für speziell interessierte Leser gleich im Abschnitt
3.3.7.
Im Vergleich zu den Programmiersprachen C, C++ und C# fällt auf, dass Java auf vorzeichenfreie
Ganzzahltypen verzichtet.
1
Quelle: http://de.wikipedia.org/wiki/Konrad_Zuse
104 Kapitel 3 Elementare Sprachelemente
Die abwertend klingende Bezeichnung primitiv darf keinesfalls so verstanden werden, dass elemen-
tare Datentypen nach Möglichkeit in Java-Programmen zu vermeiden wären. Sie sind bei den Fel-
dern von Klassen und bei den lokalen Variablen von Methoden unverzichtbar.
Bei einer Ausgabe mit mehr als sieben Nachkommastellen zeigt sich, dass die float-Zahl 1,3 nicht
exakt abgespeichert worden ist. Demgegenüber tritt bei der float-Zahl 1,25 keine Ungenauigkeit
auf.
Diese Ergebnisse sind durch das Speichern der Zahlen im binären Gleitkommaformat nach der
vom Institute of Electrical and Electronics Engineers (IEEE) veröffentlichten Norm IEEE-754 zu
erklären, wobei jede Zahl als Produkt aus drei getrennt gespeicherten Faktoren dargestellt wird:1
Vorzeichen Mantisse 2Exponent
Im ersten Bit einer float- oder double - Variablen wird das Vorzeichen gespeichert (0: positiv, 1:
negativ).
Für die Ablage des Exponenten (zur Basis 2) als Ganzzahl stehen 8 (float) bzw. 11 (double) Bits
zur Verfügung, die jeweils die Werte 0 oder 1 repräsentieren. Das i-te Exponenten-Bit (von rechts
nach links mit 0 beginnend nummeriert) hat die Wertigkeit 2i, sodass ein Wertebereich von 0 bis
255 (= 28-1) bzw. von 0 bis 2047 (= 211-1) resultiert:
7 bzw. 10
b 2 , b {0, 1}
i =0
i
i
i
Allerdings sind im Exponenten die Werte 0 und 255 (float) bzw. 0 und 2047 (double) für Spezial-
fälle (z. B. denormalisierte Darstellung, +/- Unendlich) reserviert (siehe unten). Um auch die für
Zahlen mit einem Betrag kleiner 1 benötigten negativen Exponenten darstellen zu können, werden
die Exponenten mit einer Verschiebung (Bias) um den Wert 127 (float) bzw. 1023 (double) abge-
speichert und interpretiert. Bei einer float-Variablen wird z. B. für den Exponenten 0 der Wert 127
und für den Exponenten -2 der Wert 125 im Speicher abgelegt.
1
https://de.wikipedia.org/wiki/IEEE_754
Abschnitt 3.3 Variablen und Datentypen 105
Abgesehen von betragsmäßig sehr kleinen Zahlen (siehe unten) werden die float- und double-
Werte normalisiert, d .h. auf eine Mantisse im Intervall [1; 2) gebracht, z. B.:
24,48 = 1,53 24
0,2448 = 1,9584 2-3
Zur Speicherung der Mantisse werden 23 (float) bzw. 52 (double) Bits verwendet. Das i-te Mantis-
sen-Bit (von links nach rechts mit 1 beginnend nummeriert) hat die Wertigkeit 2-i, sodass sich der
dezimale Mantissenwert folgendermaßen ergibt:
23 bzw. 52
1 + m mit m = b 2
i =1
i
−i
, bi {0,1}
Der Summenindex i startet mit 1, weil die führende 1 (= 20) der normalisierten Mantisse nicht abge-
speichert wird (hidden bit). Daher stehen alle Bits für die Restmantisse (die Nachkommastellen) zur
Verfügung mit dem Effekt einer verbesserten Genauigkeit. Oft wird daher die Anzahl der Mantis-
sen-Bits mit 24 (float) bzw. 53 (double) angegeben.
Eine float- bzw. double-Variable mit den Speicherinhalten
• v (0 oder 1) für das Vorzeichen
• e für den Exponenten
• m für die Mantisse
repräsentiert also bei normalisierter Darstellung den Wert:
(-1)v (1 + m) 2e-127 bzw. (-1)v (1 + m) 2e-1023
In der folgenden Tabelle finden Sie einige normalisierte float-Werte:
float-Darstellung (normalisiert)
Wert
Vorz. Exponent Mantisse
0,75 = (-1)0 2(126-127) (1+0,5) 0 01111110 10000000000000000000000
1,0 = (-1)0 2(127-127) (1+0,0) 0 01111111 00000000000000000000000
1,25 = (-1)0 2(127-127) (1+0,25) 0 01111111 01000000000000000000000
-2,0 = (-1)1 2(128-127) (1+0,0) 1 10000000 00000000000000000000000
2,75 = (-1)0 2(128-127) (1+0,25+0,125) 0 10000000 01100000000000000000000
-3,5 = (-1)1 2(128-127) (1+0,5+0,25) 1 10000000 11000000000000000000000
Nun kommen wir endlich zur Erklärung der eingangs dargestellten Genauigkeitsunterschiede beim
Speichern der Zahlen 1,25 und 1,3. Während die Restmantisse
0,25 = 0 2 -1 + 1 2 -2
1 1
= 0 + 1
2 4
perfekt dargestellt werden kann, gelingt dies bei der Restmantisse 0,3 nur approximativ:
0,3 = 0 2 −1 + 1 2 −2 + 0 2 −3 + 0 2 −4 + 1 2 −5 + ...
1 1 1 1 1
= 0 + 1 + 0 + 0 + 1 + ...
2 4 8 16 32
Sehr aufmerksame Leser werden sich darüber wundern, wieso die Tabelle mit den elementaren Da-
tentypen im Abschnitt 3.3.6 z. B.
1,4012984610-45
als betragsmäßig kleinsten positiven float-Wert nennt, obwohl der minimale Exponent nach obigen
Überlegungen -126 (= 1 - 127) beträgt, was zum (gerundeten) dezimalen Exponentialfaktor
106 Kapitel 3 Elementare Sprachelemente
1,17510-38
führt. Dahinter steckt die denormalisierte (synonym: subnormale) Gleitkommadarstellung, die als
Ergänzung zur bisher beschriebenen normalisierten Darstellung eingeführt wurde, um eine bessere
Annäherung an die Zahl 0 zu erreichen. Alle Exponenten-Bits sind auf 0 gesetzt, und dem Exponen-
tialfaktor wird der feste Wert 2-126 (float) bzw. 2-1022 (double) zugeordnet. Die Mantissen-Bits haben
dieselben Wertigkeiten (2-i) wie bei der normalisierten Darstellung (siehe oben). Weil es kein hid-
den bit gibt, stellen sie aber nun einen dezimalen Wert im Intervall [0, 1) dar. Eine float- bzw. dou-
ble-Variable mit dem Vorzeichen v (0 oder 1), mit komplett auf 0 gesetzten Exponenten-Bits und
mit dem gespeicherten Mantissenwert m repräsentiert also bei denormalisierter Darstellung die
Zahl:
(-1)v 2-126 m bzw. (-1)v 2-1022 m
In der folgenden Tabelle finden Sie einige denormalisierte float-Werte:
float-Darstellung (denormalisiert)
Wert
Vorz. Exponent Mantisse
0,0 = (-1)0 2-126 0 0 00000000 00000000000000000000000
-5,87747210-39 (-1)1 2-126 2-1 1 00000000 10000000000000000000000
1,40129810-45 (-1)0 2-126 2-23 0 00000000 00000000000000000000001
Weil die Mantissen-Bits auch zur Darstellung der Größenordnung verwendet werden, schwindet die
Genauigkeit mit der Annäherung an die Null.1
IntelliJ-Projekte mit Java-Programmen zur Anzeige der Bits einer (de)normalisierten float- bzw.
double-Zahl finden Sie in den Ordnern
…\BspUeb\Elementare Sprachelemente\Bits\FloatBits
…\BspUeb\Elementare Sprachelemente\Bits\DoubleBits
Weil im Quellcode der Programme mehrere noch unbekannte Sprachelemente auftreten, wird hier
auf eine Wiedergabe verzichtet. Einer Nutzung der Programme steht aber nichts im Wege. Hier
wird z. B. mit dem Programm FloatBits das Speicherabbild der float-Zahl -3,5 ermittelt (vgl. obige
Tabelle):
float: -3,5
Bits:
1 76543210 12345678901234567890123
1 10000000 11000000000000000000000
Zur Verarbeitung von binären Gleitkommazahlen wurde die binäre Gleitkommaarithmetik entwi-
ckelt, normiert und zur Verbesserung der Verarbeitungsgeschwindigkeit sogar teilweise in Compu-
ter-Hardware realisiert.
1
Bei einer formatierten Ausgaben in wissenschaftlicher Notation (vgl. Abschnitt 3.2.2) liegt die Anzahl der signifi-
kanten Dezimalstellen in der Mantisse deutlich unter 7.
Abschnitt 3.3 Variablen und Datentypen 107
Gespeichert werden:
• Eine Ganzzahl beliebiger Größe für den unskalierten Wert (uv)
• Eine Ganzzahl mit 32 Bit für die Anzahl der Nachkommastellen (scale)
Bei der Zahl
1,3 = 13 10-1
gelingt eine verlustfreie Speicherung mit:
uv = 13, scale = 1
Die Ausgabe des folgenden Programms
import java.math.BigDecimal;
class Prog {
public static void main(String[] args) {
BigDecimal bdd = new BigDecimal(1.3);
System.out.println(bdd);
BigDecimal bds = new BigDecimal("1.3");
System.out.println(bds);
}
}
belegt zunächst als Nachtrag zum Abschnitt 3.3.7.1, dass auch eine double-Variable den Wert 1,3
nur approximativ speichern kann:
1.3000000000000000444089209850062616169452667236328125
1.3
Zwar zeigt die Variable bdd auf ein Objekt vom Typ BigDecimal, doch wird zur Erstellung dieses
Objekts ein double-Wert verwendet, der im Speicher nicht exakt abgelegt werden kann.
Erfolgt die Kreation des BigDecimal-Objekts über eine Zeichenfolge, dann kann die Zahl 1,3 exakt
gespeichert werden, wie die zweite Ausgabezeile belegt.
Allerdings hat der Typ BigDecimal auch Nachteile im Vergleich zu den binären Gleitkommatypen
float und double:
• Höherer Speicherbedarf
• Höherer Zeitaufwand bei arithmetischen Operationen
• Aufwändigere Syntax
Bei der Aufgabe,
1000000000
1700000000 - 1,7
i =1
zu berechnen, ergeben sich für die Datentypen double und BigDecimal die folgenden Genauig-
keits- und Laufzeitunterschiede (gemessen auf einem PC mit der Intel-CPU Core i3 mit 3,2 GHz):1
1
Ein IntelliJ-Projekt mit dem Java-Programm, das die Berechnungen angestellt hat, ist hier zu finden:
…\BspUeb\Elementare Sprachelemente\BigDecimalDouble
108 Kapitel 3 Elementare Sprachelemente
double:
Abweichung: -29.96745276451111
Zeit in Millisekunden: 1206
BigDecimal:
Abweichung: 0.0
Zeit in Millisekunden: 8929
Die gut bezahlten Verantwortlichen vieler Banken, die sich gerne als „Global Player“ betätigen und
dabei den vollen Sinn der beiden Worte ausschöpfen (mit Niederlassungen in Schanghai, New
York, Mumbai etc. und einem Verhalten wie im Spielcasino) wären heilfroh, wenn nach einem
Spiel mit 1,7 Milliarden Euro Einsatz nur 30 Euro in der Kasse fehlen würden. Generell sind im
Finanzsektor solche Fehlbeträge aber unerwünscht, sodass man bei finanzmathematischen Aufga-
ben trotz des erhöhten Zeitaufwands (im Beispiel: Faktor ca. 7) die Klasse BigDecimal verwenden
sollte.
Sind in einem Algorithmus nur die Addition und die Subtraktion von ganzen Zahlen (z. B. Rech-
nungsbeträge in Cent) erforderlich, dann taugen auch die Ganzzahltypen int und long für monetäre
Berechnungen. Sie verursachen sehr wenig Aufwand und bieten eine perfekte Genauigkeit, sofern
ihr Wertebereich nicht verlassen wird.
gen Ausdrücken, die einen Wert von passendem Datentyp liefern müssen, werden wir uns noch aus-
führlich beschäftigen.
Es ist üblich, Variablennamen mit einem Kleinbuchstaben beginnen zu lassen (vgl. Abschnitt
3.3.2), sodass man sie im Quelltext z. B. gut von Klassennamen unterscheiden kann, die per Kon-
vention mit einem Großbuchstaben beginnen.
Weil lokale Variablen nicht automatisch initialisiert werden, muss man ihnen vor dem ersten lesen-
den Zugriff einen Wert zuweisen. Auch im Umgang mit uninitialisierten lokalen Variablen zeigt
sich das Bemühen der Java-Designer um robuste Programme. Während C++ - Compiler in der Re-
gel nur warnen, produzieren Java-Compiler eine Fehlermeldung und erstellen keinen Bytecode.1
Dieses Verhalten wird durch das folgende Programm demonstriert:
class Prog {
public static void main(String[] args) {
int argument;
System.out.print("Argument = " + argument);
}
}
Der OpenJDK 17 - Compiler meint dazu:
Prog.java:4: error: variable argument might not have been initialized
System.out.print("Argument = " + argument);
^
1 error
IntelliJ markiert den Fehler und schlägt eine sinnvolle Reparaturmaßnahme vor:
Weil Instanz- und Klassenvariablen automatisch mit dem typspezifischen Nullwert initialisiert wer-
den (siehe unten), kann in einem Java-Programm kein Zugriff auf undefinierte Werte stattfinden.
Wie bereits erwähnt, können Java-Compiler seit der Version 10 den Typ von lokalen Variablen aus
einem zugewiesenen Initialisierungswert erschließen (Typinferenz), und man darf in der Variablen-
deklaration die Typangabe durch das Schlüsselwort var ersetzen, z. B.:
1
Der im Visual Studio 2019 enthaltene C++ - Compiler der Firma Microsoft produziert beim Lesezugriff auf eine
nicht-initialisierte lokale Variable z. B. die Warnung C4700, siehe https://docs.microsoft.com/de-de/cpp/error-
messages/compiler-warnings/compiler-warning-level-1-and-level-4-c4700?view=vs-2019.
110 Kapitel 3 Elementare Sprachelemente
Im Beispiel gelingt die Inferenz, weil die zugewiesene Zahl 4711, die wir im Abschnitt 3.3.11.1 als
Ganzzahlliteral bezeichnen werden, den Typ int besitzt.
Wie das Syntaxdiagramm zur Deklaration einer lokalen Variablen mit Typinferenz zeigt,
Deklaration einer lokalen Variablen mit Typinferenz
Variablenname = Ausdruck ;
Beispiel:
az = az - an;
Durch diese Wertzuweisungsanweisung aus der kuerze() - Methode unserer Klasse Bruch (siehe
Abschnitt 1.1.2) erhält die int-Variable az den neuen Wert az - an.
Es wird sich bald herausstellen, dass auch ein Ausdruck stets einen Datentyp besitzt. Bei der Wert-
zuweisung muss dieser Typ kompatibel zum Datentyp der Variablen sein.
Mittlerweile haben Sie Java-Anweisungen für die folgenden Zwecke kennengelernt:
• Deklaration einer lokalen Variablen
• Wertzuweisung
Abschnitt 3.3 Variablen und Datentypen 111
{ Anweisung }
Man spricht hier auch von einer Block- bzw. Verbundanweisung, und diese kann überall stehen,
wo eine einzelne Anweisung erlaubt ist.1
Unter den Anweisungen innerhalb eines Blocks dürfen sich selbstverständlich auch wiederum Ver-
bundanweisungen befinden. Einfacher ausgedrückt: Blöcke dürfen geschachtelt werden.
In der Regel verwendet man die Blockanweisung als Bestandteil einer bedingten Anweisung oder
einer Wiederholungsanweisung (siehe Abschnitt 3.7). Bei diesen Kontrollstrukturen wird eine An-
weisung unter einer Bedingung bzw. wiederholt ausgeführt. Sollen z. B. unter einer Bedingung
mehrere Anweisungen ausgeführt werden, wäre die Wiederholung der Bedingung für jede einzelne
Anweisung außerordentlich lästig. Stattdessen fasst man die Anweisungen zu einem Block zusam-
men, der als eine Anweisung gilt, sodass die Bedingung nur einmal formuliert werden muss. Dieses
sehr oft benötigte Muster ist z. B. in der Methode setzeNenner() der Klasse Bruch zu sehen:
public boolean setzeNenner(int n) {
if (n != 0) {
nenner = n;
return true;
} else
return false;
}
Anweisungsblöcke haben einen wichtigen Effekt auf die Sichtbarkeit (alias: Gültigkeit) der darin
deklarierten Variablen: Eine lokale Variable ist verfügbar von der deklarierenden Anweisung bis
zur schließenden Klammer des Blocks, in dem sich die Deklaration befindet. Nur in diesem Sicht-
barkeitsbereich (alias: Gültigkeitsbereich, engl. scope) kann sie über ihren Namen angesprochen
werden. Im folgenden (weitgehend sinnfreien) Beispielprogramm wird versucht, auf die Variable
wert2 außerhalb ihres Sichtbarkeitsbereichs zuzugreifen:
class Prog {
public static void main(String[] args) {
int wert1 = 1;
System.out.println("Wert1 = " + wert1);
if (wert1 == 1) {
int wert2 = 2;
System.out.println("Gesamtwert = " + (wert1 + wert2));
}
System.out.println("Wert2 = " + wert2);
}
}
Das veranlasst den OpenJDK 17 - Compiler zu der folgenden Fehlermeldung:
1
Ein Block ohne enthaltene Anweisung
{}
wird vom Compiler als Anweisung akzeptiert, z. B. als Rumpf einer Methode, die keinerlei Tätigkeit entfalten soll.
112 Kapitel 3 Elementare Sprachelemente
Wird die fehlerhafte Zeile auskommentiert, lässt sich das Programm übersetzen. In dem zur if-
Anweisung gehörenden Block ist die im übergeordneten Block der main() - Methode deklarierte
Variable wert1 also gültig.
Bei hierarchisch geschachtelten Blöcken ist es in Java nicht erlaubt, auf mehreren Stufen Variablen
mit identischem Namen zu deklarieren. Diese kaum sinnvolle Option ist z. B. in der Programmier-
sprache C++ vorhanden und erlaubt dort Fehler, die schwer aufzuspüren sind. In Java gehört ein
eingeschachtelter Block zum Gültigkeitsbereich des umgebenden Blocks.
Der Sichtbarkeitsbereich einer lokalen Variablen sollte möglichst klein gehalten werden, um die
Lesbarkeit und die Wartungsfreundlichkeit des Quellcodes zu verbessern. Vor allem wird auf diese
Weise das Risiko von Programmierfehlern reduziert. Wird eine Variable zu früh deklariert, beste-
hen viele Gelegenheiten für schädliche Wertzuweisungen. Aus einer längst überwundenen Ver-
pflichtung alter Programmiersprachen ist bei manchen Programmierern die Gewohnheit entstanden,
alle lokale Variablen am Blockbeginn zu deklarieren. Stattdessen sollten lokale Variablen zur Mi-
nimierung ihres Sichtbarkeitsbereichs unmittelbar vor der ersten Verwendung deklariert werden
(Bloch 2018, S. 261).
Zur übersichtlichen Gestaltung von Java-Programmen ist das Einrücken von Anweisungsblöcken
sehr zu empfehlen, wobei Sie die Position der einleitenden Blockklammer und die Einrücktiefe
nach persönlichem Geschmack wählen können, z. B.:
if (wert1 == 1) { if (wert1 == 1)
int wert2 = 2; {
System.out.println("Wert2 = " + wert2); int wert2 = 2;
} System.out.println("Wert2 = " + wert2);
}
In IntelliJ kann man Regeln zum Quellcode-Layout definieren und auf eine Quellcodedatei anwen-
den (siehe Abschnitt 3.1.4). Wie man einstellt, ob IntelliJ zum Einrücken ein Tabulatorzeichen oder
eine (wählbare) Anzahl von Leerzeichen verwenden soll, wurde im Abschnitt 2.4.6.4 beschrieben.
Ein markierter Block aus mehreren Zeilen kann in IntelliJ mit
Tab komplett nach rechts eingerückt
und mit
Umschalt + Tab komplett nach links ausgerückt
werden.
Außerdem kann man sich zu einer Blockklammer das Gegenstück anzeigen lassen:
Abschnitt 3.3 Variablen und Datentypen 113
hervorgehobene Endklammer
Lokale Variablen, die nach ihrer Initialisierung auf denselben Wert fixiert bleiben sollen, deklariert
man als final. Für finalisierte lokale (in einer Methode deklarierte) Variablen erhalten wir folgendes
Syntaxdiagramm:
Deklaration einer finalisierten lokalen Variablen
Im Unterschied zur gewöhnlichen Variablendeklaration ist einleitend der Modifikator final zu set-
zen. Das Initialisieren einer finalisierten Variablen kann bei der Deklaration oder in einer späteren
Wertzuweisung erfolgen. Danach ist keine weitere Wertveränderung mehr erlaubt.
Auch für eine finalisierte lokale Variable kann bei der Deklaration aufgrund der Fähigkeit des
Compilers zur Typinferenz das Schlüsselwort var statt des Datentyps angegeben werden, z. B.:
final var mwst = 1.19;
114 Kapitel 3 Elementare Sprachelemente
Im Beispiel kann der Compiler für die Variable mwst den Datentyp double aus dem Initialisie-
rungswert ableiten (siehe Abschnitt 3.3.11.2).
Durch Verwendung des Modifikators final schützen wir uns davor, einen als fixiert geplanten Wert
versehentlich doch zu ändern. In manchen Fällen wird auf diese Weise ein unangenehmer und nur
mit großem Aufwand aufzuklärender Logikfehler zu einem harmlosen Syntaxfehler, der vom Com-
piler aufgedeckt, vom Entwickler ohne nennenswerten Aufwand beseitigt und vom Benutzer nie
erlebt wird (Simons 2004, S. 51).
Weitere Argumente für das Finalisieren:
• Andere Programmierer, die später ebenfalls mit einer Methode arbeiten, erhalten durch die
final-Deklaration eine wichtige Information zur intendierten Verwendung der betroffenen
Variablen.
• Im funktionalen Programmierstil werden finalisierte (unveränderliche) Variablen strikt be-
vorzugt (vgl. Kapitel 12). Unsere Entwicklungsumgebung trägt dem modernen Trend in der
Programmiertheorie Rechnung und macht durch Unterstreichen darauf aufmerksam, dass
der Wert einer Variablen geändert wird:
Daraus sollte auf keinen Fall die Empfehlung abgeleitet werden, auf veränderbare Variablen
zu verzichten.
Durch den systematischen Gebrauch des final-Modifikators für lokale Variablen wirken Beispiel-
programme allerdings etwas komplizierter, sodass im Manuskript oft der Einfachheit halber auf den
final-Modifikator verzichtet wird.
Neben lokalen Variablen können auch (statische) Felder einer Klasse als final deklariert werden
(siehe Abschnitte 4.2.5 und 4.5.1).
Die empfohlene Camel Casing - Namenskonvention (vgl. Abschnitt 3.3.2) gilt bei lokalen Variab-
len trotz final-Deklaration. Nur bei static-Feldern mit final-Modifikator ist es üblich, den Namen
komplett in Großbuchstaben zu schreiben (siehe Bloch 2018, S. 290).
3.3.11 Literale
Die im Quellcode auftauchenden expliziten Werte bezeichnet man als Literale. Wie Sie aus dem
Abschnitt 3.3.10 wissen, sollten Literale vorzugsweise bei der Initialisierung von finalen Variablen
verwendet werden, z. B.:
final double mwst = 1.19;
Auch die Literale besitzen in Java stets einen Datentyp, wobei einige Regeln zu beachten sind, die
gleich erläutert werden. Im aktuellen Abschnitt 3.3.11 haben manche Passagen Nachschlage-
charakter, sodass man beim ersten Lesen nicht jedes Detail aufnehmen muss bzw. kann.
3.3.11.1 Ganzzahlliterale
Für ein Ganzzahlliteral wird meist das dezimale Zahlensystem verwendet, z. B.:
final int wasser = 4711;
Java unterstützt aber auch alternative Zahlensysteme:
Abschnitt 3.3 Variablen und Datentypen 115
oktal 0 System.out.println(011); 9
Für das Ganzzahlliteral 0x11 ergibt sich der dezimale Wert 17 aufgrund der Stellenwertigkeiten im
Hexadezimalsystem folgendermaßen:
11Hex = 1 161 + 1 160 = 1 16 + 1 1 = 17
Vermutlich fragen Sie sich, wozu man sich mit dem Hexadezimalsystem plagen sollte. Gelegentlich
ist ein ganzzahliger Wert (z. B. als Methodenparameter) anzugeben, den man (z. B. aus einer
Tabelle) nur in hexadezimaler Darstellung kennt. In diesem Fall spart man sich durch Verwendung
dieser Darstellung die Wandlung in das Dezimalsystem.
Tückisch ist der Präfix für die (selten benötigten) Literale im Oktalsystem. Die führende Null im
Ganzzahlliteral 011 ist keinesfalls irrelevant, sondern bewirkt eine oktale Interpretation:
11Oktal = 1 8 + 1 1 = 9
Unabhängig vom verwendeten Zahlensystem haben Ganzzahlliterale in Java den Datentyp int,
wenn nicht durch das Suffix L oder l der Datentyp long erzwungen wird. Das ist im folgenden Bei-
spiel
final long betrag = 2147483648L;
erforderlich, weil anderenfalls bei der Zwischenspeicherung des int-wertigen Ausdrucks rechts vom
Gleichheitszeichen ein Ganzzahlüberlauf (vgl. Abschnitt 3.6.1) auftreten würde:
Die schlussendliche Speicherung in der Variablen betrag vom Typ long (mit einem sehr viel grö-
ßeren Wertebereich) würde den Defekt im Zwischenergebnis nicht verhindern.
Der Kleinbuchstabe l ist leicht mit der Ziffer 1 zu verwechseln und daher als Suffix wenig geeignet.
Dass ein Ganzzahlliteral tatsächlich per Voreinstellung den Datentyp int besitzt, können Sie mit
Hilfe unserer Entwicklungsumgebung überprüfen. Befindet sich die Einfügemarke neben einem
(oder in einem) Ganzzahlliteral, dann liefert die Tastenkombination
Umschalt + Strg + P
den Datentyp, z. B.:
116 Kapitel 3 Elementare Sprachelemente
Seit Java 7 dürfen bei Ganzzahlliteralen zwischen zwei Ziffern Unterstriche zur optischen Gruppie-
rung gesetzt werden, z. B.:
final int wasser = 4_711;
Weil int-Literale als Bestandteile der im nächsten Abschnitt behandelten Gleitkommaliterale auftre-
ten, lässt sich die Zifferngruppierung durch Unterstriche auch dort verwenden.
3.3.11.2 Gleitkommaliterale
Zahlen mit Dezimalpunkt oder Exponent sind in Java vom Typ double, wenn nicht durch das Suf-
fix F oder f der Datentyp float erzwungen wird, z. B.:
final double mwst = 1.19;
final float ff = 9.78f;
Mit dem Suffix D oder d wird auch bei einer Zahl ohne Dezimalpunkt oder Exponent der Datentyp
double erzwungen. Warum das Suffix d im folgenden Beispiel für das korrekte Rechenergebnis
sorgt, erfahren Sie im Abschnitt 3.5.1 bei der Behandlung des Unterschieds zwischen der Ganzzahl-
und der Gleitkommaarithmetik:
Quellcode Ausgabe
class Prog { 2
public static void main(String[] args) { 2.5
System.out.println(5/2);
System.out.println(5d/2);
}
}
Die Java-Compiler achten bei Wertzuweisungen unter Verwendung von Gleitkommaliteralen streng
auf die Typkompatibilität. Z. B. führt die folgende Deklaration mit Initialisierung:
final float mwst = 1.19;
zu einer Fehlermeldung, weil das Gleitkommaliteral (und damit der Ausdruck rechts vom Gleich-
heitszeichen) den Typ double besitzt, die Variable mwst hingegen den Typ float:
Neben der alltagsüblichen Schreibweise (mit dem Punkt als Dezimaltrennzeichen) erlaubt Java bei
Gleitkommaliteralen auch die wissenschaftliche Exponentialnotation (mit der Basis 10), z. B. bei
der Zahl -0,00000000010745875):
Vorzeichen Vorzeichen
Mantisse Exponent
-1.0745875e-10
Mantisse Exponent
Eine Veränderung des Exponenten lässt das Dezimaltrennzeichen gleiten und macht somit die Be-
zeichnung Gleitkommaliteral (engl.: floating-point literal) plausibel.
Abschnitt 3.3 Variablen und Datentypen 117
In den folgenden Syntaxdiagrammen werden die wichtigsten Regeln für Gleitkommaliterale be-
schrieben:
Gleitkommaliteral
+ f
- d
Mantisse Exponent
Mantisse
int-Literal . int-Literal
Exponent
e -
int-Literal
E
Die in der Mantisse und im Exponenten auftretenden Ganzzahlliterale müssen das dezimale Zahlen-
system verwenden und den Datentyp int besitzen, sodass die im Abschnitt 3.3.11.1 beschriebenen
Präfixe (0, 0b, 0B, 0x, 0X) und Suffixe (L, l) verboten sind. Die Exponenten werden zur Basis 10
verstanden.
Der Einfachheit halber unterschlagen die Syntaxdiagramme die folgende, im letzten Beispielpro-
gramm benutzte Konstruktion eines Gleitkommaliterals über das Suffix d:
System.out.println(5d/2);
3.3.11.3 boolean-Literale
Als Literale vom Typ boolean sind nur die beiden reservierten Wörter true und false erlaubt, z. B.:
boolean cond = true;
3.3.11.4 char-Literale
char-Literale werden in Java durch einfache Hochkommata begrenzt. Es sind erlaubt:
• Einfache Zeichen
Beispiel:
char bst = 'b';
Das einfache Hochkomma kann allerdings auf diese Weise ebenso wenig zum char-Literal
werden wie der Rückwärts-Schrägstrich (\). In diesen Fällen benötigt man eine sogenannte
Escape-Sequenz:
• Escape-Sequenzen
Indem man ein Zeichen hinter einen einleitenden Rückwärts-Schrägstrich setzt (z. B. \',
\n) und damit eine sogenannte Escape-Sequenz bildet, kann man …
118 Kapitel 3 Elementare Sprachelemente
o ein Zeichen von seiner besonderen Bedeutung befreien (z. B. das zur Begrenzung
von char-Literalen dienende Hochkomma) und wie ein einfaches Zeichen behan-
deln, z. B.:
\'
\\
o ein Steuerzeichen für die Textausgabe im Konsolenfenster ansprechen, z. B.:
Neue Zeile \n
Horizontaler Tabulator \t
Space (Leerzeichen) \s
Backspace (Löschen nach links) \b
Wir werden die Escape-Sequenz \n oft in Zeichenfolgenliteralen (siehe Abschnitt
3.3.11.5) unter normale Zeichen mischen, um bei der Konsolenausgabe einen Zei-
lenwechsel anzuordnen.1
Beispiel:
final char rs = '\\';
• Unicode - Escape-Sequenzen
Eine Unicode - Escape-Sequenz enthält eine Unicode-Zeichennummer (vorzeichenlose
Ganzzahl mit 16 Bits, also im Bereich von 0 bis 216-1 = 65535) in hexadezimaler, vierstelli-
ger Schreibweise (ggf. links mit Nullen aufgefüllt) ohne Hexadezimal-Präfix) nach der Ein-
leitung durch \u (kleines u!). So lassen sich Zeichen ansprechen, die per Tastatur nicht ein-
zugeben sind, z. B.:
final char alpha = '\u03b1';
Im Konsolenfenster werden die Unicode-Zeichen oberhalb von \u00ff in der Regel als
Fragezeichen dargestellt. In einem GUI-Fenster erscheinen sie jedoch in voller Pracht (siehe
nächsten Abschnitt).
3.3.11.5 Zeichenfolgenliterale
Zeichenfolgenliterale werden (im Unterschied zu char-Literalen) durch doppelte Hochkommata
begrenzt. Ein Zeichenfolgenliteral kann einfache Zeichen, Escape-Sequenzen und Unicode - Es-
cape-Sequenzen enthalten (vgl. Abschnitt 3.3.11.4). Das einfache Hochkomma hat innerhalb eines
Zeichenfolgenliterals keine Sonderrolle, z. B.:
System.out.println("Otto's Welt");
Um ein doppeltes Hochkomma in eine Zeichenfolge aufzunehmen, ist die Escape-Sequenz \" zu
verwenden.
Zeichenkettenliterale sind vom Datentyp String, und später wird sich herausstellen, dass es sich bei
diesem Datentyp um eine Klasse aus dem Java-API handelt.
Während ein char-Literal stets genau ein Zeichen enthält, kann ein Zeichenkettenliteral aus belie-
big vielen Zeichen bestehen oder auch leer sein, z. B.:
final String leerStr = "";
1
Bei der Ausgabe in eine Textdatei sollte die Escape-Sequenz \n nicht verwendet werden, weil sie nicht auf allen
Plattformen bzw. von allen Editoren als Zeilenwechsel interpretiert wird. Durch \n wird nämlich auf allen Plattfor-
men dasselbe Byte (Bedeutung: Line Feed) in den Ausgabestrom befördert. In Textdateien wird unter den Betriebs-
systemen Linux, Unix und macOS durch \n eine Zeilentrennung signalisiert; unter Windows wird dieser Zweck hin-
gegen durch die Sequenz \r\n erreicht. Wird in eine auszugebende Zeichenfolge ein Zeilenwechsel mit Hilfe der
Formatspezifikation %n eingefügt (vgl. Abschnitt 3.2.2), dann landet auf jeder Plattform in einer Textausgabedatei
die plattformspezifische Zeilentrennung.
Abschnitt 3.3 Variablen und Datentypen 119
Das folgende Programm verwendet einen Aufruf der statischen Methode showMessageDialog() der
Klasse JOptionPane aus dem Paket javax.swing (seit Java 9 im Modul java.desktop) zur Anzeige
eines Zeichenfolgenliterals, das drei Unicode - Escape-Sequenzen enthält:1
class Prog {
public static void main(String[] args) {
javax.swing.JOptionPane.showMessageDialog(null, "\u03b1, \u03b2, \u03b3");
}
}
Beim Programmstart erscheint das folgende Fenster:
1
Im Manuskript wird überwiegend an Stelle des betagten GUI-Frameworks Swing die moderne Alternative JavaFX
(alias OpenJFX) verwendet. Beim aktuellen Beispiel verursacht die JavaFX-Variante aber erheblich mehr Aufwand
und Vorgriffe auf noch unbehandelte Kursthemen (z. B. Vererbung):
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.application.Application;
import javafx.stage.Stage;
2
Da Java eine streng typisierte Programmiersprache ist, und das Literal null einen Ausdruck darstellt (vgl. Abschnitt
3.5), muss es einen Datentyp besitzen. Es ist der Nulltyp (engl.: null type). Weil es in Java keinen Bezeichner für
den Nulltyp gibt, kann man keine Variable von diesem Typ deklarieren. Wie das folgende Zitat aus der aktuellen Ja-
va-Sprachspezifikation (Gosling et al. 2021, S. 54) belegt, müssen Sie sich um den Nulltyp keine Gedanken machen:
In practice, the programmer can ignore the null type and just pretend that null is merely a special literal that can
be of any reference type.
120 Kapitel 3 Elementare Sprachelemente
Auf ungültige Benutzereingaben reagiert die Methode nextInt() mit einem sogenannten Ausnah-
mefehler (siehe Kapitel 11), und das Programm „stürzt ab“, z. B.:
Argument: vier
Exception in thread "main" java.util.InputMismatchException
at java.base/java.util.Scanner.throwFor(Scanner.java:939)
at java.base/java.util.Scanner.next(Scanner.java:1594)
at java.base/java.util.Scanner.nextInt(Scanner.java:2258)
at java.base/java.util.Scanner.nextInt(Scanner.java:2212)
at Prog.main(Prog.java:6)
Es wäre nicht allzu aufwändig, in der Fakultätsanwendung ungültige Eingaben abzufangen. Aller-
dings stehen uns die erforderlichen Programmiertechniken (der Ausnahmebehandlung) noch nicht
zur Verfügung, und außerdem ist bei den möglichst kurzen Demonstrations- bzw. Übungspro-
grammen jeder Zusatzaufwand störend.
1
Mit den Paketen und Modulen der Standardbibliothek werden wir uns später ausführlich beschäftigen. An dieser
Stelle dient die Angabe der Paket- und Modulzugehörigkeit dazu, eine Klasse eindeutig zu identifizieren und die
Standardbibliothek allmählich kennenzulernen.
Abschnitt 3.4 Eingabe bei Konsolenprogrammen 121
Fakultät: 1.0
Die Simput-Klassenmethode gint() besitzt den Zugriffsmodifikator public, liefert eine Rückga-
be vom Typ int und hat keine Parameter. Diese Eigenschaften werden durch den Methodenkopf
dokumentiert:
public static int gint()
Auch in der Java-API - Dokumentation wird zur Beschreibung einer Methode deren Definitions-
kopf angegeben, z. B. bei der statischen Methode exp() der Klasse Math im Paket java.lang:
1
Die Datei Simput.java ist im folgenden Verzeichnis zu finden (weitere Ortsangaben siehe Vorwort):
…\BspUeb\Simput\Standardpaket\IntelliJ-Projekt\src
Die zugehörige Bytecode-Datei Simput.class steckt im Ordner
...\BspUeb\Simput\Standardpaket\IntelliJ-Projekt\out\production\Simput
und der Bequemlichkeit auch im Ordner
…\BspUeb\Simput\Standardpaket
122 Kapitel 3 Elementare Sprachelemente
Ergänzend liefert die API-Dokumentation aber auch Informationen zur Arbeitsweise der Methode,
zur Rolle der Parameter, zum Inhalt der Rückgabe und ggf. zu den möglichen Ausnahmefehlern.
Bei gint() oder anderen Simput-Methoden, die auf Eingabefehler nicht mit einer Ausnahme rea-
gieren (vgl. Abschnitt 11), kann man sich durch einen Aufruf der Simput-Klassenmethode
checkError() mit Rückgabetyp boolean darüber informieren, ob ein Fehler aufgetreten ist
(Rückgabewert true) oder nicht (Rückgabewert false). Die Simput-Klassenmethode
getErrorDescription() hält im Fehlerfall darüber hinaus eine Erläuterung bereit.1 In obigem
Beispielprogramm ignoriert die aufrufende Methode main() allerdings die diagnostischen Informa-
tionen und liefert ggf. eine unpassende Ausgabe. Wir werden in vielen weiteren Beispielprogram-
men den gint() - Rückgabewert der Kürze halber ohne Fehlerstatuskontrolle benutzen. Bei An-
wendungen für den praktischen Einsatz sollte aber wie in der folgenden Variante des Fakultätspro-
gramms eine Überprüfung stattfinden. Die dazu erforderliche if-Anweisung wird im Abschnitt 3.7.2
behandelt.
Quellcode Eingabe (grün, kursiv) und Ausgabe
class Prog { Argument: vier
public static void main(String args[]) { Falsche Eingabe!
System.out.print("Argument: ");
int argument = Simput.gint(); Die Eingabe konnte nicht konvertiert werden.
double fakul = 1.0;
if (!Simput.checkError()) {
for (int i = 2; i <= argument; i += 1)
fakul = fakul * i;
System.out.println("Fakultät: "+fakul);
} else
System.out.println(
Simput.getErrorDescription());
}
}
1
Weil Simput der Einfachheit halber mit statischen Methoden arbeitet, darf die Klasse nicht simultan durch mehrere
Threads verwendet werden. Ansonsten könnten die Rückgaben von checkError() und getErrorDescription()
auf die zwischenzeitliche Tätigkeit eines anderen Threads zurückgehen. Mit dem Multithreading werden wir uns in
Kapitel 15 beschäftigen.
Abschnitt 3.4 Eingabe bei Konsolenprogrammen 123
Neben gint() besitzt die Klasse Simput noch analoge Methoden für andere Datentypen, u. a.:
• public static long glong()
Liest eine ganze Zahl vom Typ long von der Konsole
• public static double gdouble()
Liest eine Gleitkommazahl vom Typ double von der Konsole, wobei das erwartete Dezi-
maltrennzeichen vom eingestellten Gebietsschema des Benutzers abhängt. Bei der Einstel-
lung de_DE wird ein Dezimalkomma erwartet.
• public static char gchar()
Liest ein Zeichen von der Konsole
3.4.2 Eine globale Bibliothek mit der Klasse Simput in IntelliJ einrichten
Benutzt ein Programm die Klasse Simput, dann muss ...
• beim Übersetzen des Programms durch den OpenJDK-Compiler (javac.exe) entweder die
Quellcodedatei Simput.java im aktuellen Verzeichnis liegen, oder die Bytecode-Datei
Simput.class über den Klassenpfad auffindbar sein,
• bei der Ausführung des Programms durch die JVM (java.exe) die Bytecode-Datei Sim-
put.class über den Klassenpfad auffindbar sein.
Der Klassenpfad kann über die CLASSPATH-Umgebungsvariable oder durch die beim Compiler-
bzw. Interpreter-Aufruf verwendete classpath - Option definiert werden (vgl. Abschnitt 2.2.4).
Unsere Entwicklungsumgebung IntelliJ IDEA ignoriert die CLASSPATH-Umgebungsvariable,
bietet aber die äquivalente Möglichkeit zur Definition von Bibliotheken auf Modul-, Projekt- oder
IDE-Ebene.1 Beim Aufruf der Werkzeuge zum Übersetzen oder Starten von Java-Programmen
(z. B. javac.exe oder java.exe) erstellt IntelliJ jeweils eine -classpath - Option aus den Biblio-
theksdefinitionen.
Damit eine als Bytecode vorliegende Klasse bei der Übersetzung im Rahmen der Entwicklungsum-
gebung gefunden wird, sollte sie unbedingt in einer Java-Archivdatei vorliegen. Im Ordner
…\BspUeb\Simput\Standardpaket
(weitere Ortsangaben im Vorwort) finden Sie daher neben der Bytecode-Datei Simput.class auch
die Archivdatei Simput.jar. Wir werden uns im Kapitel 6 mit Java-Archivdateien beschäftigen.
Wir definieren nun die Datei Simput.jar als IDE-globale Bibliothek, die in beliebigen Projekten
genutzt werden kann. Nach
File > Project Structure > Global Libraries
klicken wir im folgenden Dialog
1
Mit der IDE-Ebene ist die Ebene der integrierten Entwicklungsumgebung (engl.: integrated development environ-
ment, Abkürzung: IDE) gemeint.
124 Kapitel 3 Elementare Sprachelemente
Der Übernahme in das Projekt bzw. Modul Prog, das zum Üben von diversen elementaren Spra-
chelementen dient, kann zugestimmt werden:
in einem konkreten Projekt bzw. Modul benutzt werden kann, muss sie in die Liste der Abhängig-
keiten des Moduls aufgenommen werden. Für das aktuell geöffnete Projekt ist das eben schon ge-
schehen. Bei einem anderen Projekt öffnet man nach
File > Project Structure > Modules
im folgenden Fenster für das meist einzige vorhandene IntelliJ-Modul die Registerkarte Depend-
encies:
Nach einem Klick auf den Schalter über der Liste der Bibliotheken entscheidet man sich für die
Kategorie Library
Nun können die statischen Methoden der Klasse Simput im Projekt genutzt werden.
az = az - an;
Ausdruck
Durch diese Anweisung aus der kuerze() - Methode unserer Klasse Bruch (siehe Abschnitt 1.1)
wird der lokalen int-Variablen az der Wert des Ausdrucks az - an zugewiesen. Wie in diesem
Beispiel landen die Werte von Ausdrücken oft in Variablen, wobei Ausdruck und Variable typkom-
patibel sein müssen. Den Datentyp eines Ausdrucks bestimmen im Wesentlichen die Datentypen
der Argumente, manchmal beeinflusst aber auch der Operator den Typ des Ausdrucks (z.B. bei ei-
nem Vergleichsoperator).
1
Im Abschnitt 3.5.8 werden Sie eine Möglichkeit kennenlernen, diese Anweisung etwas kompakter zu formulieren.
Abschnitt 3.5 Operatoren und Ausdrücke 127
Schon bei einem Literal, einer Variablen oder einem Methodenaufruf haben wir es mit einem Aus-
druck zu tun.1
Beispiele:
• 1.5
Dieses Gleitkommaliteral ist ein Ausdruck mit dem Typ double und dem Wert 1,5.
• Simput.gint()
Dieser Methodenaufruf ist ein Ausdruck mit dem Typ int (= Rückgabetyp der Methode),
wobei die Eingabe des Benutzers über den Wert entscheidet (siehe Abschnitt 3.4.1 zur Be-
schreibung der Klassenmethode Simput.gint(), die nicht zum Java-API gehört).
Mit Hilfe diverser Operatoren entsteht ein komplexerer Ausdruck, dessen Typ und Wert von den
Argumenten und den Operatoren abhängen.
Beispiele:
• 2 * 1.5
Hier resultiert der double-Wert 3,0.
• 2 * 3
Hier resultiert der int-Wert 6.
• 2 > 1.5
Hier resultiert der boolean-Wert true.
In der Regel beschränken sich die Operatoren darauf, aus ihren Argumenten (Operanden) einen
Wert zu ermitteln und für die weitere Verarbeitung zur Verfügung zu stellen. Einige Operatoren
haben jedoch zusätzlich einen Nebeneffekt auf eine als Argument fungierende Variable, z. B.:
int i = 12;
int j = i++;
In der zweiten Anweisung des Beispiels tritt der Postinkrementoperator ++ mit der int-Variablen
i als Argument auf. Der Ausdruck i++ hat den Typ int und den Wert 12, welcher in der Zielvariab-
len j landet. Außerdem wird die Argumentvariable i beim Auswerten des Ausdrucks durch den
Postinkrementoperator auf den neuen Wert 13 gebracht.
Die meisten Operatoren verarbeiten zwei Operanden (Argumente) und heißen daher zweistellig
oder binär. Im folgenden Beispiel ist der Additionsoperator zu sehen, der zwei numerische Ar-
gumente erwartet:
a + b
Manche Operatoren begnügen sich mit einem Argument und heißen daher einstellig oder unär. Als
Beispiel haben wir eben schon den Postinkrementoperator kennengelernt. Ein weiteres ist der Nega-
tionsoperator, der durch ein Ausrufezeichen dargestellt wird, ein Argument vom Typ boolean er-
wartet und dessen Wahrheitswert umdreht (true und false vertauscht), z. B.:
!cond
Wir werden auch noch einen dreistelligen (ternären) Operator kennenlernen.
Weil Ausdrücke von passendem Ergebnistyp als Argumente einer Operation erlaubt sind, können
beliebig komplexe Ausdrücke aufgebaut werden. Unübersichtliche Exemplare sollten jedoch als
potentielle Fehlerquellen vermieden werden.
1
Besteht ein Ausdruck aus einem Methodenaufruf mit dem Pseudorückgabetyp void, dann liegt allerdings kein Wert
vor.
128 Kapitel 3 Elementare Sprachelemente
Bei der Ganzzahldivision werden die Nachkommastellen abgeschnitten, was gelegentlich durchaus
erwünscht ist. Im Zusammenhang mit dem Über- bzw. Unterlauf (siehe Abschnitt 3.6) werden Sie
noch weitere Unterschiede zwischen der Ganzzahl- und der Gleitkommaarithmetik kennenlernen.
Trifft ein arithmetischer Operator auf Argumente mit unterschiedlichen Datentypen, dann findet vor
der Berechnung automatisch eine erweiternde Typanpassung statt, bei der z. B. ein ganzzahliges
Argument in einen Gleitkommatyp gewandelt wird (siehe Abschnitt 3.5.7.1). Im obigen Beispiel-
programm trifft der Divisionsoperator im Ausdruck
a / j
auf ein double- und ein int-Argument. In dieser Situation wird der int-Wert in den „größeren“ Typ
double gewandet, bevor schließlich die Gleitkommaarithmetik zum Einsatz kommt.
Wie der vom Compiler gewählte Arithmetiktyp und der Ergebnisdatentyp von den Datentypen der
Argumente abhängen, ist der folgenden Tabelle zu entnehmen:
Verwendete Datentyp des
Datentypen der Operanden
Arithmetik Ergebniswertes
Beide Operanden haben den Typ byte, short,
int
char oder int (nicht unbedingt denselben).
Ganzzahlarithmetik
Beide Operanden haben einen integralen Typ,
long
und mind. ein Operand hat den Datentyp long.
Mindestens ein Operand hat den Typ float, kei-
float
ner hat den Typ double. Gleitkomma-
Mindestens ein Operand hat den Datentyp arithmetik
double
double.
In der nächsten Tabelle werden alle arithmetischen Operatoren beschrieben, wobei die Platzhalter
Num, Num1 und Num2 für Ausdrücke mit einem numerischen Typ stehen, und Var für eine numeri-
sche Variable:
Abschnitt 3.5 Operatoren und Ausdrücke 129
Beispiel
Operator Bedeutung
Programmfragment Ausgabe
-Num Vorzeichenumkehr int i = 2, j = -3;
System.out.printf("%d %d",-i,-j); -2 3
Num1 + Num2 Addition System.out.println(2 + 3); 5
Num1 – Num2 Subtraktion System.out.println(2.6 - 1.1); 1.5
Num1 * Num2 Multiplikation System.out.println(4 * 5); 20
Num1 / Num2 Division System.out.println(8.0 / 5); 1.6
System.out.println(8 / 5); 1
Num1 % Num2 Modulo (Divisionsrest) System.out.println(19 % 5); 4
Sei GAD der ganzzahlige An- System.out.println(-19 % 5.25); -3.25
teil aus dem Ergebnis der Di-
vision (Num1 / Num2). Dann
ist Num1 % Num2 def. durch
Num1 - GAD Num2
++Var Präinkrement bzw. int i = 4;
--Var -dekrement double a = 0.2;
System.out.println(++i + "\n" + 5
Als Argumente sind hier nur --a); -0.8
Variablen erlaubt.
++Var erhöht Var um 1 und
liefert Var + 1
--Var reduz. Var um 1 und
liefert Var - 1
Var++ Postinkrement bzw. int i = 4;
Var-- -dekrement System.out.println(i++ + "\n" + 4
i); 5
Als Argumente sind hier nur
Variablen erlaubt.
Var++ liefert Var und
erhöht Var um 1
Var-- liefert Var und
reduziert Var um 1
Bei den Inkrement- bzw. Dekrementoperatoren ist zu beachten, dass sie zwei Effekte haben:
• Das Argument wird ausgelesen, um den Wert des Ausdrucks zu ermitteln.
• Die als Argument fungierende numerische Variable wird vor oder nach dem Auslesen ver-
ändert. Wegen dieses Nebeneffekts sind Inkrement- bzw. Dekrementausdrücke im Unter-
schied zu den sonstigen arithmetischen Ausdrücken bereits vollständige Anweisungen (vgl.
Abschnitt 3.7.1), wenn man ein Semikolon dahinter setzt, z. B.:
Quellcode Ausgabe
class Prog { 13
public static void main(String[] args) {
int i = 12;
i++;
System.out.println(i);
}
}
Ein In- bzw. Dekrementoperator erhöht bzw. vermindert durch seinen Nebeneffekt den Wert einer
Variablen um 1 und bietet für diese oft benötigte Operation eine vereinfachte Schreibweise. So ist
z. B. die folgende Anweisung
j = ++i;
130 Kapitel 3 Elementare Sprachelemente
• Man kann bei einer Gleitkommazahl den gebrochenen Anteil ermitteln bzw. abspalten:1
Quellcode-Fragment Ausgabe
double a = 7.124824;
double rest = a % 1.0;
double ganz = a - rest;
System.out.printf("%f = %1.0f + %f", a, ganz, rest); 7,124824 = 7 + 0,124824
Der Modulo-Operator wird meist auf zwei ganzzahlige Argumente angewendet, sodass nach der
Tabelle auf Seite 128 auch das Ergebnis einen ganzzahligen Typ besitzt. Wie der zweite Punkt in
der letzten Aufzählung zeigt, kann die Modulo-Operation aber auch auf Gleitkommaargumente an-
gewendet werden, wobei ein Ergebnis mit Gleitkommatyp resultiert.
3.5.2 Methodenaufrufe
Obwohl Ihnen eine gründliche Behandlung der Methoden noch bevorsteht, haben Sie doch schon
einige Erfahrungen mit diesen Handlungskompetenzen von Klassen bzw. Objekten gesammelt:
• Die Arbeitsweise einer Methode kann von Argumenten (Parametern) abhängen.
• Viele Methoden liefern ein Ergebnis an den Aufrufer. Die im Abschnitt 3.4.1 vorgestellte
Methode Simput.gint() liefert z. B. einen int-Wert. Bei der Methodendefinition ist der
Datentyp der Rückgabe anzugeben (siehe Syntaxdiagramm im Abschnitt 3.1.3.2). Liefert ei-
ne Methode dem Aufrufer kein Ergebnis, dann ist in der Definition der Pseudo-Rückgabetyp
void anzugeben.
• Neben der Wertrückgabe hat ein Methodenaufruf oft weitere Effekte, z. B. auf die Merk-
malsausprägungen des handelnden Objekts oder auf die Konsolenausgabe.
In syntaktischer Hinsicht ist festzuhalten, dass ein Methodenaufruf einen Ausdruck darstellt, wobei
seine Rückgabe den Datentyp und den Wert des Ausdrucks bestimmt.
Bei passendem Rückgabetyp darf ein Methodenaufruf auch als Argument für komplexere Ausdrü-
cke oder für Methodenaufrufe verwendet werden (siehe Abschnitt 4.3.1.2). Bei einer Methode ohne
Rückgabewert resultiert ein Ausdruck vom Typ void, der nicht als Argument für Operatoren oder
andere Methoden taugt.
Ein Methodenaufruf mit angehängtem Semikolon stellt eine Anweisung dar (vgl. Abschnitt 3.7),
was Sie z. B. bei den zahlreichen Einsätzen der statischen Methode println() in unseren Beispiel-
programmen beobachten konnten.
1
Der ganzzahlige Anteil eines double-Werts lässt sich auch über die statische Methode floor() aus der Klasse Math
ermitteln. Für eine double-Variable d mit einem nicht-negativen Wert ist d-Math.floor(d) identisch mit d%1.0.
Abschnitt 3.5 Operatoren und Ausdrücke 131
Mit den im Abschnitt 3.5.1 beschriebenen arithmetischen Operatoren lassen sich nur elementare
mathematische Probleme lösen. Darüber hinaus stellt Java eine große Zahl mathematischer Stan-
dardfunktionen (z. B. Potenzfunktion, Logarithmus, Wurzel, trigonometrische Funktionen) über
statische Methoden der Klasse Math im API-Paket java.lang (ab Java 9 im Modul java.base) zur
Verfügung.1 Im folgenden Programm wird die Methode pow() zur Berechnung der allgemeinen
Potenzfunktion ( b e ) genutzt:
Quellcode Ausgabe
class Prog { 8.0
public static void main(String[] args) {
System.out.println(Math.pow(2, 3));
}
}
Im Beispielprogramm liefert die Methode pow() einen Rückgabewert vom Typ double, der gleich
als Argument der Methode println() Verwendung findet. Solche Verschachtelungen sind bei Pro-
grammierern wegen ihrer Kompaktheit ähnlich beliebt wie die Inkrement- bzw. Dekrementoperato-
ren. Ein etwas umständliches, aber für Einsteiger leichter nachvollziehbares Äquivalent zum obigen
println() - Aufruf könnte z. B. so aussehen:
double d;
d = Math.pow(2.0, 3.0);
System.out.println(d);
3.5.3 Vergleichsoperatoren
Durch die Anwendung eines Vergleichsoperators auf zwei komparable (miteinander vergleichbare)
Ausdrücke entsteht ein Vergleich. Dies ist ein einfacher logischer Ausdruck (vgl. Abschnitt 3.5.5).
Folglich kann ein Vergleich die booleschen Werte true (wahr) und false (falsch) annehmen und zur
Formulierung einer Bedingung verwendet werden. Das folgende Beispiel dürfte verständlich sein,
obwohl die if-Anweisung noch nicht behandelt wurde:
if (arg > 0)
System.out.println(Math.log(arg));
1
Mit den Paketen und Modulen der Standardbibliothek werden wir uns später ausführlich beschäftigen. An dieser
Stelle dient die Angabe der Paket- und Modulzugehörigkeit dazu, eine Klasse eindeutig zu identifizieren und die
Standardbibliothek allmählich kennenzulernen. Das Paket java.lang wird im Unterschied zu allen anderen API-
Paketen automatisch in jede Quellcodedatei importiert.
132 Kapitel 3 Elementare Sprachelemente
In der folgenden Tabelle mit den von Java unterstützten Vergleichsoperatoren stehen
• Expr1 und Expr2 für komparable Ausdrücke
• Num1 und Num2 für numerische Ausdrücke (mit dem Datentyp byte, short, int, long, char,
float oder double)
Beispiel
Operator Bedeutung
Programmfragment Ausgabe
String s = "2.4";
Expr1 = = Expr2 gleich true
System.out.println(s == "2.4");
Expr1 != Expr2 ungleich System.out.println(2 != 3); true
Num1 > Num2 größer System.out.println(3 > 2); true
Num1 < Num2 kleiner System.out.println(3 < 2); false
Num1 >= Num2 größer oder gleich System.out.println(3 >= 3); true
Num1 <= Num2 kleiner oder gleich System.out.println(3 <= 2); false
Achten Sie unbedingt darauf, dass der Identitätsoperator durch zwei „=“ - Zeichen ausgedrückt
wird. Ein nicht ganz seltener Java-Programmierfehler besteht darin, beim Identitätsoperator nur ein
Gleichheitszeichen zu schreiben. Dabei muss nicht unbedingt ein harmloser Syntaxfehler entstehen,
der nach dem Studium einer Compiler-Fehlermeldung leicht zu beseitigen ist, sondern es kann auch
ein unangenehmer Logikfehler resultieren, also ein irreguläres Verhalten des Programms (vgl. Ab-
schnitt 2.2.5 zur Unterscheidung von Syntax- und Logikfehlern). Im ersten println() - Aufruf des
folgenden Beispielprogramms wird das Ergebnis eines Vergleichs auf die Konsole geschrieben:1
Quellcode Ausgabe
class Prog { false
public static void main(String[] args) { 1
int i = 1;
System.out.println(i == 2);
System.out.println(i);
}
}
Nach dem Entfernen eines Gleichheitszeichens wird aus dem logischen Ausdruck ein Wertzuwei-
sungsausdruck (siehe Abschnitt 3.5.8) mit dem Datentyp int und dem Wert 2:
Quellcode Ausgabe
class Prog { 2
public static void main(String[] args) { 2
int i = 1;
System.out.println(i = 2);
System.out.println(i);
}
}
Die versehentlich entstandene Zuweisung sorgt nicht nur für eine unerwartete Ausgabe, sondern
verändert auch den Wert der Variablen i, was im weiteren Verlauf eines Programms unangenehme
Folgen haben kann.
1
Wir wissen schon aus dem Abschnitt 3.2, dass println() einen beliebigen Ausdruck verarbeiten kann, wobei auto-
matisch eine Zeichenfolgen-Repräsentation erstellt wird.
Abschnitt 3.5 Operatoren und Ausdrücke 133
Der Vergleich
10.0 - 9.9 == 0.1
führt trotz des Datentyps double (mit mindestens 15 signifikanten Dezimalstellen) zum Ergebnis
false. Wenn man die im Abschnitt 3.3.7.1 beschriebenen Genauigkeitsprobleme bei der Speiche-
rung von binären Gleitkommazahlen berücksichtigt, ist das Vergleichsergebnis durchaus nicht über-
raschend. Im Kern besteht das Problem darin, dass mit der binären Gleitkommatechnik auch relativ
„glatte“ rationale Zahlen (wie z. B. 0,1) nicht exakt gespeichert werden können. Im zwischenge-
speicherten Berechnungsergebnis 10,0 - 9,9 steckt ein anderer Fehler als im Speicherabbild der Zahl
0,1. Weil die Vergleichspartner nicht Bit für Bit identisch sind, meldet der Identitätsoperator das
Ergebnis false.
Bei der Ausgabe eines Gleitkommawerts, also bei der Wandlung des Werts in eine Zeichenfolge,
verwendet Java eine Glättungstechnik an, die eine Beurteilung der Verhältnisse im Hauptspeicher
erschwert. Obwohl z. B. die Zahl 0,1 im Hauptspeicher nicht exakt gespeichert werden kann, liefern
die folgenden Anweisungen
double tenth = 0.1;
System.out.println(tenth);
die Ausgabe
0.1
Daran ist wesentlich die Methode toString() der Klasse Double beteiligt, die zum double-Wert d
nach den folgenden Regeln eine Zeichenfolge produziert:1
• Es wird mindestens eine Nachkommastelle produziert.
• Es werden nur so viele weitere Nachkommastellen produziert, bis sich die Zeichenfolge zu d
von den Zeichenfolgen zum nächstkleineren bzw. nächstgrößeren möglichen double-Wert
unterscheidet.
Mit Hilfe der im Abschnitt 3.3.7 vorgestellten und insbesondere für Anwendungen im Bereich der
Finanzmathematik empfohlenen Klasse BigDecimal gewinnt man einen korrekten Eindruck von
den Verhältnissen im Hauptspeicher. Das folgende Programm
1
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Double.html#toString(double)
134 Kapitel 3 Elementare Sprachelemente
import java.math.BigDecimal;
class Prog {
public static void main(String[] args) {
double tenth = 0.1;
BigDecimal tenthBD = new BigDecimal(tenth);
System.out.println(tenth);
System.out.println(tenthBD);
}
}
liefert die Ausgabe:
0.1
0.1000000000000000055511151231257827021181583404541015625
Verwendet man String-Parameter im Konstruktor der Klasse BigDecimal, dann entfallen die für
binäre Gleitkommawerte beschriebenen Probleme bei der Speichergenauigkeit und bei Identitäts-
vergleichen, z. B.:
Quellcode Ausgabe
import java.math.*; true
class Prog {
public static void main(String[] args) {
BigDecimal bd1 = new BigDecimal("10.0");
BigDecimal bd2 = new BigDecimal("9.9");
BigDecimal bd3 = new BigDecimal("0.1");
System.out.println(bd3.equals(bd1.subtract(bd2)));
}
}
Die Vergabe der d1-Rolle, also die Wahl des Nenners, ist beliebig. Um das Verfahren vollständig
festzulegen, wird die Verwendung der betragsmäßig größeren Zahl vorgeschlagen.
Ein Vorschlag zur Definition der numerischen Identität von zwei double-Werten muss die relative
Differenz zugrunde legen, weil die technisch bedingten Mantissen-Fehler bei zwei double-
Variablen mit eigentlich identischem Wert in Abhängigkeit vom Exponenten zu sehr unterschiedli-
chen Gesamtfehlern führen können. Vom gelegentlich anzutreffenden Vorschlag, die betragsmäßige
Differenz
d1 − d2
mit einer Schwelle zu vergleichen, ist daher abzuraten. Dieses Verfahren ist (bei geeignet gewählter
Schwelle) nur tauglich für Zahlen in einem engen Größenbereich. Bei einer Änderung der Größen-
ordnung muss die Schwelle angepasst werden.
Abschnitt 3.5 Operatoren und Ausdrücke 135
d1 − d 2
Zu einer Schwelle für die relative Abweichung gelangt man durch Betrachtung von zwei
d1
normalisierten double-Variablen d1 und d2, die bis auf ihre durch begrenzte Speicher- und Rechen-
genauigkeit bedingten Mantissenfehler e1 bzw. e2 denselben Wert (1 + m) 2k enthalten:
d1 = (1 + m + e1) 2k und d2 = (1 + m + e2) 2k
Bei einem normalisierten double-Wert (mit 52 Mantissen-Bits) kann aufgrund der begrenzten Spei-
chergenauigkeit als maximaler absoluter Mantissenfehler der halbe Abstand zwischen zwei be-
nachbarten Mantissenwerten auftreten:
= 2 −53 1,1 10 -16
Für den Betrag der technisch bedingten relativen Abweichung von zwei eigentlich identischen nor-
malisierten Werten (mit einer Mantisse im Intervall [1, 2)) gilt die Abschätzung:
d1 − d 2 e −e e + e2 2
= 1 2 1 2 ( wegen (1 + m + e1 ) [1, 2))
d1 1 + m + e1 1 + m + e1 1 + m + e1
Die oben vorgeschlagene Schwelle 1,010-14 berücksichtigt über den Speicherfehler hinaus auch
noch eingeflossene Rechnungsungenauigkeiten. Mit welcher Fehlerkumulation bzw. -verstärkung
zu rechnen ist, hängt vom konkreten Algorithmus ab, sodass die Unterschiedlichkeitsschwelle even-
tuell angehoben werden muss. Immerhin hängt sie (anders als bei einem Kriterium auf Basis des
Betrags der einfachen Differenz d1 − d 2 ) nicht von der Größenordnung der Zahlen ab.
An der vorgeschlagenen Identitätsbeurteilung mit Hilfe einer Schwelle für den relativen Abwei-
chungsbetrag ist u. a. zu bemängeln, dass eine Verallgemeinerung für die mit einer geringeren rela-
tiven Genauigkeit gespeicherten denormalisierten Werte (Betrag kleiner als 2-1022 beim Typ double,
siehe Abschnitt 3.3.7.1) benötigt wird.
Dass die definierte numerische Identität nicht transitiv ist, muss hingenommen werden. Für drei
double-Werte a, b und c kann also das folgende Ergebnismuster auftreten:
• a numerisch identisch mit b
• b numerisch identisch mit c
• a nicht numerisch identisch mit c
Für den Vergleich einer double-Zahl a mit dem Wert 0.0 ist eine Schwelle für die absolute Abwei-
chung (statt der relativen) sinnvoll, z. B.:
a 1,0 10-14
Die besprochenen Genauigkeitsprobleme sind auch bei den gerichteten Vergleichen (<, <=, >, >=)
relevant.
Bei vielen naturwissenschaftlichen oder technischen Problemen ist es generell wenig sinnvoll, zwei
Größen auf exakte Übereinstimmung zu testen, weil z. B. schon aufgrund von Messungenauigkeiten
eine Abweichung von der theoretischen Identität zu erwarten ist. Bei Verwendung einer anwen-
dungslogisch gebotenen Unterschiedsschwelle dürften die technischen Beschränkungen der Gleit-
kommatypen keine große Rolle mehr spielen. Präzisere Aussagen zur Computer-Arithmetik finden
sich z. B. bei Strey (2005).
136 Kapitel 3 Elementare Sprachelemente
Mit den anschließend beschriebenen binären logischen Operatoren erstellt man aus zwei Argu-
mentausdrücken einen Ergebnisausdruck:
Argument 1 Argument 2 Logisches UND Logisches ODER Exklusives ODER
LA1 LA2 LA1 && LA2 LA1 || LA2 LA1 ^ LA2
LA1 & LA2 LA1 | LA2
true true true true false
true false false true true
false true false true true
false false false false false
Es folgt eine Tabelle mit Erläuterungen und Beispielen zu den logischen Operatoren:
Beispiel
Operator Bedeutung
Programmfragment Ausgabe
!LA Negation boolean erg = true;
Der Wahrheitswert wird durch sein System.out.println(!erg); false
Gegenteil ersetzt.
LA1 && LA2 Logisches UND mit bedingter int i = 3;
Auswertung boolean erg = false && i++ > 3;
System.out.println(erg + "\n"+i); false
LA1 && LA2 ist genau dann wahr, 3
wenn beide Argumente wahr sind.
Ist LA1 falsch, wird LA2 nicht erg = true && i++ > 3;
ausgewertet. System.out.println(erg + "\n"+i); false
4
LA1 & LA2 Logisches UND mit unbedingter int i = 3;
Auswertung boolean erg = false & i++ > 3;
System.out.println(erg + "\n"+i); false
LA1 & LA2 ist genau dann wahr, 4
wenn beide Argumente wahr sind.
Es werden auf jeden Fall beide
Ausdrücke ausgewertet.
Abschnitt 3.5 Operatoren und Ausdrücke 137
Beispiel
Operator Bedeutung
Programmfragment Ausgabe
LA1 || LA2 Logisches ODER mit bedingter int i = 3;
Auswertung boolean erg = true || i++ == 3;
System.out.println(erg + "\n"+i); true
LA1 || LA2 ist genau dann wahr, 3
wenn mindestens ein Argument
wahr ist. Ist LA1 wahr, wird LA2 erg = false || i++ == 3;
nicht ausgewertet. System.out.println(erg + "\n"+i); true
4
LA1 | LA2 Logisches ODER mit unbeding- int i = 3;
ter Auswertung boolean erg = true | i++ == 3;
System.out.println(erg + "\n"+i); true
LA1 | LA2 ist genau dann wahr, 4
wenn mindestens ein Argument
wahr ist. Es werden auf jeden Fall
beide Ausdrücke ausgewertet.
LA1 ^ LA2 Exklusives logisches ODER boolean erg = true ^ true;
LA1 ^ LA2 ist genau dann wahr, System.out.println(erg); false
wenn genau ein Argument wahr
ist, wenn also die Argumente ver-
schiedene Wahrheitswerte haben.
Der Unterschied zwischen den beiden logischen UND-Operatoren && und & bzw. zwischen den
beiden logischen ODER-Operatoren || und | ist für Einsteiger vielleicht wenig beeindruckend, weil
man spontan den nicht ausgewerteten logischen Ausdrücken keine Bedeutung beimisst. Allerdings
ist es in Java nicht unüblich, „Nebeneffekte“ in einen logischen Ausdruck einzubauen, z. B.
bv & i++ > 3
Hier erhöht der Postinkrementoperator beim Auswerten des rechten &-Arguments den Wert der
Variablen i. Eine solche Auswertung wird jedoch in der folgenden Variante des Beispiels (mit
&&-Operator) unterlassen, wenn bereits nach Auswertung des linken &&-Arguments das Gesamt-
ergebnis false feststeht:
bv && i++ > 3
Das vom Programmierer nicht erwartete Ausbleiben einer Auswertung (z. B. bei i++) kann erhebli-
che Auswirkungen auf die Programmausführung haben.
Dank der beim Operator && realisierten bedingten Auswertung kann man sich im rechten Operan-
den darauf verlassen, dass der linke Operand den Wert true besitzt, was im folgenden Beispiel aus-
genutzt wird. Dort prüft der linke Operand die Existenz und der rechte Operand die Länge einer
Zeichenfolge:
if(str != null && str.length() < 10) {...}
Wenn die Referenzvariable str vom Typ der Klasse String keine Objektadresse enthält, darf der
rechte Ausdruck nicht ausgewertet werden, weil eine Längenanfrage an ein nicht existentes Objekt
zu einem Laufzeitfehler führen würde.
Mit der Entscheidung, grundsätzlich die unbedingte Operatorvariante zu verwenden, verzichtet man
auf die eben beschriebene Option, im rechten Ausdruck den Wert true des linken Ausdrucks vo-
raussetzen zu können, und man nimmt (mehr oder weniger relevante) Leistungseinbußen durch
überflüssige Auswertungen des rechten Ausdrucks in Kauf. Eher empfehlenswert ist der Verzicht
auf Nebeneffekt-Konstruktionen im Zusammenhang mit bedingt arbeitenden Operatoren.
Wie der Tabelle auf Seite 149 zu entnehmen ist, unterscheiden sich die beiden UND-Operatoren
&& und & bzw. die beiden ODER-Operatoren || und | auch hinsichtlich der Bindungskraft auf Ope-
randen (Auswertungspriorität).
138 Kapitel 3 Elementare Sprachelemente
Die bedingte Auswertung wird gelegentlich als Kurzschlussauswertung bezeichnet (engl.: short-
circuiting).
Um die Verwirrung noch ein wenig zu steigern, werden die Zeichen & und | auch für bitorientierte
Operatoren verwendet (siehe Abschnitt 3.5.6). Diese Operatoren erwarten zwei integrale Argumen-
te (z. B. mit dem Datentyp int), während die logischen Operatoren den Datentyp boolean voraus-
setzen. Folglich kann der Compiler erkennen, ob ein logischer oder ein bitorientierter Operator ge-
meint ist.
0000000001000000
Nach dem Links-Shift-Operator kommt der bitweise UND-Operator zum Einsatz:
1 << i & cbit
Das Operatorzeichen & wird in Java leider in doppelter Bedeutung verwendet: Wenn beide Argu-
mente vom Typ boolean sind, wird & als logischer Operator interpretiert (siehe Abschnitt 3.5.5).
Sind jedoch wie im vorliegenden Fall beide Argumente von integralem Typ, was auch für den Typ
char zutrifft, dann wird & als UND-Operator für Bits aufgefasst. Er erzeugt dann ein Bitmuster, das
an der Stelle k genau dann eine 1 enthält, wenn beide Argumentmuster an dieser Stelle eine 1 besit-
zen und anderenfalls eine 0. Hat in einem Programmablauf die char-Variable cbit z. B. den Wert
'x' erhalten (dezimale Unicode-Zeichensatznummer 120), dann ist dieses Bitmuster
0000000001111000
im Spiel, und 1 << i & cbit liefert z. B. bei i = 6 das Muster:
0000000001000000
Der von 1 << i & cbit erzeugte Wert hat den Typ int und kann daher mit dem int-Literal 0
verglichen werden:1
(1 << i & cbit) != 0
Dieser logische Ausdruck wird bei einem Schleifendurchgang genau dann wahr, wenn das zum ak-
tuellen i-Wert gehörende Bit in der Binärdarstellung des untersuchten Zeichens den Wert 1 besitzt.
char
(16 Bit)
1
Die runden Klammern sind erforderlich, um die korrekte Auswertungsreihenfolge zu erreichen (siehe Abschnitt
3.5.10).
140 Kapitel 3 Elementare Sprachelemente
Weil eine char-Variable die Unicode-Nummer eines Zeichens speichert, macht die Konvertierung
in numerische Typen kein Problem, z. B.:
Quellcode Ausgabe
class Prog { x/2 = 60
public static void main(String[] args) {
System.out.printf("x/2 = %5d", 'x'/2);
}
}
Noch eine Randnotiz zur impliziten Typanpassung bei numerischen Literalen: Während sich Java-
Compiler weigern, ein double-Literal in einer float-Variablen zu speichern, erlauben sie z. B. das
Speichern eines int-Literals in einer Variablen vom Typ byte (Ganzzahltyp mit 8 Bits), sofern der
Wertebereich dieses Typs nicht verlassen wird, z. B.:
a = 7294452388.13;
System.out.println((int)a);
}
}
Manchmal ist es erforderlich, einen Gleitkommawert in eine Ganzzahl zu wandeln, weil z. B. bei
einem Methodenaufruf für einen Parameter ein ganzzahliger Datentyp benötigt wird. Dabei werden
die Nachkommastellen abgeschnitten. Soll auf die nächstgelegene ganze Zahl gerundet werden,
addiert man vor der Typumwandlung 0,5 zum Gleitkommawert.
Es ist auf jeden Fall zu beachten, dass dabei eine einschränkende Konvertierung stattfindet, und
dass die zu erwartende Gleitkommazahl im Wertebereich des Ganzzahltyps liegen muss. Wie die
letzte Ausgabe zeigt, sind kapitale Programmierfehler möglich, wenn die Wertebereiche der betei-
ligten Datentypen nicht beachtet werden, und bei der Zielvariablen ein Überlauf auftritt (vgl. Ab-
schnitt 3.6.1). So soll die Explosion der europäischen Rakete Ariane 5 am 4. Juni 1996 (Schaden:
ca. 500 Millionen Dollar)
Abschnitt 3.5 Operatoren und Ausdrücke 141
( Zieltyp ) Ausdruck
Am Rand soll noch erwähnt werden, dass die Wandlung in einen Ganzzahltyp keine sinnvolle
Technik ist, um die Nachkommastellen in einem Gleitkommawert zu entfernen oder zu extrahieren.
Dazu kann man den Modulo-Operator verwenden (vgl. Abschnitt 3.5.1), ohne ein Wertebereichs-
problem befürchten zu müssen, z. B.:1
Quellcode Ausgabe
class Prog { 85347483648,13
public static void main(String[] args) { 2147483647
double a = 85347483648.13, b; 85347483648,00
int i = (int) a;
b = a - a%1;
System.out.printf("%15.2f%n%12d%n%15.2f", a, i, b);
}
}
3.5.8 Zuweisungsoperatoren
Bei den ersten Erläuterungen zu Wertzuweisungen (vgl. Abschnitt 3.3.8) blieb aus didaktischen
Gründen unerwähnt, dass eine Wertzuweisung ein Ausdruck ist, dass wir es also mit dem binären
(zweistelligen) Operator „=“ zu tun haben, für den die folgenden Regeln gelten:
1
Der ganzzahlige Anteil eines double-Werts lässt sich auch über die statische Methode floor() aus der Klasse Math
ermitteln. Für eine double-Variable d mit einem nicht-negativen Wert ist d-Math.floor(d) identisch mit d%1.0.
142 Kapitel 3 Elementare Sprachelemente
Beim Auswerten des Ausdrucks ivar = 4711 entsteht der an println() zu übergebende Wert
(identisch mit dem zugewiesenen Wert), und die Variable ivar wird verändert.
Selbstverständlich kann eine Zuweisung auch als Operand in einen übergeordneten Ausdruck inte-
griert werden, z. B.:
Quellcode Ausgabe
class Prog { 8
public static void main(String[] args) { 8
int i = 2, j = 4;
i = j = j * i;
System.out.println(i + "\n" + j);
}
}
Beim mehrfachen Auftreten des Zuweisungsoperators erfolgt eine Abarbeitung von rechts nach
links (vgl. Tabelle im Abschnitt 3.5.10), sodass die Anweisung
i = j = j * i;
folgendermaßen ausgeführt wird:
• Weil der Multiplikationsoperator eine höhere Bindungskraft besitzt als der Zuweisungsope-
rator (siehe Abschnitt 3.5.10.1), wird zuerst der Ausdruck j * i ausgewertet, was zum
Zwischenergebnis 8 (mit Datentyp int) führt.
• Nun wird die rechte Zuweisung ausgeführt. Der folgende Ausdruck mit dem Wert 8 und
dem Typ int
j = 8
verschafft der Variablen j einen neuen Wert.
• In der zweiten Zuweisung (bei Betrachtung von rechts nach links) wird der Wert des Aus-
drucks j = 8 an die Variable i übergeben.
Anweisungen der Art
i = j = k;
Abschnitt 3.5 Operatoren und Ausdrücke 143
Es ist eine vertretbare Entscheidung, in eigenen Programmen der Klarheit halber auf die Aktualisie-
rungsoperatoren zu verzichten. In fremden Programmen muss man aber mit diesen Operatoren
rechnen, und manche Entwicklungsumgebungen fordern sogar zu Ihrer Verwendung auf.
Ein weiteres Argument gegen die Aktualisierungsoperatoren ist die implizit darin enthaltene
Typwandlung. Während z. B. für die beiden Variablen
int ivar = 1;
double dvar = 3_000_000_000.0;
die folgende Zuweisung
ivar = ivar + dvar; // verboten
vom Compiler verhindert wird, weil der Ausdruck (ivar + dvar) den Typ double besitzt (vgl.
Tabelle mit den Ergebnistypen der arithmetischen Operationen im Abschnitt 3.5.1), akzeptiert der
Compiler die folgende Anweisung mit Aktualisierungsoperator:
ivar += dvar;
Es kommt zum Ganzzahlüberlauf (vgl. Abschnitt 3.6.1), und man erhält für ivar den ebenso sinn-
losen wie gefährlichen Wert 2147483647:
144 Kapitel 3 Elementare Sprachelemente
Quellcode Ausgabe
class Prog { 2147483647
public static void main(String[] args) {
int ivar = 1;
double dvar = 3_000_000_000.0;
ivar += dvar;
System.out.println(ivar);
}
}
In der Java-Sprachspezifikation (Gosling et al. 2021, Abschnitt 15.26.2) findet sich die folgende
Erläuterung zum Verhalten des Java-Compilers, der bei Aktualisierungsoperatoren eine untypische
und gefährliche Laxheit zeigt:
A compound assignment expression of the form E1 op= E2 is equivalent to E1 = (T)
((E1) op (E2)), where T is the type of E1, except that E1 is evaluated only once.
Der Ausdruck ivar += dvar steht also für
ivar = (int) (ivar + dvar)
und enthält eine riskante einschränkende Typanpassung.
Beim Einsatz eines Aktualisierungsoperators sollte der Wertebereich des rechten Operanden keines-
falls größer sein als der Wertebereich des linken Operanden, und es ist zu bedauern, dass keine ent-
sprechende Compiler-Regel existiert.
3.5.9 Konditionaloperator
Der Konditionaloperator erlaubt eine sehr kompakte Schreibweise, wenn beim neuen Wert für
eine Zielvariable bedingungsabhängig zwischen zwei Ausdrücken zu entscheiden ist, z. B.
i + j falls k 0
i=
i − j sonst
In Java ist für diese Zuweisung mit Fallunterscheidung nur eine einzige Zeile erforderlich:
Quellcode Ausgabe
class Prog { 3
public static void main(String[] args) {
int i = 2, j = 1, k = 7;
i = k>0 ? i+j : i-j;
System.out.println(i);
}
}
Eine Besonderheit des Konditionaloperators besteht darin, dass er drei Argumente verarbeitet, wel-
che durch die Zeichen ? und : getrennt werden:
Konditionaloperator
Ist der logische Ausdruck wahr, liefert der Konditionaloperator den Wert von Ausdruck 1, anderen-
falls den Wert von Ausdruck 2.
Die Frage nach dem Datentyp eines Konditionalausdrucks ist etwas knifflig, und in der Java
Sprachspezifikation werden zahlreiche Fälle unterschieden (Gosling et al. 2021, Abschnitt 15.25).
Abschnitt 3.5 Operatoren und Ausdrücke 145
Es liegt an Ihnen, sich auf den einfachsten und wichtigsten Fall zu beschränken: Wenn der zweite
und der dritte Operand denselben Datentyp haben, dann ist dies auch der Datentyp des Konditional-
ausdrucks.
3.5.10 Auswertungsreihenfolge
Bisher haben wir zusammengesetzte Ausdrücke mit mehreren Operatoren und das damit verbunde-
ne Problem der Auswertungsreihenfolge nach Möglichkeit gemieden. Wie sich gleich zeigen wird,
sind für Schwierigkeiten und Fehler bei der Verwendung zusammengesetzter Ausdrücke die fol-
genden Gründe hauptverantwortlich:
• Komplexität des Ausdrucks (Anzahl der Operatoren, Schachtelungstiefe)
• Operatoren mit Nebeneffekten
Um Problemen aus dem Weg zu gehen, sollte man also eine übertriebene Komplexität vermeiden
und auf Nebeneffekte weitgehend verzichten.
3.5.10.1 Regeln
In diesem Abschnitt werden die Regeln vorgestellt, nach denen der Java-Compiler einen Ausdruck
mit mehreren Operatoren auswertet.
1) Runde Klammern
Wenn aus den anschließend erläuterten Regeln zur Bindungskraft und Assoziativität der beteiligten
Operatoren nicht die gewünschte Operandenzuordnung bzw. Auswertungsreihenfolge resultiert,
dann greift man mit runden Klammern steuernd ein, wobei auch eine Schachtelung erlaubt ist.
Durch Klammern werden Terme zu einem Operanden zusammengefasst, sodass die internen Opera-
tionen ausgeführt sind, bevor der Klammerausdruck von einem externen Operator verarbeitet wird.
2) Bindungskraft (Priorität)
Steht ein Operand (ein Ausdruck) zwischen zwei Operatoren, dann wird er dem Operator mit der
stärkeren Bindungskraft (siehe Tabelle im Abschnitt 3.5.10.2) zugeordnet. Mit den numerischen
Variablen a, b und c als Operanden wird z. B. der Ausdruck
a + b * c
nach der Regel „Punktrechnung geht vor Strichrechnung“ interpretiert als
a + (b * c)
In der Konkurrenz um die Zuständigkeit für den Operanden b hat der Multiplikationsoperator Vor-
rang gegenüber dem Additionsoperator.
Die implizite Klammerung kann durch eine explizite Klammerung dominiert werden:
(a + b) * c
3) Assoziativität (Orientierung)
Steht ein Operand zwischen zwei Operatoren mit gleicher Bindungskraft, dann entscheidet deren
Assoziativität (Orientierung) über die Zuordnung des Operanden:
• Mit Ausnahme der Zuweisungsoperatoren sind alle binären Operatoren links-assoziativ.
Z. B. wird
x – y – z
ausgewertet als
(x – y) – z
146 Kapitel 3 Elementare Sprachelemente
Diese implizite Klammerung kann durch eine explizite Klammerung dominiert werden:
x – (y – z)
• Die Zuweisungsoperatoren sind rechts-assoziativ. Z. B. wird
a += b -= c = d
ausgewertet als
a += (b -= (c = d))
Diese implizite Klammerung kann nicht durch eine explizite Klammerung geändert werden,
weil der linke Operand einer Zuweisung eine Variable sein muss.
In Java ist dafür gesorgt, dass Operatoren mit gleicher Bindungskraft stets auch die gleiche Assozia-
tivität besitzen, z. B. die im letzten Beispiel enthaltenen Operatoren +=, -= und =.
Für manche Operationen gilt das mathematische Assoziativitätsgesetz, sodass die Reihenfolge der
Auswertung irrelevant ist, z. B.:
(3 + 2) + 1 = 6 = 3 + (2 + 1)
Anderen Operationen fehlt diese Eigenschaft, z. B.:
(3 – 2) – 1 = 0 3 – (2 – 1) = 2
Während sich die Addition und die Multiplikation von Ganzzahltypen in Java tatsächlich assoziativ
verhalten, gilt das aus technischen Gründen nicht für die Addition und die Multiplikation von Gleit-
kommatypen (Gosling et al 2021, Abschnitt 15.7.3).
4) Links vor rechts bei der Auswertung der Argumente eines binären Operators
Bevor ein Operator ausgeführt werden kann, müssen erst seine Argumente (Operanden) ausgewertet
sein. Bei jedem binären Operator ist in Java sichergestellt, dass erst der linke Operand ausgewertet
wird, dann der rechte.1 Im folgenden Beispiel tritt der Ausdruck ++ivar als rechter Operand einer
Multiplikation auf. Die hohe Bindungskraft (Priorität) des Präinkrementoperators (siehe Tabelle im
Abschnitt 3.5.10.2) führt nicht dazu, dass sich der Nebeneffekt des Ausdrucks ++ivar auf den lin-
ken Operanden der Multiplikation auswirkt:
Quellcode Ausgabe
class Prog { 6
public static void main(String[] args) { 3
int ivar = 2;
int erg = ivar * ++ivar;
System.out.printf("%d%n%d", erg, ivar);
}
}
1
In den folgenden Fällen unterbleibt die Auswertung des rechten Operanden:
• Bei der Auswertung des linken Operanden kommt es zu einem Ausnahmefehler (siehe unten).
• Bei den logischen Operatoren mit bedingter Ausführung (&&, ||) verhindert ein bestimmter Wert des linken
Operanden die Auswertung des rechten Operanden (siehe Abschnitt 3.5.5).
Abschnitt 3.5 Operatoren und Ausdrücke 147
Das Beispiel zeigt auch, dass der Begriff der Bindungskraft gegenüber dem Begriff der Priorität zu
bevorzugen ist. Weil sich kein Operand zwischen den Operatoren * und ++ befindet, können deren
Bindungskräfte offensichtlich keine Rolle spielen. Der Begriff der Priorität suggeriert aber trotz-
dem, dass der Präinkrementoperator einen Vorrang bei der Ausführung hätte.
Wie eine leichte Variation des letzten Beispiels zeigt, kann sich ein Nebeneffekt im linken Operan-
den einer binären Operation auf den rechten Operanden auswirken:
Quellcode Ausgabe
class Prog { 9
public static void main(String[] args) { 3
int ivar = 2;
int erg = ++ivar * ivar;
System.out.printf("%d%n%d", erg, ivar);
}
}
Im folgenden Beispiel stehen a, b und c für beliebige numerische Operanden (z. B. ++ivar). Für
den Ausdruck
a+b*c
resultiert aus der Bindungskraftregel die folgende Zuordnung der Operanden:
a + (b * c)
Zusammen mit der Links-vor-rechts - Regel ergibt sich für die Auswertung der Operanden bzw.
Ausführung der Operatoren die folgende Reihenfolge:
a, b, c, *, +
Wenn als Operanden numerische Literale oder Variablen auftreten, wird bei der „Auswertung“ ei-
nes Operanden lediglich sein Wert ermittelt, und die Reihenfolge der Operandenauswertungen ist
belanglos. Im letzten Beispiel eine falsche Auswertungsreihenfolge zu unterstellen (z. B. b, c, *, a,
+), bleibt ungestraft. Wenn Operanden Nebeneffekte enthalten (Zuweisungen, In- bzw. Dekrement-
operationen oder Methodenaufrufe), dann ist die Reihenfolge der Operandenauswertungen jedoch
relevant, und eine falsche Vermutung kann gravierende Fehler verursachen. Im folgenden Beispiel
Quellcode Ausgabe
class Prog { 8
public static void main(String[] args) {
int ivar = 2;
System.out.print(ivar++ + ivar * 2);
}
}
3.5.10.2 Operatorentabelle
In der folgenden Tabelle sind die bisher behandelten Operatoren mit absteigender Bindungskraft
(Priorität) aufgelistet. Gruppen von Operatoren mit gleicher Bindungskraft sind durch eine horizon-
tale Linie voneinander getrennt. In der Operanden-Spalte werden die zulässigen Datentypen der
Argumentausdrücke mit Hilfe der folgenden Platzhalter beschrieben:
N Ausdruck mit numerischem Datentyp (byte, short, int, long, char, float, double)
I Ausdruck mit integralem (ganzzahligem) Datentyp (byte, short, int, long, char)
L logischer Ausdruck (Typ boolean)
K Ausdruck mit kompatiblem Datentyp
S String (Zeichenfolge)
V Variable mit kompatiblem Datentyp
Vn Variable mit numerischem Datentyp (byte, short, int, long, char, float, double)
Abschnitt 3.6 Über- und Unterlauf bei numerischen Variablen 149
! Negation L
++, -- Prä- oder Postinkrement bzw. -dekrement Vn
- Vorzeichenumkehr N
(Typ) Typumwandlung K
*, / Punktrechnung N, N
% Modulo N, N
+, - Strichrechnung N, N
+ String-Verkettung S, K oder K, S
<<, >> Links- bzw. Rechts-Verschiebung I, I
>, <,
Vergleichsoperatoren N, N
>=, <=
==, != Gleichheit, Ungleichheit K, K
& Bitweises UND I, I
& Logisches UND (mit unbedingter Auswertung) L, L
^ Exklusives logisches ODER L, L
| Bitweises ODER I, I
| Logisches ODER (mit unbedingter Auswertung) L, L
&& Logisches UND (mit bedingter Auswertung) L, L
|| Logisches ODER (mit bedingter Auswertung) L, L
?: Konditionaloperator L, K, K
= Wertzuweisung V, K
+=, -=,
*=, /=, Wertzuweisung mit Aktualisierung Vn, N
%=
Im Anhang A finden Sie eine erweiterte Version dieser Tabelle, die zusätzlich alle Operatoren ent-
hält, die im weiteren Verlauf des Manuskripts noch behandelt werden.
betroffenen Programm ist mit einem mehr oder weniger gravierenden Fehlverhalten zu rechnen,
sodass Wertebereichsprobleme unbedingt vermieden bzw. rechtzeitig diagnostiziert werden müssen.
Im Zusammenhang mit Wertebereichsproblemen bieten sich gelegentlich die Klassen BigDecimal
und BigInteger aus dem Paket java.math als Alternativen zu den primitiven Datentypen an. Wenn
wir gleich auf einen solchen Fall stoßen, verzichten wir nicht auf eine kurze Beschreibung der je-
weiligen Vor- und Nachteile, obwohl die beiden Klassen nicht zu den elementaren Sprachelementen
gehören. Analog wurde schon im Abschnitt 3.3.7.2 demonstriert, dass die Klasse BigDecimal bei
finanzmathematischen Anwendungen wegen ihrer praktisch unbeschränkten Genauigkeit gegenüber
den binären Gleitkommatypen (double und float) zu bevorzugen ist.
Speziell bei der Steuerung von Raketenmotoren (vgl. Abschnitt 3.5.7) ist also Vorsicht geboten,
weil ansonsten das Kommando „Mr. Spock, please push the engine.“ zum heftigen Rückwärtsschub
führen könnte.1 Es zeigt sich erneut, dass eine erfolgreiche Raketenforschung und -entwicklung
ohne die sichere Beherrschung der elementaren Sprachelemente kaum möglich ist.
1
Mr. Spock arbeitete jahrelang als erster Offizier auf dem Raumschiff Enterprise.
Abschnitt 3.6 Über- und Unterlauf bei numerischen Variablen 151
Natürlich kann nicht nur der positive Rand eines Ganzzahlwertebereichs überschritten werden, son-
dern auch der negative Rand, indem z. B. vom kleinstmöglichen Wert eine positive Zahl subtrahiert
wird:
Quellcode Ausgabe
class Prog { -2147483648 - 5 = 2147483643
public static void main(String[] args) {
int i = -2_147_483_648, j = 5, k;
k = i - j;
System.out.println(i+" - "+j+" = "+k);
}
}
Bei Wertebereichsproblemen durch eine betragsmäßig zu große Zahl wird im Manuskript generell
von einem Überlauf gesprochen. Unter einem Unterlauf soll später das Verlassen eines Gleitkom-
mawertebereichs in Richtung null durch eine betragsmäßig zu kleine Zahl verstanden werden (vgl.
Abschnitt 3.6.3).
Oft lässt sich ein Überlauf durch die Wahl eines geeigneten Datentyps verhindern. Mit den Deklara-
tionen
long i = 2_147_483_647, j = 5, k;
kommt es in der Anweisung
k = i + j;
nicht zum Überlauf, weil neben i, j und k nun auch der Ausdruck i+j den Typ long besitzt (siehe
Tabelle im Abschnitt 3.5.1). Die Anweisung
System.out.println(i + " + " + j + " = " + k);
liefert das korrekte Ergebnis:
2147483647 + 5 = 2147483652
Im Beispiel genügt es nicht, für die Zielvariable k den beschränkten Typ int durch long zu ersetzen,
weil der Überlauf beim Berechnen des Ausdrucks („unterwegs“) auftritt. Mit den Deklarationen
int i = 2_147_483_647, j = 5;
long k;
bleibt das Ergebnis falsch, denn …
• In der Anweisung
k = i + j;
wird der Ausdruck i + j berechnet, bevor die Zuweisung ausgeführt wird.
• Weil beide Operanden vom Typ int sind, erhält auch der Ausdruck diesen Typ (siehe Tabel-
le im Abschnitt 3.5.1), und die Summe kann nicht korrekt berechnet bzw. zwischenspeichert
werden.
• Schließlich wird der long-Variablen k das falsche Ergebnis zugewiesen.
Wenn auch der long-Wertebereich nicht ausreicht, und weiterhin mit ganzen Zahlen gerechnet wer-
den soll, dann bietet sich die Klasse BigInteger aus dem Paket java.math an.1 Das folgende Pro-
gramm
1
Ab Java 9 befindet sich das Paket java.util im Modul java.base. Das gilt bis auf wenige Ausnahmen für alle im
Manuskript verwendeten Pakete, sodass der Hinweis auf die Modulzugehörigkeit nur noch in den Ausnahmefällen
erscheint.
152 Kapitel 3 Elementare Sprachelemente
import java.math.*;
class Prog {
public static void main(String[] args) {
BigInteger bigint = new BigInteger("9223372036854775808");
bigint = bigint.multiply(bigint);
System.out.println("2 hoch 126 = " + bigint);
}
}
speichert im BigInteger-Objekt bigint die knapp außerhalb des long-Wertebereichs liegende
Zahl 263, quadriert diese auch noch mutig und findet selbstverständlich das korrekte Ergebnis:
2 hoch 126 = 85070591730234615865843651857942052864
Im Vergleich zu den primitiven Ganzzahltypen verursacht die Klasse BigInteger allerdings einen
höheren Speicher- und Rechenzeitaufwand.
Seit Java 8 bietet die Klasse Math im Paket java.lang statische Methoden für arithmetische Opera-
tionen mit Ganzzahltypen, die auf einen Überlauf mit einem Ausnahmefehler reagieren. Neben den
anschließend aufgelisteten Methoden für int-Argumente sind analog arbeitende Methoden für long-
Argumente vorhanden:
• public static int addExact(int x, int y)
• public static int subtractExact(int x, int y)
• public static int multiplyExact(int x, int y)
• public static int incrementExact(int a)
• public static int decrementExact(int a)
• public static int negateExact(int a)
Falls ein Ausnahmefehler nicht abgefangen wird, endet das betroffene Programm, statt mit sinnlo-
sen Zwischenergebnissen weiterzurechnen, z. B.:
Quellcode Ausgabe
class Prog { Exception in thread "main" java.lang.ArithmeticException:
integer overflow
public static void main(String[] a) {
at java.base/java.lang.Math.addExact(Math.java:825)
int i = 2147483647, j = 5, k; at Prog.main(Prog.java:4)
k = Math.addExact(i, j);
System.out.printf("%d + %d = %d",
i, j, k);
}
}
3.6.2 Unendliche und undefinierte Werte bei den Typen float und double
Auch bei den binären Gleitkommatypen float und double kann ein Überlauf auftreten, obwohl die
unterstützten Wertebereiche hier weit größer sind. Dabei kommt es aber weder zu einem sinnlosen
Zufallswert, sondern zu den speziellen Gleitkommawerten +/- Unendlich, mit denen anschließend
sogar weitergerechnet werden kann. Das folgende Programm
class Prog {
public static void main(String[] args) {
double bigd = Double.MAX_VALUE;
System.out.printf("Double.MAX_VALUE = %15e%n", bigd);
bigd = Double.MAX_VALUE * 10.0;
System.out.printf("Double.MaxValue * 10 = %15e%n", bigd);
System.out.printf("Unendlich + 10 = %15e%n", bigd + 10);
System.out.printf("Unendlich * (-1) = %15e%n",bigd * -1);
System.out.printf("13.0/0.0 = %15e", 13.0 / 0.0);
}
}
Abschnitt 3.6 Über- und Unterlauf bei numerischen Variablen 153
Im Programm erhält die double-Variable bigd den größtmöglichen Wert ihres Typs. Anschließend
wird bigd mit dem Faktor 10 multipliziert, was zum Ergebnis +Unendlich führt. Mit diesem Zwi-
schenergebnis kann Java durchaus rechnen:
• Addiert man die Zahl 10, dann bleibt es beim Wert +Unendlich.
• Eine Multiplikation von +Unendlich mit (-1) führt zum Wert -Unendlich.
Mit Hilfe der Unendlich-Werte „gelingt“ offenbar bei der Gleitkommaarithmetik sogar die Division
durch null, während bei der Ganzzahlarithmetik ein solcher Versuch zu einem Laufzeitfehler (aus
der Klasse ArithmeticException) führt.
Bei den folgenden „Berechnungen“
Unendlich − Unendlich
Unendlich
Unendlich
Unendlich 0
0
0
resultiert der spezielle Gleitkommawert NaN (Not a Number), wie das nächste Beispielprogramm
zeigt:
class Prog {
public static void main(String[] args) {
double bigd = Double.MAX_VALUE * 10.0;
System.out.printf("Unendlich – Unendlich = %3f%n", bigd-bigd);
System.out.printf("Unendlich / Unendlich = %3f%n", bigd/bigd);
System.out.printf("Unendlich * 0.0 = %3f%n", bigd * 0.0);
System.out.printf("0.0 / 0.0 = %3f", 0.0/0.0);
}
}
Es liefert die Ausgaben:
Unendlich – Unendlich = NaN
Unendlich / Unendlich = NaN
Unendlich * 0.0 = NaN
0.0 / 0.0 = NaN
Zu den letzten Beispielprogrammen ist noch anzumerken, dass man über das öffentliche, statische
und finalisierte Feld MAX_VALUE der Klasse Double aus dem Paket java.lang den größten Wert
in Erfahrung bringt, der in einer double-Variablen gespeichert werden kann.
Über die statischen Double-Methoden
• public static boolean isInfinite(double arg)
• public static boolean isNaN(double arg)
154 Kapitel 3 Elementare Sprachelemente
mit Rückgabetyp boolean lässt sich für eine double-Variable prüfen, ob sie einen unendlichen oder
undefinierten Wert besitzt, z. B.:
Quellcode Ausgabe
class Prog { true
public static void main(String[] args) { true
System.out.println(Double.isInfinite(1.0/0.0));
System.out.print(Double.isNaN(0.0/0.0));
}
}
Für besonders neugierige Leser sollen abschließend noch die float-Darstellungen der speziellen
Gleitkommawerte angegeben werden (vgl. Abschnitt 3.3.7.1):
float-Darstellung
Wert
Vorz. Exponent Mantisse
+unendlich 0 11111111 00000000000000000000000
-unendlich 1 11111111 00000000000000000000000
NaN 0 11111111 10000000000000000000000
Wenn der double-Wertebereich längst in Richtung Infinity überschritten ist, kann man mit Objek-
ten der Klasse BigDecimal aus dem Paket java.math noch rechnen:
Quellcode Ausgabe
import java.math.*; Very Big: 1.057066e+3000
class Prog {
public static void main(String[] args) {
BigDecimal bigd = new BigDecimal("1000111");
bigd = bigd.pow(500);
System.out.printf("Very Big: %e", bigd);
}
}
Ein Überlauf ist bei BigDecimal-Objekten nicht zu befürchten, solange das Programm genügend
Hauptspeicher zur Verfügung hat.
Das statische, öffentliche und finalisierte Feld MIN_VALUE der Klasse Double im Paket
java.lang enthält den betragsmäßig kleinsten Wert, der in einer double-Variablen gespeichert wer-
den kann (vgl. Abschnitt 3.3.6).
Abschnitt 3.6 Über- und Unterlauf bei numerischen Variablen 155
In unglücklichen Fällen wird aber ein deutlich von null verschiedenes Endergebnis grob falsch be-
rechnet, weil unterwegs ein Zwischenergebnis der Null zu nahe gekommen ist, z. B.
Quellcode Ausgabe
class Prog { 9.881312916824932
public static void main(String[] args) { 0.0
double a = 1E-323;
double b = 1E308;
double c = 1E16;
System.out.println(a * b * c);
System.out.print(a * 0.1 * b * 10.0 * c);
}
}
Er ist aus Kompatibilitätsgründen weiterhin erlaubt, bewirkt aber ab Java 17 nur noch eine Compi-
ler-Warnung.
3.7.1 Überblick
Ein ausführbarer Programmteil, also der Rumpf einer Methode, besteht aus Anweisungen (engl.
statements).
Am Ende von Abschnitt 3.7 werden Sie die folgenden Sorten von Anweisungen kennen:
• Deklarationsanweisung für lokale Variablen
Die Anweisung zur Deklaration von lokalen Variablen wurde schon im Abschnitt 3.3.8 ein-
geführt.
Beispiel: int i = 1, j = 2, k;
• Ausdrucksanweisungen
Folgende Ausdrücke werden zu Anweisungen, sobald man ein Semikolon dahinter setzt:
o Wertzuweisung (vgl. Abschnitte 3.3.8 und 3.5.8)
Beispiel: k = i + j;
o Prä- bzw. Postinkrement- oder -dekrementoperation
Beispiel: i++;
Im Beispiel ist nur der „Nebeneffekt“ des Ausdrucks i++ von Bedeutung (vgl. Ab-
schnitt 3.5.1). Sein Wert bleibt ungenutzt.
o Methodenaufruf
Beispiel: System.out.println(k);
Besitzt die im Rahmen einer eigenständigen Anweisung aufgerufene Methode einen
Rückgabewert, dann wird dieser ignoriert.
• Leere Anweisung
Beispiel: ;
Die durch ein einsames (nicht anderweitig eingebundenes) Semikolon ausgedrückte leere
Anweisung hat keinerlei Effekte und kommt gelegentlich zum Einsatz, wenn syntaktisch ei-
ne Anweisung erforderlich ist, aber nichts geschehen soll.
• Blockanweisung
Eine Folge von Anweisungen, die durch geschweifte Klammern zusammengefasst bzw. ab-
gegrenzt werden, bildet eine Block- bzw. Verbundanweisung. Wir haben uns bereits im
Abschnitt 3.3.9 im Zusammenhang mit dem Gültigkeitsbereich für lokale Variablen mit der
Blockanweisung beschäftigt. Wie gleich näher erläutert wird, fasst man z. B. dann mehrere
Abweisungen zu einem Block zusammen, wenn diese Anweisungen unter einer gemeinsa-
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung) 157
men Bedingung ausgeführt werden sollen. Es wäre sehr unpraktisch, dieselbe Bedingung für
jede betroffene Anweisung wiederholen zu müssen.
• Anweisungen zur Ablaufsteuerung
Die main() - Methoden der bisherigen Beispielprogramme im Kapitel 3 bestanden meist aus
einer Sequenz von Anweisungen, die bei jedem Programmeinsatz komplett durchlaufen
wurde:
Anweisung 1
Anweisung 2
Anweisung 3
Oft möchte man jedoch ...
o die Ausführung einer Anweisung (eines Anweisungsblocks) von einer Bedingung
abhängig machen
o oder eine Anweisung (einen Anweisungsblock) wiederholt ausführen lassen.
Für solche Zwecke enthält Java etliche Anweisungen zur Ablaufsteuerung, die bald ausführ-
lich behandelt werden (bedingte Anweisung, Fallunterscheidung, Schleifen).
Blockanweisungen sowie Anweisungen zur Ablaufsteuerung enthalten andere Anweisungen und
werden daher auch als zusammengesetzte Anweisungen bezeichnet.
Anweisungen werden durch ein Semikolon abgeschlossen, sofern sie nicht mit einer schließenden
Blockklammer enden.
3.7.2.1 if-Anweisung
Nach dem folgenden Programmablaufplan (PAP) bzw. Flussdiagramm soll eine Anweisung nur
dann ausgeführt werden, wenn ein logischer Ausdruck den Wert true besitzt:
Log. Ausdruck
true false
Anweisung
158 Kapitel 3 Elementare Sprachelemente
Wir werden diese Darstellungstechnik ab jetzt verwenden, um einen Algorithmus bzw. Programm-
ablauf zu beschreiben. Die verwendeten Symbole sind hoffentlich anschaulich, entsprechen aber
keiner strengen Normierung.
Während der Programmablaufplan den Zweck (die Semantik) eines Sprachbestandteils erläutert,
beschreibt das vertraute Syntaxdiagramm, wie zulässige Exemplare des Sprachbestandteils zu bil-
den sind. Das folgende Syntaxdiagramm beschreibt die zur Realisation einer bedingten Ausführung
dienende if-Anweisung:
if-Anweisung
Die eingebettete (bedingt auszuführende) Anweisung darf keine Variablendeklaration (im Sinn von
Abschnitt 3.3.8) sein. Ein Block ist aber selbstverständlich erlaubt, und darin dürfen auch lokale
Variablen definiert werden.
Es ist übrigens nicht vergessen worden, ein Semikolon ans Ende des if-Syntaxdiagramms zu setzen.
Dort wird eine eingebettete Anweisung verlangt, wobei konkrete Beispiele oft mit einem Semikolon
enden, manchmal aber auch mit einer schließenden geschweiften Klammer.
Im folgenden Beispiel wird eine Meldung ausgegeben, wenn die Variable anz den Wert 0 besitzt:
if (anz == 0)
System.out.println("Die Anzahl muss > 0 sein!");
Der Zeilenumbruch zwischen dem logischen Ausdruck und der eingebetteten Anweisung dient nur
der Übersichtlichkeit und ist für den Compiler irrelevant.
Log. Ausdruck
true false
Anweisung 1 Anweisung 2
if (Logischer Ausdruck)
Anweisung 1
else
Anweisung 2
Wie bei den Syntaxdiagrammen gilt auch für diese Form der Syntaxbeschreibung:
• Für terminale Sprachbestandteile, die exakt in der angegebenen Form in konkreten Quell-
code zu übernehmen sind, wird fette Schrift verwendet.
• Platzhalter sind an kursiver Schrift zu erkennen.
Während die Syntaxbeschreibung im Quellcode-Layout relativ einfache Bildungsregeln (mit einer
einzigen zulässigen Sequenz) sehr anschaulich beschreibt, kann das manchmal weniger anschauli-
che Syntaxdiagramm bei einer komplizierten und variantenreichen Syntax alle zulässigen Sequen-
zen kompakt und präzise dokumentieren.
Bei den eingebetteten Anweisungen (Anweisung 1 bzw. Anweisung 2) darf es sich nicht um Variab-
lendeklarationen (im Sinn von Abschnitt 3.3.8) handeln. Wird ein Block als eingebettete Anweisung
verwendet, dann sind darin aber auch Variablendeklarationen erlaubt.
Im folgenden if-else - Beispiel wird der natürliche Logarithmus zu einer Zahl berechnet, falls diese
positiv ist. Anderenfalls erscheint eine Fehlermeldung. Das Argument wird vom Benutzer über die
Simput-Methode gdouble() erfragt (vgl. Abschnitt 3.4).1
Eingabe (grün, kursiv)
Quellcode
und Ausgabe
class Prog { Argument > 0: 2,4
public static void main(String[] args) { ln(2,400) = 0,875
System.out.print("Argument > 0: ");
double arg = Simput.gdouble();
if (arg > 0)
System.out.printf("ln(%.3f) = %.3f", arg, Math.log(arg));
else
System.out.println("Argument ungültig oder <= 0!");
}
}
Eine bedingt auszuführende Anweisung darf durchaus wiederum vom if- bzw. if-else - Typ sein,
sodass sich mehrere, hierarchisch geschachtelte Fälle unterscheiden lassen. Den folgenden Pro-
grammablauf mit „sukzessiver Restaufspaltung“
1
Bei einer irregulären Eingabe liefert gdouble() den (Verlegenheits-)Rückgabewert 0.0. Man kann sich aber durch
einen Aufruf der Simput-Klassenmethode checkError() mit Rückgabetyp boolean darüber informieren, ob ein
Fehler aufgetreten ist (Rückgabewert true) oder nicht (Rückgabewert false).
160 Kapitel 3 Elementare Sprachelemente
Log. Ausdr. 1
true false
Anweisung 1
Log. Ausdr. 2
true false
Anweisung 2
Log. Ausdr. 3
true false
Anweisung 3 Anweisung 4
Beim Schachteln von bedingten Anweisungen kann es zum genannten dangling-else - Problem1
kommen, wobei ein Missverständnis zwischen Programmierer und Compiler hinsichtlich der Zu-
ordnung einer else-Klausel besteht. Im folgenden Code-Fragment2
if (i > 0)
if (j > i)
k = j;
else
k = 13;
lassen die Einrücktiefen vermuten, dass der Programmierer die else-Klausel auf die erste if-
Anweisung bezogen zu haben glaubt:
i > 0 ?
true false
k = 13;
j > i ?
true false
k = j;
Der Compiler ordnet eine else-Klausel jedoch dem in Aufwärtsrichtung nächstgelegenen if zu, das
nicht durch Blockklammern abgeschottet ist und noch keine else-Klausel besitzt. Im Beispiel be-
zieht er die else-Klausel also auf die zweite if-Anweisung, sodass de facto der folgende Programm-
ablauf resultiert:
1
Deutsche Übersetzung von dangling: baumelnd.
2
Fügt man das Quellcodesegment mit den „fehlerhaften“ Einrücktiefen in ein Editorfenster unserer Entwicklungsum-
gebung IntelliJ ein, dann wird der „Layout-Fehler“ übrigens automatisch behoben. IntelliJ verhindert also, dass der
Logikfehler durch einen „Layout-Fehler“ getarnt wird.
162 Kapitel 3 Elementare Sprachelemente
i > 0 ?
true false
j > i ?
true false
k = j k = 13;
Bei i 0 geht der Programmierer vom neuen k-Wert 13 aus, der beim tatsächlichen Programmab-
lauf jedoch nicht unbedingt zu erwarten ist.
Mit Hilfe von Blockklammern kann man die gewünschte Zuordnung erzwingen:
if (i > 0)
{if (j > i)
k = j;}
else
k = 13;
Eine alternative Lösung besteht darin, auch dem zweiten if eine else-Klausel zu spendieren und
dabei die leere Anweisung zu verwenden:
if (i > 0)
if (j > i)
k = j;
else
;
else
k = 13;
Gelegentlich kommt als Alternative zu einer if-else - Anweisung, die zur Berechnung eines Wertes
bedingungsabhängig zwei unterschiedliche Ausdrücke benutzt, der Konditionaloperator (vgl. Ab-
schnitt 3.5.9) in Frage, z. B.:
if-else - Anweisung Konditionaloperator
double arg = 3, d; double arg = 3, d;
if (arg >= 0) d = arg >= 0 ? arg * arg : 0;
d = arg * arg;
else
d = 0;
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung) 163
3.7.2.3 switch-Anweisung
Wenn eine Fallunterscheidung mit mehr als zwei Alternativen wie im folgenden Flussdiagramm in
Abhängigkeit vom Wert eines Ausdrucks vorgenommen werden soll,
k = ?
1 2 3
dann ist eine switch-Anweisung weitaus handlicher als eine verschachtelte if-else - Konstruktion.
In Java 14 ist die switch-Syntax erheblich verbessert worden, und mit der bald zu erwartenden Ver-
fügbarkeit kostenfreier LTS-Distributionen von Java 17 besteht kein nennenswertes Argument ge-
gen die Verwendung der modernen switch-Syntax. Wir starten trotzdem mit der traditionellen,
schon angestaubten Syntax, die den Vorteil der maximalen Kompatibilität besitzt, also z. B. mit
einer JVM auf dem Stand von Java 8 genutzt werden kann.
switch-Anweisung
switch ( switch-Argument ) {
default : Anweisung }
Weil später noch ein praxisnahes (und damit auch etwas kompliziertes) Beispiel folgt, ist hier ein
ebenso einfaches wie sinnfreies Exemplar zur Erläuterung der Syntax angemessen:
Quellcode Ausgabe
class Prog { Fall 2 (mit Durchfall)
public static void main(String[] args) { Fälle 3 und 4
int zahl = 2;
final int marke1 = 1;
switch (zahl) {
case marke1:
System.out.println("Fall 1 (mit break-Stopper)");
break;
case marke1 + 1:
System.out.println("Fall 2 (mit Durchfall)");
case 3:
case 4:
System.out.println("Fälle 3 und 4");
break;
default:
System.out.println("Restkategorie");
}
}
}
Als case-Marken sind konstante Ausdrücke erlaubt, deren Wert schon der Compiler ermitteln kann
(Literale, finalisierte Variablen oder daraus gebildete Ausdrücke). Anderenfalls könnte der Compi-
ler z. B. nicht verhindern, dass mehrere Marken denselben Wert haben. Außerdem muss der Daten-
typ einer Marke natürlich kompatibel zum deklarierten Typ des switch-Arguments sein.
Stimmt beim Ablauf des Programms der Wert des switch-Arguments mit einer case-Marke überein,
dann wird die zugehörige Anweisung ausgeführt, ansonsten (falls vorhanden) die default-Anwei-
sung.
Nach der Ausführung einer Anweisung mit passender Marke wird die switch-Konstruktion nur
dann verlassen, wenn der Fall mit einer break-Anweisung abgeschlossen wird, oder wenn kein wei-
terer Fall mehr folgt. Ansonsten werden auch noch die Anweisungen der nächsten Fälle (ggf. inkl.
default) ausgeführt, bis der „Durchfall“ nach unten entweder durch eine break-Anweisung ge-
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung) 165
stoppt wird, oder die switch-Anweisung endet. Mit dem etwas gewöhnungsbedürftigen Durchfall-
Prinzip kann man für geeignet angeordnete Fälle mit wenig Schreibaufwand kumulative Effekte
kodieren, aber auch ärgerliche Programmierfehler durch vergessene break-Anweisungen produzie-
ren.
Neben der break-Anweisung stehen noch zwei weitere, bisher der Einfachheit verschwiegene Opti-
onen zum vorzeitigen Verlassen einer switch-Anweisung zur Verfügung, die Sie im weiteren Ver-
lauf des Kurses kennenlernen werden:
• return-Anweisung
Über die return-Anweisung (siehe Abschnitt 4.3.1.2) wird nicht nur die switch-Anweisung,
sondern auch die Methode verlassen, was im Fall der Methode main() einer Beendigung des
Programms gleichkommt.
• Werfen eines Ausnahmefehlers
Auch über das Werfen eines Ausnahmefehlers (siehe Kapitel 11) kann eine switch-
Anweisung verlassen werden, wobei das weitere Verhalten des Programms davon anhängt,
ob und wo der Ausnahmefehler aufgefangen wird.
Soll für mehrere Werte des switch-Arguments dieselbe Anweisung ausgeführt werden, setzt man
die zugehörigen case-Marken (inklusive Schlüsselwort case) hintereinander und lässt die Anwei-
sung auf die letzte Marke folgen. Leider gibt es keine Möglichkeit, eine Serie von Fällen durch An-
gabe der Randwerte (z. B. von a bis k) festzulegen. In Java 14 und Java 17 wird die Möglichkeit,
für mehrere Fälle dieselbe Behandlung anzuordnen, sukzessive verbessert (siehe Abschnitte
3.7.2.3.2 und 3.7.2.5).
Das folgende Beispielprogramm analysiert die Persönlichkeit des Benutzers anhand seiner Farb-
und Zahlpräferenzen. Während bei einer Vorliebe für Rot oder Schwarz die Diagnose sofort fest-
steht, wird bei den restlichen Farben auch noch die Lieblingszahl berücksichtigt:
class PerST {
public static void main(String[] args) {
String farbe = args[0].toLowerCase();
int zahl = Integer.parseInt(args[1]);
switch (farbe) {
case "rot":
System.out.println("Sie sind durchsetzungsfreudig und impulsiv.");
break;
case "schwarz":
System.out.println("Nehmen Sie nicht alles so tragisch.");
break;
default:
System.out.println("Ihre Emotionalität ist unauffällig.");
if (zahl%2 == 0)
System.out.println("Sie haben einen geradlinigen Charakter.");
else
System.out.println("Sie machen wohl gerne krumme Touren.");
}
}
}
Das Programm PerST demonstriert nicht nur die switch-Anweisung (hier mit einem steuernden
Ausdruck vom Typ String), sondern auch den Zugriff auf Programmargumente über den String[]
- Parameter der main() - Methode. Benutzer des Programms sollen beim Start ihre bevorzugte Far-
be sowie ihre Lieblingszahl über Programmargumente (Kommandozeilenparameter) angeben. Wer
z. B. die Farbe Blau und die Zahl 17 bevorzugt, sollte das Programm folgendermaßen starten:
>java PerST Blau 17
166 Kapitel 3 Elementare Sprachelemente
Im Programm wird jeweils nur eine Anweisung benötigt, um ein Programmargument in eine
String- bzw. int-Variable zu befördern. Die zugehörigen Erklärungen werden Sie mit Leichtigkeit
verstehen, sobald Methodenparameter sowie Arrays und Zeichenfolgen behandelt worden sind. An
dieser Stelle greifen wir späteren Erläuterungen mal wieder etwas vor (hoffentlich mit motivieren-
dem Effekt):
• Bei einem Array handelt es sich um ein Objekt, das eine Serie von Elementen desselben
Typs aufnimmt, auf die man per Index, d .h. durch die mit eckigen Klammern begrenzte
Elementnummer, zugreifen kann.
• In unserem Beispiel kommt ein Array mit Elementen vom Datentyp String zum Einsatz,
wobei es sich um Zeichenfolgen handelt. Literale mit diesem Datentyp sind uns schon öfter
begegnet (z. B. "Hallo").
• Über die Parameterliste kann man eine Methode mit Daten versorgen und/oder ihre Ar-
beitsweise beeinflussen.
• Die main() - Methode einer Startklasse besitzt einen (ersten und einzigen) Parameter vom
Datentyp String[] (Array mit String-Elementen). Der Datentyp dieses Parameters ist fest
vorgegeben, sein Name ist jedoch frei wählbar (im Beispiel: args). In der Methode main()
kann man auf args genauso zugreifen wie auf eine lokale Variable.
• Beim Programmstart werden der Methode main() von der Java Virtual Machine (JVM) als
Elemente des String[] - Arrays args die Programmargumente übergeben, die der Anwen-
der beim Start hinter den Namen der Startklasse, jeweils durch Leerzeichen getrennt, in die
Kommandozeile geschrieben hat (siehe obiges Beispiel).
• Das erste Programmargument landet im ersten Element des Zeichenfolgen-Arrays args und
wird mit args[0] angesprochen, weil Array-Elemente mit 0 beginnend nummeriert wer-
den. Als Objekt der Klasse String wird args[0] im Beispielprogramm aufgefordert, die
Methode toLowerCase() auszuführen:
String farbe = args[0].toLowerCase();
Diese Methode erstellt ein neues String-Objekt, das im Unterschied zum angesprochenen
Original auf Kleinschreibung normiert ist, was die spätere Verwendung im Rahmen der
switch-Anweisung erleichtert. Die Adresse dieses Objekts landet als toLowerCase() -
Rückgabewert in der lokalen String-Referenzvariablen farbe.
• Das zweite Element des Zeichenfolgen-Arrays args (mit der Nummer 1) enthält das zweite
Programmargument (falls vorhanden). Zumindest bei kooperativen Benutzern des Beispiel-
programms kann diese Zeichenfolge mit der statischen Methode parseInt() der Klasse Inte-
ger in eine Zahl vom Datentyp int gewandelt und anschließend der lokalen Variablen zahl
zugewiesen werden:
int zahl = Integer.parseInt(args[1]);
Nach einem Programmstart mit dem Aufruf
>java PerST Blau 17
landet der String-Array args als Objekt im Heap-Bereich des programmeigenen Speichers:1
1
Hier wird aus didaktischen Gründen ein wenig gemogelt: Die beiden Zeichenfolgen sind selbst Objekte und liegen
„neben“ dem Array-Objekt auf dem Heap. Die Array-Elemente sind Referenzen, die auf die zugehörigen String-
Objekte zeigen.
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung) 167
Heap
args[0] args[1]
B l a u 1 7
Damit ein Java-Programm innerhalb unserer Entwicklungsumgebung ausgeführt werden kann, wird
eine Run/Debug Configuration benötigt. Eine solche wird vom Assistenten für ein neues Intel-
liJ-Projekt automatisch angelegt, und wir hatten bisher kaum einen Anlass zur Nachbesserung (vgl.
Abschnitt 3.1.2). Für das oben vorgestellte Programm PerST müssen allerdings per Ausführungs-
konfiguration die vom Benutzer beim Programmstart übergebenen Argumente simuliert werden,
sodass die automatisch erstellte Ausführungskonfiguration zu erweitern ist.
Wenn wir das Drop-Down - Menü zur Ausführungskonfiguration öffnen und das Item Edit Con-
figurations
wählen, dann können wir im folgenden Dialog u. a. die gewünschten Programmargumente eintra-
gen
Die Doppelpunktsyntax ist in Java weiterhin erlaubt, darf aber nicht mit der Pfeilsyntax gemischt
werden. Ein Grund für die Verwendung der Doppelpunktsyntax besteht z. B. dann, wenn aus-
nahmsweise ein Durchfall tatsächlich erwünscht ist.
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung) 169
Ein erfolgreich unter Verwendung der modernen switch-Syntax übersetztes Programm kann z. B.
von einer JVM mit der Version 8 nicht ausgeführt werden:
UnsupportedClassVersionError: Prog has been compiled by a more recent version of the
Java Runtime (class file version 58.0), this version of the Java Runtime only
recognizes class file versions up to 52.0
1
Wir befinden uns gerade im Abschnitt über Anweisungen, und die switch-Ausdrücke hätten eigentlich im Abschnitt
3.5 behandelt werden müssen. Trotz dieses Arguments ist eine Behandlung der switch-Ausdrücke nach den traditio-
nellen (und noch stark verbreiteten) switch-Anweisungen aber didaktisch sinnvoller.
2
Statt für einen Fall einen Wert zu liefern, darf man aber auch mit der throw-Anweisung eine Ausnahme werfen
(siehe Kapitel 11 über die Ausnahmebehandlung).
170 Kapitel 3 Elementare Sprachelemente
An Stelle eines Ausdrucks darf auf einen Pfeil aber auch ein Anweisungsblock folgen, wobei dann
per yield-Anweisung der Wert zum Fall geliefert werden muss, z. B.:
String swr = switch (zahl) {
case marke1 -> "Fall 1 (OHNE Durchfall)";
case 2, 3, 4 -> "Fälle 2, 3 und 4";
default -> {System.out.print("default: "); yield "Restkategorie";}
};
Weil ein switch-Ausdruck zu jedem möglichen Wert des switch-Arguments ein Ergebnis liefern
muss (Exhaustivität), bestehen folgende Besonderheiten im Vergleich zur switch-Anweisung:
• In der Regel ist ein default-Fall erforderlich. Eine Ausnahme von dieser Regel erlaubt der
Compiler bei einem switch-Argument mit Aufzählungstyp, weil dann eine endliche Anzahl
bekannter Werte vorliegt (siehe Abschnitt 5.4).
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung) 171
• Ein Ausstieg aus einem switch-Ausdruck per break oder continue (siehe Abschnitt 3.7.3.5)
oder per return (siehe Abschnitt 4.3.1.2) ist verboten. Ein Ausstieg per Ausnahmefehler ist
aber möglich (siehe Kapitel 11), z. B.:
Von dem zu einem Fall gehörenden Referenzdatentyp wird eine sogenannte Mustervariable dekla-
riert, die nach einem erfolgreichen Typtest eine Kopie der Objektadresse erhält. Im ersten Fall des
Beispiels werden Zeichenfolgen (String-Objekte) behandelt:
case String a -> a.length() == 3;
Im Ausdruck zur Ermittlung der Rückgabe steht die Mustervariable zur Verfügung. Die Gültigkeit
einer Mustervariablen ist auf das switch-Muster beschränkt, in dem sie definiert wird.
Im zweiten Fall des Beispiels werden int-Werte (verpackt in Integer-Objekte, siehe Abschnitt 5.3)
versorgt:
case Integer i -> i >= 5 && i <= 10;
Wenn die bisher in den Ergebnisausdrücken untergebrachten Bedingungen in die case-Definitionen
wandern, wird der switch-Ausdruck übersichtlicher:
172 Kapitel 3 Elementare Sprachelemente
Bei switch-Ausdrücken ist generell die Exhaustivität gefordert, und der Compiler stellt auch bei
Mustervergleichen sicher, dass alle möglichen Werte des switch-Arguments versorgt sind. Zur De-
finition eines Falles für sonstige Werte bestehen zwei Optionen, die nicht gleichzeitig erlaubt sind:
• default-Fall (siehe obige Beispiele)
• ein Fall mit dem sogenannten totalen Muster (engl.: total pattern), z. B.:
boolean result = switch (s) {
case String a && a.length() == 3 -> true;
case Integer i && i >= 5 && i <= 10 -> true;
case Object obj -> false;
};
In die Definition des totalen Musters gehen leider Begriffe aus dem Kapitel 7 über die Ver-
erbung und aus dem Kapitel 8 über generische Typen ein. Ein Typmuster
Tt
ist total für einen Typ S, wenn die Typlöschung von S eine Spezialisierung der Typlöschung
von T ist.1
Zur Komplexität der Exhaustivitäts-Konsequenzen für Typmuster leisten auch die in Java 17 einge-
führten versiegelten Typen (siehe Abschnitt 7.11.3) einen Beitrag, der hier noch nicht beschrieben
werden kann.
Weil ein switch-Ausdruck nach einem zutreffenden Fall verlassen wird, darf kein Fall spezieller als
ein vorheriger Fall definiert sein. Der Compiler überwacht die verbotene Dominanz durch einen
früheren Fall, z. B.:
1
https://docs.oracle.com/en/java/javase/17/language/pattern-matching-switch-expressions-and-statements.html
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung) 173
Gupta (2021) weist darauf hin, dass der Compiler bei einer analogen Lösung durch verschachtelte
if-Anweisungen keine Dominanzüberwachung vornimmt.1
Während der Wert null für den switch-Ausdruck vor Java 17 unweigerlich zu einer NullPointer-
Exception geführt hat, kann dieser Wert nun berücksichtigt werden. Ein totales Muster (nicht aber
der default-Fall) bezieht den Wert null mit ein. Außerdem ist das Schlüsselwort null zur Falldefini-
tion erlaubt, um den Wert null exklusiv zu behandeln, z. B.:
int resultEx = switch (s) {
case String a && a.length() == 3 -> 1;
case Integer i && i >= 5 && i <= 10 -> 2;
default -> 3;
case null -> -99;
};
Werden in einer switch-Anweisung die modernen Mustervergleiche mit der traditionellen Doppel-
punkt-Syntax kombiniert, dann führt eine vergessene break-Anweisung zu einer Fehlermeldung
des Compilers statt zum gefürchteten Durchfall, z. B.:
Während der Compiler in einer switch-Anweisung mit Doppelpunktsyntax und einer Falldefinition
durch konstante Werte ein fehlendes break als geplanten Durchfall akzeptieren muss, ist bei einer
Falldefinition durch Muster ein Durchfall als Programmierfehler zu reklamieren. Im Beispiel würde
ein Integer-Objekt als String-Objekt angesprochen, könnte aber die Methode length() nicht aus-
führen.
Seit Java 17 wird von einer erweiterten switch-Anweisung (engl.: enhanced switch-statement) ge-
sprochen wenn eine von den beiden folgenden, seit Java 17 möglichen Bedingungen erfüllt ist:2
• Der Argumenttyp stammt nicht aus der traditionellen Typenliste (byte, short, char oder int,
Verpackungsklassen zu den vorgenannten Typen, Aufzählungstypen, String).
• Mindestens ein Fall ist durch ein Typmuster oder durch das Schlüsselwort null definiert.
Für eine erweiterte switch-Anweisung wird die Exhaustivität verlangt, z. B.:
Die Begründung für das neue Verhalten des Compilers ist mäßig überzeugend:
1
https://blog.jetbrains.com/idea/2021/09/java-17-and-intellij-idea/
2
https://docs.oracle.com/javase/specs/jls/se17/preview/specs/patterns-switch-jls.html
174 Kapitel 3 Elementare Sprachelemente
This is often the cause of difficult to detect bugs, where no switch label applies and the switch
statement will silently do nothing.
Es ist nicht allzu ungewöhnlich, wenn von mehreren bedingt auszuführenden Anweisungen keine
ausgeführt wird, weil keine Bedingung erfüllt ist. Aus Kompatibilitätsgründen fordert Java 17 die
Exhaustivität nur für die erweiterte switch-Anweisung.
Abschließend soll noch einmal herausgestellt werden, dass mit Hilfe von Mustervergleichen endlich
Fälle durch Wertintervalle für numerische switch-Argumente definiert werden können. Die folgen-
de statische Methode bildet jeden double-Wert in Abhängigkeit von seiner Intervallzugehörigkeit
auf eine ganze Zahl ab:
static int mapIntervals(Double dbl) {
return switch (dbl) {
case Double d && d <= 5.0 -> 1;
case Double d && d > 5.0 && d <= 10.0 -> 2;
case Double d && d > 10.0 && d <= 100.0 -> 3;
default -> 4;
};
}
Selbstverständlich kann man diese Methode auch durch verschachtelte if-else - Anweisungen reali-
sieren, z. B.:
static int mapIntervals(Double dbl) {
if (dbl <= 5.0)
return 1;
else if (dbl <= 10.0)
return 2;
else if (dbl <= 100)
return 3;
else
return 4;
}
Auf analoge Weise lassen sich Mustervergleiche generell durch eine traditionelle Syntax ersetzen,
wobei aber in der Regel die Lesbarkeit leidet und das Fehlerrisiko steigt.
Die in Java 17 eingeführten Mustervergleiche für switch-Anweisungen und -Ausdrücke haben noch
Vorschaustatus, d. h.:
• In späteren Java-Versionen können Details geändert werden. Prinzipiell könnten die switch-
Mustervergleiche wieder komplett aus dem Java-Sprachumfang entfernt werden.
• Weil die Mustervergleiche noch den Vorschaustatus besitzen, sind sie per Voreinstellung
blockiert und müssen beim Übersetzen sowie beim Ausführen eines Programms über Kom-
mandozeilenoptionen freigegeben werden, z. B.:
>javac.exe --release 17 --enable-preview Prog.java
>java.exe --enable-preview Prog
In IntelliJ 2021.2 müssen wir uns nicht um Kommandozeilenoptionen kümmern, doch ist
eine Experimental Feature Alert - Anfrage zu akzeptieren, nachdem für ein Projekt über
File > Project Structure > Project > Project language level
das Sprachniveau auf
17 (Preview) - Pattern matching for switch
gesetzt worden ist, z. B.:
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung) 175
3.7.3 Wiederholungsanweisung
Eine Wiederholungsanweisung (oder schlicht: Schleife) kommt dann zum Einsatz, wenn eine (Ver-
bund-)Anweisung mehrfach (eventuell mit systematischer Variation von Details) ausgeführt werden
soll, wobei sich in der Regel schon der Gedanke daran verbietet, die Anweisung entsprechend oft in
den Quelltext zu schreiben.
Im folgenden Flussdiagramm ist ein iterativer Algorithmus zu sehen, der die Summe der quadrier-
ten natürlichen Zahlen von 1 bis 5 berechnet:1
1
Das Verzweigungssymbol sieht aus darstellungstechnischen Gründen etwas anders aus als im Abschnitt 3.7.2, was
aber keine Verwirrung stiften sollte. Obwohl im Beispiel eine Steigerung der Laufgrenze für die Variable i kaum in
Frage kommt, soll an dieser Stelle das Thema Ganzzahlüberlauf (vgl. Abschnitt 3.6.1) in Erinnerung gerufen wer-
den. Weil die Variable i vom Typ long ist, kann der Algorithmus bis zur Laufgrenze 3037000499 verwendet wer-
den. Für größere i-Werte tritt beim Ausdruck i*i ein Überlauf auf, und das Ergebnis ist unbrauchbar. Eine einfa-
che Möglichkeit zur Steigerung der maximalen sinnvollen Laufgrenze besteht darin, für eine Berechnung der Sum-
manden per Gleitkommaarithmetik zu sorgen:
(double) i * i
176 Kapitel 3 Elementare Sprachelemente
double s = 0.0;
long i = 1;
false
i <= 5 ?
true
s += i*i;
i++;
Der Vorbereitungsteil wird vor dem ersten Durchlauf ausgeführt. Eine hier deklarierte Vari-
able ist lokal bzgl. der for-Schleife, ist also nur in deren Anweisung(sblock) sichtbar. Eine
möglichst eingeschränkte Sichtbarkeit mindert das Risiko von Programmierfehlern (siehe
Abschnitt 3.3.9).
• Bedingung
Üblicherweise wird eine Ober- oder Untergrenze für die Laufvariable gesetzt, doch erlaubt
Java beliebige logische Ausdrücke. Die Bedingung wird vor jedem Schleifendurchgang ge-
prüft. Resultiert der Wert true, dann wird die eingebettete Anweisung ausgeführt, anderen-
falls wird die for-Schleife verlassen. Folglich kann es auch passieren, dass überhaupt kein
Schleifendurchgang zustande kommt.
• Aktualisierung
Am Ende jedes Schleifendurchgangs (nach der Ausführung der Anweisung) wird die Aktua-
lisierung ausgeführt. Dabei wird meist die Laufvariable in- oder dekrementiert.
Im folgenden Flussdiagramm ist das Ablaufverhalten der for-Schleife dargestellt, wobei die Be-
standteile der Schleifensteuerung an der grünen Farbe zu erkennen sind:
178 Kapitel 3 Elementare Sprachelemente
Vorbereitung
false
Bedingung
true
Anweisung
Aktualisierung
Zu den (zumindest stilistisch) bedenklichen Konstruktionen, die der Compiler klaglos akzeptiert,
gehören for-Schleifenköpfe ohne Vorbereitung oder ohne Aktualisierung, wobei die trennenden
Strichpunkte trotzdem zu setzen sind.
3.7.3.3.1 while-Schleife
Die while-Anweisung kann als vereinfachte for-Anweisung beschrieben kann: Wer im Kopf einer
for-Schleife auf Vorbereitung und Aktualisierung verzichten möchte, ersetzt besser das Schlüssel-
wort for durch while und erhält dann folgende Syntax:
while (Bedingung)
Anweisung
Wie bei der for-Anweisung wird die Bedingung vor Beginn eines Schleifendurchgangs geprüft.
Resultiert der Wert true, so wird die Anweisung (ein weiteres Mal) ausgeführt, anderenfalls wird
die while-Schleife verlassen, eventuell ohne eine einzige Ausführung der eingebetteten Anweisung:
false
Bedingung
true
Anweisung
Ein Nachteil der im Beispiel verwendeten while-Schleife gegenüber der im Abschnitt 3.7.3.1 be-
schriebenen for-Schleife besteht darin, dass die Laufvariable i außerhalb der while-Schleife dekla-
riert werden muss, was zu einem unnötig großen Gültigkeitsbereich für diese lokale Variable führt
(vgl. Abschnitt 3.3.9). Außerdem sind bei der while-Lösung der Schreibaufwand höher und die
Lesbarkeit schlechter.
3.7.3.3.2 do-Schleife
Bei der do-Schleife wird die Fortsetzungsbedingung am Ende der Schleifendurchläufe geprüft, so-
dass wenigstens ein Durchlauf stattfindet:
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung) 181
Anweisung
false
Bedingung
true
Das Schlüsselwort while tritt auch in der Syntax der do-Schleife auf:
do
Anweisung
while (Bedingung);
do-Schleifen werden seltener benötigt als while-Schleifen, sind aber z. B. dann von Vorteil, wenn
man vom Benutzer eine Eingabe mit bestimmten Eigenschaften einfordern möchte. Im folgenden
Codesegment kommt die statische Methode gchar() aus der Klasse Simput zum Einsatz (siehe
Abschnitt 3.4), die ein vom Benutzer eingetipptes und mit Enter quittiertes Zeichen als char-Wert
abliefert:
char antwort;
do {
System.out.println("Soll das Programm beendet werden (j/n)? ");
antwort = Simput.gchar();
} while (antwort != 'j' && antwort != 'n' );
Bei einer do-Schleife mit Anweisungsblock sollte man die while-Klausel unmittelbar hinter die
schließende Blockklammer setzen (in dieselbe Zeile), um sie optisch von einer selbständigen while-
Anweisung abzuheben (siehe Beispiel).
3.7.3.4 Endlosschleifen
Bei einer Wiederholungsanweisung (for, while oder do) kann es in Abhängigkeit von der Fortset-
zungsbedingung passieren, dass der Anweisungsteil so lange wiederholt wird, bis das Programm
von außen abgebrochen wird. Im folgenden Beispiel resultiert eine Endlosschleife aus einer unge-
schickten Identitätsprüfung bei double-Werten (vgl. Abschnitt 3.5.4):
class Prog {
public static void main(String[] args) {
double d = 1.0;
do {
System.out.printf("d = %.1f\n", d);
d -= 0.1;
} while (d != 0.0); // bessere Bedingung: (d > 0.01)
System.out.println("Fertig!");
}
}
Endlosschleifen sind als gravierende Programmierfehler unbedingt zu vermeiden. Befindet sich ein
Programm in diesem Zustand muss es mit Hilfe des Betriebssystems abgebrochen werden, bei unse-
ren Konsolenanwendungen unter Windows z. B. über die Tastenkombination Strg+C. Wurde der
Dauerläufer innerhalb von IntelliJ gestartet, klickt man stattdessen auf das roten Quadrat in der
Symbolleiste bzw. im Run-Fenster:
182 Kapitel 3 Elementare Sprachelemente
Die zu untersuchende Zahl erfragt das Programm mit der statischen Methode glong() der im Ab-
schnitt 3.4 vorgestellten Klasse Simput, die eine Rückgabe vom Typ long liefert. Ob die Benutzer-
eingabe in eine long-Zahl gewandelt werden konnte, erfährt das Programm durch einen Aufruf der
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung) 183
te Typumwandlung stattfindet, besitzt auch mtk den Typ long, so dass für das sqrt() - Ergebnis
(Datentyp double) eine explizite Typanpassung erforderlich ist:
mtk = (long) Math.sqrt(zahl);
Dabei kann es nicht zum Ganzzahlüberlauf kommen, weil das sqrt() - Argument eine long-Zahl ist.
Bei zwei verschachtelten Schleifen kann der Fall auftreten, dass aus der inneren Schleife ...
• per continue der aktuelle Durchgang der äußeren Schleife abgebrochen werden soll,
• per break die äußere Schleife komplett abgebrochen werden soll.
Dies ist in Java folgendermaßen zu realisieren
• Man macht von der (sehr selten benötigten) Möglichkeit zur Benennung von Anweisungen
Gebrauch und gibt der äußeren Schleife einen Namen, z. B.:
wloop: while (true) {
. . .
for (i = 2; i <= mtk; i++) {
if (zahl % i == 0) {
tg = true;
break;
}
if (System.currentTimeMillis() - start > 10_000) {
System.out.println("Nutzungszeit abgelaufen");
break wloop;
}
}
. . .
}
• In der break- oder continue-Anweisung wird die äußere Schleife über ihren Namen gezielt
ausgewählt.
import javax.swing.JOptionPane;
class PrimitivJop {
public static void main(String[] args) {
String s;
boolean tg;
long i, mtk, zahl;
while (true) {
s = JOptionPane.showInputDialog(null,
"Welche ganze Zahl von 2 bis 2^63-1 soll untersucht werden?",
"Primzahlendetektor", JOptionPane.QUESTION_MESSAGE);
zahl = Long.parseLong(s);
if (zahl <= 1)
continue;
mtk = (long) Math.sqrt(zahl); //maximaler Teilerkandidat
tg = false;
for (i = 2; i <= mtk; i++)
if (zahl % i == 0) {
tg = true;
break;
}
if (tg)
s = String.valueOf(zahl) +
" ist keine Primzahl (kleinster Teiler: " + String.valueOf(i)+")";
else
s = String.valueOf(zahl) + " ist eine Primzahl";
JOptionPane.showMessageDialog(null,
s, "Primzahlendetektor", JOptionPane.INFORMATION_MESSAGE);
}
}
}
Die linke Dialogbox zur Erfassung des Primzahlkandidaten geht auf einen Aufruf der statischen
JOptionPane-Methode showInputDialog() zurück:
public static String showInputDialog(Component parentComponent,
Object message, String title, int messageType)
Auf die Disziplin des Benutzers vertrauend lassen wir die als Rückgabewert gelieferte Zeichenfolge
ohne Prüfung von der statischen Long-Methode parseLong() in einen long-Wert wandeln.
Die rechte Dialogbox mit dem Ergebnis der Primzahlendiagnose produzieren wir mit Hilfe der sta-
tischen JOptionPane-Methode showMessageDialog():
public static void showMessageDialog(Component parentComponent,
Object message, String title, int messageType)
Die auszugebende Zeichenfolge wird folgendermaßen erstellt:
• Von der statischen Methode valueOf() der Klasse String erhalten wir die Zeichenfolgen-
Repräsentationen des darzustellenden long-Werts.
• Die Möglichkeit, mehrere Zeichenfolgen mit dem Plusoperator zu verketten, kennen wir
schon seit dem Abschnitt 3.2.1, z. B.:
s = String.valueOf(zahl) + " ist eine Primzahl";
186 Kapitel 3 Elementare Sprachelemente
Weil der Klassenname JOptionPane im Quellcode mehrfach auftaucht, wird er zu Beginn impor-
tiert, damit anschließend kein Paketnamenspräfix erforderlich ist (vgl. Abschnitt 3.1.7).
Die statischen JOptionPane-Methoden showInputDialog() und showMessageDialog() kennen
etliche Parameter (Argumente zur näheren Bestimmung der Ausführung), die in der folgenden Ta-
belle beschrieben werden:
Name Erläuterung
parentComponent Standarddialoge sind oft einem anderen (elterlichen) Fenster zu- oder unterge-
ordnet. Die Angabe eines Fensterobjekts (an Stelle der Alternative null) hat zur
Folge, dass der Standarddialog in der Nähe dieses Fensters erscheint.
message Dieser Text erscheint in der Dialogbox.
title Dieser Text erscheint in der Titelzeile der Dialogbox.
messageType Dieser Parameter legt den Typ der Nachricht fest, der auch über das Icon am
linken Rand der Dialogbox entscheidet. Als Werte sind die folgenden statischen
und finalisierten int-Felder der Klasse JOptionPane erlaubt:
JOptionPane-Konstante int
JOptionPane.PLAIN_MESSAGE -1
JOptionPane.ERROR_MESSAGE 0
JOptionPane.INFORMATION_MESSAGE 1
JOptionPane.WARNING_MESSAGE 2
JOptionPane.QUESTION_MESSAGE 3
In den folgenden Fällen liefert die Methode showInputDialog() keine als ganze Zahl im long-
Wertebereich interpretierbare Rückgabe:
• Der Benutzer hat eine ungültige Zeichenfolge eingetragen, z. B. ...
o „sieben“ (keine Zahl)
o „3,14“ (keine ganze Zahl)
o „9223372036854775808“ (ganze Zahl außerhalb des long-Wertebereichs)
• Der Benutzer hat den Input-Dialog abgebrochen (auf die Schaltfläche Abbrechen geklickt,
auf das Schließkreuz am rechten Rand der Titelzeile geklickt oder die Esc-Taste gedrückt).
Unser Programm endet dann mit einer unbehandelten Ausnahme, z. B.:
Exception in thread "main" java.lang.NumberFormatException: null
at java.base/java.lang.Long.parseLong(Long.java:552)
at java.base/java.lang.Long.parseLong(Long.java:631)
at PrimitivJop.main(PrimitivJop.java:11)
Im Kapitel 11 werden Sie erfahren, wie man solche Ausnahmen abfangen und behandeln kann.
Wird der Primzahlendetektor konsolenfrei (mit dem JVM-Werkzeug javaw.exe) gestartet, bemerkt
der Benutzer nichts von der Ausnahme:
>javaw PrimitivJop
Wie man unter Windows eine Verknüpfungsdatei zum Programmstart per Doppelklick anlegt, wur-
de im Abschnitt 1.2.3 beschrieben.
Von den zahlreichen weiteren Möglichkeiten der Klasse JOptionPane (siehe API-Dokumentation)
soll noch die statische Methode showConfirmDialog() erwähnt werden:
public static int showConfirmDialog(Component parentComponent,
Object message, String title, int optionType)
Abschnitt 3.8 Entspannungs- und Motivationseinschub: GUI-Standarddialoge 187
Sie eignet sich für Ja/Nein - Fragen an den Benutzer, präsentiert ein konfigurierbares Ensemble von
Schaltflächen (OK, Ja, Nein, Abbrechen) und teilt per int-Rückgabewert mit, über welche
Schalfläche der Benutzer den Dialog beendet hat. Das folgende Beispielprogramm wird auf Benut-
zerwunsch über die statische Methode exit() der Klasse System beendet, wobei das Betriebssystem
per exit() - Parameter den Returncode 0 erfährt:
import javax.swing.JOptionPane;
class Prog {
public static void main(String[] args) {
while (true)
if (JOptionPane.showConfirmDialog(null,
"Wollen Sie das Programm wirklich beenden?",
"Dämo", JOptionPane.YES_NO_CANCEL_OPTION) == JOptionPane.YES_OPTION)
System.exit(0);
}
}
Über den Parameter optionType (Typ: int) steuert man die Schaltflächenausstattung, z. B.:
Über int-Werte oder äquivalente statische und finalisierte Felder der Klasse JOptionPane sind vier
Ausstattungsvarianten wählbar:
optionType-Wert
Resultierende Schalter
JOptionPane-Konstante int
JOptionPane.DEFAULT_OPTION -1 OK
JOptionPane.YES_NO_OPTION 0 Ja, Nein
JOptionPane.YES_NO_CANCEL_OPTION 1 Ja, Nein, Abbrechen
JOptionPane.OK_CANCEL_OPTION 2 OK, Abbrechen
Durch ihren Rückgabewert informiert die Methode showConfirmDialog() darüber, welchen Schal-
ter der Benutzer betätigt hat. Bei der Schalterausstattung wie im obigen Beispiel (JOption-
Pane.YES_NO_CANCEL_OPTION) können die folgenden Rückgabewerte vom Typ int auftre-
ten, die auch über statische und finalisierte Felder der Klasse JOptionPane ansprechbar sind:
showConfirmDialog() - Rückgabewerte
Vom Benutzer gewählter Schalter
JOptionPane-Konstante int
Schließkreuz in der Titelzeile oder Esc-Taste JOptionPane.CLOSED_OPTION -1
Ja JOptionPane.YES_OPTION 0
Nein JOptionPane.NO_OPTION 1
Abbrechen JOptionPane.CANCEL_OPTION 2
188 Kapitel 3 Elementare Sprachelemente
3) Das folgende Programm gibt den Wert der Klassenvariablen PI aus der API-Klasse Math im
Paket java.lang aus:
class Prog {
public static void main(String[] args) {
System.out.println("PI = " + Math.PI);
}
}
Warum ist es hier nicht erforderlich, den Paketnamen anzugeben bzw. zu importieren?
Das zur exakten Beantwortung der Frage benötigte Hintergrundwissen (über die Auswertungsrei-
henfolge von Operatoren) wurde noch nicht vermittelt, sodass Sie nicht allzu viel Zeit investieren
sollten. Vielleicht hilft der Tipp, dass ein geschickt positioniertes Paar runder Klammern zur ge-
wünschten Ausgabe führt:
3.3 + 2 = 5.3
Abschnitt 3.9 Übungsaufgaben zum Kapitel 3 189
2) Im folgenden Programm wird der char-Variablen z eine Zahl zugewiesen, die sie offenbar unbe-
schädigt an eine int-Variable weitergeben kann, wobei der z-Inhalt von println() aber als Buchsta-
be ausgegeben wird. Wie erklären sich diese Merkwürdigkeiten?
Quellcode Ausgabe
class Prog { z = c
public static void main(String args[]) { i = 99
char z = 99;
int i = z;
System.out.println("z = " + z + "\ni = " + i);
}
}
Wie kann man das Zeichen ‚c‘ über eine Unicode-Escape-Sequenz ansprechen?
3) Wieso klagt der OpenJDK 17 - Compiler über ein unbekanntes Symbol, obwohl die Variable i
deklariert worden ist?
Quellcode Fehlermeldung des OpenJDK 17 - Compilers
class Prog { Prog.java:5: error: cannot find symbol
public static void main(String[] args) {{ System.out.println(i);
int i = 2; ^
} symbol: variable i
System.out.println(i); location: class Prog
} 1 error
}
4) Schreiben Sie bitte ein Java-Programm, das die folgende Ausgabe macht:
Dies ist ein Java-Zeichenfolgenliteral:
"Hallo"
2) Welcher Datentyp resultiert, wenn man eine byte- und eine short-Variable addiert?
3) Welche Werte haben die int-Variablen erg1 und erg2 am Ende des folgenden Programms?
class Prog {
public static void main(String[] args) {
int i = 2, j = 3, erg1, erg2;
erg1 = (i++ == j ? 7 : 8) % 3;
erg2 = (++i == j ? 7 : 8) % 2;
System.out.println("erg1 = " + erg1 + "\nerg2 = " + erg2);
}
}
4) Welche Wahrheitswerte erhalten im folgenden Programm die booleschen Variablen la1 bis
la3?
class Prog {
public static void main(String[] args) {
boolean la1, la2, la3;
int i = 3;
char c = 'n';
5) Erstellen Sie ein Java-Programm, das den Exponentialfunktionswert ex zu einer vom Benutzer
eingegebenen Zahl x bestimmt und ausgibt, z. B.:
Eingabe: Argument: 1
Ausgabe: exp(1,000) = 2,718
Hinweise:
Abschnitt 3.9 Übungsaufgaben zum Kapitel 3 191
• Suchen Sie mit Hilfe der Dokumentation zur Klasse Math im API-Paket java.lang eine
passende Methode.
• Zum Einlesen des Arguments können Sie die Methode gdouble() aus der Klasse Simput
verwenden, die eine vom Benutzer (mit Komma als Dezimaltrennzeichen) eingetippte und
mit Enter quittierte Zahl als double-Wert abliefert (siehe Abschnitt 3.4).
• Über Möglichkeiten zur formatierten Ausgabe informiert der Abschnitt 3.2.2.
6) Erstellen Sie ein Programm, das einen DM-Betrag entgegennimmt und diesen in Euro konver-
tiert. In der Ausgabe sollen ganzzahlige, korrekt gerundete Werte für Euro und Cent erscheinen,
z. B.:
Eingabe: DM-Betrag: 321
Ausgabe: 164 Euro und 12 Cent
Hinweise:
• Umrechnungsfaktor: 1 Euro = 1,95583 DM
• Zum Einlesen des DM-Betrags können Sie die Methode gdouble() aus unserer Eingabe-
klasse Simput verwenden (siehe Abschnitt 3.4).
7) Erstellen Sie ein Programm, das eine ganze Zahl entgegen nimmt und den Benutzer darüber in-
formiert, ob die Zahl gerade ist oder nicht, z. B.:
Eingabe: Ganze Zahl: 13
Ausgabe: ungerade
Außer einem Methodenaufruf für die Eingabeaufforderung, z. B.:
System.out.print("Ganze Zahl: ");
soll das Programm nur eine einzige Anweisung enthalten.
Hinweis: Verwenden Sie die Methode gint() aus der Klasse Simput, um die Eingabe entge-
genzunehmen (siehe Abschnitt 3.4).
switch (bst) {
case 'a': nr = 1;
case 'b': nr = 2;
case 'c': nr = 3;
}
System.out.println("Zu "+bst+
" gehört die Nummer "+nr);
}
}
Warum liefert es zum Buchstaben a die Nummer 3, obwohl für diesen Fall die Anweisung
nr = 1
vorhanden ist?
3) Erstellen Sie eine Variante des Primzahlen-Diagnoseprogramms aus dem Abschnitt 3.7.3.5, die
ohne break und continue auskommt.
5) Verbessern Sie das als Übungsaufgabe zum Abschnitt 3.5 in Auftrag gegebene Programm zur
DM-Euro - Konvertierung so, dass es nicht für jeden Betrag neu gestartet werden muss. Vereinba-
ren Sie mit dem Benutzer ein geeignetes Verfahren für den Fall, dass er das Programm doch ir-
gendwann einmal beenden möchte.
Abschnitt 3.9 Übungsaufgaben zum Kapitel 3 193
6) In dieser Aufgabe sollen Sie verschiedene Varianten von Euklids Algorithmus zur Bestimmung
des größten gemeinsamen Teilers (GGT) zweier natürlicher Zahlen u und v implementieren und die
Laufzeitunterschiede messen. Verwenden Sie als ersten Kandidaten den im Einführungsbeispiel
zum Kürzen von Brüchen (Methode kuerze() der Klasse Bruch) benutzten Algorithmus (siehe
Abschnitt 1.1.2). Sein Problem besteht darin, dass bei stark unterschiedlichen Zahlen u und v sehr
viele Subtraktions-Operationen erforderlich sind. In der meist benutzten Variante des euklidischen
Verfahrens wird dieses Problem vermieden, indem an Stelle der Subtraktion die Modulo-Operation
zum Einsatz kommt, basierend auf dem folgendem Satz der mathematischen Zahlentheorie:
Für zwei natürliche Zahlen u und v (mit u > v) ist der GGT gleich dem GGT von u und u % v (u
modulo v).
Begründung (analog zu Abschnitt 1.1.3): Für natürliche Zahlen u und v mit u > v gilt:
x ist gemeinsamer Teiler von u und v
x ist gemeinsamer Teiler von v und u % v
Der GGT-Algorithmus per Modulo-Operation läuft für zwei natürliche Zahlen u und v (u v > 0)
folgendermaßen ab:
Es wird geprüft, ob u durch v teilbar ist.
Trifft dies zu, ist v der GGT.
Anderenfalls ersetzt man:
u durch v
v durch u % v
Das Verfahren startet neu mit den kleineren Zahlen.
Die Voraussetzung u v ist nicht wesentlich, weil beim Start mit u < v der erste Algorithmusschritt
die beiden Zahlen vertauscht.
Um den Zeitaufwand für beide Varianten zu messen, eignet sich die statische Methode
currentTimeMillis() aus der Klasse System im Paket java.lang (siehe API-Dokumentation). Sie
liefert als long-Wert die aktuelle Zeit in Millisekunden (seit dem 1. Januar 1970).
Für die Beispielwerte u = 999000999 und v = 36 liefern beide Euklid-Varianten sehr verschiedene
Laufzeiten (CPU: Intel Core i3 mit 3,2 GHz):
GGT-Bestimmung mit Euklid (Differenz) GGT-Bestimmung mit Euklid (Modulo)
GGT: 9 GGT: 9
7) Wegen der beschränkten Genauigkeit bei der Speicherung von binären Gleitkommazahlen (siehe
−i
Abschnitt 3.3.6) kann ein Rechner die double-Werte 1,0 und 1,0 + 2 ab einem bestimmten Expo-
nenten i nicht mehr voneinander unterscheiden. Bestimmen Sie mit einem Testprogramm den größ-
ten ganzzahligen Exponenten i, für den man noch erhält:
1,0 + 2 −i 1,0
Im Abschnitt 3.3.7.1 findet sich eine Erklärung für das Ergebnis.
4 Klassen und Objekte
Objektorientierte Software-Entwicklung besteht nach unserem bisherigen Kenntnisstand im We-
sentlichen aus der Definition von Klassen, die aufgrund einer vorangegangenen objektorientierten
Analyse …
• als Baupläne für Objekte
• und/oder als Akteure
konzipiert werden. Wenn ein spezieller Akteur im Programm nur einfach benötigt wird, kann eine
handelnde Klasse diese Rolle übernehmen.1 Sind hingegen mehrere Individuen einer Gattung erfor-
derlich (z. B. mehrere Brüche in einem Bruchrechnungsprogramm oder mehrere Fahrzeuge in der
Speditionsverwaltung), dann ist eine Klasse mit Bauplancharakter gefragt.
Für eine Klasse und/oder ihre Objekte werden Eigenschaften (Felder) und Handlungskompeten-
zen (Methoden) deklariert bzw. definiert. Diese werden als Member der Klasse bezeichnet (dt.:
Mitglieder).
In den Methoden eines Programms werden Aufgaben erledigt bzw. Algorithmen realisiert. Ein agie-
rendes (eine Methode ausführendes) Objekt bzw. eine agierende Klasse muss nicht alles selbst erle-
digen, sondern kann vordefinierte (z. B. der Standardbibliothek entstammende) oder im Programm
definierte Klassen einspannen, z. B.:
• Eine Klasse aus der Standardbibliothek wird beauftragt:
double res = Math.exp(arg);
• Ein Objekt, das beim Laden einer Klasse aus der Standardbibliothek automatisch entsteht
und über eine statische (klassenbezogene) Referenzvariable ansprechbar ist, wird beauftragt:
System.out.println(arg);
• Ein explizit im Programm erstelltes Objekt aus einer im Programm definierten Klasse wird
beauftragt:
Bruch b1 = new Bruch();
b1.frage();
Mit dem „Beauftragen“ eines Objekts oder einer Klasse bzw. mit dem „Zustellen einer Botschaft“
ist nichts anderes gemeint als ein Methodenaufruf.
Unsere vorläufige, auch im aktuellen Kapitel 4 zugrundeliegende Vorstellung von einem Computer-
Programm lässt sich so beschreiben:
• Ein Programm besteht aus Klassen, die als Baupläne für Objekte und/oder als Akteure die-
nen.
• Die Akteure (Objekte und Klassen) haben jeweils einen Zustand (abgelegt in Feldern).
• Sie können Botschaften empfangen und senden (Methoden ausführen und aufrufen).
In der Hoffnung, dass die bisher präsentierten Eindrücke von der objektorientierten Programmie-
rung (OOP) neugierig gemacht und nicht abgeschreckt haben, kommen wir nun zur systematischen
1
Eine nur einfach zu besetzende Rolle von einer Klasse übernehmen zu lassen, ist keinesfalls in jeder Situation eine
ideale Design-Entscheidung und wird im Manuskript hauptsächlich der Einfachheit halber bevorzugt. In einer späte-
ren Phase auf dem Weg zum professionellen Entwickler sollte man sich unbedingt mit dem sogenannten Singleton-
Pattern beschäftigen (siehe z. B. Bloch 2018, S. 17ff). Dabei geht es um Klassen, von denen innerhalb einer An-
wendung garantiert nur ein Objekt entsteht. Hier fungiert also ein Objekt statt einer Klasse als Solist, was etliche
Vorteile bietet, z. B.:
• Die Adresse des Solo-Objekts kann an Methoden als Parameter übergeben werden.
• Die Vererbungstechnik der OOP wird besser unterstützt (inkl. Polymorphie, siehe Abschnitt 7.7).
• Eine Singleton-Klasse kann Interfaces implementieren (siehe Kapitel 9).
196 Kapitel 4 Klassen und Objekte
Behandlung dieser Software-Technologie. Für die im Kapitel 1 speziell für größere Projekte emp-
fohlene objektorientierte Analyse und Modellierung, z. B. mit Hilfe der Unified Modeling Lan-
guage (UML), ist dabei leider keine Zeit vorhanden (siehe z. B. Balzert 2011; Booch et al. 2007).
de
tho
Methode
Me
Me
e
Merkmal
od
Me
al
th
Feld
th
rkm
od
rkm
Me
e
Me
al
FeldKlasse AFeld
al
Me
rkm
Me
de
rkm
priv. Methode
Me
tho
tho
al
Merkmal
Me
de
de
Methode
tho
Me
Es kann aber auch private Methoden für den ausschließlich internen Gebrauch geben. Ebenso sind
öffentliche Felder möglich, die damit zur Schnittstelle einer Klasse gehören. Solche Felder sollten
finalisiert (siehe Abschnitt 4.2.5), also vor Veränderungen geschützt sein. Wir haben mit den stati-
schen, öffentlichen und finalisierten Feldern System.out und Math.PI entsprechende Beispiele
kennengelernt.
Klassen mit Datenkapselung realisieren besser als frühere Software-Technologien (siehe Abschnitt
4.1.2) das Prinzip der Modularisierung, das schon Julius Cäsar (100 v. Chr. - 44 v. Chr.) bei seiner
beruflichen Tätigkeit als römischer Kaiser und Feldherr erfolgreich einsetzte (Divide et impera!).1
Die Modularisierung ist ein probates, ja unverzichtbares Mittel der Software-Entwickler zur Bewäl-
tigung von umfangreichen Projekten.
Zugunsten einer häufigen und erfolgreichen Wiederverwendung sind Klassen mit hoher Komplexi-
tät (vielfältigen Aufgaben) und auch Methoden mit hoher Komplexität zu vermeiden. Als eine Leit-
linie für den Entwurf von Klassen findet das von Robert C. Martin2 erstmals formulierte Prinzip
einer einzigen Verantwortung (engl.: Single Responsibility Principle, SRP) (Martin 2002) bei den
Vordenkern der objektorientierten Programmierung breite Zustimmung (siehe z. B. Lahres & Ra-
yman 2009, Abschnitt 3.1). Multifunktionale Klassen tendieren zu stärkeren Abhängigkeiten von
anderen Klassen, wobei die Wahrscheinlichkeit einer erfolgreichen Wiederverwendung sinkt. Ein
negatives Beispiel wäre eine Klasse aus einem Personalverwaltungsprogramm, die sich sowohl um
Gehaltsberechnungen als auch um die Interaktion mit dem Benutzer über eine grafische Bedien-
oberfläche kümmert.3
Aus der Datenkapselung und anderen Prinzipien der Modularisierung (z. B. Klassendesign nach
dem Prinzip einer einzigen Verantwortung) ergeben sich gravierende Vorteile für die Software-
Entwicklung:
1
Deutsche Übersetzung: Teile und herrsche!
2
Der als Uncle Bob bekannte Software-Berater und Autor erläutert auf der folgenden Webseite seine Vorstellungen
von objektorientiertem Design: http://butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod
3
In einem sehr kleinen Programm ist es angemessen, wenn eine einzige Klasse für die „Geschäftslogik“ und die Be-
nutzerinteraktion zuständig ist.
198 Kapitel 4 Klassen und Objekte
4.1.1.2 Vererbung
Zu den Vorzügen der „super-modularen“ Klassenkonzeption gesellt sich in der OOP ein Verer-
bungsverfahren, das gute Voraussetzungen für die Erweiterung von Software-Systemen bei ratio-
neller Wiederverwendung der bisherigen Code-Basis schafft: Bei der Definition einer abgeleiteten
Klasse werden alle Eigenschaften (Felder) und Handlungskompetenzen (Methoden) der Basisklasse
übernommen. Es ist also leicht möglich, ein Software-System um neue Klassen mit speziellen Leis-
tungen zu erweitern. Durch systematische Anwendung des Vererbungsprinzips entstehen mächtige
Klassenhierarchien, die in zahlreichen Projekten einsetzbar sind. Neben der direkten Nutzung vor-
handener Klassen (über statische Methoden oder erzeugte Objekte) bietet die OOP mit der Verer-
bungstechnik eine weitere Möglichkeit zur Wiederverwendung von Software.
Abschnitt 4.1 Überblick, historische Wurzeln, Beispiel 199
In Java wird das Vererbungsprinzip sogar auf die Spitze getrieben: Alle Klassen stammen von der
Urahnklasse Object ab, die an der Spitze des hierarchisch organisierten Java-Klassensystems steht.
Hier ist ein winziger Ausschnitt aus der Hierarchie zu sehen mit einigen Klassen, die uns im Manu-
skript schon begegnet sind (JOptionPane, System, Bruch):
java.lang.Object
java.awt.Container
javax.swing.JComponent
javax.swing.JOptionPane
Zu jeder Klasse ist auch ihre Paketzugehörigkeit angegeben (unsere Klasse Bruch gehört zum un-
benannten Standardpaket).
Wird bei einer Klassendefinition keine Basisklasse explizit angegeben (wie bei unserer Beispiel-
klasse Bruch aus dem Abschnitt 1.1), dann beerbt die neue Klasse implizit die Urahnklasse Object.
Weil sich im Handlungsrepertoire der Urahnklasse u. a. auch die Methode getClass() befindet, kann
man Instanzen beliebiger Klassen durch einen getClass() - Aufruf nach ihrem Datentyp befragen.
Im folgenden Programm wird ein Bruch-Objekt nach seiner Klassenzugehörigkeit befragt:
Quellcode Ausgabe
class Bruchrechnung { Bruch
public static void main(String[] args) {
Bruch b = new Bruch();
System.out.println(b.getClass().getName());
}
}
Die Methode getClass() liefert als Rückgabewert ein Objekt der Klasse Class im Paket java.lang,
das über die Methode getName() aufgefordert wird, eine Zeichenfolge mit dem Namen der Klasse
zu liefern. Diese Zeichenfolge (ein Objekt der Klasse String) bildet schließlich den Parameter des
println() - Aufrufs und landet auf der Konsole. In unserem Kursstadium ist es angemessen, die
komplexe Anweisung unter Beteiligung von fünf Klassen (System, PrintStream, Bruch, Class,
String), drei Methoden (println(), getClass(), getName()), zwei expliziten Referenzvariablen (out,
b) und einer impliziten Referenz (getClass() - Rückgabewert) genau zu erläutern:
System.out.println(b.getClass().getName());
Durch die technischen Details darf nicht der Blick auf das wesentliche Thema des aktuellen Ab-
schnitts verstellt werden: Eine abgeleitete Klasse erbt die Eigenschaften und Handlungskompeten-
zen ihrer Basisklasse. Wenn diese Basisklasse ihrerseits abgeleitet ist, kommen indirekt erworbene
Erbstücke hinzu. Die als Beispiel betrachtete Klasse Bruch stammt direkt von der Klasse Object
ab, und ihre Objekte beherrschen dank Vererbung u. a. die Methode getClass(), obwohl in der
Bruch-Klassendefinition nichts davon zu sehen ist.
4.1.1.3 Polymorphie
Obwohl in unseren bisherigen Beispielen die Polymorphie noch nicht zum Einsatz kam, soll doch
versucht werden, die Kernidee hinter diesem Begriff schon jetzt zu vermitteln. In diesem Abschnitt
sind einige Vorgriffe auf das Kapitel 7 erforderlich. Wer sich jetzt noch nicht stark für den Begriff
der Polymorphie interessiert, kann den Abschnitt ohne Risiko für den weiteren Kursverlauf über-
springen.
Beim Klassendesign ist generell das Open-Closed - Prinzip beachtenswert:1
• Eine Klasse soll offen sein für Erweiterungen, die zur Lösung von neuen oder geänderten
Aufgaben benötigt werden.
• Dabei darf es nicht erforderlich werden, vorhandenen Code zu verändern. Er soll abge-
schlossen bleiben, möglichst für immer. In ungünstigen Fällen zieht eine Änderung am
Quellcode weitere nach sich, sodass eine Kaskade von Anpassungen (eventuell unter Betei-
ligung von anderen Klassen) resultiert. Dadurch verursacht die Anpassung einer Klasse an
neue Aufgaben hohe Kosten und oft ein fehlerhaftes Ergebnis.
Einen exzellenten Beitrag zur Erstellung von änderungsoffenem und doch abgeschlossenem Code
leistet schon die Vererbungstechnik der OOP. Zur Modellierung einer neuen, spezialisierten Rolle
kann man oft auf eine Basisklasse zurückgreifen und muss nur die zusätzlichen Eigenschaften
und/oder Verhaltenskompetenzen ergänzen.
In Java können über eine Referenzvariable Objekte vom deklarierten Typ und von jedem abgeleite-
ten Typ angesprochen werden. In einer abgeleiteten Klasse können nicht nur zusätzliche Methoden
erstellt, sondern auch geerbte überschrieben werden, um das Verhalten an spezielle Einsatzbereiche
anzupassen. Ergeht ein Methodenaufruf an Objekte aus verschiedenen abgeleiteten Klassen, die
jeweils die Methode überschrieben haben, unter Verwendung von Basisklassenreferenzen, dann
zeigen die Objekte ihr artgerechtes Verhalten. Obwohl alle Objekte mit einer Referenz vom selben
Basisklassentyp angesprochen werden und denselben Methodenaufruf erhalten, agieren sie unter-
schiedlich. Welche Methode tatsächlich ausgeführt wird, entscheidet sich erst zur Laufzeit (späte
Bindung). Genau in dieser Situation spricht man von Polymorphie, und diese Software-Technik
leistet einen wichtigen Beitrag zur Realisation des Open-Closed - Prinzips.
Wird z. B. in einer Klasse zur Verwaltung von geometrischen Objekten eine Referenzvariable vom
relativ allgemeinen Typ Figur deklariert und beim Aufruf der Methode meldeInhalt() verwen-
det, dann führt das angesprochene Objekt, das bei einem konkreten Programmeinsatz z. B. aus der
abgeleiteten Klasse Kreis oder Rechteck stammt, seine spezifischen Berechnungen durch. Die
Klasse zur Verwaltung von geometrischen Objekten kann ohne Quellcodeänderungen mit beliebi-
gen, eventuell sehr viel später definierten Figur-Ableitungen kooperieren.
Weil in der allgemeinen Klasse Figur keine Inhaltsberechnungsmethode realisiert werden kann,
wird hier die Methode meldeInhalt() zwar deklariert, aber nicht implementiert, sodass eine so-
genannte abstrakte Methode entsteht. Enthält eine Klasse mindestens eine abstrakte Methode, ist sie
1
Das Open-Closed - Prinzip wird von Robert C. Martin (Uncle Bob) in einem Text erläutert, der über folgende Web-
Adresse zu beziehen ist: http://butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod
Abschnitt 4.1 Überblick, historische Wurzeln, Beispiel 201
ihrerseits abstrakt und kann nicht zum Erzeugen von Objekten genutzt werden. Eine abstrakte Klas-
se ist aber gleichwohl als Datentyp erlaubt und spielt eine wichtige Rolle bei der Realisation von
Polymorphie.1
Dank Polymorphie ist eine lose Kopplung von Klassen möglich, und die Wiederverwendbarkeit von
vorhandenem Code wird verbessert. Um die Offenheit für neue Aufgaben zu ermöglichen, verwen-
det man beim Klassendesign für Felder und Methodenparameter mit Referenztyp einen möglichst
allgemeinen Datentyp, der die benötigten Verhaltenskompetenzen vorschreibt, aber keine darüber
hinausgehende Einschränkung enthält.
Dank Vererbung und Polymorphie kann objektorientierte Software anpassungs- und erweite-
rungsfähig bei weitgehend fixiertem Bestands-Code, also unter Beachtung des Open-Closed -
Prinzips, gestaltet werden.
1
Neben den abstrakten Klassen, die mindestens eine abstrakte Methode (Definitionskopf ohne Implementation) ent-
halten, spielen bei der Polymorphie auch die sogenannten Schnittstellen eine wichtige Rolle als Datentypen für ein
veränderungsoffenes Design. Eine Schnittstelle kann näherungsweise als Klasse mit ausschließlich abstrakten Me-
thoden charakterisiert werden. Abstrakte Klassen und Schnittstellen (engl.: Interfaces) werden später ausführlich
behandelt.
202 Kapitel 4 Klassen und Objekte
• Problemadäquate Datentypen
Zusammengehörige Daten unter einem Variablennamen ansprechen zu können, vereinfacht
das Programmieren erheblich. Über das Schlüsselwort struct der Programmiersprache C
oder das analoge Schlüsselwort record der Programmiersprache Pascal lassen sich prob-
lemadäquate Datentypen mit mehreren Bestandteilen definieren, die jeweils einen beliebi-
gen, bereits bekannten Typ haben dürfen. So eignet sich etwa für ein Programm zur Adres-
senverwaltung ein neu definierter Datentyp mit Variablen für Name, Vorname, Telefon-
nummer etc. Alle Adressinformationen zu einer Person lassen sich dann in einer Variablen
vom selbst definierten Typ speichern. Dies vereinfacht z. B. das Lesen, Kopieren oder
Schreiben solcher Daten.
Die problemadäquaten Datentypen der älteren Programmiersprachen werden in der OOP durch
Klassen ersetzt, wobei diese Datentypen nicht nur durch eine Anzahl von Eigenschaften (Feldern)
beliebigen Typs charakterisiert sind, sondern auch Handlungskompetenzen (Methoden) besitzen, die
die Aufgaben der Funktionen bzw. Prozeduren der älteren Programmiersprachen übernehmen.
Im Vergleich zur strukturierten Programmierung bietet die OOP u. a. folgende Vorteile:
• Optimierte Modularisierung mit Zugriffsschutz
Die Daten sind sicher in Objekten gekapselt, während sie bei traditionellen Programmier-
sprachen entweder als globale Variablen allen Missgriffen ausgeliefert sind oder zwischen
Unterprogrammen „wandern“ (Goll et al. 2000, S. 21), was bei Fehlern zu einer aufwändi-
gen Suche entlang der Verarbeitungskette führen kann.
• Gute Voraussetzungen für die Teamarbeit
Durch die optimierte Modularisierung wird die (vor allem in großen Projekten wichtige)
Kooperation in Entwicklungs-Teams erleichtert.
• Rationelle (Weiter-)Entwicklung von Software nach dem Open-Closed - Prinzip durch Ver-
erbung und Polymorphie
• Bessere Abbildung des Anwendungsbereichs
Das erleichtert die Kommunikation zwischen dem Auftraggeber bzw. Anwender einerseits
und dem Software-Architekten bzw. -Entwickler andererseits.
• Mehr Komfort für Bibliotheksbenutzer
Jede rationelle Software-Produktion greift in hohem Maß auf Bibliotheken mit bereits vor-
handenen Lösungen zurück. Dabei sind die Klassenbibliotheken der OOP einfacher zu ver-
wenden als klassische Funktionsbibliotheken.
• Erleichterte Wiederverwendung
Die komfortable Nutzung von Lösungsbibliotheken sowie die rationelle Weiterentwicklung
von Software durch Vererbung und Polymorphie führen zu einer erleichterten Wiederver-
wendung von vorhandener Software.
Dass objektorientierte Programmiersprachen im Vergleich zu ihren strukturierten Vorgängern etwas
mehr Speicherplatz und CPU-Leistung verbrauchen, spielt schon lange keine Rolle mehr.
benötigt wird. Später sind weitere Klassen zu ergänzen (z. B. Aufgabe, Übungsaufgabe, Testaufga-
be, Schüler, Lernepisode, Testepisode, Fehler).
Wir nehmen nun bei der Bruch-Klassendefinition im Vergleich zur Variante im Abschnitt 1.1 eini-
ge Verbesserungen vor:
• Als zusätzliches Feld erhält jeder Bruch ein etikett vom Datentyp der Klasse String.
Damit wird eine beschreibende Zeichenfolge verwaltet, die z. B. beim Aufruf der Methode
zeige() zusätzlich zu anderen Eigenschaften auf dem Bildschirm erscheint. Objekte der
erweiterten Klasse Bruch besitzen also auch ein Feld mit Referenztyp (neben den Feldern
zaehler und nenner vom primitiven Typ int).
• Weil die Klasse Bruch ihre Eigenschaften systematisch kapselt, also fremden Klassen keine
direkten Zugriffe erlaubt, muss sie auch für das etikett zum Lesen bzw. Setzen des Wer-
tes jeweils eine Methode bereitstellen.
• In der Methode kuerze() wird die performante Modulo-Variante von Euklids Algorithmus
zur Bestimmung des größten gemeinsamen Teilers von zwei ganzen Zahlen verwendet (vgl.
Übungsaufgabe auf Seite 193).
Im folgenden Quellcode der erweiterten Klasse Bruch sind die unveränderten Methoden gekürzt
wiedergegeben:
public class Bruch {
private int zaehler; // wird automatisch mit 0 initialisiert
private int nenner = 1; // wird manuell mit 1 initialisiert
private String etikett = ""; // die Referenztyp-Init. auf null wird ersetzt
Für die bei diversen Demonstrationen in den folgenden Abschnitten verwendeten Startklassen (mit
jeweils spezieller Implementierung) werden wir generell den Namen Bruchrechnung verwenden,
z. B.:
Quellcode Ausgabe
class Bruchrechnung { 1
public static void main(String[] args) { Der gekürzte Bruch: -----
Bruch b = new Bruch(); 4
b.setzeZaehler(4);
b.setzeNenner(16);
b.kuerze();
b.setzeEtikett("Der gekürzte Bruch:");
b.zeige();
}
}
Die Instanzvariablen zaehler und nenner der Klasse Bruch haben bei der Renovierung den Da-
tentyp int beibehalten und sind daher nach wie vor mit einem potentiellen Überlaufproblem (vgl.
Abschnitt 3.6.1) belastet, das im folgenden Programm demonstriert wird:
Quellcode Ausgabe
class Bruchrechnung { -2147483647
public static void main(String[] args) { -----
Bruch b1 = new Bruch(), b2 = new Bruch();
1
b1.setzeZaehler(2147483647); b1.setzeNenner(1);
b2.setzeZaehler(2); b2.setzeNenner(1);
b1.addiere(b2);
b1.zeige();
}
}
Der Einfachheit halber verzichten wir auf die im Abschnitt 3.6.1 beschriebenen Techniken zur
Vermeidung des Problems.
Im Unterschied zur Präsentation im Abschnitt 1.1 wird die Bruch-Klassendefinition anschließend
gründlich erläutert. Dabei machen die im Abschnitt 4.2 behandelten Instanzvariablen (Felder) rela-
tiv wenig Mühe, weil wir viele Details schon von den lokalen Variablen her kennen (siehe Ab-
schnitt 3.3). Bei den Methoden gibt es mehr Neues zu lernen, sodass wir uns im Abschnitt 4.3 auf
elementare Themen beschränken und später noch wichtige Ergänzungen vornehmen.
Wir arbeiten weiterhin mit dem aus dem Abschnitt 3.1.3.1 bekannten Syntaxdiagramm zur Klas-
sendefinition, das aus didaktischen Gründen einige Vereinfachungen enthält:
Abschnitt 4.1 Überblick, historische Wurzeln, Beispiel 205
Klassendefinition
class Name { }
Modifikator
Felddeklaration
Methodendefinition
1
Dazu muss die Klasse später allerdings noch in ein explizites Paket aufgenommen werden. Noch gehört die Klasse
Bruch zum Standardpaket, und dessen Klassen sind in anderen Paketen generell (auch bei Zugriffsstufe public)
nicht verfügbar. Das mit Java 9 (im September 2017) eingeführte Modulsystem macht es zudem möglich, den Zu-
griff auf die Pakete in den Klassen eines Moduls auf berechtigte andere Module einzuschränken. Aktuell (im No-
vember 2021) ist Java 8 nach einer aktuellen Umfrage (https://www.jetbrains.com/lp/devecosystem-2021/java/) der
Firma JetBrains (Hersteller der im Kurs bevorzugten Entwicklungsumgebung IntelliJ IDEA) unter Entwicklern im-
mer noch die Java-Version mit der größten Verbreitung, und in Java 8 erlaubt der Modifikator public den Zugriff
noch für alle Klassen.
2
Bei einer Startklasse ist ein komplizierter Name zu vermeiden, wenn dieser vom Benutzer beim Programmstart
eingetippt werden muss (mit korrekt eingehaltener Groß-/Kleinschreibung!).
206 Kapitel 4 Klassen und Objekte
class Bruchrechnung {
public static void main(String[] args) {
Bruch b1 = new Bruch(), b2 = new Bruch();
int i = 13, j = 4711;
b1.setzeEtikett("b1");
b2.setzeEtikett("b2");
. . .
}
}
ausgeführt wird, befinden sich auf dem Stack die lokalen Variablen b1, b2, i und j. Die beiden
Bruch-Referenzvariablen (b1, b2) zeigen jeweils auf ein Bruch-Objekt auf dem Heap, das einen
kompletten Satz der Bruch-Instanzvariablen besitzt:1
Stack Heap
b1 Bruch-Objekt
0 1 "b1"
lokale Variablen der
b2
Bruch@1960f0
Bruch-Objekt
i
zaehler nenner etikett
13
0 1 "b2"
j
4711
Hier wird aus didaktischen Gründen ein wenig gemogelt: Die beiden Etiketten sind selbst Objekte
und liegen „neben“ den Bruch-Objekten auf dem Heap. In jedem Bruch-Objekt befindet sich eine
Referenz-Instanzvariable namens etikett, die auf das zugehörige String-Objekt zeigt.
4.2.2 Deklaration mit Modifikatoren für den Zugriffsschutz und für andere Zwecke
Während lokale Variablen im Rumpf einer Methode deklariert werden, erscheinen die Deklaratio-
nen der Instanzvariablen in der Klassendefinition außerhalb jeder Methodendefinition. Man sollte
die Instanzvariablen der Übersichtlichkeit halber am Anfang der Klassendefinition deklarieren,
wenngleich der Compiler auch ein späteres Erscheinen akzeptiert.2
Für die Deklaration einer lokalen Variablen haben wir final als einzigen Modifikator kennengelernt
und in einem speziellen Syntaxdiagramm beschrieben (vgl. Abschnitt 3.3.10). Dieser Modifikator
ist auch bei Instanzvariablen erlaubt (siehe Abschnitt 4.2.5). Außerdem kommen hier weitere Modi-
fikatoren in Frage, die z. B. zur Spezifikation der Schutzstufe dienen. Insgesamt ist es sinnvoll, in
1
Die Abbildung zeigt zu den beiden Bruch-Referenzvariablen (b1, b2) jeweils den Rückgabewert der (von Object
geerbten) Methode toString() als Inhalt. Hinter dem @-Zeichen steht genau genommen der hashCode() - Wert (vgl.
Abschnitt 10.6) der Klasse Object, der allerdings wesentlich auf der Speicheradresse basiert.
2
Anders als bei lokalen Variablen von Methoden hat der Deklarationsort bei Instanzvariablen keinen Einfluss auf den
Sichtbarkeitsbereich.
208 Kapitel 4 Klassen und Objekte
das Syntaxdiagramm zur Deklaration von Instanzvariablen den allgemeinen Begriff des Modifika-
tors aufzunehmen:1
Deklaration von Instanzvariablen
Modifikator ,
Die bei lokalen Variablen durch Typinferenz ermöglichte Ersetzung des Typnamens durch das
Schlüsselwort var (siehe Abschnitt 3.3.8) ist bei Instanzvariablen verboten.
Im Bruch-Beispiel wird im Sinne einer perfekten Datenkapselung für alle Instanzvariablen mit
dem Modifikator private angeordnet, dass nur klasseneigenen Methoden der direkte Zugriff erlaubt
sein soll:
private int zaehler;
private int nenner = 1;
private String etikett = "";
Um fremden Klassen trotzdem einen (allerdings kontrollierten) Zugang zu den Bruch-Instanz-
variablen zu ermöglichen, enthält die Klassendefinition jeweils ein Methodenpaar für den lesenden
bzw. schreibenden Zugriff (z. B. gibNenner(), setzeNenner()).
Gibt man bei der Deklaration einer Instanzvariablen keine Schutzstufe an, dann haben alle anderen
Klassen im selben Paket (siehe Kapitel 6) das direkte Zugriffsrecht, was in der Regel unerwünscht
ist.
In der Klasse Bruch scheint die Datenkapselung auf den ersten Blick nur beim Nenner relevant zu
sein, doch auch bei den restlichen Instanzvariablen bringt sie potentiell Vorteile:
• Zugunsten einer übersichtlichen Bildschirmausgabe soll das Etikett auf 40 Zeichen be-
schränkt bleiben. Mit Hilfe der Zugriffsmethode setzteEtikett() kann dies auf einfache
Weise gewährleistet werden.
• Abgeleitete (erbende) Klassen (siehe Kapitel 7) können in die Zugriffsmethoden für
zaehler und nenner neben der Null-Überwachung für den Nenner noch weitere Intelli-
genz einbauen und z. B. mit speziellen Aktionen reagieren, wenn der Wert auf eine Primzahl
gesetzt wird. Ein zwingendes Argument für die Kapselung von nenner und zaehler wür-
de aus der Entscheidung resultieren, beim Zähler und beim Nenner eines Bruch-Objekts
negative Werte zu verbieten (vgl. Abschnitt 1.1.1).
Trotz ihrer überzeugenden Vorteile soll die Datenkapselung nicht zum Dogma erhoben werden.2
Sie verliert an Bedeutung, wenn ...
1
Es ist sinnlos und verboten, einen Modifikator mehrfach auf eine Instanzvariable anzuwenden. Im Syntaxdiagramm
zur Instanzvariablendeklaration wird der Einfachheit halber darauf verzichtet, die Mehrfachvergabe durch eine auf-
wändige Darstellungstechnik zu verhindern.
2
Bei öffentlichen Klassen (Zugriffsmodifikator public) ist die durch Datenkapselung realisierte Sicherheit und Flexi-
bilität von weitaus größerer Bedeutung als bei Klassen, die nur im eigenen Paket einer Anwendung sichtbar sind
(Bloch 2018, S.78). Wenn z. B. die Datenablage in einer paket-privaten Klasse geändert werden muss, dann sind nur
wenige andere Klassen betroffen, die vom selben Entwickler oder von derselben Firma kontrolliert werden.
Abschnitt 4.2 Instanzvariablen (Felder) 209
• bei einem Feld Lese- und Schreibzugriffe uneingeschränkt erlaubt sein sollen, wenn es also
insbesondere nicht erforderlich ist, die möglichen Werte zu restringieren.
• es nicht von Interesse ist, auf bestimmte Wertzuweisungen zu reagieren, um z. B. bestimmte
Objekteigenschaften (man sagt auch: Invarianten) sicherzustellen.
Um allen Klassen in Paketen aus berechtigten Modulen den Direktzugriff auf eine Instanzvariable
zu erlauben, wird in der Deklaration der Modifikator public angegeben, z. B.:1
public int zaehler;
Bei finalisierten Instanzvariablen, die nach einer initialen Wertzuweisung nicht mehr geändert wer-
den können (siehe Abschnitt 4.2.5), ist keine Datenkapselung als Schutz gegen irreguläre Wertzu-
weisungen erforderlich, sofern sie ...
• entweder einen primitiven Datentyp besitzen
• oder als Referenzvariablen auf ein nicht-veränderbares Objekt zeigen (z. B. vom Typ
String).
Wenn eine finalisierte Instanzvariable (vgl. Abschnitt 4.2.5) auf ein veränderliches Member-Objekt
zeigt, sollte durch das Kapseln der Instanzvariablen eine Veränderung des Member-Objekts verhin-
dert werden.
Insgesamt ist für Instanzvariablen in der Regel die Datenkapselung, also die private-Deklaration zu
empfehlen. Das gilt insbesondere für öffentlich zugängliche Klassen (definiert mit dem Modifikator
public).
Im Zusammenhang mit den Modulen und Paketen (siehe Kapitel 6) werden wir uns noch ausführ-
lich mit dem Thema Zugriffsschutz beschäftigen. Die wichtigsten Regeln für die Sichtbarkeit von
Instanzvariablen können Sie aber jetzt schon verstehen:
• Bevor sich die Frage nach der Sichtbarkeit von Instanzvariablen stellt, muss die Klasse
selbst sichtbar sein:
o Eine Klasse ist grundsätzlich in anderen Klassen des eigenen Pakets sichtbar.
o Für die Sichtbarkeit in allen Klassen (aus Paketen in berechtigten Modulen) muss
durch den Klassen-Zugriffsmodifikator public gesorgt werden.
• Per Voreinstellung ist der Zugriff auf Instanzvariablen allen Klassen des eigenen Pakets er-
laubt.
• Mit einem Member-Zugriffsmodifikator lassen sich alternative Schutzstufen wählen, z. B.:
o private
Alle fremden Klassen werden ausgeschlossen (auch die Klassen im selben Paket).
o public
Alle Klassen aus Paketen in berechtigten Modulen dürfen zugreifen.
In Bezug auf die Benennung gibt es keine Unterschiede zwischen den Instanzvariablen und den
lokalen Variablen (vgl. Abschnitt 3.3). Insbesondere sollten die folgenden Namenskonventionen
eingehalten werden:
• Variablennamen beginnen mit einem Kleinbuchstaben.
• Besteht ein Name aus mehreren Wörtern (z. B. currentSpeed), schreibt man ab dem zwei-
ten Wort die Anfangsbuchstaben groß (Camel Casing)
1
Module wurden mit Java 9 eingeführt (im September 2017). Aktuell (im November 2021) setzen die meisten Java-
Programme nur eine JVM-Version 8 voraus, sodass der Modifikator public den Zugriff für alle Klassen erlaubt.
210 Kapitel 4 Klassen und Objekte
Datentyp Voreinstellungswert
char 0 (Unicode-Zeichennummer)
boolean false
Referenztyp null
In der von uns tatsächlich realisierten Bruch-Definition werden solche Zugriffe jedoch verhindert.
Der OpenJDK 17 - Compiler meldet:
Bruchrechnung.java:4: error: zaehler has private access in Bruch
b.zaehler = 1;
^
Bruchrechnung.java:5: error: nenner has private access in Bruch
b.nenner = 0;
^
2 errors
Unsere Entwicklungsumgebung IntelliJ signalisiert die Problemstellen sehr deutlich im Quellcode-
Editor:
212 Kapitel 4 Klassen und Objekte
• String
• Die Verpackungsklassen Integer, Double etc. für primitive Datentypen (siehe Abschnitt
5.3)
Bloch (2018, S. 82ff) nennt folgende Vorteile unveränderlicher Klassen:
• Wird bei der Kreation eines Objekts für einen gültigen Zustand gesorgt, ist die Gültigkeit
während der gesamten Lebenszeit garantiert, was die Handhabung von Objekten sicher und
einfach macht.
• Ein unveränderliches Objekt kann ohne Synchronisierungsaufwand von mehreren Threads
genutzt werden (siehe Kapitel 15). Es wird also auf besonders einfache Weise Thread-
Sicherheit erzielt.
• Unveränderliche Objekte können an mehreren Stellen einer Anwendung wiederverwendet
werden, statt jeweils ein neues Objekt zu erzeugen (z. B. ein String-Objekt mit dem Inhalt
„N.N.“). Diese Option zur Reduktion von Aufwand bei der Kreation und Entsorgung von
Objekten benutzen z. B. die sogenannten Fabrikmethoden (siehe Abschnitt 4.4.5).
Die Klasse Bruch folgt dem modernen Trend hin zu unveränderlichen Objekten noch nicht, was
vermutlich Programmiereinsteigern entgegenkommt. Sobald die sichere Verwendung der Klasse in
einer Multithreading-Umgebung relevant wird, sollte eine unveränderliche Neukonzeption erwogen
werden.
Dem funktionalen Programmierstil verpflichtete Methoden verändern nicht den Zustand von Objek-
ten (z. B. die Koordinaten einer Position), sondern produzieren nach Bedarf neue Objekte (z. B.
eine neue Position). Man kann sich vorstellen, dass die funktionale Programmierung nicht für alle
Aufgabenstellungen angemessen ist. Sehr viele Objekte zu erstellen, verursacht einen hohen Zeit-
aufwand und bei großen Objekten auch einen hohen Speicherbedarf.
Bloch (2018, S. 86) kommt nach Abwägung von Vor- und Nachteilen der Unveränderlichkeit zur
Empfehlung, Klassen nach Möglichkeit als komplett unveränderlich zu konzipieren.1 Wenn die
Unveränderlichkeit einer Klasse (z. B. aus Performanzgründen) nicht vollständig sein kann, dann
sollte sie doch so weit wie möglich realisiert werden:
Declare every field private final unless there's a good reason to do otherwise.
Seit Java 16 lassen sich unveränderliche Datenklassen, die hauptsächlich zur Aufbewahrung von
Daten dienen und nur wenige Handlungskompetenzen besitzen, über die neuen Record-Datentypen
mit einem sehr geringen syntaktischen Aufwand definieren (siehe Abschnitt 5.5).
Traditionelle, veränderliche Klassen sind aber für viele Aufgaben weiterhin unverzichtbar. Wir
werden als Alternative zur Klasse String, die für unveränderliche Zeichenfolgen optimiert ist, spä-
ter die Klassen StringBuilder und StringBuffer kennenlernen, die für variable Zeichenfolgen kon-
zipiert sind. In einem Testprogramm wird sich zeigen, dass die unveränderliche Klasse String für
bestimmte Algorithmen nicht geeignet ist.
4.3 Instanzmethoden
Durch eine Bauplan-Klassendefinition werden Objekte mit einer Anzahl von Verhaltenskompeten-
zen entworfen, die sich über Methodenaufrufe nutzen lassen. Objekte sind also Dienstleister, die
eine Reihe von Nachrichten interpretieren und mit passendem Verhalten beantworten können. Ihre
Instanzvariablen (Eigenschaften) sind bei konsequenter Datenkapselung für fremde Klassen un-
1
Ein auf unserem aktuellen Ausbildungsstand schwer nachvollziehbarer Satz: Um die komplette Unveränderlichkeit
von Objekten zu erzielen, muss man nicht nur Schreibzugriffe auf die Felder verhindern (z. B. durch das Finalisie-
ren), sondern z. B. auch die Definition einer abgeleiteten Klasse unterbinden (wie z. B. bei der API-Klasse String).
214 Kapitel 4 Klassen und Objekte
sichtbar (information hiding). Um fremden Klassen trotzdem (kontrollierte) Zugriffe auf eine In-
stanzvariable zu ermöglichen, sind entsprechende Methoden zum Lesen bzw. Verändern erforder-
lich. Zu diesen speziellen Methoden (oft als getter und setter bezeichnet) gesellen sich diverse an-
dere Methoden, die in der Regel komplexere Dienstleistungen erbringen.
Beim Aufruf einer Methode werden oft durch sogenannte Parameter erforderliche Daten und/oder
Anweisungen zur Steuerung der Arbeitsweise an die Methode übergeben, und von vielen Methoden
wird dem Aufrufer ein Rückgabewert geliefert (z. B. mit der angeforderten Information).
Ziel einer typischen Klassendefinition sind kompetente, einfach und sicher einsetzbare Objekte, die
oft auch noch reale Objekte aus dem Aufgabenbereich der Software repräsentieren. Wenn ein ande-
rer Programmierer z. B. ein Objekt aus unserer Beispielklasse Bruch verwendet, dann kann er es
mit einen Aufruf der Methode addiere() veranlassen, einen per Parameter benannten zweiten
Bruch zum eigenen Wert zu addieren, wobei das Ergebnis auch noch gleich gekürzt wird:
public void addiere(Bruch b) {
zaehler = zaehler*b.nenner + b.zaehler*nenner;
nenner = nenner*b.nenner;
kuerze();
}
Weil diese Methode auch für fremde Klassen aus Paketen in berechtigten Modulen verfügbar sein
soll, wird per Modifikator die Schutzstufe public gewählt.
Da es vom Verlauf der Auftragserledigung nichts zu berichten gibt, liefert addiere() keinen
Rückgabewert. Folglich wird im Kopf der Methodendefinition der Rückgabetyp void angegeben.
Während sich jedes Objekt mit seinem eigenen vollständigen Satz von Instanzvariablen auf dem
Heap befindet, ist der Bytecode der Instanzmethoden nur einmal im Speicher vorhanden und wird
von allen Objekten der Klasse verwendet. Er befindet sich in einem Bereich des programmeigenen
Speichers, der als Method Area bezeichnet wird.
4.3.1 Methodendefinition
Die folgende Serie von Syntaxdiagrammen zur Methodendefinition unterscheidet sich von der im
Abschnitt 3.1.3.2 präsentierten Variante durch eine genauere Erklärung der (im Abschnitt 4.3.1.4
behandelten) Formalparameter:
Methodendefinition
Methodenkopf Methodenrumpf
Methodenkopf
Formal- Serienformal-
Rückgabetyp Name ( parameter , parameter )
Modifikator
,
Abschnitt 4.3 Instanzmethoden 215
Formalparameter
Datentyp Parametername
final
Serienformalparamer
Datentyp … Parametername
final
Methodenrumpf
{ }
Anweisung
In den nächsten Abschnitten werden die (mehr oder weniger) neuen Bestandteile dieser Syntaxdia-
gramme erläutert. Dabei werden Methodendefinition und -aufruf keinesfalls so sequentiell und ge-
trennt dargestellt, wie es die Abschnittsüberschriften vermuten lassen. Schließlich ist die Bedeutung
mancher Details der Methodendefinition am besten am Effekt auf den Methodenaufruf zu erkennen.
4.3.1.1 Modifikatoren
Bei einer Methodendefinition kann per Modifikator u. a. der voreingestellte Zugriffsschutz verän-
dert werden. Wie für Instanzvariablen gelten auch für Instanzmethoden beim Zugriffsschutz die
folgenden Regeln:
• Bevor sich die Frage nach der Sichtbarkeit von Instanzmethoden stellt, muss die Klasse
selbst sichtbar sein:
o Eine Klasse ist grundsätzlich in anderen Klassen des eigenen Pakets sichtbar.
o Für die Sichtbarkeit in allen Klassen (aus Paketen in berechtigten Modulen) muss
durch den Klassen-Zugriffsmodifikator public gesorgt werden.1
• Per Voreinstellung ist der Zugriff auf Instanzmethoden allen Klassen des eigenen Pakets er-
laubt.
• Mit einem Member-Zugriffsmodifikator lassen sich alternative Schutzstufen wählen, z. B.:
o private
Alle fremden Klassen werden ausgeschlossen (auch die im selben Paket).
o public
Alle Klassen in Paketen aus berechtigten Modulen dürfen zugreifen.
In unserer Beispielklasse Bruch haben alle Methoden den Zugriffsmodifikator public erhalten.
Damit diese Klasse mit ihren Methoden tatsächlich universell einsetzbar ist, muss sie allerdings
1
Module wurden mit Java 9 eingeführt (im September 2017). Aktuell (im November 2021) setzen die meisten Java-
Programme nur eine JVM-Version 8 voraus, sodass der Modifikator public den Zugriff für alle Klassen erlaubt.
216 Kapitel 4 Klassen und Objekte
noch in ein explizites Paket aufgenommen werden. Noch gehört die Klasse Bruch zum Standard-
paket, und dessen Klassen sind in anderen Paketen generell nicht verfügbar. Im Kapitel 6 über Mo-
dule und Pakete werden wir den Zugriffsschutz für Klassen und ihre Member ausführlich und end-
gültig behandeln.
Später (z. B. im Zusammenhang mit der Vererbung) werden uns noch Methoden-Modifikatoren
begegnen, die anderen Zwecken als der Zugriffsregulation dienen (z. B. final, abstract).
Ist der Rückgabetyp einer Methode von void verschieden, dann muss im Rumpf der Methode dafür
gesorgt werden, dass jeder mögliche Ausführungspfad mit einer return-Anweisung endet, die einen
Rückgabewert von kompatiblem Typ liefert:2
return-Anweisung für Methoden mit Rückgabewert
return Ausdruck ;
Bei Methoden ohne Rückgabewert (mit dem Rückgabetyp void) ist die return-Anweisung nicht
unbedingt erforderlich, kann jedoch (in einer Variante ohne Ausdruck) dazu verwendet werden, um
die Methode vorzeitig zu beenden, was meist im Rahmen einer bedingten Anweisung geschieht:
return-Anweisung für Methoden ohne Rückgabewert
return ;
1
Wenn wir das Wissen aus Kapitel 11 über die Ausnahmebehandlung schon zur Verfügung hätten, dann würden wir
zur Benachrichtigung des Aufrufers über einen ungeeigneten Parameterwert einen Ausnahmefehler gegenüber dem
Rückgabewert bevorzugen.
2
Wenn ein Ausführungspfad allerdings mit dem Werfen eines Ausnahmefehlers endet (siehe Kapitel 11), dann ist für
diesen Pfad eine return-Anweisung weder erforderlich noch erlaubt.
Abschnitt 4.3 Instanzmethoden 217
4.3.1.3 Namen
Bei der Benennung einer Methode sollten in Java die folgenden Konventionen eingehalten werden:
• Der Name beginnt mit einem Kleinbuchstaben.
• Am Anfang sollte ein Verb stehen (z. B. addiere(), kuerze()). Wie die Methode
toUpperCase() aus der wichtigen API-Klasse String zeigt, ist die Aktionsorientierung von
Methodennamen kein Dogma.
• Folgen auf das Verb noch weitere Wörter, die meist keine Verben sind, (z. B.
setzeNenner(), compareTo()), dann schreibt man ab dem zweiten Wort die Anfangs-
buchstaben groß (Camel Casing).
Abgesehen von der empfohlenen aktionsorientierten Benennung durch den Start mit einem von
Verb bestehen also keine Unterschiede zu den Namen von lokalen Variablen oder Feldern.
4.3.1.4 Formalparameter
Parameter wurden bisher leicht vereinfachend als Daten und/oder Informationen beschrieben, die
einer Methode beim Aufruf übergeben werden. Tatsächlich ermöglichen Parameter aber den Infor-
mationsaustausch zwischen einer rufenden und einer aufgerufenen Methode in beide Richtungen.
218 Kapitel 4 Klassen und Objekte
Im Kopf der Methodendefinition werden über sogenannte Formalparameter Daten von bestimm-
tem Typ spezifiziert, die der Methode beim Aufruf zur Verfügung gestellt werden müssen.
In den Anweisungen des Methodenrumpfs werden die Formalparameter wie lokale Variablen ver-
wendet, die mit den beim Aufruf übergebenen Aktualparameterwerten (siehe Abschnitt 4.3.2) initia-
lisiert worden sind.
Methodeninterne Änderungen an den Inhalten dieser speziellen lokalen Variablen haben keinen
Effekt auf die Außenwelt (siehe Abschnitt 4.3.1.4.1). Werden einer Methode Referenzen übergeben,
dann kann sie jedoch im Rahmen ihrer Zugriffsrechte auf die zugehörigen Objekte einwirken (siehe
Abschnitt 4.3.1.4.2) und so Informationen nach Außen transportieren.
Für jeden Formalparameter sind folgende Angaben zu machen:
• Datentyp
Es sind beliebige Typen erlaubt (primitive Typen, Referenztypen). Man muss den Datentyp
eines Formalparameters auch dann explizit angeben, wenn er mit dem Typ des linken Nach-
barn übereinstimmt.
• Name
Für Parameternamen gelten dieselben Regeln bzw. Konventionen wie für Variablennamen.
Weil Formalparameter im Methodenrumpf wie lokale Variablen funktionieren, …
o müssen sich die Parameternamen von den Namen der (anderen) lokalen Variablen
unterscheiden,
o werden namensgleiche Instanz- bzw. Klassenvariablen überlagert.
Diese bleiben jedoch über ein geeignetes Präfix weiter ansprechbar. Durch einen
Punktoperator separiert setzt man ...
▪ das Schlüsselwort this vor eine Instanzvariable
▪ den Klassennamen vor eine statische Variable
Um Namenskonflikte (mit lokalen Variablen oder Feldern) zu vermeiden, hängen manche
Programmierer an Parameternamen ein Suffix an, z. B. par oder einen Unterstrich.
• Position
Die Position eines Formalparameters ist natürlich nicht gesondert anzugeben, sondern liegt
durch die Methodendefinition fest. Sie wird hier als relevante Eigenschaft erwähnt, weil die
beim späteren Aufruf der Methode übergebenen Aktualparameter gemäß ihrer Reihenfolge
den Formalparametern zugeordnet werden. Java kennt keine Namensparameter, sondern nur
Positionsparameter.
Ein Formalparameter kann wie jede andere lokale Variable mit dem Modifikator final auf den Initi-
alisierungswert fixiert werden. Auf diese Weise lässt sich die (kaum jemals sinnvolle) Änderung
des Initialisierungswertes verhindern. Welche Vorteile es hat, ungeplante Veränderungen von loka-
len Variablen (und damit auch von Formalparametern) systematisch per final-Deklaration zu ver-
hindern, wurde im Abschnitt 3.3.10 erläutert.
Im Beispielprogramm ist die Klasse Prog startfähig; sie besitzt also eine öffentliche und statische
Methode namens main() mit dem Rückgabetyp void und einem Parameter vom Typ String[]. In
main() wird ein Objekt der Klasse Prog erzeugt und beauftragt, die Instanzmethode demoPrimP-
ar() auszuführen. Mit dieser auch in den folgenden Abschnitten anzutreffenden, etwas umständ-
lich wirkenden Konstruktion wird es vermieden, im aktuellen Abschnitt 4.3.1 über Details bei der
Definition von Instanzmethoden zur Demonstration statische Methoden (außer main()) verwenden
zu müssen. Bei den Parametern und beim Rückgabewert gibt es allerdings keine Unterschiede zwi-
schen den Instanz- und den Klassenmethoden (siehe Abschnitt 4.5.3).
Die Referenzparametertechnik eröffnet den (berechtigten) Methoden nicht nur mehr oder weniger
weitgehende Wirkungsmöglichkeiten, sondern spart auch Zeit und Speicherplatz beim Methoden-
aufruf. Über einen Referenzparameter wird ein beliebig voluminöses Objekt in der aufgerufenen
Methode verfügbar, ohne dass es (mit Zeit- und Speicheraufwand) kopiert werden müsste.
Abschnitt 4.3 Instanzmethoden 221
4.3.1.4.3 Serienformalparameter
Seit der Version 5.0 (alias 1.5) bietet Java auch Parameterlisten variabler Länge, wozu am Ende der
Formalparameterliste eine Serie von Elementen desselben Typs über die folgende Syntax deklariert
wird:
Serienformalparamer
Datentyp … Parametername
final
Man spricht von einem Serienparameter oder von einem Varargs-Parameter. Methoden mit einem
Varargs-Parameter werden in der englischsprachigen Literatur gelegentlich als variable arity meth-
ods bezeichnet und den fixed arity methods gegenübergestellt, die keinen Varargs-Parameter besit-
zen.
Als Beispiel betrachten wir eine weitere Variante der Bruch-Methode addiere(), mit der ein
Objekt beauftragt werden kann, mehrere andere Brüche zum eigenen Wert zu addieren:
public void addiere(Bruch... bar) {
for (Bruch b : bar)
addiere(b);
}
Ob man zwischen den Typbezeichner und die drei Punkte (das sogenannte Auslassungszeichen,
engl.: ellipsis) ein trennendes Leerzeichen setzt oder (wie im Beispiel) der Konvention folgend (vgl.
Gosling et al. 2021, Abschnitt 8.4.1) darauf verzichtet, ist für den Compiler irrelevant.
Ein Serienparameter besitzt einen Array-Datentyp, zeigt also auf ein Objekt mit einer Serie von
Instanzvariablen desselben Typs. Wir haben Arrays zwar noch nicht offiziell behandelt (siehe Ab-
schnitt 5.1), aber doch schon gelegentlich verwendet, zuletzt im Zusammenhang mit der for-
Schleifen - Variante für Arrays und andere Kollektionen (siehe Abschnitt 3.7.3.2). Im aktuellen
Beispiel wird diese Schleifenkonstruktion dazu genutzt, um jedes Element im Array bar mit
Bruch-Objekten durch einen Aufruf der ursprünglichen addiere() - Methode zum handelnden
Bruch zu addieren.
Mit den Bruch-Objekten b1 bis b4 sind z. B. folgende Aufrufe erlaubt:
b1.addiere(b2);
b1.addiere(b2, b3);
b1.addiere(b2, b3, b4);
Es ist sogar erlaubt (im aktuellen Beispiel allerdings sinnlos), für einen Serienformalparameter beim
Aufruf überhaupt keinen Aktualparameter anzugeben:
b1.addiere();
Weil per Serienparametersyntax letztlich ein Parameter mit Array-Datentyp deklariert wird, kann
man beim Methodenaufruf an Stelle einer Serie von einzelnen Aktualparametern auch einen Array
mit diesen Elementen übergeben. In der ersten Anweisung des folgenden Beispiels wird (dem Ab-
schnitt 5.1.8 vorgreifend) ein Array-Objekt per Initialisierungsliste erzeugt. In der zweiten Anwei-
sung wird dieses Objekt an die obige Serienparametervariante der addiere() - Methode überge-
ben:
222 Kapitel 4 Klassen und Objekte
Eine weitere Methode mit Serienparameter kennen Sie übrigens schon aus dem Abschnitt 3.2.2 über
die formatierte Ausgabe mit der PrintStream-Methode printf(), die folgenden Definitionskopf
besitzt:
public PrintStream printf (String format, Object... args)
Dass die Methode printf() eine Referenz auf das handelnde PrintStream-Objekt als (meist igno-
rierten) Rückgabewert liefert, kann uns momentan gleichgültig sein.
Weil im aktuellen Abschnitt bisher zwei Serienparameter-Beispiele mit einer Klasse als Elementtyp
aufgetreten sind, soll in einem weiteren Beispiel demonstriert werden, dass auch ein primitiver
Elementtyp erlaubt ist, z. B.:
public void prInt(int... iar) {
for (int i : iar)
System.out.println(i);
}
4.3.1.5 Methodenrumpf
Über die Blockanweisung, die den Rumpf einer Methode bildet, haben Sie bereits erfahren:
• Hier werden die Formalparameter wie lokale Variablen verwendet. Ihre Besonderheit be-
steht darin, dass sie bei jedem Methodenaufruf über Aktualparameter vom Aufrufer initiali-
siert werden, sodass dieser den Ablauf der Methode beeinflussen kann.
• Die return-Anweisung dient zur Rückgabe eines Wertes an den Aufrufer und/oder zum Be-
enden der Methodenausführung. Bei einer Methode mit Rückgabe muss jeder (nicht durch
das Werfen einer Ausnahme abgebrochene) Ausführungspfad mit einer return-Anweisung
enden, die einen Wert von kompatiblem Typ liefert. Bei einer Methode mit dem Pseu-
dorückgabetyp void kann die return-Anweisung (in der Variante ohne Ausdruck) optional
dazu verwendet werden, um die Methode vorzeitig zu verlassen.
Ansonsten können beliebige Anweisungen unter Verwendung von elementaren und objektorientier-
ten Sprachelementen eingesetzt werden, um den Zweck der Methode zu realisieren.
Im letzten Satz war bewusst von dem Zweck einer Methode die Rede und nicht von den Zwecken.
Bei Mehrzweckmethoden leiden die Lesbarkeit und die Wartungsfreundlichkeit, während das
Fehlerrisiko steigt, weil z. B. die für eine Teilaufgabe benötigten Variablen auch im Code-Segment
anderer Teilaufgaben gültig sind und durch Tippfehler unverhofft ins Spiel kommen bzw. in Mitlei-
denschaft gezogen werden können. Um diese Nachteile zu vermeiden, sollte für jede Aufgabe bzw.
Aktivität eine eigene Methode definiert werden (Bloch 2018, S. 263).
Weil in einer Methode häufig andere Methoden aufgerufen werden, kommt es in der Regel zu
mehrstufig verschachtelten Methodenaufrufen, wobei die Höhe des Stacks (Stapelspeichers) zur
Verwaltung der Methodenaufrufe entsprechend wächst (siehe Abschnitt 4.3.3).
Als Syntaxregel ist festzuhalten, dass zwischen dem Objektnamen (genauer: dem Namen der Refe-
renzvariablen, die auf das Objekt zeigt) und dem Methodennamen der Punktoperator zu stehen
hat. Eine analoge Syntaxregel haben Sie beim Zugriff auf Instanzvariablen kennengelernt.
Beim Aufruf einer Methode folgt ihrem Namen die in runde Klammern eingeschlossene Liste mit
den Aktualparametern, wobei es sich um eine analog zur Formalparameterliste geordnete Sequenz
von Ausdrücken mit kompatiblen Datentypen handeln muss.
Methodenaufruf
Name ( Aktualparameter )
Es muss grundsätzlich eine Parameterliste angegeben werden, ggf. eine leere wie im obigen Aufruf
der Methode zeige().
Als Beispiel mit Aktualparametern betrachten wir einen Aufruf der im Abschnitt 4.3.1.4.1 vorge-
stellten Variante der Bruch-Methode addiere():
b1.addiere(1, 2, true);
An einer bestimmten Position der Parameterliste ist als Aktualparameter ein Ausdruck zugelassen,
dessen Typ entweder direkt mit dem korrespondierenden Formalparametertyp übereinstimmt oder
mit diesem Typ kompatibel ist:
• Bei primitiven Datentypen findet automatisch eine erweiternde Typanpassung statt (vgl. Ab-
schnitt 3.5.7.1).
• Hat der Formalparameter den Typ eine Klasse, werden auch Objekte aus einer abgeleiteten
Klasse als Aktualparameter akzeptiert (siehe Kapitel 7).
• Hat der Formalparameter den Typ einer Schnittstelle, werden Objekte aus allen Klassen ak-
zeptiert, die die Schnittstelle implementieren. Dieser Satz steht der Vollständigkeit halber
hier, obwohl er erst nach der Behandlung der Schnittstellen im Kapitel 9 verständlich ist.
Java kennt keine Namensparameter, sondern nur Positionsparameter. Um einen Parameter mit ei-
nem Wert zu versorgen, muss dieser Wert im Methodenaufruf an der korrekten Position stehen.
Außerdem müssen stets alle Parameter mit Ausnahme eines eventuell am Ende der Parameterliste
stehenden Serienparameters (siehe Abschnitt 4.3.1.4.3) mit Werten versorgt werden. Oft existieren
aber zu einer Methode mehrere Überladungen mit unterschiedlich langen Parameterlisten, sodass
man durch Wahl einer Überladung doch die Option hat, auf manche Parameter zu verzichten (vgl.
Abschnitt 4.3.4).
Liefert eine Methode einen Wert zurück, dann kann der aus ihrem Aufruf bestehende Ausdruck als
Argument in komplexeren Ausdrücken verwenden werden, z. B.:
Quellcodesegment Ausgabe
double arg = 0.0, logist; 0.5
logist = Math.exp(arg)/(1+Math.exp(arg));
System.out.println(logist);
0.8
0.6
0.4
0.2
-6 -4 -2 2 4 6
unter Verwendung der statischen Methode exp() aus der Klasse Math im Paket java.lang an der
Stelle 0,0 ausgewertet.
Außerdem ist ein Methodenaufruf als Aktualparameter erlaubt, wenn er eine Rückgabe mit kompa-
tiblem Typ liefert, z. B.:
System.out.println(b.gibNenner());
Wie Sie schon aus dem Abschnitt 3.7.1 wissen, wird jeder Methodenaufruf durch ein angehängtes
Semikolon zur vollständigen Anweisung, wobei ein Rückgabewert ggf. ignoriert wird.
Soll in einer Methodenimplementierung vom aktuell handelnden Objekt eine andere Instanzmetho-
de ausgeführt werden, so muss beim Aufruf keine Objektbezeichnung angegeben werden. In den
verschiedenen Varianten der Bruch-Methode addiere() soll das beauftragte Objekt den via Pa-
rameterliste übergebenen Bruch (bzw. die übergebenen Brüche) zu seinem eigenen Wert addieren
und das Resultat (bei der Variante aus dem Abschnitt 4.3.1.4.1 parametergesteuert) gleich kürzen.
Zum Kürzen kommt natürlich die entsprechende Bruch-Methode zum Einsatz. Weil sie vom gera-
de agierenden Objekt auszuführen ist, wird keine Objektbezeichnung benötigt, z. B.:
public void addiere(Bruch b) {
zaehler = zaehler*b.nenner + b.zaehler*nenner;
nenner = nenner*b.nenner;
kuerze();
}
Wer auch solche Methodenaufrufe nach dem Schema
Empfänger.Botschaft
realisieren möchte, kann mit dem Schlüsselwort this das aktuelle Objekt ansprechen, z. B.:
this.kuerze();
Mit dem Schlüsselwort this samt angehängtem Punktoperator gibt man außerdem unserer Entwick-
lungsumgebung IntelliJ den Anlass, eine Liste mit allen für das agierende Objekt möglichen Me-
thodenaufrufen und Feldnamen anzuzeigen, z. B.:
Unsere Entwicklungsumgebung zeigt bei manchen Methodenaufrufen für manche Parameter den
Namen an, z. B.:
class Bruchrechnung {
public static void main(String[] args) {
Bruch b1 = new Bruch(), b2 = new Bruch();
b1.setzeZaehler(2); b1.setzeNenner(8);
b2.setzeZaehler(2); b2.setzeNenner(3);
b1.addiere(b2); public void addiere(Bruch b) {
b1.zeige(); zaehler = zaehler*b.nenner
} + b.zaehler*nenner;
} nenner = nenner*b.nenner;
this.kuerze(); public void kuerze() {
} if (zaehler != 0) {
int rest;
int ggt = Math.abs(zaehler);
int divisor = Math.abs(nenner);
do {
rest = ggt % divisor;
ggt = divisor;
divisor = rest;
} while (rest > 0);
zaehler /= ggt;
nenner /= ggt;
} else
nenner = 1;
}
Wir verwenden die zur Fehlersuche konzipierte Debug-Technik von IntelliJ. Das aktuelle Bruch-
rechnungsprogramm soll bei der späteren Ausführung im Debug-Modus an mehreren Stellen durch
einen sogenannten Unterbrechungspunkt (engl. breakpoint) angehalten werden, sodass wir je-
weils die Lage im Hauptspeicher inspizieren können. Einen Unterbrechungspunkt einzurichten oder
aufzuheben, macht wenig Mühe:
• Um einen Unterbrechungspunkt zu setzen, klickt man auf die Infospalte am linken Rand des
Editors in Höhe der betroffenen Zeile.
• Zum Entfernen eines Unterbrechungspunkts klickt man sein Symbol erneut an.
Hier ist die main() - Methode des Beispielprogramms mit Unterbrechungspunkt zu sehen:
Im Variables-Bereich auf der rechten Seite der Debugger-Registerkarte sind die lokalen Variab-
len der Methode main() zu sehen:
• Parameter args
• die lokalen Referenzvariablen b1 und b2
Man kann auch die Instanzvariablen der referenzierten Bruch-Objekte anzeigen lassen.
Lassen Sie das Programm mit dem Schalter aus der Symbolleiste am linken Rand des Debug-
Fensters oder mit der Taste F9 fortsetzen. Beim Erreichen des nächsten Unterbrechungspunkts
(Anweisung kuerze(); in der Methode addiere()) liegen die Stack-Frames der Methoden ad-
diere() und main() übereinander:
228 Kapitel 4 Klassen und Objekte
Der Variables-Bereich der Debugger-Registerkarte zeigt als lokale Variablen der Methode
addiere():
• this (Referenz auf das handelnde Bruch-Objekt)
• Parameter b
Man kann sich auch die Instanzvariablen der referenzierten Bruch-Objekte anzeigen lassen.
Beim Erreichen des nächsten Unterbrechungspunkts (Anweisung ggt = divisor; in der Metho-
de kuerze()) liegen die Stack-Frames der Methoden kuerze(), addiere() und main() überei-
nander:
Der Variables-Bereich der Debugger-Registerkarte zeigt als lokale Variablen der Methode
kuerze():
• this (Referenz auf das handelnde Bruch-Objekt)
• die lokalen, im Block zur if-Anweisung deklarierten Variablen ggt, divisor und rest.
Weil sich der dritte Unterbrechungspunkt in einer do-while - Schleife befindet, sind mehrere Fort-
setzungsbefehle bis zum Verlassen der Methode kuerze() erforderlich, wobei die Werte der loka-
len Variablen den Verarbeitungsfortschritt erkennen lassen, z. B.:
Bei Erreichen des letzten Unterbrechungspunkts (Anweisung b1.zeige(); in main()) ist nur
noch der Stack Frame der Methode main() vorhanden:
Abschnitt 4.3 Instanzmethoden 229
Die anderen Stack Frames sind verschwunden, und die dort ehemals vorhandenen lokalen Variablen
existieren nicht mehr.
Beenden Sie das Programm durch einen letzten Fortsetzungsklick auf den Schalter . Anschlie-
ßend zeigt die Registerkarte Console des Debug-Fensters die Ausgabe der Methode zeige():
1
Befindet sich beim Betätigen der Tastenkombination die Einfügemarke in einer Editorzeile mit Unterbrechungs-
punkt, dann erscheint zunächst ein Kontextmenü mit Eigenschaften des lokalen Unterbrechungspunkts, und die Tas-
tenkombination Umschalt + Strg + F8 ist erneut zu drücken, um das Breakpoints-Fenster zu öffnen.
230 Kapitel 4 Klassen und Objekte
Hier kann man z. B. einzelne oder auch alle Unterbrechungspunkte deaktivieren oder löschen.
Weil der verfügbare Speicher endlich ist, kann es bei einer Aufrufverschachtelung und der damit
verbundenen Stapelung von Stack-Frames zu dem bereits genannten Laufzeitfehler vom Typ Stack-
OverflowError kommen. Dies wird aber nur bei einem schlecht entworfenen bzw. fehlerhaften
Algorithmus passieren.
1
Bei den im Kapitel 8 zu behandelnden generischen Methoden muss die Liste der Kriterien für die Identität von Sig-
naturen erweitert werden.
Abschnitt 4.3 Instanzmethoden 231
Ist bei einem Methodenaufruf die angeforderte Überladung nicht eindeutig zu bestimmen, meldet
der Compiler einen Fehler. Um diese Konstellation in einer Variante unsere Klasse Bruch zu pro-
vozieren, sind einige Verrenkungen nötig:
• Die Bruch-Instanzvariablen zaehler und nenner erhalten den Datentyp long.
• Es werden zwei neue addiere() - Überladungen mit wenig sinnvollen Parameterlisten de-
finiert:
public void addiere(long z, int n) {
if (n == 0) return;
zaehler = zaehler*n + z*nenner;
nenner = nenner*n;
}
public void addiere(int z, long n) {
if (n == 0) return;
zaehler = zaehler*n + z*nenner;
nenner = nenner*n;
}
Aufgrund dieser „Vorarbeiten“ enthält das folgende Programm
class Bruchrechnung {
public static void main(String[] args) {
Bruch b = new Bruch();
b.setzeZaehler(1);
b.setzeNenner(2);
b.addiere(3, 4);
b.zeige();
}
}
im Aufruf
b.addiere(3, 4);
eine Mehrdeutigkeit, weil keine addiere() - Überladung perfekt passt, und für zwei Überladun-
gen gleich viele erweiternde Typanpassungen (vgl. Abschnitt 3.5.7) erforderlich sind. Der Open-
JDK 17 - Compiler äußert sich so:
Bruchrechnung.java:6: error: reference to addiere is ambiguous
b.addiere(3, 4);
^
both method addiere(long,int) in Bruch and method addiere(int,long) in Bruch match
1 error
Bei einem sinnvollen Entwurf von überladenen Methoden treten solche Mehrdeutigkeiten nur sehr
selten auf.
Von einer Methode unterschiedlich parametrisierte Varianten in eine Klassendefinition aufzuneh-
men, lohnt sich z. B. in den folgenden Situationen:
• Für verschiedene Datentypen werden analog arbeitende Methoden benötigt. So besitzt z. B.
die Klasse Math im Paket java.lang die folgenden Methoden, um den Betrag einer Zahl zu
ermitteln:
public static double abs(double value)
public static float abs(float value)
public static int abs(int value)
public static long abs(long value)
Seit der Java - Version 5 bieten generische Methoden (siehe Abschnitt 8.2) eine elegantere
Lösung für die Unterstützung verschiedener Datentypen. Allerdings führt die generische Lö-
sung bei primitiven Datentypen zu einem deutlich höheren Zeitaufwand für die Methoden-
ausführung, sodass hier die Überladungstechnik weiterhin sinnvoll sein kann.
232 Kapitel 4 Klassen und Objekte
4.4 Objekte
Im aktuellen Abschnitt geht es darum, wie Objekte erzeugt, genutzt und im obsoleten Zustand wie-
der aus dem Speicher entfernt werden.
1
Sollte einmal eine Ableitung (Spezialisierung) der Klasse Bruch definiert werden, können deren Objekte ebenfalls
über Bruch-Referenzvariablen verwaltet werden. Vom Vererbungsprinzip der objektorientierten Programmierung
haben Sie schon einiges gehört, doch steht die gründliche Behandlung noch aus.
Abschnitt 4.4 Objekte 233
Referenzvariable b
Heap
Bruch-Objekt
0 1 ""
1
Hier wird aus didaktischen Gründen ein wenig gemogelt. Die Instanzvariable etikett ist vom Typ der Klasse
String, zeigt also auf ein String-Objekt, das „neben“ dem Bruch-Objekt auf dem Heap liegt. In der Bruch-
Referenz-Instanzvariablen etikett befindet sich die Adresse des String-Objekts.
234 Kapitel 4 Klassen und Objekte
Quellcode Ausgabe
class Bruchrechnung { 1
public static void main(String[] args) { b1 = -----
Bruch b1 = new Bruch(); 3
b1.setzeZaehler(1);
b1.setzeNenner(3);
b1.setzeEtikett("b1 = ");
Bruch b2 = b1;
b2.zeige();
}
}
In der Anweisung
Bruch b2 = b1;
wird die neue Referenzvariable b2 vom Typ Bruch angelegt und mit dem Inhalt von b1 (also mit
der Adresse des bereits vorhandenen Bruch-Objekts) initialisiert. Es resultiert die folgende Situati-
on im Speicher:
Stack Heap
b1 Bruch-Objekt
Bruch@87a5cc zaehler nenner etikett
1 3 "b1 = "
b2
Bruch@87a5cc
Hier sollte nur die Möglichkeit der Mehrfachreferenzierung demonstriert werden. Bei einer ernst-
haften Anwendung des Prinzips befinden sich die alternativen Referenzen an verschiedenen Stellen
des Programms, z. B. in Instanzvariablen verschiedener Objekte. In einem Speditionsverwaltungs-
programm kennen z. B. alle Objekte zu einzelnen Fahrzeugen die Adresse des Planerobjekts, dem
sie besondere Ereignisse wie Pannen melden.
Eventuell empfinden manche Leser den doppelten Auftritt des Klassennamens bei einer Refe-
renzvariablendeklaration mit Initialisierung als störend redundant, z. B.:
Bruch b = new Bruch();
Hier sind aber zwei Sprachbestandteile (Variablendeklaration und Objektkreation) involviert, die
beide den Klassennamen enthalten:
• In der Variablendeklaration wird der Datentyp angegeben.
• Wenn der new-Operator ein Objekt von bestimmtem Typ kreieren soll, kommt man um die
Nennung des Klassennamens nicht herum, weil ein Konstruktor der Klasse ins Spiel kommt.
Es ist aber keinesfalls immer so, dass im new-Operanden als Klasse der deklarierte Datentyp
Verwendung findet.
Bei der Referenzvariablendeklaration mit Initialisierung stehen beide Sprachbestandteile unmittel-
bar hintereinander, sodass der Eindruck von Redundanz entsteht, wenn (wie in unseren einfachen
Beispielen) der deklarierte Datentyp und die Klasse im new-Operanden identisch sind.
Wie Sie aus dem Abschnitt 3.3.8 wissen, kann seit Java 10 bei der Deklaration einer lokalen Vari-
ablen mit Initialisierung über das Schlüsselwort var dank der Fähigkeit des Compilers zur Typinfe-
Abschnitt 4.4 Objekte 235
renz etwas Schreibaufwand gespart und die doppelte Nennung des Klassennamens vermieden wer-
den, z. B.:
var b = new Bruch();
Um Einsatzflexibilität und Polymorphie zu ermöglichen, sind auch Basisklassen, abstrakte Klassen
und Schnittstellen als Datentypen für eine Referenzvariable erlaubt und sinnvoll. Nutzt man solche
Datentypen, dann stimmen bei der Referenzvariablendeklaration mit Initialisierung der deklarierte
Datentyp und der Klassenname im new-Operanden nicht überein.
4.4.3 Konstruktoren
In diesem Abschnitt werden mit den sogenannten Konstruktoren spezielle Methoden behandelt, die
beim Erzeugen von neuen Objekten ausgeführt werden, um deren Instanzvariablen zu initialisieren
und/oder andere Arbeiten zu verrichten (z. B. Öffnen einer Datei). Ziel der Konstruktor-Tätigkeit ist
ein neues Objekt in einem validen Zustand, das für seinen Einsatz gut vorbereitet ist.1 Wie Sie be-
reits wissen, wird zum Erzeugen von Objekten der new-Operator verwendet. Als Operand ist ein
Konstruktor der gewünschten Klasse zu übergeben.
Hat der Programmierer zu einer Klasse keinen Konstruktor definiert, dann erhält diese Klasse auto-
matisch einen Standardkonstruktor (engl.: default constructor). Weil dieser Konstruktor keine
Parameter besitzt, ergibt sich sein Aufruf aus dem Klassennamen durch Anhängen einer leeren Pa-
rameterliste, z. B.:
Bruch b = new Bruch();
Der Standardkonstruktor ruft den parameterfreien Konstruktor der Basisklasse auf und führt die
Initialisierungen für Instanzvariablen aus, die bei der Deklaration oder in einem Instanzinitialisierer
(siehe Abschnitt 4.4.4) vorgenommen werden.
Er hat dieselbe Schutzstufe wie die Klasse, sodass z. B. beim Standardkonstruktor der Klasse
Bruch die Schutzstufe public resultiert.
In der Regel ist es beim Klassendesign sinnvoll, Konstruktoren explizit zu definieren, um das indi-
viduelle Initialisieren der Instanzvariablen von neuen Objekten zu ermöglichen. Dabei sind die fol-
genden Regeln zu beachten:
• Ein Konstruktor trägt denselben Namen wie die Klasse.
• In der Definition wird kein Rückgabetyp angegeben.
• Wie bei einer gewöhnlichen Methodendefinition ist eine Parameterliste anzugeben, ggf. eine
leere. Parameter erlauben das individuelle Initialisieren der Instanzvariablen von neuen Ob-
jekten.
• Sobald man einen expliziten Konstruktor definiert hat, steht der Standardkonstruktor nicht
mehr zur Verfügung. Ist weiterhin ein parameterfreier Konstruktor erwünscht, so muss die-
ser zusätzlich explizit definiert werden.
• Als Modifikatoren sind nur solche erlaubt, die die Sichtbarkeit des Konstruktors (den Zu-
griffsschutz) regeln (z. B. public, private), sodass pro Konstruktor maximal ein Modifikator
verwendet werden kann.
1
Man ist geneigt, der Klasse eine aktive Rolle beim Erzeugen eines neuen Objekts zuzuschreiben. Allerdings lassen
sich in einem Konstruktor die Instanz-Member des neuen Objekts genauso verwenden wie in einer Instanzmethode,
was (wie die Abwesenheit des Modifikators static, vgl. Abschnitt 4.5.3) den Konstruktor in die Nähe einer Instanz-
methode rückt. Laut Sprachbeschreibung zu Java 17 ist ein Konstruktor allerdings überhaupt kein Member, also we-
der eine Instanz- noch eine Klassenmethode (Gosling et al. 2021, Abschnitt 8.2). Für die Praxis der Programmierung
ist es irrelevant, welchem Akteur man die Ausführung des Konstruktors zuschreibt.
236 Kapitel 4 Klassen und Objekte
• Während der Standardkonstruktor die Schutzstufe der Klasse übernimmt, gelten für explizit
definierte Konstruktoren beim Zugriffsschutz dieselben Regeln wie für andere Methoden.
Per Voreinstellung sind sie also in allen Klassen desselben Pakets nutzbar. Mit der dekla-
rierten Schutzstufe private kann man verhindern, dass ein Konstruktor von fremden Klassen
benutzt wird.1
• Eine Klasse erbt die Konstruktoren ihrer Basisklasse nicht. Allerdings wird bei jeder Ob-
jektkreation ein Basisklassenkonstruktor aufgerufen. Wenn dies nicht explizit über das
Schlüsselwort super als Bezeichnung für einen Basisklassenkonstruktor geschieht, wird der
parameterfreie Basisklassenkonstruktor automatisch aufgerufen. Mit Fragen zur Objektkrea-
tion, die im Zusammenhang mit der Vererbung stehen, werden wir uns im Abschnitt 7.3 be-
schäftigen.
• In einer Klasse sind beliebig viele Konstruktoren möglich, die alle denselben Namen und
jeweils eine individuelle Parameterliste haben müssen. Das Überladen (vgl. Abschnitt 4.3.4)
ist also auch bei Konstruktoren erlaubt.
Hier ist das Syntaxdiagramm zur Konstruktordefinition zu sehen:
Konstruktordefinition
Anweisung
,
public Bruch() {}
. . .
}
1
Gelegentlich ist es sinnvoll, alle Konstruktoren durch den Modifikator private für die Nutzung durch fremde Klas-
sen zu sperren. Das hat allerdings zur Folge, dass keine abgeleitete Klasse definiert werden kann (siehe Abschnitt
7.3).
Abschnitt 4.4 Objekte 237
Weil im parametrisierten Konstruktor die „beantragten“ Initialisierungswerte nicht direkt den Fel-
dern zugewiesen, sondern durch die Zugriffsmethoden geschleust werden, bleibt die Datenkapse-
lung erhalten. Wie jede andere Methode einer Klasse muss auch ein Konstruktor so entworfen sein,
dass die Objekte der Klasse unter allen Umständen konsistent und funktionstüchtig sind. In der
Klassendokumentation sollte darauf hingewiesen werden, dass dem Wunsch, den Nenner eines neu-
en Bruch-Objekts per Konstruktor auf den Wert 0 zu setzen, nicht entsprochen wird, und dass
stattdessen der Wert 1 resultiert.1
Im folgenden Testprogramm werden beide Konstruktoren eingesetzt:
Quellcode Ausgabe
class Bruchrechnung { 1
public static void main(String[] args) { b1 = -----
Bruch b1 = new Bruch(1, 2, "b1 = "); 2
Bruch b2 = new Bruch();
b1.zeige(); 0
b2.zeige(); -----
} 1
}
Konstruktoren können nicht direkt aufgerufen, sondern nur per new-Operator genutzt werden. Als
Ausnahme von dieser Regel ist es allerdings möglich, im Anweisungsblock eines Konstruktors ei-
nen anderen Konstruktor derselben Klasse über das Schlüsselwort this aufrufen, z. B.:
public Bruch() {
this(0, 1, "unbenannt");
}
So verhindert man, dass es beim Überladen von Konstruktoren zu Wiederholungen im Quellcode
kommt.
Wird wie bei der folgenden minimalistischen Klasse mit immerhin zwei Konstruktoren
class Prog {
int ivar = 4711;
Prog() {}
Prog(int ip) {ivar = ip;}
}
eine Instanzvariable im Rahmen der Deklaration initialisiert, dann landen die zugehörigen Byteco-
de-Anweisungen am Anhang jedes Konstruktors. Um diese Aussage zu verifizieren, verwenden wir
das JDK-Werkzeug javap, das u. a. den Bytecode zu einer Klasse auflisten kann. Der Aufruf
>javap -c Prog
führt im Beispiel zum folgenden Ergebnis:
1
Bei ungültigen Parameterwerten sollte ein Konstruktor besser eine sogenannte Ausnahme werfen, um den Aufrufer
über das Scheitern seiner Absicht zu informieren. Das tut z. B. der folgende Konstruktor der API-Klasse FileOut-
putStream im Paket java.io:
public FileOutputStream(File file) throws FileNotFoundException
Mit der Kommunikation über Ausnahmeobjekte werden wir uns im Kapitel 11 beschäftigen.
238 Kapitel 4 Klassen und Objekte
Prog();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: sipush 4711
8: putfield #2 // Field ivar:I
11: return
Prog(int);
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: sipush 4711
8: putfield #2 // Field ivar:I
11: aload_0
12: iload_1
13: putfield #2 // Field ivar:I
16: return
}
Beide Konstruktoren enthalten die Anweisungen sipusch und putfield zur Initialisierung der In-
stanzvariablen ivar mit dem Wert 4711.
Analog verfährt der Compiler auch mit einem Instanzinitialisierer (siehe Abschnitt 4.4.4).
4.4.4 Instanzinitialisierer
Zur Initialisierung von Instanzvariablen kann in eine Klassendefinition eine Blockanweisung an
jeder Position eingefügt werden, an der eine Felddeklaration oder eine Methodendefinition erlaubt
ist. Es sind sogar beliebig viele Instanzinitialisierer erlaubt. Der Compiler fügt den Code aller In-
stanzinitialisierer am Anfang jedes Konstruktors ein. Dies geschieht in der Auftretensreihenfolge
der Initialisierer, unmittelbar hinter dem Code aufgrund von initialisierenden Felddeklarationen.
Instanzinitialisierer
{ Anweisung }
Bei gewöhnlichen Klassen werden Instanzinitialisierer nur selten verwendet, weil sich Objektinitia-
lisierungen sehr übersichtlich mit Konstruktoren erledigen lassen. Wird eine Initialisierung in meh-
reren Konstruktoren benötigt, kann man sie einem elementaren Konstruktor vornehmen, der von
anderen Konstruktoren aufgerufen wird (siehe Abschnitt 4.4.3). In den später vorzustellenden ano-
nymen Klassen (siehe Abschnitt 12.1.1.2) werden Instanzinitialisierer aber gelegentlich benötigt,
weil dort mangels Klassenname keine Konstruktoren definiert werden können.
Um jetzt schon ein Beispiel für einen Instanzinitialisierer präsentieren zu können, statten wir eine
normale Klasse damit aus. In der folgenden Klasse wird mit Hilfe der statischen Methode gint()
aus unserer Bequemlichkeitsklasse Simput (vgl. Abschnitt 3.4) der Wert einer Instanzvariablen
beim Benutzer erfragt:
Abschnitt 4.4 Objekte 239
class Prog {
private int alter;
{
System.out.print("Ihr Alter: ");
alter = Simput.gint();
}
1
Auf der folgenden Webseite finden sich diesbezügliche Erläuterungen und Tipps der Firma Oracle:
https://docs.oracle.com/en/java/javase/17/gctuning/introduction-garbage-collection-tuning.html
242 Kapitel 4 Klassen und Objekte
1
Zitat aus der API-Dokumentation zu Java 9 (https://docs.oracle.com/javase/9/docs/api/java/lang/Object.html):
The finalization mechanism is inherently problematic. Finalization can lead to performance issues, deadlocks,
and hangs.
Unsere Entwicklungsumgebung IntelliJ stellt abgewertete Methoden mit durchgestrichenem Namen dar.
Abschnitt 4.4 Objekte 243
• super.finalize();
Bereits die Urahnklasse Object aus dem Paket java.lang, von der alle Java-Klassen ab-
stammen, verfügt über eine finalize() - Methode. Überschreibt man in einer abgeleiteten
Klasse die finalize() - Methode der Basisklasse, dann sollte am Anfang der eigenen Imple-
mentation die überschriebene Variante aufgerufen werden, wobei das Schlüsselwort super
die Basisklasse anspricht.
• this
In der aus didaktischen Gründen eingefügten Kontrollausgabe wird mit dem Schlüsselwort
this (vgl. Abschnitt 4.4.6.2) das aktuell handelnde Objekt angesprochen. Bei der automati-
schen Konvertierung der Referenz in eine Zeichenfolge wird die vom Laufzeitsystem ver-
waltete Objektbezeichnung zu Tage gefördert.
Um die baldige Freigabe von externen Ressourcen (z. B. Datenbank- oder Netzwerkverbindungen)
zu erreichen, sollte man sich nicht auf die Methode finalize() verlassen, weil sie nur dann vom
Garbage Collector aufgerufen wird, wenn ein Speichermangel auftritt. Durch einen Aufruf der stati-
schen Methode gc() aus der Klasse System kann man den sofortigen Einsatz des Müllsammlers
vorschlagen, z. B. vor einer Aktion mit großem Speicherbedarf:
System.gc();
Allerdings ist nicht sicher, ob der Garbage Collector tatsächlich tätig wird. Außerdem ist nicht vor-
hersehbar, in welcher Reihenfolge die obsoleten Objekte entfernt werden.
Im folgenden Beispielprogramm werden zwei Bruch-Objekte erzeugt und nach einer Ausgabe ihrer
Identifikation durch das Entfernen der Referenzen wieder aufgegeben:
class Bruchrechnung {
public static void main(String[] args) {
Bruch b1 = new Bruch();
Bruch b2 = new Bruch();
System.out.println("b1: " + b1 + ", b2: " + b2 + "\n");
b1 = b2 = null;
System.gc();
}
}
Ob anschließend der Garbage Collector aufgrund der expliziten Aufforderung System.gc() tat-
sächlich tätig wird, ist an den Kontrollausgaben der finalize() - Methode (siehe oben) zu erken-
nen. Bei Tests unter Verwendung des Compilers und der JVM aus Java 17 (eingestellt über Pro-
ject language level bzw. Project SDK im IntelliJ-Dialog Project Structure > Project) tra-
ten die folgenden Varianten auf:
1) Die Objekte werden finalisiert in der Reihenfolge b2, b1:
b1: Bruch@58372a00, b2: Bruch@4dd8dc3
Bruch@4dd8dc3 finalisiert
Bruch@58372a00 finalisiert
2) Die Objekte werden finalisiert in der Reihenfolge b1, b2:
b1: Bruch@58372a00, b2: Bruch@4dd8dc3
Bruch@58372a00 finalisiert
Bruch@4dd8dc3 finalisiert
3) Die Objekte werden nicht finalisiert:
b1: Bruch@58372a00, b2: Bruch@4dd8dc3
244 Kapitel 4 Klassen und Objekte
Seit Java 9 ist die Klasse Cleaner vorhanden, um als Ersatz für die abgewertete Methode finalize()
die Wahrscheinlichkeit für einen Schaden durch einen unterlassenen close() - Aufruf zu reduzieren.1
Hinsichtlich der Unbestimmtheit des Aufrufs besteht keine Verbesserung gegenüber finalize(). Al-
lerdings verursacht Cleaner weniger Kosten, und beim Einsatz auftretende Ausnahmefehler können
abgefangen werden.
Nach Bloch (2018, S. 29) sind weder die Methode finalize() noch die Klasse Cleaner zu empfeh-
len:
Finalizers are unpredictable, often dangerous, and generally unnecessary. ...
Cleaners are less dangerous than finalizers, but still unpredictable, slow, and generally unneces-
sary.
4.5.1 Klassenvariablen
In unserem Bruchrechnungsbeispiel soll ein statisches Feld dazu dienen, die Anzahl der bei einem
Programmeinsatz bisher erzeugten Bruch-Objekte aufzunehmen:
public class Bruch {
private int zaehler;
private int nenner = 1;
private String etikett = "";
. . .
}
1
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/ref/Cleaner.html
2
Module wurden mit Java 9 eingeführt (im September 2017). Die meisten aktuellen Java-Programme wurden für eine
JVM-Version ( 8) erstellt, sodass dort der der Modifikator public den Zugriff für alle Klassen erlaubt.
Abschnitt 4.5 Klassenvariablen und -methoden 245
Die Klassenvariable anzahl ist als private deklariert, also nur in Methoden der eigenen Klasse
sichtbar. Sie wird in den beiden Konstruktoren inkrementiert.
Während jedes Objekt einer Klasse über einen eigenen Satz mit allen Instanzvariablen verfügt, die
beim Erzeugen des Objekts auf dem Heap landen, existiert eine klassenbezogene Variable nur ein-
mal. Sie wird beim Laden der Klasse in der sogenannten Method Area des programmeigenen Spei-
chers angelegt.
Wie für Instanz- gilt auch für Klassenvariablen:
• Sie werden außerhalb jeder Methodendefinition deklariert.
• Sie werden (sofern nicht finalisiert, siehe unten) automatisch mit dem typspezifischen Null-
wert initialisiert (vgl. Abschnitt 4.2.3), sodass im Beispiel die Variable anzahl mit dem int-
Wert 0 startet.
Im Editor unserer Entwicklungsumgebung IntelliJ IDEA wird für statische Variablen per Vorein-
stellung die kursive Schriftauszeichnung verwendet (siehe obigen Quellcode).
In Instanz- oder Klassenmethoden der eigenen Klasse lassen sich Klassenvariablen ohne jedes Prä-
fix ansprechen (siehe obige Bruch-Konstruktoren). Sofern Methoden fremder Klassen der direkte
Zugriff auf eine Klassenvariable gewährt wird, müssen diese dem Variablennamen einen Vorspann
aus Klassennamen und Punktoperator voranstellen, z. B.:
System.out.println("Hallo");
Wir verwenden seit Beginn des Kurses in fast jedem Programm die Klassenvariable out aus der
Klasse System (im Paket java.lang). Diese zeigt auf ein Objekt der Klasse PrintStream, dem wir
Ausgabeaufträge geben. Vor Schreibzugriffen ist diese öffentliche Klassenvariable durch das Fina-
lisieren geschützt.
Mit dem Modifikator final können nicht nur lokale Variablen (siehe Abschnitt 3.3.10) und In-
stanzvariablen (siehe Abschnitt 4.2.5) sondern auch statische Variablen als finalisiert deklariert
werden. Dadurch entfällt die automatische Initialisierung mit dem typspezifischen Nullwert. Die
somit erforderliche explizite Initialisierung kann bei der Deklaration oder in einem statischen Initia-
lisierer (siehe Abschnitt 4.5.4) erfolgen. Im weiteren Programmverlauf ist bei finalisierten Klassen-
variablen keine Wertänderung mehr möglich.
Von einem konstanten Feld spricht man nach Bloch (2018, S. 290) bei einem statischen und unver-
änderlichen Feld (dekoriert mit static und final), wenn außerdem eine von den folgenden Bedin-
gungen erfüllt ist:
• Das Feld besitzt einen primitiven Datentyp.
• Das Feld zeigt auf ein Objekt, das nicht geändert werden kann (z. B. vom Datentyp String).
Ein Beispiel ist das double-Feld PI in der API-Klasse Math (Paket java.lang), das eine Approxi-
mation der Kreiszahl enthält:
public static final double PI = 3.14159265358979323846;
Im Namen eines konstanten Felds verwendet man per Konvention ausschließlich Großbuchstaben.
Besteht ein Name aus mehreren Wörtern, werden diese der Lesbarkeit halber durch einen Unter-
strich getrennt, z. B.:
public final static int DEFAULT_SIZE = 100;
Wenn sich die Java-Designer an die eben beschriebene Notationskonvention gehalten haben, sollte
das statische Feld out in der Klasse System (mit klein geschriebenem Namen) nicht konstant sein:
System.out.println("Hallo");
246 Kapitel 4 Klassen und Objekte
Wie die (z. B. im Abschnitt 4.5.2 präsentierte) Deklaration von System.out zeigt, ist die Variable
als final deklariert:
public static final PrintStream out;
Allerdings ist ein PrintStream-Objekt nicht unveränderlich, weil es z. B. einen variablen Ausgabe-
puffer besitzt, sodass System.out tatsächlich kein konstantes Feld ist.1
In der folgenden Tabelle sind wichtige Unterschiede zwischen Klassen- und Instanzvariablen zu-
sammengestellt:
Instanzvariablen Klassenvariablen
1
Im Abschnitt 14.3.1.6 wird sich zeigen, dass der Inhalt von System.out trotz final-Deklaration mit der statischen
System-Methode setOut() geändert werden kann. Diesen Verstoß im Verhalten des Java-Compilers gegen die final-
Deklaration rechtfertigt die JLS (Gosling et al. 2021, Abschnitt 17.5.4) so:
Normally, a field that is final and static may not be modified. However, System.in, System.out, and
System.err are static final fields that, for legacy reasons, must be allowed to be changed by the methods
System.setIn, System.setOut, and System.setErr.
2
Das Entladen einer Klasse zur Speicheroptimierung ist einer Java-Implementierung prinzipiell erlaubt, aber mit
Problemen verbunden und folglich an spezielle Voraussetzungen gebunden (siehe Gosling et al 2021, Abschnitt
12.7). Eine vom regulären Klassenlader der JVM geladene Klasse wird nicht vor dem Ende des Programms entladen
(Ullenboom 2012a, Abschnitt 11.5).
Abschnitt 4.5 Klassenvariablen und -methoden 247
• Klassenvariablen ...
werden außerhalb jeder Methode mit dem Modifikator static deklariert,
landen (als Bestandteile von Klassen) in der Method Area des programmeigenen Speichers,
werden (falls nicht finalisiert) automatisch mit dem typspezifischen Nullwert initialisiert,
sind verwendbar, wo der Zugriff erlaubt ist.
• Referenzvariablen ...
zeichnen sich durch ihren speziellen Inhalt aus (Referenz auf ein Objekt). Es kann sich um
lokale Variablen (z. B. b1 in der main() - Methode von Bruchrechnung), um Instanzvari-
ablen (z. B. etikett in der Bruch-Definition) oder um Klassenvariablen handeln (z. B.
out in der Klasse System).
Man kann die Variablen kategorisieren nach ...
• Datentyp (Inhalt)
Hinsichtlich des Variableninhalts sind Werte von primitivem Datentyp und Objektreferen-
zen zu unterscheiden.
• Zuordnung
Eine Variable kann zu einem Objekt (Instanzvariable), zu einer Klasse (statische Variable)
oder zu einer Methode (lokale Variable) gehören. Damit sind weitere Eigenschaften wie Ab-
lageort, Initialisierung und Gültigkeitsbereich festgelegt (siehe oben).
Aus den Dimensionen Datentyp und Zuordnung resultiert eine (2 3) - Matrix zur Einteilung der
Java-Variablen:
Einteilung nach Zuordnung
Lokale Variable Instanzvariable Klassenvariable
// aus der Bruch- // aus der Klasse Bruch // aus der Klasse Bruch
Einteilung Prim. // Methode frage() private int zaehler; public static int anzahl;
nach Datentyp int n;
Datentyp // aus der Bruch- // aus der Klasse Bruch // aus der Klasse System
Referenz // Methode zeige() private String etikett = ""; public static final
(Inhalt) String luecke = ""; PrintStream out;
4.5.3 Klassenmethoden
Es ist vielfach sinnvoll oder gar erforderlich, einer Klasse Handlungskompetenzen (Methoden) zu
verschaffen, die nicht von der Existenz konkreter Objekte abhängen. So muss z. B. beim Start eines
Java-Programms die main() - Methode der Startklasse ausgeführt werden, bevor irgendein Objekt
existiert. Sofern Klassenmethoden vorhanden sind, kann man auch eine Klasse als Akteur auf der
objektorientierten Bühne betrachten.
Sind ausschließlich Klassenmethoden vorhanden, dann ist das Erzeugen von Objekten nicht sinn-
voll. Man kann fremde Klassen durch den Zugriffsmodifikator private für die Konstruktoren daran
hindern. Auch das Java-API enthält etliche Klassen, die ausschließlich klassenbezogene Methoden
besitzen und damit nicht zum Erzeugen von Objekten konzipiert sind. Mit der Klasse Math aus
dem API-Paket java.lang haben wir ein wichtiges Beispiel bereits kennengelernt. Im Math-
Quellcode wird das Instanziieren folgendermaßen verhindert:
/**
* Don't let anyone instantiate this class.
*/
private Math() {}
Die folgende Anweisung zeigt, wie die statische Math-Methode pow() von einer fremden Klasse
aufgerufen werden kann:
System.out.println(Math.pow(2, 3));
248 Kapitel 4 Klassen und Objekte
Vor den Namen der auszuführenden Methode setzt man (durch den Punktoperator getrennt) den
Namen der angesprochenen Klasse, der eventuell durch den Paketnamen vervollständigt werden
muss. Ob der Paketname angegeben werden muss, hängt von der Paketzugehörigkeit der Klasse und
von den am Anfang des Quellcodes vorhandenen import-Deklarationen ab. Das Paket java.lang
wird automatisch in jede Java-Quellcodedatei importiert (vgl. Abschnitt 3.1.7).
Da unsere Bruch-Klasse mittlerweile über eine (private) Klassenvariable für die Anzahl der er-
zeugten Objekte verfügt, bietet sich die Definition einer Klassenmethode an, mit der diese Anzahl
auch von fremden Klassen ermittelt werden kann. Bei der Definition einer Klassenmethode wird
(analog zur Deklaration einer Klassenvariablen) der Modifikator static angegeben, z. B.:
public static int hanz() {
return anzahl;
}
Ansonsten gelten die Aussagen von Abschnitt 4.3 über die Definition und den Aufruf von Instanz-
methoden analog auch für Klassenmethoden.
Im folgenden Programm wird die Bruch-Klassenmethode hanz() in der Bruchrechnung-
Klassenmethode main() aufgerufen, um die Anzahl der bisher erzeugten Brüche zu ermitteln:
Quellcode Ausgabe
class Bruchrechnung { 0 Brüche erzeugt
public static void main(String[] args) { 1
System.out.println(Bruch.hanz() + " Brüche erzeugt"); Bruch 1 -----
Bruch b1 = new Bruch(1, 2, "Bruch 1"); 2
Bruch b2 = new Bruch(5, 6, "Bruch 2");
b1.zeige(); 5
b2.zeige(); Bruch 2 -----
System.out.println(Bruch.hanz() + " Brüche erzeugt"); 6
}
} 2 Brüche erzeugt
Wird eine Klassenmethode von anderen Methoden der eigenen Klasse (objekt- oder klassenbezo-
gen) verwendet, dann muss der Klassenname nicht angegeben werden. Wir könnten z. B. in der
Bruch-Instanzmethode klone() die Bruch-Klassenmethode hanz() aufrufen:
public Bruch klone() {
Bruch b = new Bruch(zaehler, nenner, etikett);
System.out.println(hanz() + " Brüche erzeugt");
return b;
}
Gelegentlich wird missverständlich behauptet, in einer statischen Methode könnten keine Instanz-
methoden aufgerufen werden, z. B. (Mössenböck 2005, S. 153):
Objektmethoden können Klassenmethoden aufrufen aber nicht umgekehrt.
Sofern eine statische Methode eine Referenz zu einem Objekt besitzt, das sie eventuell selbst er-
zeugt hat, kann sie im Rahmen der Zugriffsberechtigung (bei Objekten der eigenen Klasse also un-
eingeschränkt) Instanzmethoden dieses Objekts aufrufen. In einer Klassenmethode eine Instanzme-
thode ohne vorangestellte Objektreferenz aufzurufen, wäre reichlich sinnlos. Wer einen Auftrag an
ein Objekt schicken möchte, muss den Empfänger natürlich benennen.
alisierer, die beim Laden der Klasse ausgeführt werden (siehe z. B. Gosling et al. 2021, Abschnitt
8.7).
Syntaktisch unterscheidet sich ein statischer Initialisierer von einem Instanzinitialisierer durch das
vorangestellte Schlüsselwort static:
Statischer Initialisierer
static { Anweisung }
Zugriffsmodifikatoren sind verboten und überflüssig, weil ein statischer Initialisierer ohnehin nur
vom Laufzeitsystem aufgerufen wird (beim Laden der Klasse).
Eine Klassendefinition kann mehrere statische Initialisierungsblöcke enthalten. Beim Laden der
Klasse werden sie nach der Reihenfolge im Quelltext ausgeführt.
Bei einer etwas künstlichen (und in weiteren Ausbaustufen nicht mitgeschleppten) Erweiterung der
Klasse Bruch soll der parameterfreie Konstruktor zufallsabhängige, aber pro Programmeinsatz
identische Werte zur Initialisierung der Felder zaehler und nenner verwenden:
public Bruch() {
zaehler = ZAEHLER_VOREINST;
nenner = NENNER_VOREINST;
anzahl++;
}
Dazu erhält die Bruch-Klasse private, statische und finalisierte Felder, die von einem statischen
Initialisierer beim Laden der Klasse auf Zufallswerte gesetzt werden sollen:
private static final int ZAEHLER_VOREINST;
private static final int NENNER_VOREINST;
Im statischen Initialisierer wird ein Objekt der Klasse Random aus dem Paket java.util erzeugt und
dann durch nextInt() - Methodenaufrufe mit der Produktion von int-Zufallswerten aus dem Bereich
von 0 bis 4 beauftragt. Daraus entstehen Startwerte für die Felder zaehler und nenner:
public class Bruch {
private int zaehler;
private int nenner = 1;
private String etikett = "";
private static int anzahl;
private static final int ZAEHLER_VOREINST;
private static final int NENNER_VOREINST;
static {
java.util.Random zuf = new java.util.Random();
ZAEHLER_VOREINST = zuf.nextInt(5) + 1;
NENNER_VOREINST = zuf.nextInt(5) + ZAEHLER_VOREINST;
System.out.println("Klasse Bruch geladen");
}
. . .
}
Außerdem protokolliert der statische Initialisierer das Laden der Klasse, z. B.:
Quellcode Ausgabe
class Bruchrechnung { Klasse Bruch geladen
public static void main(String[] args) { 5
Bruch b = new Bruch(); -----
b.zeige(); 9
}
}
250 Kapitel 4 Klassen und Objekte
Ein wesentlicher, gegeben den momentanen Kursfortschritt noch nicht vorführbarer Nutzen eines
statischen Initialisierers im Vergleich zur Initialisierung von statischen Feldern bei der Deklaration
besteht darin, dass Ausnahmen abgefangen und geworfen werden können (siehe Kapitel 11).
Die mit einer do-while - Schleife operierende Methode ggTi() kann durch die folgende rekursive
Variante ggTr() ersetzt werden:
1
Bislang sind wir beim GGT stets von zwei natürlichen Zahlen {1, 2, ...} ausgegangen, jedoch können wir ohne
Probleme auch negative ganze Zahlen zulassen. Unabhängig von den Vorzeichen von zwei betrachteten ganzen
Zahlen ist ihr größter gemeinsamer Teiler definitionsgemäß positiv.
2
Wenn die Methode ggTi() oder die gleich darzustellende Methode ggTr() beim Aufruf den Wert 0 als zweiten
Aktualparameter erhält, dann kommt es zu einem Laufzeitfehler (java.lang.ArithmeticException: / by
zero). Vorsichtsmaßnahmen gegen diesen Fehler sind nicht unbedingt erforderlich, weil die Methoden als private
deklariert sind und ausschließlich in kuerze() aufgerufen werden. Dabei ist der zweite Aktualparameter stets der
Nenner eines Bruch-Objekts, also niemals gleich 0. Wenn die Methoden ggTi() oder ggTr() permanent in der
Klasse Bruch verbleiben würden und es somit später zu weiteren klasseninternen Verwendungen kommen könnte,
dann wäre eine Absicherung gegen eine Division durch 0 durchaus sinnvoll.
Abschnitt 4.6 Rekursive Methoden 251
ggTr(10, 6) {
2 .
.
.
return ggTr(6, 4); ggTr(6, 4) {
} .
.
.
return ggTr(4, 2); ggTr(4, 2) {
} .
.
return 2;
.
.
}
Generell läuft eine rekursive Methode mit Lösungsübermittlung per Rückgabewert nach der im fol-
genden Struktogramm beschriebenen Logik ab:
Ja Nein
Im Beispiel ist die Lösung des einfacheren Problems identisch mit der Lösung des ursprünglichen
Problems.
252 Kapitel 4 Klassen und Objekte
Wird bei einem fehlerhaften Algorithmus der linke Zweig nie oder zu spät erreicht, dann erschöpfen
die geschachtelten Methodenaufrufe die Stack-Kapazität, und es kommt zu einem Ausnahmefehler,
z. B.:
Exception in thread "main" java.lang.StackOverflowError
Zu einem rekursiven Algorithmus (per Selbstaufruf einer Methode) existiert stets auch ein iterativer
Algorithmus (per Wiederholungsanweisung). Rekursive Algorithmen lassen sich zwar oft eleganter
formulieren als die iterativen Alternativen, benötigen aber durch die hohe Zahl von Methodenaufru-
fen in der Regel mehr Rechenzeit und mehr Speicher.
4.7 Komposition
Bei Instanz- und Klassenvariablen sind beliebige Datentypen zugelassen, auch Referenztypen (siehe
Abschnitt 4.2). Z. B. ist in der aktuellen Bruch-Definition eine Instanzvariable vom Referenztyp
String vorhanden. Es ist also möglich, Objekte vorhandener Klassen als Bestandteile von neuen,
komplexeren Klassen zu verwenden. Neben der Vererbung und der Polymorphie ist diese Komposi-
tion (alias: Aggregation) eine effektive Technik zur Wiederverwendung von vorhandenen Typen
bei der Definition von neuen Typen. Außerdem ist sie im Sinne einer realitätsnahen Modellierung
unverzichtbar, denn auch ein reales Objekt (z. B. eine Firma) enthält andere Objekte1 (z. B. Mitar-
beiter, Kunden), die ihrerseits wiederum Objekte enthalten (z. B. ein Gehaltskonto und einen Ter-
minkalender bei den Mitarbeitern) usw.
Man kann den Standpunkt einnehmen, dass die Komposition eine selbstverständliche, wenig spek-
takuläre Angelegenheit sei, eigentlich nur ein neuer Begriff für eine längst vertraute Situation (In-
stanzvariablen mit Referenztyp). Es ist tatsächlich für den weiteren Lernerfolg im Kurs unkritisch,
wenn Sie den Rest des aktuellen Abschnitts mit einem recht länglichen Beispiel zur Komposition
überspringen.
Wir erweitern das Bruchrechnungsprogramm um eine Klasse namens Aufgabe, die Trainingssit-
zungen unterstützen soll und dazu mehrere Bruch-Objekte verwendet. In der Aufgabe-
Klassendefinition tauchen vier Instanzvariablen vom Typ Bruch auf:
public class Aufgabe {
private Bruch b1, b2, lsg, antwort;
private char op = '+';
public Aufgabe(char op_, int b1Z, int b1N, int b2Z, int b2N) {
if (op_ == '*')
op = op_;
b1 = new Bruch(b1Z, b1N, "1. Argument:");
b2 = new Bruch(b2Z, b2N, "2. Argument:");
lsg = new Bruch(b1Z, b1N, "Das korrekte Ergebnis:");
antwort = new Bruch();
init();
}
1
Die betroffenen Personen mögen den Fachterminus Objekt nicht persönlich nehmen.
Abschnitt 4.7 Komposition 253
public void neueWerte(char op_, int b1Z, int b1N, int b2Z, int b2N) {
op = op_;
b1.setzeZaehler(b1Z); b1.setzeNenner(b1N);
b2.setzeZaehler(b2Z); b2.setzeNenner(b2N);
lsg.setzeZaehler(b1Z); lsg.setzeNenner(b1N);
init();
}
}
Im folgenden Programm wird die Klasse Aufgabe für ein Bruchrechnungstraining verwendet:
class Bruchrechnung {
public static void main(String[] args) {
Aufgabe auf = new Aufgabe('*', 3, 4, 2, 3);
auf.pruefe();
auf.neueWerte('+', 1, 2, 2, 5);
auf.pruefe();
}
}
Man kann immerhin schon ahnen, wie die praxistaugliche Endversion des Programms einmal arbei-
ten wird:
Berechne bitte:
3 2
------ * -----
4 3
Richtig!
Berechne bitte:
1 2
------ + -----
2 5
Falsch!
9
Das korrekte Ergebnis: -----
10
Weil die resultierenden Konstruktionen etwas kompliziert sind, sollten sich Programmiereinsteiger
zunächst auf eine oberflächliche Lektüre von Abschnitt 4.8 beschränken und sich erst dann für De-
tails interessieren, wenn diese später relevant werden.
Bei der Begriffsverwendung orientiert sich das Manuskript am Java-Tutorial (Oracle 2021a).1
4.8.1 Mitgliedsklassen
Eine Mitgliedsklasse befindet sich im Quellcode einer umgebenden Klassendefinition, aber nicht in
einer Methodendefinition, z. B.:
class Top {
. . .
class MemberClass {
. . .
}
. . .
}
Im Java-Tutorial (Oracle 2021a) werden Mitgliedsklassen auch als eingeschachtelte Klassen (engl.
nested classes) bezeichnet. Manche Autoren verwenden diesen Ausdruck allerdings in einem all-
gemeineren Sinn und beziehen dabei auch die lokalen Klassen ein.
Einige Eigenschaften von Mitgliedsklassen sind:
• Während für Top-Level - Klassen (Klassen auf Paktebene) nur der Zugriffsmodifikator
public erlaubt ist, können bei Mitgliedsklassen auch die Zugriffsmodifikatoren private und
protected verwendet werden (vgl. Abschnitt 6.3.2). Für Mitgliedsklassen ist der Zugriffs-
schutz (die Sichtbarkeit) also genauso geregelt wie bei anderen Klassenmitgliedern (Feldern
oder Methoden).
• Als Klassenmitglieder können eingeschachtelte Klassen den Modifikator static erhalten. Ist
er vorhanden, spricht man von einer statischen Mitgliedsklasse (siehe Abschnitt 4.8.1.2),
anderenfalls von einer inneren Klasse (siehe Abschnitt 4.8.1.1).
• Mitgliedsklassen dürfen geschachtelt werden.
• Der Compiler erzeugt auch für jede Mitgliedsklasse eine eigene class-Datei, wobei die Um-
gebung in die Benennung eingeht. Befindet sich z. B. in der Klasse Top die Klasse Mit-
glied, dann entsteht die Datei Top$Mitglied.class.
Eine Mitgliedsklasse sollte ausschließlich im Rahmen der umgebenden Klasse zum Einsatz kom-
men. Anderenfalls ist eine Top-Level - Klasse zu bevorzugen (Bloch 2018, S. 112).
1
http://docs.oracle.com/javase/tutorial/java/javaOO/whentouse.html
256 Kapitel 4 Klassen und Objekte
class Mantel {
private String name;
private Manteltasche links, rechts;
private int anzKnoepfe;
Mantel(String n, int zahl) {
name = n;
anzKnoepfe = zahl;
links = new Manteltasche(anzKnoepfe);
rechts = new Manteltasche(anzKnoepfe);
}
class Manteltasche {
private int anzKnoepfe;
Manteltasche(int anzahl) {
anzKnoepfe = anzahl;
}
void report() {
System.out.println("Tasche an Mantel \"" + name +
"\" mit "+ anzKnoepfe + " Knöpfen");
}
}
}
class InnerClassDemo {
public static void main(String[] args) {
Mantel mantel = new Mantel("Martin", 3);
// Für die nächsten 3 Anweisungen ist mindestens die Sichtbarkeit Paket
// bei Mantel.Manteltasche erforderlich.
Mantel.Manteltasche tasche = mantel.gibLinkeTasche();
tasche.report();
Mantel.Manteltasche isoTasche = mantel.new Manteltasche(3);
}
}
Es produziert die Ausgabe:
Tasche an Mantel "Martin" mit 3 Knöpfen
Weil die innere Klasse Manteltasche die voreingestellte Zugriffsstufe Paket benutzt, ist sie in der
Klasse InnerClassDemo sichtbar. Mit dem Zugriffsmodifikator private für die innere Klasse
könnte die Sichtbarkeit auf die umgebende Klasse Mantel eingeschränkt werden.
Einige Eigenschaften von inneren Klassen (also von Mitgliedsklassen ohne den Modifikator static):
• Bei der Erstellung eines Objekts der inneren Klasse muss ein Objekt der äußeren Klasse als
„Hülle“ beteiligt sein, was folgendermaßen geschehen kann:
o In einem Konstruktor der äußeren Klasse wird ein Objekt der inneren Klasse erstellt,
z. B.:
links = new Manteltasche(anzKnoepfe);
o Ein Objekt der äußeren Klasse führt eine Instanzmethode aus und kreiert dort das in-
nere Objekt, z. B.:
Manteltasche erstelleTasche(int anzK) {return new Manteltasche(anzK);}
o Bei der Kreation des inneren Objekts wird explizit das äußere Objekt als Umgebung
benannt, indem das Schlüsselwort new durch einen Punkt getrennt auf die Referenz
zum äußeren Objekt folgt, z. B.:
Mantel.Manteltasche tasche = mantel.new Manteltasche(3);
Abschnitt 4.8 Mitgliedsklassen und lokale Klassen 257
1
https://docs.oracle.com/en/java/javase/16/language/records.html, https://openjdk.java.net/jeps/395
2
Der vermutlich irritierende, gegen die Benennungsregeln im Abschnitt 3.1.6 verstoßende Klassenname
HashMap<K,V> resultiert aus einer generischen Klassendefinition, mit der wir uns im Kapitel 8 ausführlich be-
schäftigen werden.
258 Kapitel 4 Klassen und Objekte
class StaticMemberClassDemo {
public static void main(String[] args) {
Mantel.Motte motte = new Mantel.Motte();
motte.frissMantel();
}
}
Im Unterschied zu einem Objekt einer inneren Klasse befindet sich ein Objekt einer statischen Mit-
gliedsklasse nicht „in“ einem Objekt der äußeren Klasse.
Die statische Mitgliedsklasse kann auf statische Mitglieder der äußeren Klasse zugreifen (auch auf
die privaten). Wenn Bezeichner für statische Member der äußeren Klasse verdeckt worden sind,
muss der Klassenname vorangestellt werden.
Jede Mitgliedsklasse (ob statisch oder nicht) kann Instanzvariablen vom Typ der äußeren Klasse
verwenden und hat dabei volle Zugriffsrechte (auch auf private Member).
Eine statische Mitgliedsklasse ist vor allem dann angemessen, wenn
Abschnitt 4.8 Mitgliedsklassen und lokale Klassen 259
• die Mitgliedklasse ausschließlich von der umgebenden Klasse benutzt werden soll,
• aber die Objekte der Mitgliedsklasse ihre Methoden ohne Rückgriff auf ein Objekt der um-
gebenden Klasse ausführen.
Ergänzend zum obigen spielerischen, zur Demonstration des Prinzips aber durchaus geeigneten
Beispiel soll noch ein praxisrelevantes Beispiel aus dem äußerst wichtigen Java Collections Frame-
work (siehe Kapitel 10) betrachtet werden. In der Klasse HashMap<K,V> (Paket java.util) wird
die Mitgliedsklasse Node<K,V> definiert, die ein einzelnes Element in der HashMap<K,V> - Kol-
lektion modelliert.1 Bei der Kommunikation mit einem Node<K,V> - Objekt (z. B. über die Me-
thoden getValue() oder setValue()) ist keine Referenz auf das HashMap<K,V> - Objekt erforder-
lich, sodass eine statische Mitgliedsklasse zum Einsatz kommt:
static class Node<K,V> implements Map.Entry<K,V> {
. . .
public final V getValue() { return value; }
class LocalClassDemo {
public static void main(String[] args) {
Aussen p = new Aussen();
p.erledigeMitLokalerKlasse(3);
}
}
1
Die vermutlich irritierenden, gegen die Benennungsregeln im Abschnitt 3.1.6 verstoßenden Klassennamen
HashMap<K,V> und Node<K,V> resultieren aus generischen Klassendefinitionen, mit denen wir uns im Kapitel 8
ausführlich beschäftigen werden.
260 Kapitel 4 Klassen und Objekte
• Außerdem kann eine lokale Klasse auf die statischen Variablen und Methoden der Klasse
zugreifen, zu der die umgebende Methode gehört. Wird eine lokale Klasse in einer Instanz-
methode definiert, kann sie auch auf die Instanzvariablen und -methoden des handelnden
Objekts zugreifen. Genau dann existiert ein umgebendes Objekt wie bei einer inneren Klasse
(siehe Abschnitt 4.8.1.1)
• Felder der lokalen Klasse und lokale Variablen in ihren Methoden überdecken gleichnamige
Variablen der umgebenden Klasse. Überdeckte statische Variablen der umgebenden Klasse
können in der lokalen Klasse über den Klassennamen als Präfix angesprochen werden, z. B.
bei einer umgebenden Klasse namens Aussen mit der statischen Variablen statVar:
Aussen.statVar
Überdeckte Instanzvariablen der umgebenden Klasse können in der lokalen Klasse über ei-
nen Präfix aus dem Klassennamen und dem Schlüsselwort this angesprochen werden, z. B.
bei einer umgebenden Klasse namens Aussen mit der Instanzvariablen instVar:
Aussen.this.instVar
Überdeckte lokale Variablen der umgebenden Methode können in der lokalen Klasse nicht
angesprochen werden.
• Der Gültigkeitsbereich von lokalen Klassen ist wie bei lokalen Variablen geregelt (siehe
Abschnitt 3.3.9). Nur im Block, der eine lokale Klasse definiert, kann ein Objekt dieser
Klasse erzeugt und verwendet werden.
• Vor Java 16 waren in einer lokalen Klasse keine statischen Methoden erlaubt, und statische
Felder mussten finalisiert sein. Seit Java 16 sind statische Methoden und beliebige statische
Felder erlaubt.
• Der Compiler erzeugt auch für jede lokale Klasse eine eigene class-Datei, in deren Namen
die Bezeichner für die umgebende und die lokale Klasse eingehen, sodass im Beispiel die
folgende Datei entsteht: Aussen$1LokaleKlasse.class.
Weitere Informationen über lokale Klassen bietet das Java-Tutorial (Oracle 2021a).1
Später werden wir noch die anonymen Klassen kennenlernen, die meist innerhalb einer Methode
definiert werden und folglich viele Gemeinsamkeiten mit den lokalen Klassen besitzen (siehe Ab-
schnitt 12.1.1.2). Bei der Definition wird kein Name vergeben, aber gleich ein Objekt instanziert,
das zudem das einzige seiner Art bleibt. Meist genügen wenige Zeilen Quellcode, um ein Objekt
mit einem eingeschränkten, meist aus einer einzigen Methode bestehenden Handlungsrepertoire zu
erstellen und zum Einsatz zu bringen, z. B. zur Ereignisbehandlung in einem Programm mit grafi-
scher JavaFX-Bedienoberfläche:
1
http://docs.oracle.com/javase/tutorial/java/javaOO/localclasses.html
Abschnitt 4.9 Bruchrechnungsprogramm mit JavaFX-GUI 261
Aufgrund der individuellen Oberflächengestaltung lässt sich das Bruchkürzungsprogramm mit der
im Abschnitt 3.8 vorgestellten Klasse JOptionPane, die Swing-Standarddialoge über statische Me-
thoden anbietet, nicht realisieren.
Wir kopieren den Inhalt dieses Ordners in einen zuvor angelegten Projektordner, z. B.:
Durch die Verwendung der englischen Sprache werden Umlaute im Namen des Projektordners und
damit im Namen des Projekts vermieden. Umlaute im Projektnamen haben die bisherige Arbeit mit
unserer Entwicklungsumgebung IntelliJ nicht gestört, sabotieren aber die als Sahnehäubchen für die
anstehende Entwicklungsarbeit geplante automatischen Erstellung einer ausführbaren Java-
Archivdatei (siehe Abschnitt 6.1.3.6).
Wir öffnen das Projekt wie gewohnt über das IntelliJ-Item im Kontextmenü zum Projektordner und
geben dem Modul den neuen Namen Reduce, sodass im Project-Fenster das folgende Bild ent-
steht:
In der Projektvorlage ist ein SDK namens OpenJDK 8 eingestellt, das bei der im Kurs empfohle-
nen Praxis das JDK 8 aus dem Open Source - Projekt ojdkbuild (vgl. Abschnitt 1.2.1) und damit
auch die Datei jfxrt.jar mit dem JavaFX 8 - API enthält.
Die Projektvorlage enthält eine Hauptklasse mit dem Namen Main, die von der API-Klasse Appli-
cation abstammt. Das bei der Definition einer abgeleiteten Klasse zu verwendende Schlüsselwort
extends wird im Kapitel 7 eingeführt:
Abschnitt 4.9 Bruchrechnungsprogramm mit JavaFX-GUI 263
package sample;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
Wir wählen den Typ Application, benennen die Ausführungskonfiguration und legen die Haupt-
klasse fest:
1
Dieser Name stammt (wie die gesamte Projektvorlage) vom JavaFX-Plugin von IntelliJ 2019.2.
264 Kapitel 4 Klassen und Objekte
Wenn es bei einer einzigen Ausführungskonfiguration bleibt, dann ist deren Name irrelevant und
man kann die Konfiguration automatisch erstellen lassen. Nach einem Klick auf den grünen Pfeil
neben der Kopfzeile der Startklasse bzw. neben der Kopfzeile der Startmethode
erscheint ein Menü mit der Option zum Starten des Programms:
Das Programm ist schon lauffähig, zeigt aber bisher nur eine leere Szenerie:
Abschnitt 4.9 Bruchrechnungsprogramm mit JavaFX-GUI 265
Im Zusammenhang mit dem ersten Start ist eine Ausführungskonfiguration mit den Namen der
Startklasse angelegt worden:
Falls noch nicht geschehen (vgl. Abschnitt 2.5), informieren wir IntelliJ nach
File > Settings > Languages & Frameworks > JavaFX
über die ausführbare Datei der Scene Builder - Installation, z. B.:
266 Kapitel 4 Klassen und Objekte
Die FXML-Datei sollte sich innerhalb der Entwicklungsumgebung mit dem Scene Builder öffnen
lassen durch einen Wechsel zur Editor-Registerkarte Scene Builder. Allerdings treten zumindest
unter Windows entweder schon beim Start oder beim Editieren häufig Probleme auf, sodass der
kaum aufwändigere externe Einsatz des Scene Builders (als selbständiges Programm mit eigenem
Fenster) zu bevorzugen ist. Diese Betriebsart hat außerdem den Vorteil, dass man das Hauptmenü
des Scene Builders nutzen kann.
Man öffnet die FXML-Datei zu einem JavaFX-Projekt im selbständig agierenden grafischen GUI-
Designer über das Item Open In SceneBuilder aus dem Kontextmenü zur FXML-Datei:
Das nächste Bildschirmfoto zeigt den im eigenständigen Fenster aktiven Scene Builder, wobei zur
Erleichterung der folgenden Erläuterungen für vier Zonen der Bedienoberfläche Namen vergeben
werden:
Abschnitt 4.9 Bruchrechnungsprogramm mit JavaFX-GUI 267
Library
Inspector
Editing
Document
und setzen dann im Layout-Segment der Inspector-Zone die Eigenschaft Vgap (vertikaler Zei-
lenabstand) auf den Wert 0 und die Eigenschaften Pref Width bzw. Pref Height (bevorzugte
Breite bzw. Höhe) auf 300 bzw. 275 (passend zu der Größe des Anwendungsfensters, siehe Quell-
code der Klasse Main.java im Abschnitt 4.9.1). Der aktuelle Stand in der Editing-Zone sollte
ungefähr so aussehen:
268 Kapitel 4 Klassen und Objekte
Analog verpassen wir der einzigen Spalte als Percent Width den Wert 100.
Im nächsten Schritt übertragen wir aus der Library-Abteilung Controls jeweils eine TextField-
Komponente per Drag & Drop in die erste bzw. dritte GridPane-Zeile:
Abschnitt 4.9 Bruchrechnungsprogramm mit JavaFX-GUI 269
Wir markieren beide Textfelder, was am einfachsten über die Documents-Zone gelingt, und
verpassen ihnen über das Layout-Segment der Inspector-Zone einen linken sowie rechten Rand
mit der Breite 20 (Eigenschaft Margin):
Außerdem sollte über das Properties-Segment der Inspector-Zone die Eigenschaft Alignment
auf den Wert CENTER gesetzt werden, damit im aktiven Programm die vom Benutzer eingetrage-
nen Zeichenfolgen zentriert werden.
Nun fügen wir aus der Library-Abteilung Shapes eine Line-Komponente per Drag & Drop in die
Zelle (1, 0) des GridPane-Containers ein und setzen über das Layout-Segment der Inspector-
Zone die Halignment-Eigenschaft der Line-Komponente auf den Wert CENTER:
Schließlich befördern wir aus der Library-Abteilung Controls eine Button-Komponente per Drag
& Drop in die unterste Zelle des GridPane-Containers und setzen die Layout-Eigenschaft
Halignment auf den Wert CENTER.
Zur Änderung der Schalterbeschriftung haben wir die folgenden Möglichkeiten:
270 Kapitel 4 Klassen und Objekte
Wir werden bald eine Ereignisbehandlungsmethode erstellen, um auf das Betätigen des Schalters
regieren zu können. Darin werden wir auf GUI-Komponenten Bezug nehmen. Um dies zu ermögli-
chen, erhalten die betroffenen TextField-Komponenten nun Kennungen, die später zu Feldnamen
werden. Wir markieren das obere Textfeld und vergeben über das Code-Segment der Inspector-
Zone tfZaehler als fx:id:
Analog vergeben wir die Kennung tfNenner an das untere Textfeld. Um die verständlichen War-
nungen des Scene Builders wegen der mangelhaften Ausstattung der Controller-Klasse werden wir
uns im Abschnitt 4.9.4 kümmern:
Im fertigen Programm soll durch einen Mausklick auf den Befehlsschalter eine Ereignisbehand-
lungsmethode namens reduceFraction() gestartet werden. Wir markieren das Button-Objekt
und tragen im Code-Segment der Inspector-Zone den Namen der (noch nicht vorhandenen) Me-
thode in das Feld On Action ein:
Wir fordern eine Vorschau des momentanen Entwicklungsstands unseres Programms über den
Menübefehl
Preview > Show Preview in Window
an:
Abschnitt 4.9 Bruchrechnungsprogramm mit JavaFX-GUI 271
Auch beim selbständigen Scene Builder - Einsatz klappt die Kooperation mit IntelliJ. Beim
Verlassen des Scene Builders sichert man die Änderungen,
IntelliJ nimmt nach einem Mausklick auf das Project-Fenster den neuen Stand zur Kenntnis:
272 Kapitel 4 Klassen und Objekte
Die Klasse Bruch wird nach dem Öffnen aus zwei Gründen als fehlerhaft markiert:
• Sie verwendet die Klasse Simput, doch in IntelliJ wurde die globale Bibliothek mit Simput
nicht in die Liste der Abhängigkeiten des Moduls aufgenommen (siehe Abschnitt 3.4.2).
• Sie gehört nicht zum Paket sample.
In Bruch.java wird die Klasse Simput genutzt, die über den Klassenpfad des aktuellen Projekts
nicht auffindbar ist. Wir beseitigen den Fehler, indem wir die im aktuellen Projekt überflüssige
Bruch-Methode frage() entfernen, wo die Klasse Simput zur Interaktion mit dem Benutzer im
Rahmen einer Konsolenanwendung verwendet wird.
Man kann die Unbequemlichkeit bei der Wiederverwendung der Klasse Bruch als Indiz für einen
Verstoß gegen das im Abschnitt 4.1.1.1 angesprochene Prinzip einer einzigen Verantwortung (Sin-
gle Responsibility Principle, SRP) interpretieren. Die Klasse Bruch sollte sich auf die Kernkompe-
tenzen von Brüchen (z. B. Initialisieren, Kürzen, Addieren) beschränken und die Benutzerinterakti-
on anderen Klassen überlassen. Nachdem die Klasse Bruch mehrfach als positives Beispiel zur
Erläuterung von objektorientierten Techniken gedient hat, taugt sie nun Negativbeispiel und kon-
kretisiert die Warnung aus dem Abschnitt 4.1.1.1, dass multifunktionale Klassen zu stärkeren Ab-
hängigkeiten von anderen Klassen tendieren, wobei die Wahrscheinlichkeit einer erfolgreichen
Wiederverwendung sinkt.
Weil sich die Klassen des JavaFX-Projekts aufgrund der verwendeten Projektvorlage in einem Pa-
ket namens sample befindet, muss auch die Klasse Bruch in dieses Paket aufgenommen werden.
Dazu ist am Anfang ihres Quellcodes die folgende Zeile einzufügen:
package sample;
Danch sollten alle kritischen Einwände von IntelliJ gegen die Klasse Bruch behoben sein.
Die Klasse Controller muss mit ihren vollqualifizierten Namen (inkl. Paketname) im Wur-
zelelement der FXML-Datei sample.fxml mit der GUI-Deklaration eingetragen werden, was auf-
grund der verwendeten Projektvorlage der Fall ist:
Abschnitt 4.9 Bruchrechnungsprogramm mit JavaFX-GUI 273
Wir sorgen im nächsten Schritt dafür, dass für die per FXML deklarierten TextField-Objekte In-
stanzvariablen in die Klasse Controller aufgenommen werden, damit die Bedienelemente in Er-
eignisbehandlungsmethoden angesprochen werden können. Dazu öffnen wir im IntelliJ-Editor das
Text-Registerblatt zur Datei sample.fxml, setzen einen linken Mausklick auf das fx:id - Attribut
tfZaehler zum oberen Textfeld, öffnen das Drop-Down-Menü zur erscheinenden gelben Glüh-
birne, um die Verbesserungsvorschläge der Entwicklungsumgebung zu erfahren. Das Angebot
Create field 'tfZaehler' passt zu unserem aktuellen Ziel:
Nach der Übernahme des Vorschlags erscheint im Quellcode der Klasse Controller die passende
Felddeklaration mit dem Datentyp TextField:
public class Controller {
public TextField tfZaehler;
}
Analog ergänzen wir die TextField-Variable zum zweiten Textfeld.
IntelliJ platziert neben die Felddeklarationen jeweils einen Link zum korrespondierenden Element
der FXML-Datei:
Mit der Schutzstufe public für die Felder verstößt IntelliJ gegen das Prinzip der Datenkapselung.
Im weiteren Verlauf des Abschnitts werden wir uns um dieses Thema kümmern.
Aufgrund einer per Scene Builder vorgenommenen Konfiguration zum Befehlsschalter (vgl. Ab-
schnitt 4.9.2)
274 Kapitel 4 Klassen und Objekte
Wir akzeptieren das Angebot, vervollständigen die Methode und verzichten dabei auf jede Absiche-
rung gegen fehlerhafte Eingaben:1
package sample;
import javafx.event.ActionEvent;
import javafx.scene.control.TextField;
1
Die dazu sinnvollerweise zu verwendende Technik der Ausnahmebehandlung steht uns noch nicht zur Verfügung.
Abschnitt 4.9 Bruchrechnungsprogramm mit JavaFX-GUI 275
Im Run-Fenster von IntelliJ erscheint beim Anwendungsstart eine Warnung, weil die von Scene
Builder modifizierte FXML-Datei den XML-Namensraum zu JavaFX 17 angekündigt,
<GridPane alignment="center" hgap="10" prefHeight="275.0" prefWidth="300.0"
xmlns="http://javafx.com/javafx/17" xmlns:fx="http://javafx.com/fxml/1"
fx:controller="sample.Controller">
. . .
</GridPane>
zum Laden der FXML-Datei das OpenJFX-SDK 8.0.302 verwendet wird:
WARNING: Loading FXML document with JavaFX API of version 17 by JavaFX runtime of
version 8.0.302-ojdkbuild
Die Warnung unterbleibt, wenn in der FXML-Datei eine kompatible JavaFX-API - Version ange-
geben,
xmlns="http://javafx.com/javafx/8.0"
oder das betroffene XML-Namensraumattribut komplett gestrichen wird. Wir hätten uns dieses
kleine Problem durch eine zusätzlichen, speziell für JavaFX 8 geeignete Scene Builder - Installation
ersparen können (siehe Abschnitt 2.5). Erfahrungsgemäß lassen sich bei einer Beschränkung auf
einfache Bedienelemente die von der Scene Builder - Version 17 erstellten FXML-Dateien auch
durch eine JavaFX 8 - Laufzeit erfolgreich laden.
Dass IntelliJ in der Klasse Controller die Instanzvariablen zu den per FXML-deklarierten Bedie-
nelementen mit der Schutzstufe public angelegt hat, missfällt Ihnen vermutlich. Wenn wir für die
angemessene Datenkapselung sorgen,
private TextField tfZaehler;
private TextField tfNenner;
führt ein Klick auf den Befehlsschalter allerdings zu einem Ausnahmefehler statt zum gewünschten
Verhalten. Wir müssen mit der Annotation1 @FXML dafür sorgen, dass auch über private In-
stanzvariablen die GUI-Komponenten angesprochen werden können, die in der FXML-Datei eine
Kennung (fx.id) erhalten haben:
@FXML
private TextField tfZaehler;
@FXML
private TextField tfNenner;
Damit die Annotation FXML bekannt wird, sorgt man mit IntelliJ-Hilfe für das Importieren dieser
Klasse:
1
Annotationen werden im Kapitel 7 zusammen mit den Schnittstellen behandelt.
276 Kapitel 4 Klassen und Objekte
import javafx.fxml.FXML;
Das mit dem OpenJDK/OpenJFX 8 entwickelte IntelliJ-Projekt Reduce Fraction ist im folgen-
den Ordner zu finden
...\BspUeb\JavaFX\Reduce Fraction\Reduce Fraction mit Java 8
4.9.5 Programmstart
Ist die im Abschnitt 1.2.1 beschriebene OpenJDK 8 - Installation ausgeführt worden, dann kann das
Programm außerhalb der Entwicklungsumgebung z. B. so gestartet werden:
• Konsolenfester öffnen und auf das Verzeichnis positionieren, das den Paketordner mit den
class-Dateien enthält, z. B.:
• Weil JavaFX (alias OpenJFX) in der OpenJDK 8 - Installation enthalten ist, kann das Pro-
gramm folgendermaßen per javaw.exe gestartet werden, wobei der vollqualifizierte Name
der Startklasse (inklusive Paketname) anzugeben ist:
Wenn sich javaw.exe nicht im Windows-Pfad für ausführbare Dateien befindet, dann muss
der Dateiname des Interpreters inklusive Pfadangabe geschrieben werden, z. B.:
Das mit dem OpenJDK/OpenJFX 8 entwickelte Programm kann auch mit der JVM im OpenJDK 17
ausgeführt werden, wenn zusätzlich das OpenJFX-SDK 17 installiert worden ist. Im Startkomman-
do ist ein Modulpfad anzugeben (vgl. Abschnitt 6.2.9.2), z. B.:
Das Startverfahren lässt sich für Endbenutzer unter Windows z. B. durch die Erstellung einer Ver-
knüpfung vereinfachen (siehe Abschnitt 1.2.3).
Im Abschnitt 6.1.3.6 wird beschrieben, wie man das GUI-Bruchrechnungsprogramm in eine jar-
Datei verpackt, sodass es leicht verteilt und auf einem Rechner mit dem OpenJDK 8 und OpenJFX
8 per Doppelklick gestartet werden kann.
Abschnitt 4.10 Übungsaufgaben zum Kapitel 4 277
Ein professionelles Java-Programm wird in der Regel mit einer eigenen JVM ausgeliefert, sodass
dem Endbenutzer komplizierte Erläuterungen zum Starten unter verschiedenen Java-Versionen er-
spart bleiben.
2) Wie erhält man eine Instanzvariable mit uneingeschränktem Zugriff für die Methoden der eige-
nen Klasse, die von Methoden fremder Klassen zwar gelesen, aber nicht geändert werden kann?
3) Welche von den folgenden Aussagen über Methoden sind richtig bzw. falsch?
1. Methoden müssen generell als public deklariert werden, denn sie gehören zur Schnittstelle
einer Klasse.
2. Ändert man den Rückgabetyp einer Methode, dann ändert sich auch ihre Signatur.
3. Beim Methodenaufruf müssen die Datentypen der Aktualparameter exakt mit den Datenty-
pen der Formalparameter übereinstimmen.
4. Lokale Variablen einer Methode überdecken gleichnamige Instanzvariablen.
4) Was halten Sie von der folgenden Variante der Bruch-Methode setzeNenner()?
public boolean setzeNenner(int n) {
if (n != 0)
nenner = n;
else
return false;
}
6) Erstellen Sie die Klassen Time und Duration zur Verwaltung von Zeitpunkten (der Einfachheit
halber nur innerhalb eines Tages) und Zeitintervallen (von beliebiger Länge).
Neben der Beschäftigung mit syntaktischen Details der Klassendefinition ist es in Ihrer jetzigen
Lernphase wichtig, den Entwurf von Klassen zu üben. Dazu bieten die Klassen Time und Duration
eine geeignete, nicht allzu komplizierte Aufgabe. In der Praxis sollten Sie beim Umgang mit Zeit-
punkten und Zeitintervallen allerdings das in Java 8 gründlich renovierte Date/Time - API der Stan-
dardbibliothek verwenden.1
Die beiden Klassen Time und Duration sollen über Instanzvariablen für Stunden, Minuten und
Sekunden sowie über folgende Methoden verfügen:
• Konstruktoren mit unterschiedlichen Parameterausstattungen
• Methoden zum Abfragen bzw. Setzen von Stunden, Minuten und Sekunden
Beim Versuch zur Vereinbarung eines irregulären Werts (z. B. Uhrzeit mit einer Stundenan-
gabe größer als 23) sollte die betroffene Methode die Ausführung verweigern und den
Rückgabewert false liefern. Diese Behandlung ungültiger Parameterwerte ist akzeptabel, so-
lange wir das eigentliche angemessenere Werfen einer Ausnahme noch nicht erlernt haben
(siehe Kapitel 11).
• Eine Methode mit dem Namen toString() und dem Rückgabetyp String, die zu einem
Time- bzw. Duration-Objekt eine gut lesbare Zeichenfolgenrepräsentation liefert2
Tipp: In der Klasse String steht die statische Methode format() zur Verfügung, die analog
zur PrintStream-Methode printf() (alias format(), siehe Abschnitt 3.2.2) arbeitet und eine
formatierte String-Rückgabe liefert. Im folgenden Beispiel enthält die Formatierungszei-
chenfolge den Platzhalter %02d für eine ganze Zahl, die bei Werten kleiner als 10 mit einer
führenden Null ausgestattet wird:
return String.format("%02d:%02d:%02d Uhr", hours, minutes, seconds);
In der Klasse Time sollen außerdem Methoden mit folgenden Leistungen vorhanden sein:
• getDistenceTo()
Berechnung der Zeitdistanz zu einem anderen, als Parameter übergebenen Zeitpunkt am sel-
ben oder am folgenden Tag
• addDuration()
Addieren eines als Parameter übergebenen Zeitintervalls zu einem Zeitpunkt mit einer neuen
Uhrzeit als Ergebnis
Erstellen Sie eine Testklasse zur Demonstration der Time-Methoden getDistenceTo() und
addDuration(). Ein Programmlauf soll z. B. folgende Ausgaben produzieren:
a) Distanz zwischen zwei Zeitpunkten ermitteln:
Von 17:34:55 Uhr bis 12:24:12 Uhr vergehen 18:49:17 h:m:s.
b) Zeitdauer zu einem Zeitpunkt addieren:
20:23:10 h:m:s nach 17:34:55 Uhr sind es 13:58:05 Uhr.
1
Siehe z. B. https://docs.oracle.com/javase/tutorial/datetime/index.html
2
Dabei wird die toString() - Methode der Basisklasse Object überschrieben.
Abschnitt 4.10 Übungsaufgaben zum Kapitel 4 279
7) Lokalisieren Sie bitte in der folgenden Abbildung mit einer Kurzform der Klasse Bruch
public class Bruch {
private int zaehler;
private int nenner = 1; 1
private String etikett = "";
static private int anzahl;
2
public Bruch(int z, int n, String eti) {
setzeZaehler(z);
setzeNenner(n);
setzeEtikett(eti);
anzahl++;
}
public Bruch() {anzahl++;}
3
public void setzeZaehler(int z) {zaehler = z;}
public boolean setzeNenner(int n) {
if (n != 0) {
nenner = n;
return true;
} else
return false;
}
public void setzeEtikett(String eti) {
int rind = eti.length();
if (rind > 40)
4
rind = 40;
etikett = eti.substring(0, rind);
}
public int gibZaehler() {return zaehler;}
public int gibNenner() {return nenner;}
public String gibEtikett() {return etikett;}
neun Begriffe der objektorientierten Programmierung, und tragen Sie die Positionen in die folgende
Tabelle ein:
280 Kapitel 4 Klassen und Objekte
Methodenaufruf
Zum Eintragen benötigen Sie nicht unbedingt eine gedruckte Variante des Manuskripts, sondern
können auch das interaktive PDF-Formular in der folgenden Datei
...\BspUeb\Klassen und Objekte\Begriffe lokalisieren.pdf
benutzen.1
8) Erstellen Sie eine Klasse mit einer statischen Methode zur Berechnung der Fakultät über einen
rekursiven Algorithmus. Erstellen Sie eine Testklasse, die die rekursive Fakultätsmethode benutzt.
9) Die folgende Aufgabe eignet sich nur für Leser mit Grundkenntnissen in linearer Algebra: Erstel-
len Sie eine Klasse für Vektoren im IR2, die mindestens über Methoden mit den folgenden Leistun-
gen verfügt:
• Länge ermitteln
x
Der Betrag eines Vektors x = 1 ist definiert durch:
x2
x := x12 + x22
Verwenden Sie die Klassenmethode Math.sqrt(), um die Quadratwurzel aus einer dou-
ble-Zahl zu berechnen.
• Vektor auf Länge eins normieren
Dazu dividiert man beide Komponenten durch die Länge des Vektors, denn mit
~x := ( ~x , ~x ) sowie ~x := x1 x2
1 2 1 und ~
x2 := gilt:
x1 + x2
2 2
x1 + x22
2
2 2
x12 x22
x22 = + =
~ x1 x2
x = ~
x12 + ~ + =1
x2 + x2 x2 + x2 x12 + x22 x12 + x22
1 2 1 2
• Vektoren (komponentenweise) addieren
x y
Die Summe der Vektoren x = 1 und y = 1 ist definiert durch:
x2 y2
x + y1
x + y := 1
x2 + y2
1
Die Idee zu dieser Übungsaufgabe stammt aus Mössenböck (2003).
Abschnitt 4.10 Übungsaufgaben zum Kapitel 4 281
(0,1) y
(1,0)
cos(x, y)
Um aus cos(x, y) den Winkel in Grad zu ermitteln, können Sie folgendermaßen vorgehen:
o mit der Klassenmethode Math.acos() den zum Kosinus gehörigen Winkel im Bo-
genmaß ermitteln
o mit der Klassenmethode Math.toDegrees() das Bogenmaß (rad) in Grad umrech-
nen (deg), wobei die folgende Formel verwendet wird:
rad
deg = 360
2
• Rotation eines Vektors um einen bestimmten Winkelgrad
Mit Hilfe der Rotationsmatrix
cos() − sin()
D :=
sin() cos()
kann der Vektor x um den Winkel (im Bogenmaß!) gedreht werden:
cos() − sin() x1 cos() x1 − sin() x2
x = D x = =
sin() cos() x2 sin() x1 + cos() x2
Zur Berechnung der trigonometrischen Funktionen stehen die Klassenmethoden
Math.cos() und Math.sin() bereit. Für die Umwandlung von Winkelgraden (deg) in das
von cos() und sin() benötigte Bogenmaß (rad) steht die Methode Math.toRadians() be-
reit, die mit der folgenden Formel arbeitet:
deg
rad = 2
360
1
Dies folgt aus dem Additionstheorem für den Kosinus.
282 Kapitel 4 Klassen und Objekte
Erstellen Sie ein Demonstrationsprogramm, das Ihre Vektor-Klasse verwendet und ungefähr den
folgenden Programmablauf ermöglicht (Eingabe grün, kursiv):
Vektor 1: (1,00; 0,00)
Vektor 2: (1,00; 1,00)
5.1 Arrays
Ein Array ist ein Objekt, das eine feste Anzahl von Elementen desselben Datentyps als Instanzvari-
ablen enthält, die in einem zusammenhängenden Speicherbereich hintereinander abgelegt werden.1
In der folgenden Abbildung ist ein Array namens uni mit 5 Elementen vom Typ int zu sehen:
Heap
Neben den Elementen enthält ein Array-Objekt noch Verwaltungsdaten (z. B. die finalisierte und
öffentliche Instanzvariable length mit der Anzahl der Elemente).
Im Vergleich zur Verwendung einer entsprechenden Anzahl von Einzelvariablen ermöglichen Ar-
rays eine gravierende Vereinfachung der Programmierung:
1
Arrays werden in vielen Programmiersprachen auch Felder genannt. In Java bezeichnet man jedoch recht einheitlich
die Instanz- oder Klassenvariablen als Felder, sodass der Name hier nicht mehr zur Verfügung steht.
2
Technisch gesehen liegt ein Array-Zugriffsausdruck mit dem Operator [] vor.
284 Kapitel 5 Wichtige spezielle Klassen
• Weil der Index auch durch einen Ausdruck (z. B. durch eine Variable) geliefert werden
kann, sind Arrays im Zusammenhang mit den Wiederholungsanweisungen äußerst prak-
tisch.
• Man kann die gemeinsame Verarbeitung aller Elemente (z. B. bei der Ausgabe in eine Da-
tei) per Methodenaufruf mit Array-Aktualparameter veranlassen.
• Viele Algorithmen arbeiten mit Vektoren und Matrizen. Zur Modellierung dieser mathema-
tischen Objekte sind Arrays unverzichtbar.
Wir beschäftigen uns erst jetzt mit den zur Grundausstattung praktisch jeder Programmiersprache
gehörenden Arrays, weil diese Datentypen in Java als Klassen realisiert sind und folglich zunächst
entsprechende Grundlagen zu erarbeiten waren.
Als Datentyp eines Arrays ist jeder primitive Typ und jeder Referenztyp erlaubt, und wir dürfen uns
vorstellen, dass zu jedem Datentyp eine Array-Klasse existiert, die unmittelbar von der Urahnklasse
Object im Paket java.lang abstammt. Als Besonderheit der Arrays ist uns schon begegnet, dass
ihre Elemente im Unterschied zu den Instanzvariablen normaler Klassen keine individuellen Namen
haben, sondern über einen Array-Zugriffsausdruck mit dem Operator [] angesprochen werden.
Die Elemente eines Arrays können auch einen Array-Typ besitzen, sodass sich mehrdimensionale
Arrays realisieren lassen.
Typbezeichner [] Variablenname ;
Modifikator ,
Welche Modifikatoren zulässig bzw. erforderlich sind, hängt davon, ob die Array-Variable zu einer
Methode, zu einer Klasse oder zu einer Instanz gehört. Die Array-Variable uni aus dem zu Beginn
des Abschnitts 5.1 vorgestellten und im weiteren Verlauf noch mehrfach betrachteten Beispiel ge-
hört zu einer Methode und wird folgendermaßen deklariert:
int[] uni;
Bei der Deklaration entsteht eine Referenzvariable, aber noch kein Array-Objekt. Daher ist auch
keine Array-Größe (Anzahl der Elemente) anzugeben.
Einer Array-Referenzvariablen kann als Wert die Adresse eines Arrays mit Elementen vom verein-
barten Typ oder das Referenzliteral null (Zeiger auf nichts) zugewiesen werden.
1
Alternativ dürfen bei der Deklaration die eckigen Klammern auch hinter dem Variablennamen stehen, z. B.
int uni[];
Hier wird eine Regel der Programmiersprache C unterstützt, wobei die Lesbarkeit des Quellcodes aber leidet.
Abschnitt 5.1 Arrays 285
Heap
0 0 0 0 0
Weil es sich bei den Array-Elementen um Instanzvariablen eines Objekts handelt, erfolgt eine au-
tomatische Null-Initialisierung nach den Regeln von Abschnitt 4.1.3. Die int-Elemente im Beispiel
erhalten folglich den Startwert 0.
Die Anzahl der Elemente in einem Array wird begrenzt durch den größten positiven Wert des Da-
tentyps int (= 2147483647).
Ein Array-Objekt wird vom Garbage Collector entsorgt, wenn im Programm keine Referenz mehr
vorliegt (vgl. Abschnitt 4.4.7). Um eine Referenzvariable aktiv von einem Array-Objekt zu „ent-
koppeln“, kann man ihr z. B. das Referenzliteral null oder aber ein alternatives Referenzziel zuwei-
sen.
Es ist auch möglich, dass mehrere Referenzvariablen auf dasselbe Array-Objekt zeigen, z. B.:
Quellcode Ausgabe
class Prog { 99
public static void main(String[] args) {
int[] x = new int[3], y;
x[0] = 1; x[1] = 2; x[2] = 3;
y = x; // y zeigt nun auf dasselbe Array-Objekt wie x
y[0] = 99;
System.out.println(x[0]);
}
}
286 Kapitel 5 Wichtige spezielle Klassen
Nutzfahrzeug Nutzfahrzeug[]
Personentransporter Personentransporter[]
Taxi Taxi[]
Im Kapitel 8 über die Generizität werden wir diese Spezialisierungsbeziehungen zwischen Array-
Klassen als Kovarianz bezeichnen1 und als Design-Fehler kritisieren. Aufgrund der Kovarianz-
Eigenschaft von Arrays übersetzt der Compiler nämlich z. B. die folgenden Anweisungen ohne jede
Kritik:
Object[] arrObject = new String[5];
arrObject[0] = 13;
Weil String von Object abstammt, ist Object[] aufgrund der kovarianten Spezialisierungsbezie-
hungen von Array-Klassen eine Basisklasse von String[], und eine Variable vom Typ einer Basis-
klasse kann in der objektorientierten Programmierung generell die Adresse eines Objekts aus einer
abgeleiteten Klasse aufnehmen. Zur Laufzeit kommt es jedoch zu einem Ausnahmefehler vom Typ
ArrayStoreException:
Exception in thread "main" java.lang.ArrayStoreException: java.lang.Integer
at Prog.main(Prog.java:5)
Der per
new String[5]
erzeugte Array kennt zur Laufzeit sehr wohl seinen tatsächlichen Elementtyp (String) und lehnt die
Aufnahme eines Integer-Objekts ab (zu Integer-Objekten und Autoboxing siehe Abschnitt 5.3.2).
Weil Programmierfehler nicht zur Laufzeit, sondern vom Compiler entdeckt werden sollten, ist die
bei Arrays realisierte kovariante Zuweisungskompatibilität als Mangel einzuschätzen, von dem ne-
ben Java auch andere Programmiersprachen betroffen sind (z. B. C#).
1
Die Abbildung illustriert das mutmaßliche Motiv für die Anwendung des statistischen Begriffs Kovarianz auf die
Spezialisierungsbeziehungen von Array-Klassen: Für die „Rangreihe“ der drei Klassen und die „Rangreihe“ der zu-
gehörigen Array-Klassen besteht tatsächlich eine perfekte Rangkorrelation.
Abschnitt 5.1 Arrays 287
von Abschnitt 5.1 zur Demonstration verwendeten Anweisungen lassen sich leicht zu einem Pro-
gramm erweitern, das die Qualität des Pseudozufallszahlengenerators in Java überprüft. Dieser
Generator produziert Folgen von Zahlen mit einem bestimmten Verteilungsverhalten. Obwohl eine
Serie perfekt vom Initialisierungswert des Pseudozufallszahlengenerators abhängt, kann sie in der
Regel echte Zufallszahlen ersetzen. Manchmal ist es sogar von Vorteil, eine Serie über einen festen
Initialisierungswert reproduzieren zu können. In der Regel verwendet man aber variable Initialisie-
rungen, z. B. abgeleitet aus einer Zeitangabe. Der Einfachheit halber redet man oft von Zufallszah-
len und lässt den Zusatz Pseudo weg.
Man kann übrigens mit moderner EDV-Technik unter Verwendung von physikalischen Prozessen
durchaus echte Zufallszahlen produzieren, doch ist der Zeitaufwand im Vergleich zu Pseudozufalls-
zahlen erheblich höher (Lau 2009).
Nach der folgenden Anweisung zeigt die Referenzvariable zzg auf ein Objekt der Klasse Random
aus dem API-Paket java.util, das als Pseudozufallszahlengenerator taugt:
java.util.Random zzg = new java.util.Random();
Durch die Verwendung des parameterfreien Random-Konstruktors entscheidet man sich für die
Anzahl der Millisekunden seit dem 1.1.1970, 00.00 Uhr, als Initialisierungswert für den Pseudozu-
fall.1
Das angekündigte Programm zur Prüfung des Java-Pseudozufallszahlengenerators zieht 10.000 Zu-
fallszahlen aus der Menge {0, 1, 2, 3, 4} und ermittelt die empirische Verteilung dieser Stichprobe:2
class UniRand {
public static void main(String[] args) {
int[] uni = new int[5];
java.util.Random zzg = new java.util.Random();
final int drl = 10_000;
System.out.println("Absolute Häufigkeiten:");
for (int element : uni)
System.out.print(element + " ");
System.out.println("\n\nRelative Häufigkeiten:");
for (int element : uni)
System.out.print((double)element/drl + " ");
}
}
Die Random-Methode nextInt() liefert beim Aufruf mit dem Aktualparameterwert 5 als Rückgabe
eine int-Zufallszahl aus der Menge {0, 1, 2, 3, 4}, wobei die möglichen Werte mit der gleichen
Wahrscheinlichkeit 0,2 auftreten sollten. Im Programm dient der Rückgabewert als Array-Index
dazu, ein zufällig gewähltes uni-Element zu inkrementieren. Wie das folgende Ergebnisbeispiel
zeigt, stellt sich die erwartete Gleichverteilung in sehr guter Näherung ein:
1
Lieferant dieses Wertes ist die statische Methode currentTimeMillis() der Klasse System im API-Paket java.lang
und obige Anweisung ist äquivalent mit:
java.util.Random zzg = new java.util.Random(System.currentTimeMillis());
2
In der Sprache der Wahrscheinlichkeitstheorie erfolgt die Ziehung „mit Zurücklegen“.
290 Kapitel 5 Wichtige spezielle Klassen
Absolute Haeufigkeiten:
1950 1991 1997 2057 2005
Relative Haeufigkeiten:
0.195 0.1991 0.1997 0.2057 0.2005
Ein 2-Signifikanztest mit der Gleichverteilung als Nullhypothese bestätigt durch eine Überschrei-
tungswahrscheinlichkeit von 0,569 (weit oberhalb der kritischen Grenze 0,05), dass keine Zweifel
an der Gleichverteilung bestehen:
Statt ein Random-Objekt zu erzeugen und mit der Produktion von Pseudozufallszahlen zu beauf-
tragen, kann man auch die statische Methode random() aus der Klasse Math benutzen, die gleich-
verteilte double-Werte aus dem Intervall [0, 1) liefert, z. B.:
uni[(int) (Math.random()*5)]++;
Werden sehr viele Pseudozufallszahlen benötigt, sollte statt der Klasse Random die seit Java 7 ver-
fügbare und leistungsoptimierte Klasse ThreadLocalRandom aus dem Paket java.util.concurrent
verwendet werden (Bloch 2018, S. 268). Im Beispielprogramm ist dazu die Anweisung
java.util.Random zzg = new java.util.Random();
zu ersetzen durch:
java.util.concurrent.ThreadLocalRandom zzg =
java.util.concurrent.ThreadLocalRandom.current();
In einer Variante des Beispielprogramms benötigte die Klasse ThreadLocalRandom für 5 Millio-
nen nextInt() - Aufrufe mit 70 Millisekunden tatsächlich etwas weniger Zeit als die Klasse Rand-
om (90 Millisekunden).
5.1.8 Initialisierungslisten
Bei einem Array mit wenigen Elementen ist die Möglichkeit von Interesse, beim Deklarieren der
Referenzvariablen eine Initialisierungsliste mit den Werten für die Elementvariablen anzugeben und
das Array-Objekt dabei implizit (ohne Verwendung des new-Operators) zu erzeugen, z. B.:
Quellcode Ausgabe
class Prog { 3
public static void main(String[] args) {
int[] wecktor = {1, 2, 3};
System.out.println(wecktor[2]);
}
}
Im nächsten Abschnitt lernen wir einen wichtigen Spezialfall von Arrays mit Referenztyp-Elemen-
ten kennen. Dort zeigen die Elementvariablen wiederum auf Arrays, sodass mehrdimensionale Ar-
rays entstehen.
Dieses Verfahren lässt sich verallgemeinern, um Arrays mit höherer Dimensionalität zu erzeugen,
die aber nur selten benötigt werden.
292 Kapitel 5 Wichtige spezielle Klassen
Die erforderliche Reihenfolge der Längenangaben bei der Kreation von geschachtelten Arrays ist
etwas gewöhnungsbedürftig. Mit T als Namen für einen beliebigen Datentyp haben wir bisher die
Logik kennengelernt, dass T[] einen Array mit Elementen vom Typ T bezeichnet. Daher sollte in
der folgenden Anweisung
int[][] matrix = new int[4][3];
ein äußerer Array mit 3 Elementen vom Typ int[4] entstehen. Wie das Beispielprogramm zeigt,
resultiert aber ein äußerer Array mit den 4 Elementen matrix[0] bis matrix[3], bei denen es
sich jeweils um eine Referenz auf einen Array vom Typ int[3] handelt. Die Größenangaben in der
Deklaration werden den geschachtelten Arrays von außen nach innen zugeordnet. Bei einem zwei-
dimensionalen Array (also bei einer Matrix) ist also zuerst die Anzahl der Zeilen und danach die
Anzahl der Spalten anzugeben. Beim Zugriff auf Matrixelemente resultiert aus der in Java gewähl-
ten Reihenfolge der Längenangaben bzw. Indexwerte gerade die aus der Mathematik vertraute Spe-
zifikationsreihenfolge (Zeilenindex, Spaltenindex), z. B.:
matrix[i][j] = (i+1)*(j+1);
In der folgenden Abbildung wird die Situation im Hauptspeicher beschrieben:
Heap
int-Array -
matrix[1] 2 4 6 matrix[1][2]
Adresse
int-Array -
matrix[3] 4 8 12 matrix[3][2]
Adresse
Quellcode Ausgabe
class Prog { matrix[0] 0
public static void main(String[] args) { matrix[1] 0 1
int[][] matrix = new int[5][]; matrix[2] 0 2 4
for(int i = 0; i < matrix.length; i++) { matrix[3] 0 3 6 9
matrix[i] = new int[i+1]; matrix[4] 0 4 8 12 16
System.out.printf("matrix[%d]", i);
for(int j = 0; j < matrix[i].length; j++) {
matrix[i][j] = i*j;
System.out.printf("%3d", matrix[i][j]);
}
System.out.println();
}
}
}
Im Beispiel wird ein Array-Objekt namens matrix mit den fünf Elementen matrix[0] bis
matrix[4] erzeugt, bei denen es sich jeweils um eine Referenz auf einen Array mit int-Elementen
handelt:
int[][] matrix = new int[5][];
Die Array-Objekte für die Matrixzeilen entstehen später mit individueller Länge:
matrix[i] = new int[i+1];
Mit Hilfe dieser Technik kann man sich z. B. beim Speichern einer symmetrischen Matrix Platz
sparend auf die untere Dreiecksmatrix beschränken.
Auch im mehrdimensionalen Fall können Initialisierungslisten eingesetzt werden, z. B.:
Quellcode Ausgabe
class Prog { 1
public static void main(String[] args) { 1 2
int[][] matrix = {{1}, {1,2}, {1, 2, 3}}; 1 2 3
for(int i = 0; i < matrix.length; i++) {
for(int ele : matrix[i])
System.out.print(ele+" ");
System.out.println();
}
}
}
1
https://docs.oracle.com/javase/9/tools/java.htm
Abschnitt 5.2 Klassen für Zeichenfolgen 295
Heap
Referenzvariable s1 String-Objekt
Die Klasse String besitzt auch Konstruktoren für die Objektkreation per new-Operator, wobei z. B.
ein StringBuilder- oder ein StringBuffer-Objekt als Aktualparameter in Frage kommt. Auch ein
String-Literal ist als Aktualparameter erlaubt, wenngleich sich diese Konstruktion im Abschnitt
5.2.1.3 als wenig sinnvoll herausstellen wird:
String s1 = new String("abcde");
Die Anweisung
testr = testr + "def";
verändert aber nicht das per testr ansprechbare String-Objekt (mit dem Inhalt „abc“), sondern sie
erzeugt ein neues String-Objekt (mit dem Inhalt „abcdef“) und schreibt dessen Adresse in die Refe-
renzvariable testr.
1
„Merkwürdig“ bedeutet hier, dass sich eine Aufnahme in das Langzeitgedächtnis lohnt.
Abschnitt 5.2 Klassen für Zeichenfolgen 297
Stack Heap
Referenzvariable s1
Adr. von String 1 String-Objekt 1 interner String-Pool
Referenzvariable s2 abcde
Adr. von String 1
String-Objekt 2
Referenzvariable de
Adr. von String 2 de
Referenzvariable s3 String-Objekt 3
Adr. von String 3 abcde
Referenzvariable s4 String-Objekt 4
Adr. von String 4 abcde
Später werden zwei für den Vergleich von String-Objekten relevante Methoden vorgestellt:
• Mit equals() zum Vergleich mit einem Kollegen aufgefordert, nimmt ein String-Objekt auf
jeden Fall einen Inhaltsvergleich vor (siehe Abschnitt 5.2.1.4.2).
• Mit der Methode intern() wird die Aufnahme von String-Objekten in den internen String-
Pool unterstützt, sodass anschließend Referenz- und Inhaltsvergleich äquivalent sind (siehe
Abschnitt 5.2.1.5). Das Erscheinen eines Zeichenfolgenliterals im Quellcode ist also nicht
der einzige Anlass für die Aufnahme eines String-Objekts in den String-Pool.
Es ist übrigens eine Besonderheit, dass String-Objekte mit dem „+“ - Operator verarbeitet werden
können. Bei anderen Java-Klassen ist das aus C++ und C# bekannte Überladen von Operatoren
nicht möglich.
298 Kapitel 5 Wichtige spezielle Klassen
5.2.1.4.2 Inhaltsvergleich
Für den Test auf identische Inhalte kann man die String-Methode equals()
public boolean equals(String vergl)
verwenden, um den im Abschnitt 5.2.1.3 erläuterten Tücken beim Vergleich von String-Referenz-
variablen per Identitätsoperator aus dem Weg zu gehen. Im folgenden Programm werden zwei
String-Objekte zunächst nach ihren Speicheradressen verglichen, dann nach den Inhalten:
Quellcode Ausgabe
class Prog { false
public static void main(String[] args) { true
String s1 = "abc";
String s2 = new String("abc");
System.out.println(s1 == s2);
System.out.println(s1.equals(s2));
}
}
5.2.1.4.3 Sortierungspriorität
Zum Vergleich von Zeichenfolgen hinsichtlich der Sortierungspriorität kann die String-Methode
compareTo()
public int compareTo(String vergl)
dienen, z. B.:
Quellcode Ausgabe
class Prog { < : -10
public static void main(String[] args) { = : 0
String a = "Müller, Anja", b = "Müller, Kurt", > : 10
c = "Müller, Anja";
System.out.println("< : " + a.compareTo(b));
System.out.println("= : " + a.compareTo(c));
System.out.println("> : " + b.compareTo(a));
}
}
Quellcode Ausgabe
class Prog { 3
public static void main(String[] args) { 3
char[] cvek = {'a', 'b', 'c'};
String str = "abc";
System.out.println(cvek.length);
System.out.println(str.length());
}
}
a) Teilzeichenfolge extrahieren
Mit der Methode
public String substring(int start, int ende)
lassen sich alle Zeichen zwischen den Positionen start (inklusive) und ende (exklusive) extrahieren.
b) Teilzeichenfolge suchen
Mit der Methode
public int indexOf(String gesucht)
kann man einen String nach einer anderen Zeichenkette durchsuchen. Als Rückgabewert erhält
man ...
• nach einer erfolgreichen Suche: die Startposition der ersten Trefferstelle
• nach einer vergeblichen Suche: -1
java.lang.StringIndexOutOfBoundsException
In der Anweisung mit dem equals() - Aufruf stoßen wir auf eine stattliche Anzahl von Punktopera-
toren, sodass eine kurze Erklärung angemessen ist:
Abschnitt 5.2 Klassen für Zeichenfolgen 301
• Der Methodenaufruf a.toUppercase() erzeugt ein neues String-Objekt und liefert die
zugehörige Referenz.
• Diese Referenz ermöglicht es, dem neuen Objekt Botschaften zu übermitteln, was unmittel-
bar zum Aufruf der Methode equals() genutzt wird.
class StringIntern {
public static void main(String[] args) {
final int anz = 50_000, len = 20, wdh = 50;
StringBuffer sb = new StringBuffer();
java.util.Random ran = new java.util.Random();
String[] sar = new String[anz];
start = System.currentTimeMillis();
hits = 0;
// Internieren
for (int j = 1; j < anz; j++)
sar[j] = sar[j].intern();
System.out.println("\nZeit für das Internieren: "+
(System.currentTimeMillis()-start)+" Millisekunden");
// Adressvergleiche
for (int n = 1; n <= wdh; n++)
for (int i = 0; i < anz; i++)
if (sar[i] == sar[ran.nextInt(anz)])
hits++;
System.out.println((wdh * anz)+" Adressvergleiche ("+hits+
" hits) benötigen (inkl. Internieren) "+(System.currentTimeMillis()-start)+
" Millisekunden");
}
}
Es hängt von den Aufgabenparametern anz, len und wdh ab, welche Vergleichstechnik überlegen
ist:1
Laufzeit in Millisekunden
Internieren
equals() - Vergleiche
plus Adressvergleiche
anz = 50000, len = 20, wdh = 5 56 93
anz = 50000, len = 20, wdh = 50 565 101
anz = 50000, len = 20, wdh = 500 3511 390
Erwartungsgemäß ist das Internieren umso rentabler, je mehr Vergleiche anschließend mit den Zei-
chenfolgen angestellt werden.
1
Die Ergebnisse wurden auf einem PC mit der Intel-CPU Core i3 550 (3,2 GHz) unter Windows 10 (64 Bit) mit dem
OpenJDK 8 ermittelt.
Abschnitt 5.2 Klassen für Zeichenfolgen 303
1
Ein IntelliJ-Projekt mit dem Programm ist im Ordner ...\BspUeb\Wichtige spezielle
Klassen\Zeichenfolgen\SBBBench zu finden.
304 Kapitel 5 Wichtige spezielle Klassen
1
Die Laufzeiten (in Millisekunden) wurden auf einem PC mit der Intel-CPU Core i3 550 (3,2 GHz) unter Windows
10 (64 Bit) mit dem OpenJDK 8 ermittelt.
Abschnitt 5.2 Klassen für Zeichenfolgen 305
Aus einem Textblock resultiert (wie bei einem Zeichenkettenliteral) ein String-Objekt.
Es sind die folgenden Syntaxregeln zu beachten (nach Laskey & Marks 2020):
• Ein Textblock wird durch drei doppelte Hochkommata und einen Zeilenwechsel eingeleitet,
wobei der Zeilenwechsel nicht zum Bestandteil des resultierenden String-Objekts wird (sie-
he Beispiel).
• Ein Textblock wird durch drei doppelte Hochkommata beendet, die unmittelbar auf den Text
der letzten Zeile folgen. Wenn die drei doppelten Hochkommata wie im obigen Beispiel in
einer neuen Zeile stehen, dann endet das resultierende String-Objekt mit einem Zeilenwech-
sel.
• Die Textblockzeile mit dem kleinsten Abstand zum linken Rand definiert die Startposition,
und Leerzeichen links von dieser Position werden bei allen Zeilen des Textblocks automa-
tisch entfernt, sodass eine zum Java-Quellcode passende Einrückung des Textblocks möglich
ist. IntelliJ zeigt die Startposition für den Textblock durch eine vertikale Linie an:
1
Das Beispiel stammt von https://openjdk.java.net/jeps/355
306 Kapitel 5 Wichtige spezielle Klassen
Soll eine Anzahl von Leerzeichen vor den Textblockzeilen als essentiell behandelt werden
und erhalten bleiben, dann verschiebt man die terminierenden drei doppelten Hochkommata
nach links, wobei IntelliJ die resultierenden essentiellen Leerzeichen anzeigt, z. B.:
Weil die beiden Referenzvariablen str und tbStr auf dasselbe Objekt im internen String-Pool
zeigen (vgl. Abschnitt 5.2.1.3) kommt der Identitätsoperator zum selben Ergebnis wie die Methode
equals().
Abschnitt 5.3 Verpackungsklassen für primitive Datentypen 307
Als Ersatz für die beiden Konstruktor-Überladungen wird die statische Fabrikmethode valueOf()
vorgeschlagen, die zwei analoge Überladungen besitzt, z. B. bei der Klasse Integer:
• public static Integer valueOf(int value)
• public static Integer valueOf(String str)
Die valueOf() - Methoden der Klasse Integer verwenden Zeit und Speicherplatz sparend einen
Cache mit bereits erzeugten Wrapper-Objekten vom eigenen Typ, wobei allerdings nur die Werte
von -128 bis 127 unterstützt werden:
• Liegt der Parameter im Bereich von -128 bis 127, und ist bereits ein Wrapper-Objekt mit die-
sem Wert vorhanden, dann wird dessen Adresse zurückgeliefert (analog zum internen
String-Pool, vgl. Abschnitt 5.2.1.3).
• Anderenfalls wird ein neues Objekt erstellt und dessen Adresse geliefert.
Wenn nicht unbedingt ein neues Objekt benötigt wird, sollte an Stelle eines Wrapper-Konstruktors
die Methode valueOf() verwendet werden.
Das eben beschriebene Verhalten der valueOf() - Methoden ist sinnvoll, weil die Wrapper-Objekte
unveränderlich sind (engl.: immutable). Nach dem Erzeugen eines Wrapper-Objekts kann sein
308 Kapitel 5 Wichtige spezielle Klassen
Inhalt nicht mehr geändert werden. Daher besitzen die Wrapper-Klassen keinen parameterfreien
Konstruktor.
5.3.2 Auto(un)boxing
Seit der Version 5 (alias 1.5) kann der Java-Compiler Werte eines primitiven Typs automatisch in
Wrapper-Objekte verpacken, z. B.:
Integer iw = 4711;
Damit vereinfacht sich die Nutzung von Methoden, die Parameter mit Referenzdatentyp erwarten.
Im folgenden Beispielprogramm wird ein Objekt der Klasse ArrayList aus dem Paket java.util als
bequemer und flexibler Container verwendet:1
• Ein ArrayList-Container kann Objekte beliebigen Typs als Elemente aufnehmen.
• Die Größe des Containers wird automatisch an den Bedarf angepasst.
Um Werte primitiver Typen in einen ArrayList-Container einfügen zu können, müssen sie in
Wrapper-Objekte verpackt werden, was aber dank Autoboxing keine Mühe macht:
class Autoboxing {
public static void main(String[] args) {
java.util.ArrayList al = new java.util.ArrayList();
al.add("Otto");
// AutoBoxing
al.add(4711;
al.add(23.77);
al.add('x');
Dank Autoboxing klappt auch das Erzeugen eines Arrays mit Wrapper-Elementtyp per Initialisie-
rungsliste unter Verwendung von Werten des zugehörigen primitiven Typs, z. B.:
Integer[] wia = {1, 2, 3};
Ansonsten findet aber bei Arrays kein Autoboxing statt, sodass z. B. int[] nicht automatisch in In-
teger[] gewandelt wird:
1
ArrayList ist eine generische Klasse (siehe Kapitel 8) und sollte unbedingt mit Elementen eines bestimmten Daten-
typs genutzt werden. Dieser ist beim Instanzieren anzugeben, wenn der Compiler die Typhomogenität überwachen
soll. Wir verwenden ausnahmsweise den sogenannten Rohtyp der Klasse ArrayList, der sich aus didaktischen
Gründen gut für den aktuellen Abschnitt eignet, ansonsten aber zu vermeiden ist.
Abschnitt 5.3 Verpackungsklassen für primitive Datentypen 309
Dank Autoboxing sind die primitiven Typen zuweisungskompatibel zur Klasse Object. Nach der
folgenden Anweisung hat die Variable intObj den deklarierten Typ Object und den Laufzeittyp
Integer:
Object intObj = 4711;
Zum Auspacken ist in dieser Lage eine explizite Typumwandlung erforderlich ist:
int j = (int) intObj;
Bisher haben wir die explizite Typumwandlung nur auf primitive Datentypen angewendet; sie spielt
aber auch bei Referenztypen eine wichtige Rolle. Welche Konvertierungen erlaubt sind, ist der Ja-
va-Sprachspezifikation (Gosling et al. 2021, Abschnitt 5.1) zu entnehmen.
Quellcode Ausgabe
class Prog { false
public static void main(String[] args) { true
Integer iu = Integer.valueOf(4711);
Integer iv = Integer.valueOf(4711);
System.out.println(iu == iv);
System.out.println(iu.equals(iv));
}
}
Weil Objekte zu vergleichen sind, hängt das Ergebnis von den Speicheradressen ab. Sollen die ver-
packten int-Werte den Ausschlag geben, ist die Integer-Methode equals() zu verwenden.
Verwendet man die Integer-Methode valueOf() zur Objektkreation, dann liefert bei Initialisie-
rungswerten von -128 bis 127 der (Speicheradressen-basierte) Identitätsoperator dasselbe Ergebnis
wie die Methode equals(). Den Grund kennen Sie aus dem Abschnitt 5.3.1: Liegt der valueOf() -
Parameter im Bereich von -128 bis 127, und ist bereits ein Integer-Objekt mit diesem Wert vorhan-
den, dann wird dessen Adresse zurückgeliefert. Bei größeren Initialisierungswerten erstellt
valueOf() ein neues Objekt. Die seit Java 9 abgewerteten Wrapper-Konstruktoren erstellen auf je-
den Fall ein neues Objekt, z. B.:
Quellcode Ausgabe
class Prog { false
public static void main(String[] args) { true
Integer ius = new Integer(127); false
Integer ivs = new Integer(127);
System.out.println(ius == ivs);
ius = Integer.valueOf(127);
ivs = Integer.valueOf(127);
System.out.println(ius == ivs);
ius = Integer.valueOf(128);
ivs = Integer.valueOf(128);
System.out.println(ius == ivs);
}
}
Um den Tücken aus dem Weg zu gehen, sollte bei Wrapper-Objekten der Identitätsoperator ver-
mieden werden.
Als Alternativen zu equals() kommen die beiden folgenden Integer-Vergleichsmethoden in Frage:
• public static int compare(int i, int j)
Die Rückgabe ist ...
o < 0, wenn i < j
o 0, wenn i == j (Identitätsoperator für int-Argumente!)
o > 0, wenn i > j
Werden Integer-Parameter angeboten, dann findet ein Autounboxing statt (vgl. Abschnitt
5.3.2).
• public int compareTo(Integer i)
Die Rückgabe ist ...
o < 0, wenn das angesprochene Objekt numerisch kleiner als das Parameterobjekt ist.
o 0, wenn das angesprochene Objekt numerisch identisch ist mit dem Parameterobjekt.
o > 0, wenn das angesprochene Objekt numerisch größer als das Parameterobjekt ist.
Abschnitt 5.3 Verpackungsklassen für primitive Datentypen 311
start = System.currentTimeMillis();
for (int i = 0; i < 10_000_000; i++)
summe += i;
System.out.println("Zeitaufwand:\t" +
(System.currentTimeMillis()-start));
}
}
Ersetzt man den Datentyp der Laufvariablen durch int, reduziert sich die Laufzeit erheblich.1
5.3.4 Konvertierungsmethoden
Die Wrapper-Klassen stellen statische Methoden zum Konvertieren von Zeichenfolgen in einen
Wert des zugehörigen (primitiven) Typs zur Verfügung, z. B. die Klasse Double:
• Die Double-Klassenmethode
public static double parseDouble(String str)
throws NumberFormatException
liefert einen double-Wert zurück, falls die Konvertierung der Zeichenfolge gelingt.
• Die bereits im Abschnitt 5.3.1 vorgestellte Klassenmethode valueOf()
public static Double valueOf(String str)
throws NumberFormatException
liefert einen verpackten double-Wert (also ein Double-Objekt) zurück, wenn die Konvertie-
rung der Zeichenfolge gelingt. Wegen der aufwändigen Objektkreationen ist es nicht emp-
fehlenswert, zahlreiche derartige Konvertierung vorzunehmen.
Wenn eine Konvertierung mit parseDouble() oder valueOf() scheitert, dann informieren die Me-
thoden ihren Aufrufer durch das Werfen einer Ausnahme vom Typ NumberFormatException.
Über die potentiell zu erwartende Ausnahme wird in der Methodendefinition durch eine throws-
Klausel am Ende des Methodenkopfs informiert. Bisher blieb im Manuskript bei der Beschreibung
einer Methode, die potentiell Ausnahmeobjekte wirft, diese Kommunikationstechnik aus didakti-
schen Gründen unerwähnt. Die im Kapitel 11 noch ausführlich zu behandelnde Ausnahmetechnik
ist gleich in einem Beispiel zu sehen.
1
Die Ergebnisse stammen von einem PC mit der Intel-CPU Core i3 550 (3,2 GHz) unter Windows 10 (64 Bit).
312 Kapitel 5 Wichtige spezielle Klassen
Das folgende Programm berechnet die Summe der numerisch interpretierbaren Kommandozei-
lenargumente:
class Summe {
public static void main(String[] args) {
double summe = 0.0;
int fehler = 0;
System.out.println("Ihre Eingaben:");
for (String s : args) {
System.out.println(" " + s);
try {
summe += Double.parseDouble(s);
} catch(Exception e) {
fehler++;
}
}
System.out.println("\nSumme: " + summe + "\nFehler: " + fehler);
}
}
Im Rahmen einer try-catch - Konstruktion, die wir im Abschnitt 11.3 gründlich behandeln werden,
versucht das Programm für jedes Kommandozeilenargument eine numerische Interpretation mit der
Double-Konvertierungsmethode parseDouble().
Ein Aufruf mit
java Summe 3.5 4 5 6 sieben 8 9
liefert die Ausgabe:
Ihre Eingaben:
3.5
4
5
6
sieben
8
9
Summe: 35.5
Fehler: 1
Um aus einem Wert eines primitiven Typs ein String-Objekt zu erstellen, kann man statische Me-
thode toString() der zugehörigen Verpackungsklasse verwenden, z. B.:
String s = Double.toString(summe);
Denselben Zweck erzielt man auch mit der statischen Methode valueOf() der Klasse String, die in
Überladungen für diverse Argumenttypen vorhanden ist, z. B.:
String s = String.valueOf(summe);
Konstante Inhalt
MAX_VALUE Größter (endlicher) Wert des Datentyps double
MIN_VALUE Kleinster Betrag des Datentyps double
NaN Not-a-Number - Ersatzwert für den Datentyp double
POSITIVE_INFINITY Positiv-Unendlich - Ersatzwert für den Datentyp double
NEGATIVE_INFINITY Negativ-Unendlich - Ersatzwert für den Datentyp double
Beispiel:
Quellcode Ausgabe
class Prog { Max. double-Zahl:
public static void main(String[] args) { 1.7976931348623157E308
System.out.println("Max. double-Zahl:\n"+
Double.MAX_VALUE);
}
}
5.4 Aufzählungstypen
Angenommen, Sie wollen in eine Adressendatenbank auch den Charakter der erfassten Personen
aufnehmen und sich dabei an den vier Temperamentstypen des griechischen Philosophen Hippokra-
tes (ca. 460 - 370 v. Chr.) orientieren: melancholisch, cholerisch, phlegmatisch, sanguin. Um dieses
Merkmal mit seinen vier möglichen Ausprägungen in einer Instanzvariablen zu speichern, haben
Sie verschiedene Möglichkeiten, z. B.
314 Kapitel 5 Wichtige spezielle Klassen
Modifikator
,
Weil Syntaxdiagramme zwar präzise, aber nicht unbedingt auf den ersten Blick verständlich sind,
betrachten wir ergänzend ein Beispiel:
public enum Temperament {MELANCHOLISCH, CHOLERISCH, PHLEGMATISCH, SANGUIN}
Es hat sich eingebürgert, die Namen der Enumerationskonstanten komplett groß zu schreiben.
Für eine Top-Level - Enumeration sollte eine eigene Quellcodedatei verwendet werden. Bei einer
Top-Level - Enumeration mit der Zugriffsstufe public ist dies obligatorisch. Wird ein Aufzählungs-
typ ausschließlich in einer bestimmten Klasse verwendet, kommt die Definition als (implizit stati-
scher) Mitgliedstyp in Frage, und seit Java 16 ist auch die Definition innerhalb einer Methode er-
laubt (siehe Abschnitt 5.4.3).
Objekte der folgenden Klasse Person (der Einfachheit halber ohne Datenkapselung) enthalten eine
Instanzvariable vom eben definierten Aufzählungstyp Temperament:
Abschnitt 5.4 Aufzählungstypen 315
Weil Enumerationskonstanten mit dem Typnamen qualifiziert werden müssen, ist einige Tipparbeit
erforderlich, die aber durch einen gut lesbaren Quellcode belohnt wird:1
class PersonTest {
public static void main(String[] args) {
Person otto = new Person("Otto", "Hummer", 35, Temperament.SANGUIN);
if (otto.temp == Temperament.SANGUIN)
System.out.println("Lustiger Typ");
}
Eine Variable mit Aufzählungstyp ist als steuernder Ausdruck einer switch-Anweisung erlaubt (vgl.
Abschnitt 3.7.2.3), wobei die Enumerationskonstanten in den case-Marken aber ausnahmsweise
ohne den Typnamen zu schreiben sind, z. B.:
switch (otto.temp) {
case MELANCHOLISCH: System.out.println("Nicht gut drauf"); break;
case CHOLERISCH: System.out.println("Mit Vorsicht zu genießen"); break;
case PHLEGMATISCH: System.out.println("Lahme Ente"); break;
case SANGUIN: System.out.println("Lustiger Typ");
}
Aus der bisherigen Darstellung konnte man den Eindruck gewinnen, als wäre eine Enumeration ein
Ganzzahltyp mit einer kleinen Menge von benannten Werten. Tatsächlich ist eine Enumeration aber
eine Klasse mit der Basisklasse Enum aus dem Paket java.lang und folgenden Besonderheiten:
• Die Enumerationskonstanten zeigen als statische und finalisierte Referenzvariablen auf Ob-
jekte der Enumerationsklasse, die beim Laden der Klasse automatisch erstellt werden. Nun
ist klar, warum den Enumerationskonstanten (von Ausnahmen abgesehen) der Typname vo-
rangestellt werden muss.
• Es ist nicht möglich, weitere Objekte der Enumerationsklasse (per new-Operator oder auf
andere Weise) zu erzeugen.
• Die Objekte eines einfachen Enumerationstyps sind unveränderlich, sodass die Bezeichnung
Enumerationskonstante konsistent mit dem im Abschnitt 4.5.1 beschriebenen Begriff eines
konstanten Felds verwendet wird. Die Objekte der im Abschnitt 5.4.2 beschriebenen erwei-
terten Enumerationstypen sind aber nicht unbedingt unveränderlich.
• Man kann eine Enumeration nicht beerben.
Im Abschnitt 7.1 werden wir solche Klassen als finalisiert bezeichnen.
1
Im Abschnitt 6.1.2.2 werden wir eine Möglichkeit kennenlernen, Wiederholungen des Aufzählungstypnamens im
Quellcode zu vermeiden: Mit der Deklaration import static kann man alle statischen Variablen und Methoden eines
Typs importieren, sodass sie anschließend wie klasseneigene angesprochen werden können, sofern entsprechende
Zugriffsrechte bestehen. Wie gleich zu erfahren ist, handelt es sich bei den Enumerationskonstanten um statische
und finalisierte Referenzvariablen.
316 Kapitel 5 Wichtige spezielle Klassen
Im obigen Beispiel ist die Person-Eigenschaft temp eine Referenzvariable vom Typ Tempera-
ment. Sie zeigt …
• entweder auf eines der vier Temperament-Objekte
• oder auf null.
Die Enumerationsobjekte kennen ihre Position in der definierenden Liste und liefern diese als
Rückgabewert der Instanzmethode ordinal(), z. B.:
Quellcode Ausgabe
class PersonTest { 3
public static void main(String[] args) {
Person otto = new Person("Otto", "Hummer",
35, Temperament.SANGUIN);
System.out.println(otto.temp.ordinal());
}
}
Bei jeder Enumerationsklasse kann man mit der statischen Methode values() einen Array mit ihren
Objekten anfordern, z. B.:
Quellcode Ausgabe
class PersonTest { MELANCHOLISCH
public static void main(String[] args) { CHOLERISCH
for (Temperament t : Temperament.values()) PHLEGMATISCH
System.out.println(t.name()); SANGUIN
}
}
1
Informationen zu den Persönlichkeitsdimensionen emotionale Stabilität und Extraversion sowie zum Zusammen-
hang mit den Typen des Hippokrates finden Sie z. B. auf der Seite 22 von:
Mischel, W. (1976). Introduction to Personality.
Abschnitt 5.4 Aufzählungstypen 317
In der Klasse PersonTest muss bei der Verwendung der Enumeration Person.Temperament
etwas mehr Schreibaufwand betrieben werden:
class PersonTest {
public static void main(String[] args) {
Person otto = new Person("Otto", "Hummer", 35, Person.Temperament.SANGUIN);
if (otto.temp == Person.Temperament.SANGUIN)
System.out.println("Lustiger Typ");
Weil die in Java 16 eingeführten Record-Klassen (siehe Abschnitt 5.5), die ebenso implizit statisch
sind wie die Enumerations-Klassen, auch lokal (in einer Methode) definiert werden dürfen, hat man
der Konsistenz halber das Verbot von lokalen Enumerationen aufgegeben. In der folgenden Varian-
te des Beispiels aus dem Abschnitt 5.4.2 sind die Klasse Person und die Enumeration Tempera-
ment lokal realisiert, was bei lediglich lokaler Verwendung sinnvoll sein kann:
class PersonTest {
static int stati = 13;
public static void main(String[] args) {
int loci = 13;
enum TemperamentEx {
MELANCHOLISCH(false, false),
CHOLERISCH(false, true),
PHLEGMATISCH(true, false),
SANGUIN(true, true);
class Person {
public String vorname, name;
public int alter;
public TemperamentEx temp;
public Person(String vor, String nach, int alt, TemperamentEx tp) {
vorname = vor;
name = nach;
alter = alt;
temp = tp;
}
public Person() {}
}
Das Programm demonstriert, dass die Methoden einer lokalen (implizit statischen) Enumeration im
Unterschied zu den Methoden einer normalen lokalen Klasse nicht auf lokale Variablen der umge-
benden Methode zugreifen dürfen.
Der überflüssige Modifikator static ist bei Mitglieds-Enumerationen erlaubt, bei lokalen Enumera-
tionen hingegen verboten.
5.5 Records
Zwar besitzen die Objekte vieler Klassen ...
• diverse Handlungskompetenzen (Instanzmethoden)
• und einen variablen Zustand (änderbare Instanzvariablen),
doch werden nicht selten Klassen benötigt, deren Objekte ...
• vor allem zur Aufbewahrung von Daten dienen und daher über die von Object geerbten Me-
thoden und lesende Zugriffsmethoden hinaus keine Handlungskompetenzen benötigen,
• wobei die Finalisierung aller Instanzvariablen (siehe Abschnitt 4.2.5) erforderlich ist, um für
unveränderliche Objekte zu sorgen (siehe Kapitel 12 über das funktionale Programmieren).
Mit Hilfe der seit Java 14 im Vorschaumodus und seit Java 16 endgültig im Sprachumfang vorhan-
denen Records lassen sich unveränderliche Datenklassen mit einem sehr geringen syntaktischen
Aufwand erstellen.
// Kanonischer Konstruktor
public Point(int x, int y) {
this.x = x;
this.y = y;
}
// Getter
int x() { return x; }
int y() { return y; }
// toString() - Überschreibung
public String toString() {
return "Point[x=" + this.x + ", y=" + this.y + "]";
}
}
demonstriert:
• die Erstellung von Record-Objekten per new-Operator unter Verwendung des kanonischen
Konstruktors
Während normale Java-Klassen einen parameterfreien Standardkonstruktor erhalten, wenn
sie keinen expliziten Konstruktor definieren, wird bei einer Record-Klasse in dieser Situati-
on der sogenannte kanonische Konstruktor bereitgestellt, der einen Parameter für jede Kom-
ponente besitzt. Er hat (analog zum Standardkonstruktor einer normalen Klasse) dieselbe
Schutzstufe wie die Record-Klasse.
• das Verhalten der automatisch erstellten Methoden toString(), equals() und hashCode()
Für die Instanzvariablen (Komponenten) einer Record-Klasse sind beliebige Datentypen erlaubt.
Besitzt eine Komponente einen veränderlichen Referenzdatentyp, dann kann die im Record-Objekt
enthaltene (unveränderliche) Adresse natürlich dazu verwendet werden, um das referenzierte Objekt
zu ändern.
In vielen Situationen ist die Unveränderlichkeit der Record-Objekte erwünscht, und die automati-
sche Realisation der Record-Member verhindert, dass zahlreiche Quellcodezeilen durch mechani-
sches Befolgen von strikten Regeln erstellt und später immer wieder kontrolliert werden müssen.
322 Kapitel 5 Wichtige spezielle Klassen
5.5.2.1 Vererbung
Weil die Vererbung als wichtiger Eckpfeiler der objektorientierten Programmierung schon mehr-
fach erwähnt wurde, sind die folgenden Hinweise zur Einordnung der Record-Klassen in das Java-
Typsystem wohl zu verdauen, obwohl die Vererbung erst im Kapitel 7 gründlich erläutert wird:
• Alle Record-Klassen stammen implizit von der abstrakten Basisklasse Record im Paket
java.lang ab. Es ist verboten, explizit eine Basisklasse anzugeben.
• Jede Record-Klasse ist implizit final, sodass keine abgeleiteten Klassen definiert werden
können.
1
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Record.html
324 Kapitel 5 Wichtige spezielle Klassen
• Statische Felder
Während in einer Record-Klasse als Instanzvariablen nur die Komponenten im Deskriptor
erlaubt sind, dürfen zusätzliche statische Felder definiert werden.
• Zusätzliche (statische) Methoden mit beliebiger Schutzstufe
• Statische Initialisierer
Auch lokale, innerhalb einer Methode definierte Record-Klassen sind erlaubt. Sie sind ebenso im-
plizit statisch wie die Record-Mitgliedklassen, sodass die Methoden eines lokalen Records im Un-
terschied zu den Methoden einer normalen lokalen Klasse nicht auf lokale Variablen der Umgebung
zugreifen können, z. B.:
class Prog {
public static void main(String[] args) {
int lokVar = 2;
record LokalerRecord(int x, int y) {
LokalerRecord {
// System.out.println(lokVar); //verboten
}
}
class LokaleKlasse {
LokaleKlasse(int x, int y) {
System.out.println(lokVar);
}
}
var wr = new LokalerRecord(3, 5);
System.out.println(wr.x);
}
}
So wird verhindert, dass sich der Zustand eines Record-Objekts klammheimlich über die Kompo-
nenten hinaus erweitert.1
1
https://openjdk.java.net/jeps/395
Abschnitt 5.6 Übungsaufgaben zum Kapitel 5 325
Im Beispielprogramm ist außerdem zu sehen, dass die umgebende Methode auf die privaten In-
stanzvariablen eines Record-Objekts zugreifen kann, was auch bei Objekten einer normalen lokalen
Klasse möglich ist:
var wr = new LokalerRecord(3, 5);
System.out.println(wr.x);
2) Erstellen Sie ein Java-Programm, das 6 Lottozahlen (von 1 bis 49) zieht und sortiert ausgibt.
Zum Sortieren können Sie z. B. das (sehr einfache) Auswahlverfahren (engl.: Selection Sort) ver-
wenden:
• Für den Ausgangsvektor mit den Elementen 0, …, n-1 wird das Minimum gesucht und an
den linken Rand befördert. Dann wird der Vektor mit den Elementen 1, …, n-1 analog be-
handelt, usw.
• Bei jeder Teilaufgabe muss man das kleinste Element eines Vektors an seinen linken Rand
befördern, was auf folgende Weise geschehen kann:
o Man geht davon aus, das Element am linken Rand sei das kleinste (genauer: ein Mi-
nimum).
o Es wird sukzessive mit seinen rechten Nachbarn verglichen. Ist das Element an der
Position i kleiner, so tauscht es mit dem „Linksaußen“ seinen Platz.
o Nun steht am linken Rand ein Element, das die anderen Elemente mit Positionen
kleiner oder gleich i nicht übertrifft. Es wird nun sukzessive mit den Elementen an
den Positionen ab i+1 verglichen.
o Nachdem auch das Element an der letzten Position mit dem Element am linken Rand
verglichen worden ist, steht mit Sicherheit am linken Rand ein Element, zu dem sich
kein kleineres findet.
Diese Aufgabe soll Erfahrung im Umgang mit Arrays und einen ersten Eindruck von Sortieralgo-
rithmen vermitteln. Im Programmieralltag empfiehlt sich für derartige Probleme die statische Me-
thode sort() der Klasse Arrays im Paket java.util.
3) Erstellen Sie ein Programm zur Primzahlensuche mit dem Sieb des Eratosthenes.1 Dieser Algo-
rithmus reduziert sukzessive eine Menge von Primzahlkandidaten, die initial alle natürlichen Zahlen
bis zu einer Obergrenze K enthält, also {2, 3, ..., K}:
1
Der griechische Gelehrte Eratosthenes lebte laut Wikipedia ca. von 275 bis 194 v. Chr.
326 Kapitel 5 Wichtige spezielle Klassen
• Im ersten Schritt werden alle echten Vielfachen der Basiszahl 2 (also 4, 6, ...) aus der Kan-
didatenmenge gestrichen, während die Zahl 2 in der Liste verbleibt.
• Dann geschieht iterativ folgendes:
o Als neue Basis b wird die kleinste Zahl gewählt, die die beiden folgenden Bedingun-
gen erfüllt:
b ist größer als die vorherige Basiszahl.
b ist im bisherigen Verlauf nicht gestrichen worden.
o Die echten Vielfachen der neuen Basis (also 2b, 3b, ...) werden aus der Kandida-
tenmenge gestrichen, während die Zahl b in der Liste verbleibt.
• Das Streichverfahren kann enden, wenn für eine neue Basis b gilt:
b> K
In der Kandidatenrestmenge befinden sich dann nur noch Primzahlen. Um dies einzusehen, nehmen
wir an, es hätte eine Zahl n K mit echtem Teiler das beschriebene Streichverfahren überstanden.
Mit zwei positiven Zahlen u, v würde dann gelten:
n = u v und u K oder v K (wegen n K )
Wir nehmen ohne Beschränkung der Allgemeinheit u K an und unterscheiden zwei Fälle:
• u war zuvor als Basis dran.
Dann wurde n bereits als Vielfaches von u gestrichen.
~ ~
• u wurde zuvor als Vielfaches einer früheren Basis b (< b) gestrichen ( u = kb ).
~
Dann wurde auch n bereits als Vielfaches von b gestrichen.
Damit erweist sich die Annahme als falsch, und es ist gezeigt, dass die Kandidatenrestmenge nur
noch Primzahlen enthält.
Sollen z. B. alle Primzahlen kleiner oder gleich 18 bestimmt werden, so startet man mit der folgen-
den Kandidatenmenge:
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
Im ersten Schritt werden die echten Vielfachen der Basis 2 gestrichen:
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
Als neue Basis wird die Zahl 3 gewählt (> 2, nicht gestrichen). Ihre echten Vielfachen werden ge-
strichen:
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
Als neue Basis wird die Zahl 5 gewählt (> 3, nicht gestrichen). Allerdings ist 5 größer als 18 (
4,24), und der Algorithmus daher bereits beendet. Als Primzahlen kleiner oder gleich 18 erhalten
wir also:
2, 3, 5, 7, 11, 13 und 17
4) Definieren Sie eine Klasse für eine zweidimensionale Matrix mit Elementen vom Typ double zur
Aufnahme von Beobachtungsdaten aus einer empirischen Studie. Implementieren Sie …
Abschnitt 5.6 Übungsaufgaben zum Kapitel 5 327
( xi − x )2 = xi2 − nx 2
i =1 i =1
Sie ermöglicht es, die Varianz einer Variablen bei einer einzigen Passage durch die
Daten zu berechnen, während die Originalformel eine vorgeschaltete Passage zur
Berechnung des Mittelwerts benötigt.
2) Durch welche Anweisungen des folgenden Programms wird ein String-Objekt neu in den inter-
nen String-Pool aufgenommen?
class Prog {
public static void main(String[] args) {
String s1 = "abcde"; // (1)
String s2 = new String("abcde"); // (2)
String s3 = new String("cdefg"); // (3)
String s4, s5;
s4 = s2.intern(); // (4)
s5 = s3.intern(); // (5)
System.out.print("(s1 == s2) = " + (s1==s2)+
"\n(s1 == s4) = " + (s1==s4)+
"\n(s1 == s5) = " + (s1==s5));
}
}
328 Kapitel 5 Wichtige spezielle Klassen
3) Erstellen Sie ein Programm zum Berechnen einer persönlichen Glückszahl (zwischen 1 und 100),
indem Sie:
• den Vor- und den Nachnamen als Programmargumente einlesen,
• den Anfangsbuchstaben des Vornamens sowie den letzten Buchstaben des Nachnamens er-
mitteln (beide in Großschreibung),
• die Nummern der beiden Buchstaben im Unicode-Zeichensatz bestimmen,
• die beiden Buchstabennummern addieren und die Summe als Initialisierungswert für den
Pseudozufallszahlengenerator verwenden.
Beenden Sie Ihr Programm mit einer Fehlermeldung, wenn weniger als zwei Programmargumente
übergeben werden.
Tipp: Um ein Programm spontan zu beenden und dabei einen Return-Code an das Betriebssystem
zu übergeben, kann man die statische Methode exit() der Klasse System verwenden. Ist kein
Return-Code erforderlich, dann genügt es auch, die Methode main() mit return zu verlassen.
4) Die Klassen String und StringBuilder besitzen beide eine Methode namens equals(), doch be-
stehen gravierende Verhaltensunterschiede:
Quellcode Ausgabe
class Prog { sb1 = sb2 = abc
public static void main(String[] args) { StringBuilder-Vergl.: false
StringBuilder sb1 = new StringBuilder("abc");
StringBuilder sb2 = new StringBuilder("abc"); s1 = s2 = abc
System.out.println("sb1 = sb2 = " + sb1); String-Vergl.: true
System.out.println("StringBuilder-Vergl.: " +
sb1.equals(sb2));
String s1 = sb1.toString();
String s2 = sb1.toString();
System.out.println("\ns1 = s2 = " + s1);
System.out.println("String-Vergl.: " +
s1.equals(s2));
}
}
Ermitteln Sie mit Hilfe der API-Dokumentation die Ursache für das unterschiedliche Verhalten.
5) Erstellen Sie eine Klasse StringUtil mit einer statischen Methode wrapln(), die einen
String auf die Konsole schreibt und dabei einen korrekten Zeilenumbruch vornimmt. Anwender
Ihrer Methode sollen die gewünschte Zeilenbreite (Anzahl von Zeichen) vorgeben und auch die
Trennzeichen festlegen dürfen, aber nicht müssen (Methoden überladen!). Am Anfang einer neuen
Zeile sollen außerdem keine Leerzeichen stehen.
Im folgenden Programm wird die Verwendung der Methode demonstriert:
class StringUtilTest {
public static void main(String[] args) {
String s = "Dieser Satz passt nicht in eine Schmal-Zeile, "+
"die nur wenige Spalten umfasst.";
StringUtil.wrapln(s);
StringUtil.wrapln(s, 40);
StringUtil.wrapln(s, " ", 40);
}
}
Der zweite wrapln() - Methodenaufruf sollte die folgende Ausgabe mit einer auf 40 Zeichen be-
grenzten Breite erzeugen, weil der Bindestrich zu den voreingestellten Trennzeichen gehört:
Abschnitt 5.6 Übungsaufgaben zum Kapitel 5 329
In der verwendeten Überladung des StringTokenizer - Konstruktors legt der zweite Parameter
(Typ String) die Trennzeichen fest. Hat der dritte Parameter (Typ boolean) den Wert true, dann
sind die Trennzeichen im Ergebnis als eigene Tokens (mit Länge 1) enthalten. Anderenfalls werden
sie nur zum Separieren verwendet und danach verworfen.
2) Ermitteln Sie die maximale natürliche Zahl k, für die unter Verwendung des Funktionswerteda-
tentyps double die Fakultät k! bestimmt werden kann.
3) Entwerfen Sie eine Verpackungsklasse, die die Aufnahme von int-Werten in Container wie Ar-
rayList ermöglicht, ohne (wie die Klasse Integer) die Werte der Objekte nach der Erzeugung zu
fixieren. Ein unvermeidlicher Nachteil der selbstgestrickten Verpackungsklasse im Vergleich zur
Klasse Integer ist das fehlende Auto(un)boxing.
Anmerkungen:
• Zur Vermeidung von Missverständnissen sei betont, dass im Abschnitt 5.3 vorgestellten
Verpackungsklassen nicht etwa versehentlich als unveränderlich konzipiert wurden. Benutzt
ein Programm mehrere Ausführungsfäden (Threads), dann sind unveränderliche Objekte
von Vorteil.
• Das Java-API enthält im Paket java.util.concurrent.atomic Klassen für veränderliche
Wrapper-Objekte. Ihre mit dem Wort Atomic startenden Namen (z. B. AtomicInteger) zei-
gen an, dass diese Klassen die Wertveränderlichkeit kombinieren mit der Synchronisation
von Zugriffen durch mehrere Ausführungsfäden (Threads).
330 Kapitel 5 Wichtige spezielle Klassen
2) Warum werden in der API-Dokumentation zur Klasse Record die Record-Objekte als oberfläch-
lich unveränderlich bezeichnet?
6 Pakete und Module
Jede größere Java-Anwendung oder -Bibliothek enthält zahlreiche Klassen, und in der Standardbib-
liothek von Java-SE sind ca. 5000 Klassen vorhanden.1 Um Zugriffsrechte zu regeln, Namensräume
abzugrenzen und Übersicht herzustellen, werden die Klassen der Programme und Bibliotheken in
Pakete (engl.: packages) eingeordnet. Im Java-SE - API gibt es über 200 Pakete mit zusammenge-
hörigen Klassen. Bei einfachen Anwendungen kann man auf eine explizite Paketierung verzichten
und damit das unbenannte Standardpaket (engl.: default package) verwenden. Weil jedes Programm
aber auch Bibliotheken benötigt, die stets in Paketen organisiert sind, kann man sagen, dass jedes
Java-Programm aus mehreren Paketen besteht. In den Worten der Java-Sprachspezifikation (Gos-
ling et al 2021, S. 207):
Programs are organized as sets of packages.
Bei größeren und insbesondere bei öffentlich verbreiteten Anwendungen sollte man aus gleich zu
erläuternden Gründen die Klassen des Programms in ein benanntes Paket stecken oder (je nach
Größe des Programms) auf mehrere Pakete zu verteilen.
Neben den Klassen spielen in der objektorientierten Programmierung die sogenannten Interfaces
(deutsch: Schnittstellen) eine wichtige Rolle, und in den meisten Paketen sind sowohl Klassen als
auch Interfaces zu finden. Ein Interface taugt wie eine Klasse als Datentyp und enthält in der Regel
ebenfalls Methoden, doch fehlt bei den Interface-Methoden meist die Implementation (der Anwei-
sungsblock). Eine typische Interface-Definition listet Methoden auf (definiert durch Rückgabetyp,
Name und Parameterliste), die eine Klasse implementieren muss, wenn sie von sich behaupten
möchte, dem Interface-Datentyp zu genügen. Als Beispiel betrachten wir die Schnittstelle Compa-
rable aus dem API-Paket java.lang, die sich auf eine Methode namens compareTo() beschränkt:2
public interface Comparable {
public int compareTo(Object obj);
}
Wie bei einer Klasse erstellt der Java-Compiler auch bei einer Schnittstelle aus dem Quellcode eine
Bytecode-Datei mit der Namenserweiterung class. Wir werden uns im Kapitel 9 mit den Interfaces
und ihrer Rolle bei der objektorientierten Programmierung ausführlich beschäftigen. Im Manuskript
ist ab jetzt von Typen die Rede, wenn sowohl Klassen als auch Interfaces einbezogen werden sollen.
Während wir bislang (z. B. im Abschnitt 3.1.1) dem jeweiligen Lernfortschritt angemessen festge-
stellt haben, ein Java-Programm bestehe aus Klassen, kommen wir nun zur genaueren Formulie-
rung, dass ein Java-Programm aus mehreren Paketen besteht, die Typen (Klassen und Schnittstel-
len) enthalten.
Pakete erfüllen wichtige Aufgaben:
• Zugriffskontrolle steuern
Per Voreinstellung ist ein Typ nur innerhalb des eigenen Pakets sichtbar. Damit er auch von
Typen aus fremden Paketen genutzt werden kann, muss im Kopf der Typdefinition der Zu-
griffsmodifikator public angegeben werden.
1
Hier befindet sich z. B. eine Liste mit allen Klassen in Java 17:
https://docs.oracle.com/en/java/javase/17/docs/api/allclasses-index.html
2
Damit nicht zu viele Neuerungen gleichzeitig auftauchen, wird hier die veraltete, nicht-generische Variante der
Schnittstelle Comparable präsentiert.
332 Kapitel 6 Pakete und Module
• Namenskonflikte vermeiden
Jedes Paket bildet einen eigenen Namensraum für Typen, und der vollqualifizierte Name ei-
nes Typs beginnt mit dem Namen des Pakets, in dem er sich befindet. Identische einfache
Typnamen stellen also kein Problem dar, solange sich die Typen in verschiedenen Paketen
befinden.
• Große Projekte strukturieren
Wenn in einem Programm oder in einer Bibliothek viele Typen vorhanden sind, sollte man
diese nach funktionaler Verwandtschaft auf mehrere Pakete verteilen. In jeder Quellcodeda-
tei, die zum Paket gehörige Typen (Klassen oder Interfaces) definiert, ist eine package-
Deklaration mit der Paketbezeichnung (siehe Abschnitt 6.1) an den Anfang zu stellen, z. B.:
package java.util.concurrent;
Es ist ein hierarchischer Aufbau über Unterpakete möglich. Im Namen eines Unterpakets
folgen die Namen aus dem Paketpfad durch Punkte getrennt aufeinander, z. B.:
java.util.concurrent
Bei der Ablage in einem Dateisystem wird die Paketstruktur auf einen Dateiverzeichnis-
baum abgebildet. Alle class-Dateien mit den Klassen und Schnittstellen eines Pakets werden
in einem gemeinsamen Ordner abgelegt, dessen Name mit dem Paketnamen übereinstimmt.
• Typen gelangen nur als Bestandteile von benannten Paketen in die Öffentlichkeit
Für Typen im unbenannten Standardpaket ist der public-Modifikator nutzlos, weil diese Ty-
pen definitiv nur im Standardpaket sichtbar sind. Nur für Typen in einem benannten Paket
hat der public-Modifikator die Sichtbarkeit in anderen Paketen (aus berechtigten Modulen)
zur Folge. Als wir im Abschnitt 4.9.3 unsere Klasse Bruch in ein JavaFX-Programm aufge-
nommen haben, sind wir auf dieses Problem gestoßen.
Bei der Paketierung handelt es sich nicht um eine Option für große Projekte, sondern um ein uni-
verselles Prinzip: Jeder Typ (Klasse oder Interface) gehört zu einem Paket. Wird ein Typ keinem
Paket explizit zugeordnet, gehört er zum unbenannten Standardpaket (siehe Abschnitt 6.1.1.2).
Im Quellcode eines Typs müssen fremde Typen prinzipiell über ein durch Punkt getrenntes Paar aus
Paketnamen und Typnamen angesprochen werden, wie Sie es schon in zahlreichen Beispielpro-
grammen beobachten konnten. Bei manchen Typen ist aber kein Paketname erforderlich:
• bei Typen aus demselben Paket
Bei unseren bisherigen Beispielprogrammen befanden sich meist alle selbst erstellten Klas-
sen im Standardpaket, sodass kein Paketname erforderlich war. Im speziellen Fall des Stan-
dardpakets existiert auch gar kein Name.
• bei Typen aus importierten Paketen
Importiert man ein Paket per import-Deklaration in eine Quellcodedatei (siehe Abschnitt
6.1.2.2), denn können seine Typen ohne Paketnamen angesprochen werden. Das Paket ja-
va.lang mit besonders wichtigen Klassen (z. B. Object, System, String) wird automatisch
in jede Quellcodedatei importiert.
Ale zweifellos wichtigste Neuerung in Java 9 kann die lange unter dem Projektnamen Jigsaw (dt.:
Puzzle, Stichsäge) angestrebte und schließlich unter der offiziellen Bezeichnung JPMS (Java Plat-
form Module System) realisierte Zusammenfassung von Paketen zu Modulen angesehen werden.
Wichtige Leistungen des Java-Modulsystems sind:
Abschnitt 6.1 Pakete 333
• Module erlauben eine bessere Zugriffsregulation durch eine neue Ebene oberhalb der Pa-
kete, indem zwischen öffentlich zugänglichen und privaten (für andere Module verborge-
nen) Paketen differenziert wird. Ein als public deklarierter Typ ist zunächst nur in den Pake-
ten seines eigenen Moduls sichtbar. Exportiert Modul A ein Paket, dann sind die öffentlichen
Typen dieses Pakets in allen anderen Modulen sichtbar, die eine Abhängigkeit von Modul A
deklariert haben. Die Rolle der Pakete und Module bei der Zugriffsverwaltung wird im Ab-
schnitt 6.3 genauer erläutert.
• Mit dem JPMS wurde der Modulpfad als Ersatz für den herkömmlichen Klassenpfad (z. B.
definiert über die CLASSPATH-Umgebungsvariable) eingeführt, um zur Laufzeit das Laden
eines Typs aus einem falschen Paket zu verhindern.
• Um ein selbständig lauffähiges Java-Programm zu erstellen, das auf einem Kundenrechner
keine JVM voraussetzt, muss man dank JPMS nur die tatsächlich verwendeten Module der
Java-Standardbibliothek integrieren. Das spart Speicherplatz, Übertragungszeit und mindert
das Risiko, von einem später entdeckten Sicherheitsproblem im Java-API betroffen zu sein.
Allerdings muss sich der Programmherausgeber um Updates kümmern, wenn doch Sicher-
heitsprobleme auftreten.
Pakete sind seit der ersten Java-Version ein wesentliches Konzept zur Strukturierung von Pro-
grammen, und sie bleiben es auch in einer Java-Version mit JPMS. Im Abschnitt 6.1 werden Basis-
begriffe der Paketierung behandelt sowie die bis Java 8 üblichen Techniken zur Verwendung und
Verteilung von Paketen beschrieben. Im Abschnitt 6.2 werden die mit Java 9 eingeführten Neue-
rungen (speziell die Module) vorgestellt, die die älteren Begriffe und Techniken ergänzen und teil-
weise ersetzen.
Es ist für Kompatibilität gesorgt, sodass Programme, die für Java 8 entwickelt wurden (oder wer-
den) und den traditionellen Klassenpfad verwenden, auch von einer Java-Laufzeitumgebung ab
Version 9 ausgeführt werden können.
Wie die einleitenden Bemerkungen deutlich gemacht haben, geht es im aktuellen Kapitel nicht um
die Java-Sprachspezifikation, sondern um die Organisation und die Verteilung von Programmen.
6.1 Pakete
In diesem Abschnitt wird die traditionelle Java-Pakttechnik vorgestellt, die aktuell (Dezember
2021) noch dominiert.1
1
https://www.jetbrains.com/lp/devecosystem-2021/java/
https://snyk.io/blog/developers-dont-want-to-leave-java-8-as-64-hold-firm-on-their-preferred-release/
https://www.jrebel.com/blog/2020-java-technology-report
2
Die Kleinschreibung ist nur eine empfohlene Konvention, und man findet z. B. im Java-API auch Paketnamen mit
Großbuchstaben und/oder Ziffern.
334 Kapitel 6 Pakete und Module
package demopack;
public class A {
private static int anzahl;
private int objnr;
public A() {
objnr = ++anzahl;
}
demopack
A.class
B.class
C.class
1
Soll das Paket mit einer Annotation versehen werden (vgl. Abschnitt 9.6), dann hat dies in der Datei package-
info.java zu geschehen, die im Paketordner abzulegen ist. In der Java-Sprachspezifikation wird empfohlen, in dieser
Datei (vor der package-Deklaration) auch die (von Werkzeugen wie javadoc auszuwertenden) Dokumentations-
kommentare zum Paket unterzubringen (Gosling et al. 2021, Abschnitt 7.4.1).
2
Alternative Optionen zur Ablage von Paketen (z. B. in einer Datenbank) spielen keine große Rolle und werden in
diesem Manuskript nicht behandelt (siehe Gosling et al. 2021, Abschnitt 7.2).
Abschnitt 6.1 Pakete 335
• Wo die Quellcodedateien abgelegt werden, ist nicht vorgeschrieben. In der Regel wird man
(z. B. im Hinblick auf die Weitergabe eines Programms) die Quellcode- von den Bytecode-
Dateien separieren. Unsere Entwicklungsumgebung IntelliJ verwendet per Voreinstellung
im Ordner eines Projekts für die Quellcodedateien den Unterordner src und für die Byteco-
de-Dateien den Unterordner out.
• Der übergeordnete Ordner von demopack ist im traditionellen Suchpfad für class-Dateien
enthalten, damit die class-Dateien in demopack vom Compiler und von der JVM gefunden
werden (siehe Abschnitt 6.1.2.1).
6.1.1.2 Standardpaket
Ohne package-Deklarationen am Beginn einer Quellcodedatei gehören die resultierenden Klassen
und Schnittstellen zum unbenannten Standardpaket (engl. default package oder unnamed packa-
ge). Diese Situation war bei unseren bisherigen Anwendungen meist gegeben und aufgrund der ge-
ringen Komplexität dieser Projekte auch angemessen. Eine wesentliche Einschränkung für Typen
im Standardpaket besteht darin, dass sie (auch bei einer Dekoration mit dem Zugriffsmodifikator
public) nur paketintern, d .h. nur für andere Typen im Standardpaket sichtbar sind.
Um vom Compiler und von der JVM gefunden zu werden, müssen die class-Dateien mit den Typen
des Standardpakets über den Suchpfad für Bytecode-Dateien erreichbar sein (siehe Abschnitt
6.1.2.1). Bei passender CLASSPATH-Definition dürfen sich die Dateien also in verschiedenen
Ordnern oder auch in Java-Archiven (in jar-Dateien, siehe Abschnitt 6.1.3) befinden. Wir haben
z. B. im Kursverlauf die zum Standardpaket gehörige Klasse Simput in einem Ordner oder in ei-
nem Java-Archiv abgelegt und für verschiedene Projekte (d .h. die jeweiligen Typen im Standard-
paket) nutzbar gemacht. Dazu wurde der Ordner oder das Java-Archiv mit der Datei Simput.class
per CLASSPATH-Definition oder eine äquivalente Technik unserer Entwicklungsumgebung Intel-
liJ (vgl. Abschnitt 3.4.2) in den Suchpfad für class-Dateien aufgenommen.
Während die Typen des Standardpakets trotz public-Modifikator in benannten Paketen nicht sicht-
bar sind, können umgekehrt die Typen im Standardpaket problemlos auf öffentliche Typen aus be-
nannten Paketen zugreifen.
6.1.1.3 Unterpakete
Mit Ausnahme des Standardpakets kann ein Paket Unterpakete enthalten, was bei vielen Paketen
im Java-API der Fall ist, z. B.:1
1
Die Abbildung ist für Java 8 und für Java 17 gültig.
336 Kapitel 6 Pakete und Module
Paket java
Paket java.util
Paket java.util.concurrent
Paket java.util.concurrent.atomic
Paket java.util.concurrent.locks
Paket java.util.function
. . .
Paket java.util.zip
Klasse java.util.AbstractCollection<E>
. . .
Interface java.util.Collection<E>
. . .
Klasse java.util.WeakHashMap<K,V>
Auf jeder Stufe der Pakethierarchie sind sowohl Typen (Klassen, Interfaces) als auch Unterpakete
erlaubt. So enthält z. B. das Paket java.util u. a.
• die Klassen AbstractCollection<E>, Arrays, Random, ...
• die Interfaces Collection<E>, List<E>, Map<K,V>, ...
• die Unterpakete java.util.concurrent, java.util.function, java.util.zip, ...
Das Paket java enthält ausschließlich Unterpakete und zwar in Java 17 die Unterpakete io, lang,
math, net, nio, sucurity, text, time und util.
Soll eine Klasse einem Unterpaket zugeordnet werden, dann muss in der package-Deklaration am
Anfang der Quellcodedatei der gesamte Paketpfad angegeben werden, wobei die Namensbestand-
teile jeweils durch einen Punkt getrennt werden. Es folgt der Quellcode der Klasse X, die zusammen
mit der analog definierten Klasse Y in das Unterpaket sub1 des demopack-Pakets eingeordnet
wird:1
1
Im aktuellen Kapitel tauchen mehrere Klassen mit sehr ähnlicher Ausstattung auf, sodass unter Verwendung der im
Kapitel 7 behandelten Vererbung einige Quellcodezeilen eingespart werden könnten.
Abschnitt 6.1 Pakete 337
package demopack.sub1;
public class X {
private static int anzahl;
private int objnr;
public X() {
objnr = ++anzahl;
}
Bei der Paketablage in einem Dateisystem müssen die class-Dateien in einem zur Pakethierarchie
analog aufgebauten Dateiverzeichnisbaum abgelegt werden, der in unserem Beispiel folgenderma-
ßen auszusehen hat:
demopack
A.class
B.class
C.class
sub1
X.class
Y.class
Typen eines Unterpakets gehören nicht zum übergeordneten Paket, was beim Importieren von Pake-
ten (siehe Abschnitt 6.1.2.2) zu beachten ist. Außerdem haben gemeinsame Bestandteile im Paket-
namen keine Relevanz für die wechselseitigen Zugriffsrechte (vgl. Abschnitt 6.3). Klassen im Paket
demopack.sub1 haben z. B. für Klassen im Paket demopack dieselben Rechte wie Klassen in
beliebigen anderen Paketen.
Im Project-Fenster zeigt sich die folgende Ausgangssituation mit dem Paket demopack, das sich
im src-Ordner befindet und eine vom Assistenten angelegte Klasse namens Main enthält:
338 Kapitel 6 Pakete und Module
Nun legen wir im Paket demopack die Klasse A an, z. B. über den Befehl New > Java Class aus
dem Kontextmenü zum Paket:
Wir vervollständigen den Quellcode der Klasse A (siehe Abschnitt 6.1.1.1) und legen analog auch
die Klassen B und C im Paket demopack an:
package demopack; package demopack;
Um das Unterpaket sub1 zu erstellen, wählen wir im Project-Fenster aus dem Kontextmenü zum
Paket demopack den Befehl
New > Package
und geben in folgender Dialogbox den gewünschten Namen für das Unterpaket an:
Abschnitt 6.1 Pakete 339
Nach dem Quittieren per Enter-Taste erzeugt IntelliJ den Unterordner demopack\sub1 im src-
Ordner des Projekts:
Im Unterpaket demopack.sub1 legen wir nun (z. B. über den Kontextmenübefehl New > Java
Class) die Klasse X an, deren Quellcode schon im Abschnitt 6.1.1.3 zu sehen war, und danach die
analog aufgebaute Klasse Y.
Schließlich komplettieren wir noch die vom Assistenten angelegte Startklasse Main und stören uns
nicht am überflüssigen Klassen-Modifikator public:
package demopack;
import demopack.sub1.*;
Die Ablage in einem Ordner mit dem Namen demo.pack würde den Programmstart verhindern:
demo.pack
A.class
B.class
C.class
Main.class
Bei Verwendung einfacher Paketnamen (wie im Beispiel demopack) kann es passieren, dass sich
zwei Entwickler(teams) für denselben Namen entscheiden. Das wird zum Problem, wenn irgend-
wann die beiden gleichnamigen Pakete in einem Programm verwendet werden sollen. Einfache Pa-
ketnamen sind in einem begrenzten Umfang (z. B. firmenintern) akzeptabel. Ist ein Programm bzw.
eine Bibliothek aber für die Öffentlichkeit gedacht, dann sollte durch die Beachtung der folgenden
Regeln für weltweit eindeutige Paketnamen gesorgt werden (siehe Gosling et al 2021, Abschnitt
6.1):
• Unter der Voraussetzung, dass eine eigene Internet-Domäne existiert, werden die Bestand-
teile des Domänennamens in umgekehrter Reihenfolge als führende Bestandteile der Pa-
kethierarchie verwendet. Den restlichen Paketpfad legt eine Firma bzw. Institution nach ei-
genem Ermessen fest, um Namenskonflikte innerhalb der Domäne zu vermeiden. Die Firma
IBM mit der Internet-Domäne ibm.com kann z. B. den folgenden Paketnamen verwenden:
com.ibm.xml.resolver.apps
Abschnitt 6.1 Pakete 341
Unter Beachtung dieser Regel hat die Firma IBM zusammen mit dem Programm SPSS Sta-
tistics (Version 26) in der Java-Archivdatei xml.jar (siehe Abschnitt 6.1.3) ein Paket mit
dem als Beispiel verwendeten Namen ausgeliefert:
Unter Windows werden die Einträge in der CLASSPATH-Definition durch ein Semikolon
getrennt. Durch einen Punkt als Listenelement wird der aktuelle Ordner des laufenden Be-
triebssystem-Prozesses in den Klassenpfad aufgenommen (siehe Beispiel).
Der OpenJDK-Compiler und die JVM finden z. B. den Bytecode der Klasse demopack.A,
indem sie jedes Verzeichnis in der CLASSPATH-Definition nach der Datei mit dem relati-
ven Pfad demopack\A.class durchsuchen. Befindet sich z. B. im aktuellen Ordner
>javac PackDemo.java
Der Compiler findet die benötigten class-Dateien mit den relativen Pfaden:
o demopack\A.class, demopack\B.class, demopack\B.class
o demopack\sub1\X.class, demopack\sub1\Y.class
Zum Starten taugt in derselben Situation der folgende Aufruf des Java-Starters:
>java PackDemo
Es ist zu beachten, dass die eben vorgestellte Startklasse PackDemo zum Standardpaket ge-
hört, weil sich am Anfang ihrer Quellcodedatei keine package-Deklaration befindet.
Der Java-Starter findet die Datei PackDemo.class im aktuellen Verzeichnis, weil die oben
beschriebene CLASSPATH-Definition einen Punkt als Vertreter für das aktuelle Verzeich-
nis enthält. Ohne explizite CLASSPATH-Definition ist ein voreingestellter Klassenpfad
wirksam, der ebenfalls das aktuelle Verzeichnis enthält. Zum Problem kann eine unerwarte-
te, z. B. im Rahmen einer Programminstallation automatisch erstellte CLASSPATH-
Definition werden, wenn dort der Punkt als Vertreter für das aktuelle Verzeichnis fehlt.
• Stammordner des Pakets über die -classpath - Befehlszeilenoption angegeben
Beim Aufruf der OpenJDK-Werkzeuge javac.exe, java.exe und javaw.exe lässt sich die
CLASSPATH-Umgebungsvariable durch die -classpath - Befehlszeilenoption (abzukürzen
mit -cp) dominieren, z. B.:
>javac -cp ".;U:\Eigene Dateien\Java\lib" PackDemo.java
>java -cp ".;U:\Eigene Dateien\Java\lib" PackDemo
Im bisherigen Kursverlauf haben wir per CLASSPATH-Umgebungsvariable auch die Standardpa-
ketklasse Simput.class bekanntgegeben (vgl. Abschnitt 2.2.4). Eine Bibliotheksklasse im unbe-
nannten Standardpaket bereitzuhalten, war der Einfachheit halber zu Kursbeginn eine akzeptable
Technik. Generell ist jedoch für Bibliothekstypen die Einordnung in ein benanntes Paket strikt zu
bevorzugen.
In IntelliJ spielt die CLASSPATH-Umgebungsvariable keine Rolle. Mit den (globalen) Bibliothe-
ken ist eine flexible Lösung vorhanden, die wir im Abschnitt 3.4.2 kennengelernt haben.
import java.util.Random;
class Prog {
public static void main(String[] args) {
Random zzg = new Random();
System.out.println(zzg.nextInt(101));
}
}
• Import eines kompletten Pakets
Um z. B. alle Typen aus dem Paket java.util zu importieren, setzt man den Joker-Stern ein:
import java.util.*;
Es ist zu beachten, dass Unterpakete dabei nicht einbezogen werden. Für sie ist bei Bedarf
eine separate import-Deklaration fällig.
Weil durch die Verwendung des Jokerzeichens keine Rechenzeit- oder Speicherressourcen
verschwendet werden, ist dieses bequeme Vorgehen im Allgemeinen sinnvoll, wenn aus ei-
nem Paket mehrere Typen benötigt werden. Eventuelle Namenskollisionen (durch identi-
sche Typnamen in verschiedenen Paketen) müssen durch die Verwendung des vollqualifi-
zierten Namens aufgehoben werden.
Das API-Paket java.lang mit wichtigen Klassen wie System, String, Math wird automa-
tisch importiert.
• Import von statischen Methoden und Feldern
Seit Java 5 (alias 1.5) besteht die Möglichkeit, statische Methoden und Variablen fremder
Typen so zu importieren, dass bei der Ansprache der Paket- und der Typname weggelassen
werden können. Bisher haben wir die statischen Mitglieder der Klasse Math aus dem Paket
java.lang wie im folgenden Beispielprogramm genutzt:
class Prog {
public static void main(String[] args) {
System.out.println("Sin(Pi/2) = " + Math.sin(Math.PI/2));
}
}
Seit Java 5 (alias 1.5) lassen sich die statischen Mitglieder einer Klasse einzeln
import static java.lang.Math.sin;
oder insgesamt importieren, z. B.:
import static java.lang.Math.*;
class Prog {
public static void main(String[] args) {
System.out.println("Sin(Pi/2) = " + sin(PI/2));
}
}
In der importierenden Quellcodedatei wird im Vergleich zum normalen Paketimport nicht
nur der Paket- sondern auch der Klassenname eingespart. Im Beispiel wird die Math-
Methode sin() so verwendet, als wäre es eine statische Methode der eigenen Klasse.
Die Typen im unbenannten Standardpaket sind in anderen Paketen generell (auch bei Verwendung
des Typmodifikators public) nicht verfügbar. Diese Einschränkung haben wir bisher der Einfach-
heit halber auch bei Klassen in Kauf genommen, die in mehreren Programmen verwendet wurden
(z. B. Bruch und Simput). Während bei Projekten mit Bibliotheks-Charakter eine Paketierung
unbedingt zu empfehlen ist, kann sie bei kleineren Projekten, die keine andernorts benötigten Typen
enthalten, unterbleiben.
Abschnitt 6.1 Pakete 345
Der Programmstart gelingt in dieser Situation mit einem simplen Kommando, z. B.:
Wird für ein Programm mit mehreren Klassen ein eigenes Paket verwendet, sollte in der Regel auch
die Startklasse dort untergebracht werden, damit sie z. B. auch alle Typen im Paket mit der vorein-
gestellten Schutzstufe (package) sehen kann. Bei der Startklasse des PackDemo-Beispiels (Variante
von Abschnitt 6.1.1.4) spielt die Schutzstufe package keine Rolle, weil die Klassen in den Paketen
demopack und demopack.pub1 sowie deren relevante Member alle die Zugriffsstufe public be-
sitzen. Die Startklasse befindet sich aber (aufgrund einer Entscheidung des IntelliJ-Assistenten für
neue Projekte) im Anwendungspaket:
346 Kapitel 6 Pakete und Module
package demopack;
import demopack.sub1.*;
public class Main {
public static void main(String[] args) {
A a1 = new A(), a2 = new A();
a1.prinr(); a2.prinr();
B b = new B(); b.prinr();
C c = new C(); c.prinr();
X x = new X(); x.prinr();
Y y = new Y(); y.prinr();
}
}
Wenn ein Konsolenfenster auf den folgenden Ordner demopack (mit der Datei Main.class)
positioniert ist, und keine passende CLASSPATH-Definition besteht, dann scheitert ein Startver-
such nach dem oben erfolgreich angewendeten Muster
>java Main
mit der Fehlermeldung:
Wir erinnern uns daran, dass im java-Aufruf keine Datei anzugeben ist, sondern eine Klasse. Im
aktuellen Beispiel hat die gewünschte Klasse den Namen demopack.Main, sodass der Java-Starter
zu Recht reklamiert, es sei keine Hauptklasse mit dem Namen Main zu finden.
Den vollständigen Namen der Hauptklasse anzugeben,
>java demopack.Main
hilft aber nicht, solange das Konsolenfenster auf den Ordner demopack positioniert ist:
Nun sucht der Java-Starter nämlich ausgehend vom aktuellen Ordner vergeblich nach dem Paket-
ordner demopack.
Damit diese Suche gelingt, bewegen wir uns mit dem Konsolenfenster in der Ordnerhierarchie um
eine Stufe nach oben und haben schließlich mit dem zuletzt verwendeten Startkommando Erfolg:
Abschnitt 6.1 Pakete 347
Mit den Informationen aus diesem Abschnitt sollte nun klar sein, wie ein Java-Programm in Form
von Bytecode-Dateien ausgeliefert und auf einem Kundenrechner installiert werden kann. Im
PackDemo-Beispiel (mit der Startklasse im Paket demopack) muss man ...
• im Installationsordner einen Unterordner namens demopack anlegen und die Dateien
A.class, B.class, C.class sowie Main.class dorthin kopieren,
demopack
A.class
B.class
C.class
Main.class
sub1
X.class
Y.class
• zu demopack einen Unterordner namens sub1 anlegen und die Dateien X.class sowie
Y.class dorthin kopieren.
Wenn ...
• ein Konsolenfenster auf den Installationsordner positioniert ist,
• und der Klassensuchpfad den aktuellen Ordner enthält (= Voreinstellung ohne explizite
CLASSPATH-Definition),
dann kann die Hauptklasse über ihren vollständigen Namen gestartet werden:
>java demopack.Main
Soll dieser Start aus einem Konsolenfenster mit einem beliebigen aktuellen Verzeichnis möglich
sein, dann muss ...
• entweder der Installationsordner in die CLASSPATH-Definition aufgenommen werden,
• oder im Startkommando per -cp - Argument eine äquivalente Definition des Klassensuch-
pfads vorgenommen werden, z. B.:
>java -cp "U:\Eigene Dateien\Java\PackDemo" demopack.Main
Die im aktuellen Abschnitt beschriebenen, ziemlich komplexen und fehleranfälligen Regeln für den
Programmstart entfallen, wenn ein Programm als ausführbare jar-Datei ausgeliefert wird. Mit die-
ser Distributionstechnik im Speziellen und mit jar-Dateien in Allgemeinen beschäftigt sich der
nächste Abschnitt.
.jar) an. Größere Programme enthalten oft mehrere Bibliotheken und werden als Sammlung von
mehreren jar-Dateien ausgeliefert.
In diesem Abschnitt werden traditionelle jar-Dateien behandelt, die in allen Java-Versionen ver-
wendbar sind. Die die Java 9 eingeführten modularen jar-Dateien werden im Abschnitt 6.2.6 vor-
gestellt.
Bei den bis Java 8 üblichen jar-Dateien mit API - Paketen ist dies allerdings nicht erforderlich.
Der Klassenpfad kann auch ab Java 9 zur Lokalisation von traditionellen Archivdateien verwendet
werden. Hier stehen mit dem Modulpfad und den modularen Archivdateien modernere Alternativen
zur Verfügung, die allerdings bisher (Dezember 2021) noch relativ selten eingesetzt werden (siehe
Abschnitt 6.2).
Weil Java-Archive das ZIP-Dateiformat besitzen, können sie von diversen (De-
)Komprimierungsprogrammen geöffnet werden. Das Erzeugen von Java-Archiven sollte man aber
dem speziell für diesen Zweck entworfenen JDK-Werkzeug jar.exe (siehe Abschnitte 6.1.3.2 und
6.1.3.4) oder einer entsprechend ausgestatteten Entwicklungsumgebung überlassen.
1
https://docs.oracle.com/javase/tutorial/deployment/jar/signindex.html
2
https://docs.oracle.com/javase/tutorial/deployment/jar/packageman.html
Abschnitt 6.1 Pakete 349
demopack
A.class
B.class
C.class
Main.class
sub1
X.class
Y.class
Dazu positionieren wir ein Konsolenfenster auf das Verzeichnis, das den Ordner demopack enthält,
und lassen mit dem folgenden jar-Aufruf das Archiv demarc.jar mit der gesamten Pakethierar-
chie erstellen:1
>jar cf0 demarc.jar demopack
Im Kommando bedeuten:2
• 1. Parameter: Optionen
Die Optionen werden durch einzelne Zeichen angefordert, die unmittelbar hintereinander
stehen müssen:
o c
Mit einem c (für create) wird das Erstellen eines Archivs angefordert.
o f
Mit f (für file) wird ein Name für die Archivdatei angekündigt, der als weiteres
Kommandozeilenargument auf die Optionen zu folgen hat.
o 0
Mit der Ziffer 0 wird auf die ZIP-Kompression verzichtet.
• 2. Parameter: Archivdatei
Der Archivdateiname muss einschließlich Extension (üblicherweise .jar) geschrieben wer-
den.
• 3. Parameter: Zu archivierende Dateien und Ordner
Bei einem Ordner wird rekursiv der gesamte Verzeichnisast einbezogen. Ein Ordner kann
die class-Dateien eines Pakets oder auch sonstige Dateien (z. B. mit Medien) enthalten. Soll
eine Archivdatei mehrere Pakete bzw. Ordner aufnehmen, sind die Ordnernamen durch
Leerzeichen getrennt anzugeben.
Aus obigem jar-Aufruf resultiert die folgende jar-Datei (hier angezeigt vom kostenlosen Pro-
gramm 7-Zip):
1
Sollte der Aufruf nicht klappen, befindet sich vermutlich das OpenJDK-Unterverzeichnis bin (z. B. C:\Program
Files\ojdkbuild\java-1.8.0-openjdk-1.8.0.302-1\bin) nicht im Suchpfad für ausführbare Programme. In diesem Fall
muss das Programm mit kompletter Pfadangabe gestartet werden.
2
Hier ist die jar-Dokumentation der Firma Oracle für Java 8 zu finden:
https://docs.oracle.com/javase/8/docs/technotes/guides/jar/index.html
350 Kapitel 6 Pakete und Module
Eine jar-Datei kann eine beliebig große Zahl von Paketen und Typen enthalten. Im OpenJDK 8
befindet sich z. B. die Datei rt.jar mit fast allen API-Paketen und einer Größe von ca. 70 MB.
Es ist erlaubt, dass sich die zu einem Paket gehörigen class-Dateien in verschiedenen jar-Dateien
befinden. Möglicherweise enthalten zwei jar-Dateien zufälligerweise ein namensgleiches Paket.
Wenn dann Typnamen in den beiden Paketen übereinstimmen, hängt es von der Reihenfolge der
jar-Dateien in der CLASSPATH-Definition ab, aus welcher Datei ein Typ geladen wird. Man
spricht in diesem Zusammenhang von der JAR-Hölle. Im JPMS (bei modularen jar-Dateien) ist ein
solches Package Splitting verboten (siehe Abschnitt 6.2.4).
Die Quellcodedateien sind für die Verwendung eines Archivs (als Programm oder Klassenbiblio-
thek) nicht erforderlich und sollten daher (z. B. aus urheberrechtlichen Gründen) durch die Ablage
in einer separaten Ordnerstruktur aus dem Archiv herausgehalten werden.
>java demopack.Main
Für die Nutzung von Archivdateien in IntelliJ eignen sich (globale) Bibliotheken (siehe Abschnitt
3.4.2).
Wie sich eine traditionelle jar-Datei als sogenanntes automatisches Modul im Modulsystem von
Java 9 verwenden lässt, ist im Abschnitt 6.2.9.1 zu erfahren.
Im jar-Aufruf zum Erstellen des Archivs wird über die Option m eine Datei mit Manifestinforma-
tionen angekündigt, z. B. mit dem Namen PDManifest.txt:
>jar cmf0 PDManifest.txt PDApp.jar demopack
Beachten Sie bitte, dass die Namen der Manifest- und der Archivdatei in derselben Reihenfolge wie
die zugehörigen Optionen auftauchen müssen.
Es resultiert eine jar-Datei mit dem folgenden Manifest:
352 Kapitel 6 Pakete und Module
Der obige jar-Aufruf klappt, wenn sich die Datei PDManifest.txt mit den Manifestinformationen
und das Paketverzeichnis demopack im aktuellen Ordner befinden, z. B.:
Auf eine Manifestinformationsdatei, die lediglich den Namen der Startklasse verrät, kann man seit
Java 6 verzichten und stattdessen im jar-Aufruf die Option e (für entry point) verwenden, z. B.:
>jar cef0 demopack.Main PDApp.jar demopack
Unter Verwendung der Archivdatei PDApp.jar lässt sich das Programm mit der Hauptklasse
demopack.Main in einem Konsolenfenster mit einem beliebigen aktuellen Ordner durch das fol-
gende Kommando
>java -jar PDApp.jar
starten, z. B.:
Damit dies auf einem Kundenrechner nach dem Kopieren der Datei PDApp.jar sofort möglich ist,
muss dort lediglich eine JVM mit geeigneter Version installiert sein.
Ein kleineres Java-Programm kann in einer einzigen jar-Datei ausgeliefert werden, die auch alle
benötigten Bibliothekspakete (außer dem Java-API) sowie Hilfsdateien enthält. Dann ist (neben der
erwarteten JVM) nur eine einzige Datei im Spiel, und beim Programmstart muss sich der Benutzer
nicht um den Klassensuchpfad kümmern.
Wird ein Java-Programm per jar-Datei gestartet, dann legt allein deren Manifest den class-
Suchpfad fest. Weder die Umgebungsvariable CLASSPATH, noch das Kommandozeilenargument -
classpath sind wirksam. Die Klassen im Java-API werden aber auf jeden Fall gefunden.
Über das jar-Werkzeug lässt sich der class-Suchpfad einer jar-Datei so konfigurieren, dass auch
Pakete in anderen Archivdateien gefunden werden.1 Dies ermöglicht die bei größeren Programmen
angemessene Auslieferung durch mehrere jar-Archivdateien. Im Installationsordner des überwie-
gend in Java entwickelten Programms IBM SPSS Statistics 28 (Gesamtumfang ca. 1,5 GB) befin-
den sich z. B. 466 jar-Dateien.
1
Dabei entsteht in der Manifestdatei ein Class-Path - Eintrag. Über Details informiert die folgende Webseite:
http://docs.oracle.com/javase/tutorial/deployment/jar/downman.html
Abschnitt 6.1 Pakete 353
• Wird eine Startklasse (Main Class) benannt, dann resultiert eine ausführbare jar-Datei:
Sind neben dem Java-API weitere Bibliotheken im Spiel, dann können diese in das Archiv
einbezogen werden.
354 Kapitel 6 Pakete und Module
• Wir markieren im folgenden Fenster das Kontrollkästchen Include in project build, da-
mit bei jedem Erstellen des Projekts automatisch auch eine jar-Datei entsteht.
Alternativ kann man auf die Automatik verzichten und bei Bedarf die Erstellung der jar-
Datei mit dem folgenden Menübefehl anfordern:
Build > Build Artifacts
• Wir quittieren mit OK.
Wird das Projekt anschließend z. B. über den Menübefehl
Build > Build Project
oder den Symbolschalter neu erstellt, dann erhalten wir im Ordner
...\out\artifacts
einen Unterordner (im Beispiel: PackDemo_jar) mit der Datei PackDemo.jar. Weil wir eine
Startklasse benannt haben, ist die jar-Datei ausführbar, z. B. mit dem Kommando
>java -jar PackDemo.jar
Die von IntelliJ erstellte Datei MANIFEST.MF hat im Wesentlichen den im Abschnitt 6.1.3.4 be-
schriebenen Inhalt:
[HKEY_LOCAL_MACHINE\SOFTWARE\Classes\.jar]
@="jarfile"
"Content Type"="application/jar"
[HKEY_LOCAL_MACHINE\SOFTWARE\Classes\jarfile]
[HKEY_LOCAL_MACHINE\SOFTWARE\Classes\jarfile\shell]
[HKEY_LOCAL_MACHINE\SOFTWARE\Classes\jarfile\shell\open]
[HKEY_LOCAL_MACHINE\SOFTWARE\Classes\jarfile\shell\open\command]
@="\"C:\\Program Files\\ojdkbuild\\java-1.8.0-openjdk-1.8.0.312-1\\bin\\javaw.exe\" -jar \"%1\" %*"
Zum Starten dient hier das nur für GUI-Anwendungen geeignete OpenJDK-Werkzeug javaw.exe,
das kein Konsolenfenster anzeigt, sodass z. B. der Doppelklick auf die im Abschnitt 6.1.3.4 be-
schriebenen Datei PDApp.jar ohne sichtbare Folgen bleibt. Wird statt javaw.exe das Programm
java.exe verwendet, dann sind auch Java-Konsolenprogramme per Doppelklick zu starten. Bei
GUI-Programmen erscheint dann ein Konsolenfenster, das eventuell stört, aber im Fall von Aus-
nahmefehlern auch nützliche Informationen anzeigt, z. B.:
Wir ergänzen nun das im Abschnitt 4.9 erstellte Bruchkürzungsprogramm mit JavaFX-GUI um eine
ausführbare jar-Datei, damit das Programm über eine einzelne Datei verbreitet und auf jedem
Rechner mit geeigneter JVM-Installation bequem per Doppelklick gestartet werden kann. Leider
klappt die Erstellung einer ausführbaren jar-Datei nur mit JavaFX 8, also z. B. nach der im Ab-
schnitt 1.2.1 beschriebenen OpenJDK 8 - Installation.
Wir kehren zum Projekt in
U:\Eigene Dateien\Java\BspUeb\JavaFX\Reduce Fraction\Reduce Fraction mit Java 8
zurück.1 Nach dem Menübefehl
File > Project Structure > Artifacts
bereiten wir im folgenden Fenster
1
Es ist hier zu finden: ...\BspUeb\JavaFX\Reduce Fraction\Reduce Fraction mit Java 8
356 Kapitel 6 Pakete und Module
• Wir markieren das Kontrollkästchen Include in project build, damit bei jedem Erstellen
des Projekts automatisch auch eine ausführbare jar-Datei entsteht. Alternativ kann man auf
die Automatik verzichten und bei Bedarf die Erstellung der jar-Datei mit dem folgenden
Menübefehl anfordern:
Build > Build Artifacts
• Wir tragen auf der Registerkarte Java FX die Anwendungsklasse ein (inkl. Paketname):
einen Unterordner (im Beispiel: Reduce) mit der (leider noch nicht ganz fertigen) ausführbaren jar-
Datei.
Weil das Erstellen der jar-Datei scheitert,
Java FX Packager:
BUILD FAILED
Error reading project file C:\Users\baltes\AppData\Local\JetBrains\IdeaIC2021.3\compile-
server\brüche_kürzen_ca21b66\_temp_\build.xml: Ungültiges Byte 1 von 1-Byte-UTF-8-
Sequenz.
Nun kann die jar-Datei auf einem Rechner per Doppelklick gestartet werden, wenn die installierte
JVM für jar-Dateien zuständig ist, und JavaFX (alias OpenJFX) in der Standardbibliothek enthalten
ist. Bei der im Abschnitt 1.2.1 beschriebenen Installation sind diese Bedingungen erfüllt.
Leider pflegt das Installationsprogramm zu der im Abschnitt 1.2.1 vorgestellten OpenJDK 8 -
Distribution aus dem ojdkbuild - Projekt die eingangs dokumentierten Registry-Einträge nicht,
sodass z. B. nach einer Aktualisierung der Distribution manuelle Anpassungen erforderlich sind.
Zum Erstellen einer ausführbaren jar-Datei zu einem JavaFX-Projekt benötigt IntelliJ den soge-
nannten Java FX Packager, der in JavaFX ab Version 9 leider fehlt. Ein mit dem OpenJDK 8 ent-
wickeltes JavaFX-Programm kann aber durchaus z. B. von einer JVM auf dem Versionsstand 17
ausgeführt werden, wobei lediglich das (von Ihnen als Profi eingerichtete) Startverfahren etwas
aufwändiger ist (siehe Abschnitt 4.9.5). Für die Benutzer bleibt es beim Doppelklick.
6.2 Module
Die mit Java 9 eingeführte Paket-Modularisierung (das Java Platform Module System, JPMS) bringt
folgende Optimierungen für die Java-Plattform:
• Zuverlässige Konfiguration statt JAR-Hölle
Befinden sich im Klassenpfad mehrere Ordner oder jar-Dateien, die verschiedene Versio-
nen eines Pakets enthalten, dann hängt es von der Reihenfolge im Klassenpfad ab, welche
Version einer Klasse geladen wird. Das Modulsystem verwendet statt des Klassenpfads ei-
nen sogenannten Modulpfad und verhindert, dass ein Paket in mehreren Modulen auf dem
Pfad enthalten ist (siehe Abschnitt 6.2.4).
• Zugriffsregulation oberhalb von Paketen
Bis Java 8 erlaubt der Typ-Modifikator public den Vollzugriff für fremde Typen in beliebi-
gen Paketen. Ab Java 9 kann man Pakete zu Modulen zusammenfassen und für jedes Modul
festlegen, welche seiner Pakete exportiert oder für den Modul-internen Gebrauch reserviert
werden sollen. Eine als public deklarierte Klasse in einem nicht-exportierten Paket ist nur
für andere Pakete im selben Modul sichtbar. Befindet sich eine als public deklarierte Klasse
hingegen in einem exportierten Paket, dann kann sie von Klassen in allen Modulen genutzt
werden, die entweder ihre Abhängigkeit vom Quellmodul explizit deklariert haben oder im-
plizit vom Quellmodul abhängig sind. Von der neuen Zugriffsabschottung profitiert nicht
zuletzt das Java-API, indem API-interne Pakete (z. B. com.sun.*) in Java 9 für normalen
Anwendungscode nicht mehr zugänglich sind.
• Definierte Abhängigkeiten zwischen Programmteilen
Abgesehen von Kompatibilitätslösungen deklariert ein JPMS-Modul seine Abhängigkeiten
von anderen Modulen explizit. Somit wird die Struktur einer komplexen Anwendung im
Quellcode klar artikuliert. Die im vorherigen Aufzählungspunkt beschriebene modulbasierte
Zugriffsregulation kann z. B. verhindern, dass die Klassen im GUI-Modul direkt auf Klas-
sen im Datenbankmodul zugreifen. Java 9 erleichtert die Erstellung von übersichtlichen und
wartungsfreundlichen Anwendungen, während sich traditionelle Großprojekte gelegentlich
zu einer großen Matschkugel (engl.: big ball of mud) entwickeln.1 Auf die Kundenrechner
lässt sich ein nicht-modulares Projekt zwar in jar-Dateien ausliefern, die jeweils mehrere
Pakete zusammenfassen (siehe Abschnitt 6.1.3), doch befinden sich diese jar-Dateien un-
strukturiert im Klassenpfad und verwenden sich auf schwer durchschaubare Weise gegensei-
tig.
1
https://de.wikipedia.org/wiki/Big_Ball_of_Mud
Abschnitt 6.2 Module 359
6.2.1.1 Modulnamen
Für Modulnamen gelten die folgenden Regeln und Konventionen:
• Die im Abschnitt 3.1.6 beschriebenen Namensregeln sind einzuhalten, sodass z. B. der Binde-
strich kein zulässiges Zeichen in einem Modulnamen ist.
• Durch die empfohlene Beschränkung auf Kleinbuchstaben werden Namenskonflikte mit Klassen
und Schnittstellen vermieden.
• Durch die Verwendung von DNS (Domain Name System) - Namensbestandteilen in umge-
kehrter Reihenfolge und durch Punkte separiert als Präfix wird für weltweit eindeutige Mo-
dulnamen gesorgt (analog zur entsprechenden Empfehlung für Paketnamen, siehe Abschnitt
6.1.1.5), z. B.:
de.uni_trier.zimk.util
Wenn ein Modul garantiert nie den Anwendungsbereich einer Organisation/Firma verlässt,
kann der Kürze halber auf führende DNS-Bestandteile im Modulnamen verzichtet werden.
• Existiert im Modul ein herausgehobenes exportiertes Paket, sollte dessen Name auch als
Modulname verwendet werden.
• Die exportierten Pakete eines Moduls sollten den Modulnamen als Präfix verwenden, z. B.:
de.uni_trier.zimk.util.conio
6.2.1.2 requires-Deklaration
Ein normales (sogenanntes explizites) Modul deklariert in der Datei module-info.java seine
Abhängigikeiten von anderen Modulen, legt also das Universum der Typen (Klassen und
Schnittstellen) fest, die in seinem eigenen Code benötigt werden (Gosling et al. 2021, Abschnitt
7.7). Für jedes erforderliche andere Modul (außer java.base, siehe unten) ist eine requires-
Deklaration erforderlich, die nach dem einleitenden Schlüsselwort einen Modulnamen nennt und
mit einem Semikolon endet, z. B.:
Abschnitt 6.2 Module 361
module de.uni_trier.zimk.matrain {
requires de.uni_trier.zimk.util;
. . .
}
Man sagt, dass im Beispiel die Lesbarkeit (engl.: readability) des Moduls
de.uni_trier.zimk.util durch das Modul de.uni_trier.zimk.matrain beantragt wird.
Wenn z. B. eine Klasse im Modul de.uni_trier.zimk.matrain eine Methode enthält, welche
ein Objekt mit einem in de.uni_trier.zimk.util definierten Typ abliefert, dann muss jedes
von de.uni_trier.zimk.matrain abhängige Modul ebenfalls eine Leseberechtigung für
de.uni_trier.zimk.util besitzen. Damit dazu keine explizite Abhängigkeitsdeklaration erfor-
derlich ist, kann mit dem Zusatz transitive hinter requires eine Abhängigkeit weitergegeben wer-
den an Module, welche vom deklarierenden Modul abhängen. Wenn auf Basis der folgenden Dekla-
ration
module de.uni_trier.zimk.matrain {
requires transitive de.uni_trier.zimk.util;
. . .
}
ein Modul de.uni_trier.zimk.ba seine Abhängigkeit von de.uni_trier.zimk.matrain
erklärt, dann ist de.uni_trier.zimk.ba implizit auch von de.uni_trier.zimk.util ab-
hängig. Wer eine Leseberechtigung für de.uni_trier.zimk.matrain beantragt, muss sinnvoll-
erweise dessen Abhängigkeiten nicht kennen. Selbstverständlich klappt die transitive Abhängigkeit
auch über Zwischenschritte.
Mit der kostenpflichtigen Ultimate-Edition unserer Entwicklungsumgebung IntelliJ IDEA lässt sich
ein Abhängigkeitsdiagramm für die Module eines Projekts (Java Modules Diagram) erstellen,
z. B.:
Für die einfache und die transitive Abhängigkeit werden unterschiedlich formatierte Pfeile verwen-
det.
Wechselseitige Abhängigkeiten zwischen zwei Modulen sind verboten. Wenn für zwei Module die
Möglichkeit zur wechselseitigen Abhängigkeitsdeklaration vermisst wird, sollten alle Pakete zu
einem einzigen Modul zusammengefasst werden.1
Ein sogenanntes Aggregatormodul enthält keine Pakete, aber mehrere transitive Abhängigkeitsde-
klarationen. So kann ein Bündel von Abhängigkeiten mit geringem Aufwand auf mehrere Module
übertragen und an zentraler Stelle gepflegt werden. Ein wichtiges Beispiel ist das API-Modul ja-
va.se mit der folgenden Moduldeklarationsdatei:
1
https://developer.ibm.com/tutorials/java-modularity-4/
362 Kapitel 6 Pakete und Module
module java.se {
requires transitive java.compiler;
requires transitive java.datatransfer;
requires transitive java.desktop;
requires transitive java.instrument;
requires transitive java.logging;
requires transitive java.management;
requires transitive java.management.rmi;
requires transitive java.naming;
requires transitive java.net.http;
requires transitive java.prefs;
requires transitive java.rmi;
requires transitive java.scripting;
requires transitive java.security.jgss;
requires transitive java.security.sasl;
requires transitive java.sql;
requires transitive java.sql.rowset;
requires transitive java.transaction.xa;
requires transitive java.xml;
requires transitive java.xml.crypto;
}
Ein Aggregatormodul wird auch als Wurzelmodul (engl.: root module) bezeichnet, und das Wur-
zelmodul java.se spielt im Zusammenhang mit dem sogenannten unbenannten Modul eine wichtige
Rolle (siehe Abschnitt 6.2.9.2).
Das Modul java.base der Java-Standardbibliothek ist (ohne requires-Deklaration) für jedes Modul
lesbar.
6.2.1.3 exports-Deklaration
Die in einem Modul enthaltenen Pakete sind per Voreinstellung nur modulintern sichtbar. Eine
public-Deklaration für eine im Modul enthaltene Klasse wirkt sich also nur auf die anderen Pakete
im eigenen Modul aus. Für jedes Paket, das auch außerhalb des Moduls sichtbar sein soll, ist in der
Datei module-info.java eine exports-Deklaration erforderlich, z. B.:
module de.uni_trier.zimk.util {
exports de.uni_trier.zimk.util.conio;
}
Unterpakete (also Pakete mit einem durch Anhängen von Segmenten gebildeten) Namen (z. B.
de.uni_trier.zimk.util.conio.impl) werden durch eine exports-Anweisung nicht mit einbe-
zogen. Weil keine Joker-Zeichen unterstützt werden, sind eventuell zahlreiche exports-
Deklarationen fällig.
Damit die von einem Modul A exportierten Pakete im Code eines Moduls B tatsächlich nutzbar sind,
muss für das Modul B eine explizit deklarierte oder implizit bestehende Abhängigkeit vom Modul A
vorliegen (siehe Abschnitt 6.2.1.2).
Durch eine exports-Deklaration mit to-Klausel kann die Freigabe eines Pakets auf eine Liste von
Modulen eingeschränkt werden, z. B.:
module de.uni_trier.zimk.util {
exports de.uni_trier.zimk.util.conio to
de.uni_trier.zimk.util.ba,
de.uni_trier.zimk.util.bm;
}
Man spricht hier von einer qualifizierten exports-Deklaration.
Im Java-API wird die neue Option interner (nicht exportierter) Pakete, die für Anwendungen nicht
sichtbar sind, intensiv genutzt.
Abschnitt 6.2 Module 363
exports java.sql;
exports javax.sql;
exports javax.transaction.xa;
uses java.sql.Driver;
}
Zur Service-Spezifikation kann an Stelle einer Schnittstelle auch eine abstrakte Klasse (siehe Ab-
schnitt 7.8) angegeben werden.
Bei einem benötigten Dienst sind andere Module im Spiel:
• Das Modul mit der Schnittstelle oder der abstrakten Klasse zur Beschreibung des Dienstes
Für dieses Modul ist eine explizit per requires deklarierte oder eine implizit bestehende
Abhängigkeit erforderlich.
• Die Module mit einer Implementation des Dienstes
Für diese, zur Laufzeit ermittelte Module muss keine Abhängigkeit deklariert werden oder
implizit bestehen.
Enthält ein Modul eine Implementation für einen Dienst, so wird dies in der Moduldeklaration
durch eine provides-Deklaration angezeigt. Auf das Schlüsselwort provides folgen:
• der Name der Schnittstelle bzw. der abstrakten Klasse
• das Schlüsselwort with
• der Name der implementierenden Klasse
Im folgenden Modul com.mysql.jdbc (Beispiel aus Reinhold 2016, Abschnitt 4) wird der vom Mo-
dul java.sql benötigte Dienst java.sql.Driver angeboten:
module com.mysql.jdbc {
requires java.sql;
requires org.slf4j;
exports com.mysql.jdbc;
provides java.sql.Driver with com.mysql.jdbc.Driver;
}
Beim Anwendungsstart stellt das Java-Modulsystem sicher, dass zu jeder uses-Deklaration mindes-
tens eine passende provides-Deklaration vorhanden ist und verweigert anderenfalls den Start.
364 Kapitel 6 Pakete und Module
6.2.1.5 opens-Deklaration
Einige Bibliotheken bzw. Frameworks verwenden eine bisher im Kurs noch nicht angesprochene
Software-Technik namens Reflexion zur Erledigung ihrer Aufgaben. Dabei werden zur Laufzeit
Klassen geladen, inspiziert und instanziert. Beispiele für solche Frameworks sind:
• das zum Speichern von Objekten in relationalen Datenbanken verwendete JPA (Java Persis-
tence API)
• das für Geschäftsanwendungen verbreitete Spring-Framework
• Testsysteme
Die meisten Entwickler müssen sich mit der Reflexion (im Zusammenhang mit den JPMS) nicht
beschäftigen (Reinold 2016) und können daher den Rest dieses Abschnitts überspringen.
Mit der opens-Deklaration kann ein Modul für ein Paket die Laufzeit-Reflexion durch beliebige
andere Module erlauben, die Sichtbarkeit zur Übersetzungszeit aber auf das eigene Modul be-
schränken, z. B.:
module com.example.foo {
requires java.logging;
exports com.example.foo.api;
opens com.example.foo.impl;
}
Wie bei der exports-Deklaration kann auch bei der opens-Deklaration per to-Klausel die Freigabe
auf eine Liste von Modulen eingeschränkt werden.
Man kann ein komplettes Modul für die Laufzeit-Reflexion öffnen, indem man die Moduldeklarati-
on mit dem Schlüsselwort open einleitet, z. B.:
open module com.example.foo {
. . .
}
Damit entsteht ein sogenanntes offenes Modul.
6.2.2 Quellcode-Organisation
Empfehlungen für die Quellcode-Organisation bei einem Modul:
• Für ein Modul legt man einen Ordner an, der den Namen des Moduls übernimmt.
• In diesem Ordner erstellt man die Moduldeklarationsdatei module-info.java.
• Unterhalb des Modulordners legt man eine Ordnerhierarchie mit den Paketen an (siehe Ab-
schnitt 6.1.1.3).
Im weiteren Verlauf des Abschnitts 6.2 entwickeln wir ein aus den folgenden drei Modulen beste-
hendes Beispielprogramm, das sich (wieder einmal) mit der Bruchaddition beschäftigt:
• de.uni_trier.zimk.util
• de.uni_trier.zimk.matrain
• de.uni_trier.zimk.ba
Wir erstellen das Modul de.uni_trier.zimk.util mit einem Paket namens
de.uni_trier.zimk.util.conio, das die altbekannte Klasse Simput.java enthält. In der Mo-
duldeklarationsdatei wird das Paket exportiert:
module de.uni_trier.zimk.util {
exports de.uni_trier.zimk.util.conio;
}
Abschnitt 6.2 Module 365
import java.util.*;
import java.io.*;
import de.uni_trier.zimk.util.conio.Simput;
In der Quellcodedatei der Startklasse wird die Paketzugehörigkeit deklariert. Danach wird die Klas-
se Bruch aus dem Paket
de.uni_trier.zimk.matrain.br im Modul de.uni_trier.zimk.matrain
importiert, von dem das Modul
de.uni_trier.zimk.ba
explizit abhängt. Außerdem wird die Klasse Simput aus dem Paket
de.uni_trier.zimk.util.conio im Modul de.uni_trier.zimk.util
importiert, von dem das Modul
de.uni_trier.zimk.ba
transitiv abhängt:
package de.uni_trier.zimk.ba;
import de.uni_trier.zimk.matrain.br.Bruch;
import de.uni_trier.zimk.util.conio.Simput;
class Bruchaddition {
public static void main(String[] args) {
Bruch b1 = new Bruch(), b2 = new Bruch();
System.out.println("1. Bruch");
b1.frage();
b1.kuerze();
b1.zeige();
System.out.println("\n2. Bruch");
b2.frage();
b2.kuerze();
b2.zeige();
System.out.println("\nSumme");
b1.addiere(b2);
b1.zeige();
Wir sammeln alle Modul-Quellcodedateien in einem mit dem Ordner src startenden Verzeichnisast,
der folgenden Aufbau besitzt:1
1
Die Baumansicht wurde mit dem Windows-Kommando tree /f erstellt.
Abschnitt 6.2 Module 367
└───src
├───de.uni_trier.zimk.ba
│ │ module-info.java
│ │
│ └───de
│ └───uni_trier
│ └───zimk
│ └───ba
│ Bruchaddition.java
│
├───de.uni_trier.zimk.matrain
│ │ module-info.java
│ │
│ └───de
│ └───uni_trier
│ └───zimk
│ └───matrain
│ └───br
│ Bruch.java
│
└───de.uni_trier.zimk.util
│ module-info.java
│
└───de
└───uni_trier
└───zimk
└───util
└───conio
Simput.java
werden die zu übersetzenden Module einzeln als Werte zur Option --module (Kurzform: -m) aufge-
listet. Außerdem wird ...
1
Hier ist die Dokumentation der Firma Oracle zum Java 17 - Compiler zu finden:
https://docs.oracle.com/en/java/javase/17/docs/specs/man/javac.html
2
Die javac-Aufrufe des aktuellen Abschnitts setzen voraus, dass sich der bin-Unterordner einer JDK-Installation mit
Modulunterstützung (z. B. C:\Program Files\Java\OpenJDK-17\bin) im Windows-Pfad für ausführbare Pro-
gramme befindet.
368 Kapitel 6 Pakete und Module
• durch die Option -d der Ausgabeordner für die explodierten Module gesetzt,
• durch die Option --module-source-path der Stammordner mit den Quellcodeordnern der
Module benannt,
• durch die Option -encoding die UTF-8 - Codierung der Quellcodedateien bekanntgegeben,
weil aus der per Voreinstellung angenommenen ANSI-Codierung (Windows-1252) eine fal-
sche Darstellung der deutschen Umlaute im Programm resultiert (vgl. Abschnitt 2.2.2).
Um in einem javac-Aufruf mehrere Module ohne explizite Auflistung zu übersetzen, wird eine leis-
tungsfähige Betriebssystem-Kommandosprache benötigt. Diese Voraussetzung ist in Linux, in
macOS und auch in der Windows-PowerShell erfüllt, aber nicht im traditionellen Windows-
Konsolenfenster, das wir oben verwendet haben. Ist unter Windows ein PowerShell-Fenster auf den
Stammordner von src eingestellt, lassen sich die drei in src-Unterordnern befindlichen Module
de.uni_trier.zimk.util, de.uni_trier.zimk.matrain und de.uni_trier.zimk.ba
mit dem folgenden javac-Kommando übersetzen:
>javac -encoding utf8 -d expmods --module-source-path src
$(dir src -r -i "*.java")
Als Resultat der javac-Aufrufe erhalten wir die folgende Ordnerstruktur mit den explodierten Mo-
dulen:
└──expmods
├───de.uni_trier.zimk.ba
│ │ module-info.class
│ │
│ └───de
│ └───uni_trier
│ └───zimk
│ └───ba
│ Bruchaddition.class
│
├───de.uni_trier.zimk.matrain
│ │ module-info.class
│ │
│ └───de
│ └───uni_trier
│ └───zimk
│ └───matrain
│ └───br
│ Bruch.class
│
└───de.uni_trier.zimk.util
│ module-info.class
│
└───de
└───uni_trier
└───zimk
└───util
└───conio
Simput.class
6.2.4 Modulpfad
Wir werden im Abschnitt 6.2.5 zum Starten des im Abschnitt 6.2 erstellten modularen Bruchadditi-
onsprogramms den mit Java 9 eingeführten Modulpfad benutzen, der den fehleranfälligen traditio-
nellen Klassenpfad ersetzt:
>java --module-path expmods --module
de.uni_trier.zimk.ba/de.uni_trier.zimk.ba.Bruchaddition
Abschnitt 6.2 Module 369
Zur Spezifikation des Modulpfads bei der Übersetzung oder Ausführung eines Programms dient die
Option --module-path (Kurzform: -p). Im Modulpfad ist eine Liste von Einträgen erlaubt, die unter
Windows jeweils durch ein Semikolon zu trennen sind. Als Eintrag kann ...
• ein einzelnes Modul
• oder ein Ordner mit Modulen
angegeben werden, und ein einzelnes Modul kann ...
• als explodiertes Modul (vgl. Abschnitt 6.2.3),
• als modulare jar-Datei (vgl. Abschnitt 6.2.6)
• oder als jmod-Datei (vgl. Abschnitt 6.2.10)
vorliegen (vgl. Bateman et al. 2017; Mak & Bakker 2017).
Zusammen mit den per Modulpfad zugänglich gemachten Anwendungs- oder Bibliotheksmodulen
gehören die Module der Java-Runtime zu den sogenannten beobachtbaren Modulen (engl.: observ-
able modules).
Um ein Wiederaufflammen der JAR-Hölle zu verhindern, darf im JPMS ein Paket nicht über meh-
rere Module verteilt werden, d. h. ...
• der Compiler verweigert die Übersetzung,
• und die Laufzeitumgebung verweigert den Programmstart,
wenn sich im Modulpfad zwei Module mit namensgleichen Paketen befinden. Das Package Split-
ting wird rigoros auch dann verhindert, wenn ...
• die namensgleichen Pakete nicht exportiert werden,
• die namensgleichen Pakete keine namensgleichen Typen enthalten.1
Anders als beim herkömmlichen Klassenpfad kann es beim Modulpfad nicht passieren, dass die
mehr oder weniger zufällige Reihenfolge der Pfadeinträge darüber entscheidet, aus welchem Paket
eine Klasse schließlich geladen wird.
6.2.5 Ausführen
Wir starten das Beispielprogramm in einem Konsolenfenster, dessen aktuelles Verzeichnis u. a. den
Ordner expmods mit den explodierten Modulen enthält,
1
https://www.informatik-aktuell.de/entwicklung/programmiersprachen/java-9-das-neue-modulsystem-jigsaw-
tutorial.html
2
Der java-Aufruf setzt voraus, dass sich der bin-Unterordner einer JDK-Installation mit Modulunterstützung (z. B.
C:\Program Files\Java\OpenJDK-17\bin) im Windows-Pfad für ausführbare Programme befindet.
370 Kapitel 6 Pakete und Module
1
Hier ist eine aktuelle Dokumentation der Firma Oracle zu finden:
https://docs.oracle.com/en/java/javase/12/tools/jar.html
Abschnitt 6.2 Module 371
Hier legen wir zunächst einen Ordner namens mods für die zu erstellenden modularen jar-Dateien
an:
>mkdir mods
Im folgenden jar-Aufruf wird das Modul de.uni_trier.zimk.util verpackt:
>jar --create --file mods/de.uni_trier.zimk.util-1.0.jar --module-version 1.0
-C expmods/de.uni_trier.zimk.util .
Es folgt die Verpackung des Moduls de.uni_trier.zimk.matrain:
>jar --create --file mods/de.uni_trier.zimk.matrain-1.0.jar --module-version 1.0
-C expmods/de.uni_trier.zimk.matrain .
Schließlich wird das Moduls de.uni_trier.zimk.ba (mit der Startklasse) verpackt:
>jar --create --file mods/de.uni_trier.zimk.ba-1.0.jar --module-version 1.0
--main-class de.uni_trier.zimk.ba.Bruchaddition -C expmods/de.uni_trier.zimk.ba .
Das Ergebnis im Explorer-Fenster:
Zum Starten eines in modularen jar-Dateien vorliegenden Programms per java.exe sind die folgen-
den Optionen relevant:
372 Kapitel 6 Pakete und Module
Eine modulare jar-Datei lässt sich als herkömmliche jar-Datei über den traditionellen Klassenpfad
ansprechen, wobei der Moduldeskriptor ignoriert wird.
Abschnitt 6.2 Module 373
• Weil zunächst ein IntelliJ-Modul entsteht, ist ein Modul SDK zu wählen:
• Wir schließen das Fenster New Module und anschließend das Fenster Project Structure:
Im Project-Fenster ist ein neues IntelliJ-Modul (mit dem Symbol ) zu sehen, wobei es sich noch
nicht um ein JPMS - Modul handelt:
Um ein Paket im neuen Modul anzulegen, wählen wir aus dem Kontextmenü zum src-Ordner im
neuen Modul den Befehl
New > Package
und vergeben im folgenden Fenster den Namen de.uni_trier.zimk.util.conio:
Um die Klasse Simput im neuen Paket anzulegen, wählen wir aus dem Kontextmenü zum neuen
Paket den Befehl
New > Java Class
und tragen im folgenden Fenster den Namen ein:
376 Kapitel 6 Pakete und Module
Nun machen wir aus dem IntelliJ-Modul de.uni_trier.zimk.util endlich ein Java-Modul,
indem wir aus dem Kontextmenü zum src-Ordner des Moduls den Befehl
New > module-info.java
wählen. Die Moduldeklarationsdatei wird im Editor geöffnet:
Weil IntelliJ den Unterstrich im Namensbestanteil uni_trier durch einen Punkt ersetzt hat, korri-
gieren wir den Vorschlag
Abschnitt 6.2 Module 377
Wir machen aus dem neuen IntelliJ-Modul ein JPMS-Modul, indem wir aus dem Kontextmenü zum
src-Ordner des Moduls den Befehl
New > module-info.java
wählen. In der automatisch im Editor geöffneten Moduldeklarationsdatei korrigieren wir den IntelliJ
vorgeschlagenen Modulnamen
378 Kapitel 6 Pakete und Module
Es ist zu beachten, dass es sich bei der Abhängigkeitsliste um ein Konzept der Entwicklungsumge-
bung IntelliJ handelt, nicht um ein JPMS-Konzept:
erstellten Quellcode. Dabei gelangt hinter die von IntelliJ an den Anfang des Quellcodes gesetzte
package-Deklaration eine import-Deklaration für die Klasse Simput aus dem Paket
de.uni_trier.zimk.util.conio:
import de.uni_trier.zimk.util.conio.Simput;
Neuer Zwischenstand im Project-Fenster:
Nun ergänzen wir in der Deklarationsdatei zum Modul de.uni_trier.zimk.matrain eine ex-
ports-Deklaration zum Paket de.uni_trier.zimk.matrain.br:
module de.uni_trier.zimk.matrain {
requires transitive de.uni_trier.zimk.util;
exports de.uni_trier.zimk.matrain.br;
}
Wir legen im Modul de.uni_trier.zimk.ba ein gleichnamiges Paket an (über New > Packa-
ge aus dem Kontextmenü zum src-Ordner des Moduls). Im Paket de.uni_trier.zimk.ba er-
stellen wir die folgende, aus dem Abschnitt 6.2.2 bekannte Klasse Bruchaddition:
package de.uni_trier.zimk.ba;
import de.uni_trier.zimk.matrain.br.Bruch;
import de.uni_trier.zimk.util.conio.Simput;
class Bruchaddition {
public static void main(String[] args) {
Bruch b1 = new Bruch(), b2 = new Bruch();
System.out.println("1. Bruch");
b1.frage();
b1.kuerze();
b1.zeige();
System.out.println("\n2. Bruch");
b2.frage();
b2.kuerze();
b2.zeige();
System.out.println("\nSumme");
b1.addiere(b2);
b1.zeige();
Das von IntelliJ generierte Startkommando verwendet im Wesentlichen den Modulpfad und die im
Abschnitt 6.2.6 beschriebenen Optionen (-p und -m):1
"C:\Program Files\Java\OpenJDK-17\bin\java.exe" "-javaagent:C:\Program
Files\JetBrains\IntelliJ IDEA Community Edition 2021.3\lib\idea_rt.jar=61798:C:\Program
Files\JetBrains\IntelliJ IDEA Community Edition 2021.3\bin" -Dfile.encoding=UTF-8 -p
"U:\Eigene Dateien\Java\BspUeb\Pakete und
Module\IntelliJ\Bruchaddition\out\production\de.uni_trier.zimk.util;U:\Eigene
Dateien\Java\BspUeb\Pakete und
Module\IntelliJ\Bruchaddition\out\production\de.uni_trier.zimk.matrain;U:\Eigene
Dateien\Java\BspUeb\Pakete und
Module\IntelliJ\Bruchaddition\out\production\de.uni_trier.zimk.ba" -m
de.uni_trier.zimk.ba/de.uni_trier.zimk.ba.Bruchaddition
1
Durch die Option javaagent nutzt IntelliJ eine seit Java 5 (alias 1.5) bestehende und durch Typen im Paket
java.lang.instrument realisierte Möglichkeit, ein von der JVM ausgeführtes Programm zu beeinflussen und z. B.
den Bytecode zu ändern.
2
https://docs.oracle.com/en/java/javase/13/docs/specs/man/jlink.html
Abschnitt 6.2 Module 383
1
http://www.torsten-horn.de/techdocs/Jigsaw.html
384 Kapitel 6 Pakete und Module
• Enthalten ein benanntes und das unbenannte Modul ein gleichnamiges Paket, dann wird das
Paket im unbenannten Modul ignoriert.
• Zum unbenannten Modul gehört auch das Standardpaket mit den Klassen, die keinem Paket
zugeordnet wurden.
• Sollte sich ein explizites Modul auf den Klassenpfad verirren, wird sein Moduldeskriptor
ignoriert.
Bei einer modularen Anwendung legt das Hauptmodul als Wurzel für den Modulgraphen mit den
zur Laufzeit zu berücksichtigenden Modulen fest. Weil das unbenannte Modul keine Moduldeklara-
tionsdatei besitzt, übernimmt hier in der Regel das Aggregatormodul java.se die Rolle der Wurzel
für den Modulgraphen (vgl. Abschnitt 6.2.1.2). Aufgrund der indirekten Abhängigkeiten von den
Moduleinträgen in java.se enthält der Modulgraph praktisch das gesamte Java SE - API. Folglich
kann eine Java 8 - Anwendung ohne Änderungen übersetzt und ausgeführt werden.
Weil java.se bei der Übersetzung oder Ausführung von Code aus dem unbenannten Modul die ein-
zige Wurzel ist, müssen oft weitere, über den Modulpfad erreichbare Module über die folgende Op-
tion in den Modulgraphen aufgenommen werden:
--add-modules modul , modul, ...
Im Abschnitt 4.9.5 haben wir diese Option zur Ausführung einer JavaFX 8 - Anwendung zum Kür-
zen von Brüchen durch das OpenJDK 17 und das OpenJFX 17 verwendet. Die Hauptklasse
sample.Main befand sich im unbenannten Modul, und zwei über den Modulpfad erreichbare
OpenJFX 17 - Module mussten in den Modulgraphen aufgenommen werden:1
U:\Eigene Dateien\Java\BspUeb\JavaFX\ ... \Reduce>javaw.exe
--module-path "C:\Program Files\Java\OpenJFX-SDK-17\lib"
--add-modules=javafx.controls,javafx.fxml sample.Main
Wenn alle Module auf dem Modulpfad in den Modulgraphen aufgenommen werden sollen, dann
kann ALL-MODULE-PATH statt einer Modulliste angegeben werden, z. B.:
U:\Eigene Dateien\Java\BspUeb\JavaFX\ ... \Reduce>javaw.exe
--module-path "C:\Program Files\Java\OpenJFX-SDK-17\lib"
--add-modules=ALL-MODULE-PATH sample.Main
6.2.9.3 Notlösung
Benötigt ein Programm Zugriffe auf interne (nicht exportierte) API-Pakete, dann ist die Überset-
zung mit dem Compiler einer modularen Java-Version (ab 9) trotzdem möglich mit Hilfe der neuen
Option --add-exports. Damit lassen sich interne API-Pakete für ein Modul zugänglich machen
(siehe Oracle 2021b).
1
Es ist übrigens keine Option, die jar-Dateien mit der OpenJFX-Bibliothek über den Klassenpfad zugänglich zu
machen. Weil die Hauptklasse einer OpenJFX-Anwendung von javafx.application.Application abgeleitet ist, muss
OpenJFX über Module einbezogen werden, siehe:
https://github.com/javafxports/openjdk-jfx/issues/236#issuecomment-426583174
386 Kapitel 6 Pakete und Module
package demopack;
import demopack.sub1.*;
import javax.swing.*;
1
https://docs.oracle.com/en/java/javase/17/docs/specs/man/jdeps.html
2
Seit 2018 wird die JEE mit dem neuen Namen Jakarta EE unter dem Dach der Open-Source-Organisation Eclipse
Foundation weitergeführt. Davon wird vermutlich die Dynamik der Entwicklung profitieren. Zu Details siehe z. B.:
Abschnitt 6.2 Module 387
Seit Java 9 ist die Standardbibliothek modular aufgebaut. Wie mit dem Kommando
>java --list-modules
zu ermitteln ist, besteht das API der OpenJDK-Version 17 aus 71 Modulen. Man bezeichnet sie als
Platform Explicit Modules zur Abgrenzung von den im Abschnitt 6.2 beschriebenen Application
Explicit Modules mit Programmen und Bibliotheken. Während letztere als explodierte Module oder
modulare jar-Dateien vorliegen, befinden sich die Plattform-Module gemeinsam in einer speziell
formatierten Datei namens modules im lib-Unterordner einer JDK-Installation, z. B.:
Das Modul java.base enthält die wichtigsten Pakete (z. B. java.lang, java.math und java.io) und
wird von jeder Java-Software benötigt. Es hat im JPMS eine herausgehobene Stellung:
• Jedes andere Modul hängt implizit von java.base ab.
• java.base hängt von keinem anderen Modul ab.
In einem OpenJDK - Installationsordner sind die Platform-Module zweimal vorhanden. Sie befin-
den sich nicht nur in der eben beschriebenen Datei modules im Unterordner lib, sondern auch im
Unterordner jmods, den man z. B. als Modulpfad-Bestandteil für die Erstellung einer individuellen
Laufzeitumgebung benötigt (vgl. Abschnitt 6.2.8). Diesmal stecken die Plattform-Module in indivi-
duellen Dateien mit der Namenserweiterung jmod, z. B.:
Neben den JRE-Modulen mit dem initialen Namensbestandteil java, befinden sich im Ordner
jmods auch JDK-spezifische Module, die am initialen Namensbestandteil jdk zu erkennen sind.
Mit Hilfe des JDK-Werkzeugs jmod kann man sich über eine jmod-Datei informieren, z. B.:
>C:\Program Files\Java\OpenJDK-17\jmods>jmod describe java.base.jmod
https://www.heise.de/developer/meldung/Jakarta-EE-Eclipse-Foundation-uebernimmt-die-Verantwortung-fuer-
Enterprise-Java-4030557.html
388 Kapitel 6 Pakete und Module
Man kann die jmod-Dateien in der Übersetzungs- und in der Bindungs- bzw. Link-Phase verwen-
den, aber nicht zur Laufzeit:1
JMOD files can be used at compile time and link time, but not at run time.
6.2.11 Modul-Taxonomie
Weil im Verlauf von Abschnitt 6.2 von zahlreichen Modulsorten die Rede war, sollen diese noch
einmal im Überblick präsentiert werden:
• Explizites Modul
Dies ist ein normales JPMS-Modul und besitzt einen Moduldeskriptor. Man unterscheidet:
o Application Explicit Module
Es stammt von einem Programmentwickler und wird entweder als modulare jar-
Datei oder als Verzeichnisbaum (explodiertes Modul) ausgeliefert.
o Platform Explicit Module
Es gehört zur Java-Standardbibliothek. Ein besonders wichtiges Plattform-Modul ist
java.base, von dem alle anderen Module implizit (ohne requires-Deklaration im
Moduldeskriptor) abhängig sind.
Explizite Module können die Pakete des unbenannten Moduls nicht sehen.
• Offenes Modul (siehe Abschnitt 6.2.1.5)
Wird die Moduldeklaration eines expliziten Moduls mit dem Schlüsselwort open eingeleitet,
dann öffnet es alle Pakete für die Laufzeit-Reflexion und wird zum offenen Modul.
• Automatisches Modul (siehe Abschnitt 6.2.9.1)
Wird eine traditionelle jar-Datei in den Modulpfad aufgenommen, dann entsteht ein be-
nanntes Modul mit einem Modulnamen, der aus dem jar-Dateinamen abgeleitet wird. Ein
automatisches Modul ...
o besitzt implizit eine Abhängigkeitsbeziehung zu allen anderen benannten Modulen
(explizit oder automatisch),
o exportiert alle Pakete
o und kann - im Unterschied zu den expliziten Modulen (mit Moduldeskriptor) - auch
auf das unbenannte Modul zugreifen.
• Benanntes Modul
Zu den benannten Modulen gehören die expliziten Module und die automatischen Module.
• Unbenanntes Modul (siehe Abschnitt 6.2.9.2)
Es sammelt alle per Klassenpfad zugänglichen Typen und besitzt eine implizite Abhängig-
keitsbeziehung zu allen Modulen auf dem Modulpfad, also zu jedem expliziten und zu je-
dem automatischen Modul, wobei auch die API-Module einbezogen sind. Es exportiert alle
benannten Pakete, die allerdings nur in automatischen Modulen sichtbar sind.
6.3 Zugriffsschutz
Nach der Beschäftigung mit Paketen und Modulen lässt sich endlich präzise erläutern, wie in Java
die Zugriffsrechte für Typen, Felder, Methoden, Konstruktoren und andere Member (z. B. innere
Klassen) geregelt sind. Dabei wird vorausgesetzt, dass für den aktuell angemeldeten Entwickler
bzw. Benutzer auf der Ebene des Betriebs- bzw. Dateisystems Leserechte für die beteiligten Dateien
bestehen.
1
http://openjdk.java.net/jeps/261
Abschnitt 6.3 Zugriffsschutz 389
Für eine Klasse in einem Paket des Moduls moda ist ein als public dekorierter Typ aus dem Paket
modb.pack im Modul modb genau dann sichtbar, ...
• wenn sich das Modul moda in seiner Moduldeklarationsdatei modul-info.java per requires-
Deklaration als abhängig von Modul modb erklärt hat,
• und wenn außerdem das Modul modb in seiner Moduldeklarationsdatei das Paket
modb.pack per exports-Deklaration freigegeben hat (generell oder speziell für das Modul
moda).
Ob die Member (z. B. Methoden und Felder) eines sichtbaren Typs verwendbar sind, hängt von
deren speziellem Zugriffsschutz ab (siehe Abschnitt 6.3.2).
Im Rahmen der im Kurs bisher nicht behandelten Laufzeit-Reflexion kann auf einen (nicht unbe-
dingt als public deklarierten Typ) zugegriffen werden, wenn für sein Paket in der Moduldeklaration
per opens-Deklaration die Reflexion erlaubt wurde, oder wenn das gesamte Modul mit dem open-
Modifikator dekoriert wurde.
Das im Abschnitt 6.2.7 mit IntelliJ erstellte Bruchadditionsbeispiel enthält die Klasse Simput im
Paket de.uni_trier.zimk.util.conio, das sich im Modul de.uni_trier.zimk.util be-
findet. Die Klasse Simput soll von der Klasse Bruch im Paket
de.uni_trier.zimk.matrain.br verwendet werden, das sich im Modul
de.uni_trier.zimk.matrain befindet. Abhängigkeits- und Exporterklärung sind in den Mo-
duldeklarationen vorhanden. Wenn nun die Klasse Simput nicht als public deklariert ist, beschwert
sich IntelliJ:
1
Die Mitgliedsklassen (synonym: eingeschachtelten Klassen), die innerhalb einer Top-Level -
Klasse, aber außerhalb von Methoden definiert werden (siehe Abschnitt 4.8.1), sind beim Zu-
griffsschutz wie andere Klassenmitglieder (z. B. Felder und Methoden) zu behandeln (siehe Ab-
schnitt 6.3.2). Bei den innerhalb von Methoden definierten lokalen Klassen (siehe Abschnitt
4.8.2) und den anonymen Klassen sind Zugriffsmodifikatoren irrelevant und verboten.
390 Kapitel 6 Pakete und Module
Gemeinsame Bestandteile im Paketpfad haben keine Relevanz für die wechselseitigen Zugriffsrech-
te von Klassen. Folglich haben z. B. die Klassen im Paket demopack.sub1 für Klassen im Paket
demopack dieselben Rechte wie Klassen aus beliebigen anderen Paketen.
Bei aufmerksamer Lektüre der (z. B. im Internet) zahlreich vorhandenen Java-Beschreibungen stellt
man fest, dass bei Hauptklassen neben der Startmethode main() oft auch die Klasse selbst als
public definiert wird, z. B.:
public class Hallo {
public static void main(String[] args) {
System.out.println("Hallo allerseits!");
}
}
Diese Praxis erscheint plausibel, jedoch verlangt die JVM lediglich bei der Startmethode main()
den Modifikator public. In diesem Manuskript wird (mit Assistentenproduktionen als Ausnahmen)
die Praxis der aus Java-Sprachspezifikation übernommen: Gosling et al. (2021) lassen bei Haupt-
klassen den Modifikator public systematisch weg.
Das Objekt a1 ist also nicht vor anderen A-Objekten geschützt (außer durch die Klugheit des A-
Programmierers), sondern vor der Klasse B, deren Programmierer in der Regel nur beschränktes
Wissen von der A-Klasse hat.
Bei der Deklaration bzw. Definition von Feldern, Methoden, Konstruktoren und Mitgliedstypen
können die Modifikatoren private, protected und public angegeben werden, um die Zugriffsrechte
festzulegen.1 In der folgenden Tabelle sind die Effekte der Zugriffsmodifikatoren für Mitglieder
eines Top-Level - Typs beschrieben, der selbst als public definiert ist. Bei den „Zugriffsbewerbern“
soll es sich um Top-Level - Typen2 in berechtigten Modulen handeln.3
Der Zugriff ist erlaubt für ...
Modifikator andere Typen im abgeleitete Typen in sonstige Typen in
den eigenen Typ
eigenen Paket fremden Paketen fremden Paketen
ohne4 ja ja nein nein
private ja nein nein nein
protected ja ja nur geerbte Elemente nein
public ja ja ja ja
Mit abgeleiteten Klassen und dem nur dort relevanten Zugriffsmodifikator protected werden wir
uns im Kapitel 7 beschäftigen.
Wird im Bruchadditionsbeispiel (siehe Abschnitt 6.2.7) die Klasse Simput, die sich im Paket
de.uni_trier.zimk.util.conio befindet, mit public-Zugriffsmodifikator versehen, ihre
gint() - Methode jedoch nicht, dann kann die Klasse Bruch, die sich im Paket
de.uni_trier.zimk.matrain.br befindet, die Methode gint() nicht verwenden, z. B.:
1
Unter dem Begriff Mitgliedstypen (vgl. Abschnitt 4.8.1) sind hier Klassen und Schnittstellen zu verstehen, die in-
nerhalb eines Top-Level-Typs außerhalb von Methoden definiert werden, z. B.:
public class Top {
. . .
private class MemberClass {
. . .
}
. . .
}
2
Mitgliedsklassen und lokale Klassen haben erweiterte Rechte zum Zugriff auf die Mitglieder der umgebenden Klas-
se (siehe Abschnitt 4.8).
3
Von einem berechtigten Modul modb in Bezug auf das Paket pina des Moduls moda sprechen wir dann, wenn ...
• das Modul moda in seiner Deklarationsdatei das Paket pina für das Modul modb exportiert (z. B. im Rahmen
einer generellen exports-Deklaration),
• das Modul modb sich in seiner Deklarationsdatei per requires- Deklaration als abhängig vom Modul moda er-
klärt.
4
Für die voreingestellte Sichtbarkeit (nur das eigene Paket darf zugreifen) wird gelegentlich die Bezeichnung pack-
age access verwendet.
392 Kapitel 6 Pakete und Module
class Prog {
public static void main(String[] args) {
Worker w = new Worker();
w.work();
}
}
erzeugt und verwendet die main() - Methode der Klasse Prog ein Objekt der fremden Klasse Wor-
ker, obwohl die Klasse Worker und ihre Methode work() nicht als public deklariert wurden.
Wieso ist dies möglich?
Personentransporter LKW
Einige Eigenschaften sind für alle Nutzfahrzeuge relevant (z. B. Anschaffungspreis, momentane
Position), andere betreffen nur spezielle Klassen (z. B. maximale Anzahl von Fahrgästen, maximale
Traglast). Ebenso sind einige Handlungsmöglichkeiten bei allen Nutzfahrzeugen vorhanden (z. B.
eigene Position melden, ein Ziel ansteuern), während andere Handlungskompetenzen speziellen
Fahrzeugen vorbehalten sind (z. B. Fahrgäste befördern, Lasten anheben). Ein Programm zur Ver-
waltung der Fahrzeuge und ihrer Einsätze muss diese reale Klassenhierarchie abbilden.
Übungsbeispiel
Bei unseren Beispielprogrammen bewegen wir uns in einem bescheideneren Rahmen und betrach-
ten eine einfache Hierarchie mit Klassen für geometrische Figuren:
Figur
Kreis Rechteck
Vielleicht haben manche Leser als Gegenstück zum Rechteck (auf derselben Hierarchieebene) die
Ellipse erwartet, die ebenfalls zwei ungleiche lange Hauptachsen besitzt. Weiterhin liegt es auf den
ersten Blick nahe, den Kreis als Spezialisierung der Ellipse und das Quadrat als Spezialisierung des
Rechtecks zu betrachten. Wir werden aber im Abschnitt 7.9 über das Liskovsche Substitutionsprin-
zip genau diese Ableitungen (von Kreis aus Ellipse bzw. von Quadrat aus Rechteck) kritisieren.
394 Kapitel 7 Vererbung und Polymorphie
Man spricht hier vom Kreis-Ellipse - oder Quadrat-Rechteck - Problem.1 Es ist wohl akzeptabel, an
Stelle der Ellipse den Kreis neben das Rechteck zu stellen, um das Erlernen der neuen Konzepte
durch ein möglichst einfaches Beispiel ohne Verstoß gegen das Liskovsche Substitutionsprinzip zu
erleichtern.2
Figur
Kreis Rechteck
In Java ist die in anderen objektorientierten Programmiersprachen (wie z. B. C++) erlaubte Mehr-
fachvererbung ausgeschlossen, sodass jede Klasse (mit Ausnahme von Object) genau eine Basis-
klasse hat (siehe Abschnitt 7.11.1).
Mit ihrem Vererbungsmechanismus bietet die objektorientierte Programmierung ideale Vorausset-
zungen dafür, vorhandene Software auf rationelle Weise zur Lösung neuer Aufgaben wiederzuver-
wenden. Dabei können allmählich umfangreiche Softwaresysteme entstehen, die gleichzeitig stabil
und innovationsoffen sind (vgl. Abschnitt 4.1.1.3 zum Open-Closed - Prinzip). Die nicht selten an-
zutreffende Praxis, vorhandenen Code per Copy & Paste in neuen Projekten bzw. Klassen zu ver-
wenden, hat gegenüber einer sorgfältig geplanten Klassenhierarchie offensichtliche Nachteile. Na-
türlich kann Java nicht garantieren, dass jede Klassenhierarchie exzellent entworfen ist und langfris-
tig von einer stetig wachsenden Entwicklergemeinde eingesetzt wird.
1
Siehe z. B. https://en.wikipedia.org/wiki/Circle-ellipse_problem
2
Andere Autoren verwenden (möglicherweise aus ähnlichen Gründen) zur Demonstration ebenfalls eine Klassenhie-
rarchie bestehend aus Figur, Kreis und Rechteck, z. B.:
https://docs.oracle.com/en/java/javase/17/language/pattern-matching-instanceof-operator.html
Abschnitt 7.1 Definition einer abgeleiteten Klasse 395
Die bequeme Implementation einer abgeleiteten Klasse geht manchmal einher mit dem Nachteil
einer Abhängigkeit von der Basisklasse, was insbesondere beim Beerben einer „fremden“ (nicht im
selben Paket befindlichen) Klasse zu beachten ist, weil man deren mögliche Veränderungen nicht
unter Kontrolle hat. Am Ende des Kapitels werden wir uns mit unerwünschten Abhängigkeiten zwi-
schen Klassen und Möglichkeiten zur Vermeidung von daraus resultierenden Problemen beschäfti-
gen.
public Figur() {
System.out.println("Figur-Konstruktor");
}
}
Mit Hilfe des Schlüsselwortes extends wird nun die Klasse Kreis als Spezialisierung der Klasse
Figur definiert. Sie erbt die beiden Positionsvariablen und ergänzt eine zusätzliche Instanzvariable
für den Radius:
package de.uni_trier.zimk.figuren;
public Kreis() {
System.out.println("Kreis-Konstruktor");
}
}
Es wird ein parametrisierter Kreis-Konstruktor definiert, der über das Schlüsselwort super den
parametrisierten Konstruktor der Basisklasse aufruft. Ein direkter Zugriff auf die privaten (!) In-
stanzvariablen xpos und ypos der Klasse Figur wäre dem Konstruktor der Klasse Kreis auch
nicht erlaubt. Das Schlüsselwort super hat übrigens den oben eingeführten Begriff Superklasse mo-
tiviert.
In der Klasse Kreis wird (wie in der Basisklasse Figur) auch ein parameterfreier Konstruktor
definiert. Vielleicht hat jemand gehofft, die Kreis-Klasse würde den parameterfreien Konstruktor
396 Kapitel 7 Vererbung und Polymorphie
ihrer Basisklasse (bei automatischer Anpassung des Namens) übernehmen. Konstruktoren werden
jedoch grundsätzlich nicht vererbt. Im Quellcode des parameterfreien Kreis-Konstruktor befindet
sich nur eine Konsolenausgabe zum Existenznachweis. Der Compiler ergänzt noch Bytecode zur
Initialisierung der Instanzvariablen und zum Aufruf des parameterfreien Konstruktors der Basis-
klasse. Im Abschnitt 7.3 werden wir uns mit der Beteiligung von Basisklassenkonstruktoren bei der
Objektkreation beschäftigen.
Das folgende Programm erzeugt ein Objekt aus der Basisklasse Figur und ein Objekt aus der abge-
leiteten Klasse Kreis:
Quellcode Ausgabe
import de.uni_trier.zimk.figuren.*; Figur-Konstruktor
import de.uni_trier.zimk.figuren.Figur;
public Kreis() {}
1
Falls Sie sich über die Berechnung des Kreismittelpunkts wundern: In der Computergrafik ist die Position (0, 0) in
der oberen linken Ecke des Bildschirms bzw. des aktuellen Fensters angesiedelt. Die X-Koordinaten wachsen (wie
aus der Mathematik gewohnt) von links nach rechts, während die Y-Koordinaten von oben nach unten wachsen.
Abschnitt 7.3 Basisklassenkonstruktoren und Initialisierungsmaßnahmen 397
class FigurenDemo {
public static void main(String[] args) {
Kreis k1 = new Kreis(50.0, 50.0, 30.0);
System.out.println("Abstand von (100, 100): " + k1.abstand(100.0, 100.0));
//klappt nicht: System.out.println(k1.xpos);
}
}
• Das Objekt wird mit allen Instanzvariablen (auch den geerbten) auf dem Heap angelegt, und
die Instanzvariablen werden mit den typspezifischen Nullwerten initialisiert.
• Der Unterklassenkonstruktor beginnt seine Tätigkeit mit dem (impliziten oder expliziten)
Aufruf eines Basisklassenkonstruktors. Falls in der Vererbungshierarchie die Urahnklasse
Object noch nicht erreicht ist, wird am Anfang des Basisklassenkonstruktors ein Konstruk-
tor der Super-Superklasse aufgerufen, bis diese Sequenz schließlich mit dem Aufruf eines
Object-Konstruktors endet.
Auf jeder Hierarchieebene (beginnend bei Object) laufen zwei Teilschritte ab:
o Die Instanzvariablen der Klasse werden initialisiert, wobei die Deklarationen und
eventuell vorhandene Instanzinitialisierer (siehe Abschnitt 4.4.4) zu berücksichtigen
sind.
o Der Rumpf des Konstruktors wird ausgeführt.
Betrachten wir beispielhaft das Geschehen bei einem Kreis-Objekt, das mit dem Konstruktor-
aufruf
Kreis(150.0, 200.0, 30.0)
erzeugt wird:
• Das Kreis-Objekt wird mit seinen Instanzvariablen (xpos, ypos, radius) auf dem Heap
angelegt, und die Instanzvariablen werden mit Nullen initialisiert.
• Aktionen für die Klasse Object:
o Mangels Existenz sind keine Instanzvariablen der Klasse Object auf den deklarierten
Initialisierungswert zu setzen.
o Der Rumpf des parameterfreien Object-Konstruktors wird ausgeführt.
• Aktionen für die Klasse Figur:
o Die Instanzvariablen xpos und ypos erhalten den Initialisierungswert laut Deklara-
tion (jeweils 100,0).
o Der Rumpf des Konstruktoraufrufs
super(x, y)
wird ausgeführt, wobei xpos und ypos die Werte 150,0 bzw. 200,0 erhalten.
• Aktionen für die Klasse Kreis:
o Die Instanzvariable radius erhält den Initialisierungswert 50,0 aus der Deklaration.
o Der Rumpf des Konstruktoraufrufs
Kreis(150.0, 200.0, 30.0)
wird ausgeführt, wobei radius den Wert 30,0 erhält.
package de.uni_trier.zimk.figuren;
public Figur() {}
public Kreis() {}
@Override
public void wo() {
super.wo();
System.out.println("Unten rechts: (" + (xpos+2*radius) +
", " + (ypos+2*radius) + ")");
}
}
Mit der Marker-Annotation @Override (vgl. Abschnitt 9.5) kann man seine Absicht bekunden, bei
einer Methodendefinition eine Basisklassenvariante zu überschreiben.1 Misslingt dieser Plan z. B.
aufgrund eines Tippfehlers, dann protestiert unsere Entwicklungsumgebung:
1
Im Zusammenhang mit den in Java 16 eingeführten Record-Klassen ist über eine Besonderheit beim „Überschrei-
ben“ der automatisch zu einer Record-Komponente definierten Zugriffsmethoden zu berichten (siehe Abschnitt
5.5.2.3). Obwohl dabei keine Basisklassenmethode, sondern eine automatisch erstellte Methode „überschrieben“
wird, kann doch die Marker-Annotation @Override verwendet werden, die zu diesem Zweck eine erweiterte Be-
deutung erhalten hat.
400 Kapitel 7 Vererbung und Polymorphie
In der überschreibenden Methode kann man sich oft durch Rückgriff auf die überschriebene Me-
thode die Arbeit erleichtern, wobei wieder das Schlüsselwort super zum Einsatz kommt.
Das folgende Programm schickt an eine Figur und an einen Kreis jeweils die Nachricht wo(),
und beide zeigen ihr artspezifisches Verhalten:
Quellcode Ausgabe
import de.uni_trier.zimk.figuren.*; Oben links: (10.0, 20.0)
Auch bei den vom Urahntyp Object geerbten Methoden kommt ein Überschreiben in Frage. Die
Object-Methode toString() liefert neben dem Klassennamen den (meist aus der Speicheradresse
abgeleiteten) Hashcode des Objekts. Sie wird z. B. von der String-Methode println() automatisch
genutzt, um eine Zeichenfolgendarstellung zu einem Objekt zu ermitteln, z. B.:
Quellcode Ausgabe
class Prog { Prog@15e8f2a0
public static void main(String[] args) { Prog@7090f19c
Prog tst1 = new Prog(), tst2 = new Prog();
System.out.println(tst1 + "\n"+ tst2);
}
}
In der API-Dokumentation zur Klasse Object wird das Überschreiben der Methode toString() ex-
plizit für alle Klassen empfohlen. Diese Methode wird vom Designer und von den Anwendern einer
Klasse durch expliziten oder impliziten Aufruf potentiell oft genutzt. Ein impliziter toString() -
Aufruf findet z. B. immer dann statt, wenn ein Objekt als Parameter an print(), println(), printf()
oder an den Zeichenkettenverknüpfungsoperator übergeben wird. Bloch (2018, S. 55ff) empfiehlt,
als toString() - Rückgabe interessante Informationen über das angesprochene Objekt zu liefern, und
gibt noch einige Tipps zum Implementieren der Methode.
In der folgenden Klasse Mint (ein int-Wrapper, siehe Übungsaufgabe zu Abschnitt 5.3) liefert die
toString() - Überladung den verpackten Wert:
Abschnitt 7.4 Überschreiben und Überdecken 401
public Mint() {}
@Override
public String toString() {
return String.valueOf(value);
}
}
Ein Mint-Objekt antwortet auf die toString() - Botschaft mit der Zeichenfolgendarstellung des
gekapselten int-Werts:
Quellcode Ausgabe
class MintDemo { 4711
public static void main(String[] args) {
Mint zahl = new Mint(4711);
System.out.println(zahl);
}
}
Den Versuch, eine Instanzmethode der Basisklasse durch eine statische Methode der abgeleiteten
Klasse zu überschreiben, verhindert der Compiler.
Wie sich gleich im Abschnitt 7.7 über die Polymorphie zeigen wird, besteht der Clou bei über-
schriebenen Instanzmethoden darin, dass erst zur Laufzeit in Abhängigkeit vom tatsächlichen Typ
eines handelnden, über eine Basisklassenreferenz angesprochenen Objekts entschieden wird, ob die
Basisklassen- oder die Unterklassenmethode zum Einsatz kommt. Der Typ des handelnden Objekts
ist in vielen Fällen zur Übersetzungszeit noch nicht bekannt, weil:
• über eine Basisklassenreferenzvariable durchaus auch ein Unterklassenobjekt verwaltet
werden kann (siehe Abschnitt 7.5),
• und sich der konkrete Typ oft erst zur Laufzeit entscheidet, z. B. in Abhängigkeit von einer
Benutzerentscheidung.
Quellcode Ausgabe
package de.uni_trier.zimk.figuren; Statische Figur-Methode
public class Figur {
public static void sm() {
System.out.println("Statische Figur-Methode");
}
. . .
}
package de.uni_trier.zimk.figuren;
public class Kreis extends Figur {
public static void sm() {
System.out.println("Statische Kreis-Methode");
}
. . .
}
import de.uni_trier.zimk.figuren.*;
class FigurenDemo {
public static void main(String[] ars) {
Figur kr = new Kreis();
kr.sm();
}
}
Die auszuführende statische Methode steht also grundsätzlich schon zur Übersetzungszeit fest, und
man spricht hier vom Überdecken oder Verstecken der Basisklassenmethode. Die überdeckte Basis-
klassenvariante einer statischen Methode ist natürlich durch Voranstellen des Klassennamens in den
Methoden der abgeleiteten Klasse ansprechbar.
Den Versuch, eine statische Methode der Basisklasse durch eine Instanzmethode der abgeleiteten
Klasse zu überdecken, verhindert der Compiler.
1
Wie im Abschnitt 7.11.2 zu erfahren ist, kann man auch eine komplette Klasse finalisieren, um die Definition von
abgeleiteten Klassen zu verhindern.
Abschnitt 7.4 Überschreiben und Überdecken 403
Neben der beschriebenen Anwendungssicherheit bringt das Finalisieren einer Instanzmethode noch
einen kleinen Performanzvorteil: Während bei nicht-finalisierten Instanzmethoden das Laufzeitsys-
tem feststellen muss, welche Variante in Abhängigkeit von der faktischen Klassenzugehörigkeit des
angesprochenen Objekts tatsächlich ausgeführt werden soll (vgl. Abschnitt 7.7 über Polymorphie),
steht eine final-Methode schon beim Übersetzen fest.
Während das Überschreiben von Methoden oft von entscheidender Bedeutung bei der Entwicklung
einer guten Lösung ist, finden sich für das potentiell verwirrende Überdecken von Feldern nur we-
nige sinnvolle Einsatzzwecke.
Das folgende Programm verwaltet Referenzen auf Figuren und Kreise in einem Array vom Typ
Figur. Weil wir die Klasse Rechteck noch nicht definiert haben, ist der Array fa im Vergleich
zur obigen Abbildung um ein Element gekürzt:
Quellcode Ausgabe
import de.uni_trier.zimk.figuren.*; Figur 0: kein Kreis
class FigurenDemo { Figur 1: Radius = 25.0
public static void main(String[] args) { Figur 2: kein Kreis
Figur[] fa = new Figur[4]; Figur 3: Radius = 10.0
fa[0] = new Figur(10.0, 20.0);
fa[1] = new Kreis(50.0, 50.0, 25.0);
fa[2] = new Figur(0.0, 30.0);
fa[3] = new Kreis(100.0, 100.0, 10.0);
for (int i = 0; i < fa.length; i++)
if (fa[i] instanceof Kreis)
System.out.println("Figur "+ i +": Radius = " +
((Kreis)fa[i]).gibRadius());
else
System.out.println("Figur "+ i +": kein Kreis");
}
}
Abschnitt 7.6 Der instanceof-Operator 405
Über eine Figur-Referenzvariable, die auf ein Kreis-Objekt zeigt, sind Erweiterungen der
Kreis-Klasse (zusätzliche Felder und Methoden) nicht unmittelbar zugänglich. Wenn (auf eigene
Verantwortung des Programmierers) eine Basisklassenreferenz als Unterklassenreferenz behandelt
werden soll, um eine unterklassenspezifische Methode oder Variable anzusprechen, dann muss eine
explizite Typumwandlung vorgenommen werden, z. B.:
((Kreis)fa[i]).gibRadius())
Das sollte in der Regel nach einer Typprüfung über den im Abschnitt 7.6 beschriebenen instanceof-
Operator erfolgen, der in seiner einfachsten und unmittelbar verständlichen Variante im Beispiel-
programm zum Einsatz kommt:
if (fa[i] instanceof Kreis) ...
Um den Zugriff auf Unterklassenerweiterungen demonstrieren zu können, hat die Klasse Kreis im
Vergleich zur Version im Abschnitt 7.4.1 die zusätzliche Methode gibRadius() erhalten:
public double gibRadius() {
return radius;
}
7.7 Polymorphie
Werden Objekte aus verschiedenen Klassen über Referenzvariablen eines gemeinsamen Basistyps
verwaltet, dann sind nur Methoden nutzbar, die schon in der Basisklasse definiert sind. Bei über-
schriebenen Methoden reagieren die Objekte jedoch unterschiedlich (jeweils unterklassentypisch)
auf dieselbe Botschaft. Genau dieses Phänomen bezeichnet man als Polymorphie. Wer sich hier
mit einem exotischen und nutzlosen Detail konfrontiert glaubt, sei an die Auffassung von Alan Kay
erinnert, der wesentlich zur Entwicklung der objektorientierten Programmierung beigetragen hat. Er
zählt die Polymorphie neben der Datenkapselung und der Vererbung zu den Kernideen dieser Soft-
ware-Technologie (siehe Abschnitt 4.1.1).
Gegen die unvermeidlichen Gewöhnungsprobleme mit dem Konzept der Polymorphie hilft am bes-
ten praktische Erfahrung. In welchem Ausmaß durch Polymorphie die Programmierpraxis erleich-
tert wird, kann leider durch die notwendigerweise kurzen Demonstrationsbeispiele nur ansatzweise
vermittelt werden.
Das Figurenprojekt besitzt bereits alle Voraussetzungen zur Demonstration der Polymorphie im
folgenden Beispielprogramm:1
import de.uni_trier.zimk.figuren.*;
import de.uni_trier.zimk.util.conio.Simput;
class FigurenDemo {
public static void main(String[] ars) {
Figur[] fa = new Figur[3];
fa[0] = new Figur(10.0, 20.0);
fa[1] = new Kreis(50.0, 50.0, 25.0);
fa[0].wo();
fa[1].wo();
System.out.print("\nWollen Sie zum Abschluss noch eine"+
" Figur oder einen Kreis erleben?"+
"\nWählen Sie durch Abschicken von \"f\" oder \"k\": ");
if (Character.toUpperCase(Simput.gchar()) == 'F') {
fa[2] = new Figur();
fa[2].wo();
}
else {
fa[2] = new Kreis();
fa[2].wo();
System.out.println("Radius: "+((Kreis)fa[2]).gibRadius());
}
}
}
Hier werden Referenzen auf Figur- und Kreis-Objekte in einem Array vom gemeinsamen Basis-
typ Figur verwaltet (vgl. Abschnitt 7.5). Beim Ausführen der wo() - Methode, stellt das Laufzeit-
system die tatsächliche Klassenzugehörigkeit fest und wählt die passende Methode aus (spätes bzw.
dynamisches Binden):
1
Im Beispielprogramm wird die Klasse Simput aus dem Paket de.uni_trier.zimk.util.conio bezogen (siehe
Abschnitt 6.2.7.1 zur Erstellung). Allerdings nutzen wir derzeit keine Modultechnik und behandeln die modulare
jar-Datei de.uni_trier.zimk.util-1.0.jar mit dem Modul de.uni_trier.zimk.util, das u. a. das Paket
de.uni_trier.zimk.util.conio enthält, wie eine traditionelle jar-Datei. In IntelliJ wird diese jar-Datei über
eine globale Bibliothek (siehe Abschnitt 3.4.2) in den traditionellen Klassenpfad aufgenommen. Weil die Klasse
Simput mit Java 17 übersetzt wurde, muss das Project SDK auf die Version 17 eingestellt werden.
Abschnitt 7.8 Abstrakte Methoden und Klassen 407
Wollen Sie zum Abschluss noch eine Figur oder einen Kreis erleben?
Waehlen Sie durch Abschicken von "f" oder "k": k
public Rechteck() {}
408 Kapitel 7 Vererbung und Polymorphie
@Override
public void wo() {
super.wo();
System.out.println("Unten rechts: (" + (xpos+breite) +
", " + (ypos+hoehe) + ")");
}
@Override
public double meldeInhalt() {
return breite * hoehe;
}
}
Weil die Methode zum Ermitteln des Flächeninhalts in der Basisklasse Figur nicht sinnvoll
realisierbar ist, wird sie hier abstrakt definiert:
package de.uni_trier.zimk.figuren;
class FigurenDemo {
public static void main(String[] ars) {
Figur[] fa = new Figur[2];
fa[0] = new Kreis(50.0, 50.0, 25.0);
fa[1] = new Rechteck(10.0, 10.0, 100.0, 200.0);
double ges = 0.0;
for (int i = 0; i < fa.length; i++) {
System.out.printf("Fläche Figur %d (%-34s): %15.2f\n",
i, fa[i].getClass().getName(), fa[i].meldeInhalt());
ges += fa[i].meldeInhalt();
}
System.out.printf("\nGesamtfläche: %10.2f",ges);
}
}
liefert die Ausgabe:
Fläche Figur 0 (de.uni_trier.zimk.figuren.Kreis ): 1963,50
Fläche Figur 1 (de.uni_trier.zimk.figuren.Rechteck): 20000,00
Gesamtfläche: 21963,50
Abschnitt 7.9 Das Liskovsche Substitutionsprinzip 409
Die Methode meldeInhalt() eignet sich dazu, den Nutzen der Polymorphie zu demonstrieren.
Ein Programm für das Malerhandwerk könnte zur Planung der benötigten Farbmenge seinem Be-
nutzer erlauben, beliebig viele Objekte aus diversen Figur-Unterklassen anzulegen, und dann die
gesamte Oberfläche in einer Schleife durch polymorphe Methodenaufrufe ermitteln.
Statische Methoden dürfen nicht abstrakt definiert werden.
1
Der Methodenmodifikator synchronized wird im Kapitel 15 über die Multithreading-Programmierung behandelt.
2
Das Java Collections Framework ist ein wichtiger Bestandteil im Java-API und wird im Kapitel 10 ausführlich be-
handelt.
Abschnitt 7.10 Unerwünschte Abhängigkeiten durch Vererbung 411
Weil die überschriebene Basisklassenmethode Methode addAll() intern die ebenfalls überschriebe-
ne Methode add() aufruft, stimmt das Zählergebnis in der Ableitung aber nicht. Jedes per addAll()
eingefügte Element wird doppelt gezählt. Der Fehler ist aufgetreten, weil der Programmierer der
abgeleiteten Klasse ein wichtiges Implementierungsdetail der Basisklasse nicht kannte. Um den
Fehler zu beseitigen, muss lediglich in der abgeleiteten Klasse auf das Überschreiben der Methode
addAll() verzichtet werden. Das geht solange gut, bis die Implementierung der Basisklasse sich
ändert. Die abgeleitete Klasse ist von der Basisklasse abhängig und damit fragil.
Die beiden Klassen bilden (in Blochs Worten) ein Tandem, das nur gemeinsam gepflegt werden
kann. Solange sich die beiden Klassen im selben Paket befinden und vom selben (gut organisierten)
Team gepflegt werden, sollten sich die Probleme vermeiden lassen. Riskant ist es jedoch, eine
fremde Klasse (aus einem anderen Paket) als Basisklasse zu verwenden.
Das riskante Beerben einer fremden Klasse mit unbekannten Implementationsdetails lässt sich ef-
fektiv durch eine Lösung aus den beiden folgenden Bestandteilen ersetzen, wobei sogar eine Refak-
torierung (also die automatische Transformation des Quellcodes) möglich ist (Hegel & Steimann
2008):
• Komposition
Statt eine Klasse zu beerben, verwendet man ein Member-Objekt vom Typ dieser Klasse.
Man kann hier von einer Komposition (vgl. Abschnitt 4.7) oder von einer Verpackung spre-
chen.
• Delegation
In der neu erstellten Klasse müssen die benötigten Methoden alle definiert werden, weil kei-
ne Vererbung stattfindet. Zur Implementation ist aber in der Regel nur ein Aufruf der ent-
sprechenden Methode des Member-Objekts erforderlich.
Bei der vorgeschlagenen Lösung aus Komposition und Delegation ist keine Basisklasse im Spiel,
die (aktuell oder in Zukunft) ...
• durch unerwünschte Methoden die neue Klasse stören könnte,
• oder unerwartete Aufrufe von überschreibenden Methoden ausführt.
Weitere Details und ein Beispiel finden sich bei Bloch (2018, S. 89ff).
Abschließend ist festzuhalten, dass die Definition einer abgeleiteten Klasse eine sinnvolle Technik
zur rationellen Wiederverwendung vorhandener Software ist, sofern dabei keine Paketgrenzen über-
schritten werden, sodass die Abhängigkeiten zwischen der Basisklasse und der abgeleiteten Klasse
bekannt und unter Kontrolle sind. Eine fremde Klasse sollte nur dann beerbt werden, wenn in deren
Dokumentation die Verwendung als Basisklasse explizit unterstützt wird (siehe Abschnitt 7.10.2).
Im Zweifelsfall sollte statt einer Ableitung trotz des höheren Aufwands eine Lösung aus Komposi-
tion und Delegation zum Einsatz kommen.
• Eventuell müssen Methoden (oder sogar Felder) mit der Sichtbarkeit protected definiert
und dokumentiert werden, um einer abgeleiteten Klasse performante Lösungen zu ermögli-
chen. Erneut werden Implementierungsdetails offengelegt, sodass die Weiterentwicklungs-
flexibilität leidet.
• Im Konstruktor der Basisklasse dürfen keine überschreibbaren Methoden aufgerufen wer-
den, weil der Konstruktor der Basisklasse vor dem Konstruktor der abgeleiteten Klasse aus-
geführt wird (siehe Abschnitt 7.3). Folglich würde die überschreibende Methode der abge-
leiteten Klasse vor dem Konstruktor der abgeleiteten Klasse aufgerufen. Ein Objekt der ab-
geleiteten Klasse befindet sich aber erst nach dem Konstruktoraufruf in einsatzfähigem Zu-
stand. Analoge Probleme sind möglich, wenn die Basisklasse die Schnittstellen Cloneable
oder Serializable (siehe Kapitel 9) implementiert, weil es auch in diesem Zusammenhang zu
Objektekreationen kommt (Bloch 2018, S. 95f).
Eine risikofrei, ohne sorgfältig zu erstellende und zu studierende Dokumentation verwendbare Ba-
sisklasse ist dadurch zu realisieren, dass auf jede interne Verwendung von überschreibbaren Metho-
den verzichtet wird.
Mit den folgenden Techniken lässt sich verhindern, dass zu einer Klasse Ableitungen definiert wer-
den:
• Man kann eine Klasse als final deklarieren (siehe Abschnitt 7.11.2)
• Man kann alle Konstruktoren als private deklarieren (siehe Abschnitt 7.3).
Über Konstruktoren mit der voreingestellten Sichtbarkeit (Paket) sorgt man dafür, dass sich abgelei-
tete Klassen nur im eigenen Paket definieren lassen.
1
https://docs.oracle.com/en/java/javase/17/language/sealed-classes-and-interfaces.html
2
Bei der finalisierten Klasse Kreis genügte in einem Test die Verfügbarkeit der class-Datei.
414 Kapitel 7 Vererbung und Polymorphie
• Wenn sich die versiegelte Basisklasse in einem benannten Modul befindet, dann müssen
auch die zugelassenen Spezialisierungen zu diesem Modul gehören. Wenn sich die versiegel-
te Basisklasse im unbenannten Modul befindet, dann müssen die zugelassenen Spezialisie-
rungen zum selben Paket gehören wie die Basisklasse.
Bei den in Java 17 für switch-Anweisungen und switch-Ausdrücke eingeführten, zunächst im Vor-
schaumodus verfügbaren Mustervergleichen (siehe Abschnitt 3.7.2.5) berücksichtigt der Compiler
versiegelte Klassen, sodass ggf. kein default-Fall erforderlich ist, um die Exhaustivität herzustellen.
Im folgenden Beispielprogramm verwendet eine switch-Anweisung einen steuernden Ausdruck
vom Typ der oben beschriebenen versiegelten Klasse Figur:
import de.uni_trier.zimk.figuren.*;
class FigurenDemo {
public static void main(String[] ars) {
Figur[] fa = new Figur[3];
fa[0] = new Kreis(50.0, 50.0, 50.0);
fa[1] = new Rechteck(50.0, 10.0, 100.0, 100.0);
fa[2] = new Quadrat(150.0, 110.0, 30.0);
double x = 100.0, y = 100.0;
for (var f : fa)
switch (f) {
case Kreis fig -> System.out.println("Radius: " + fig.meldeRadius());
case Rechteck fig -> System.out.println("Breite: " + fig.meldeBreite());
case Quadrat fig -> System.out.println("Seitenlänge: " + fig.meldeSeite());
}
}
}
Ein default-Fall wäre sinnlos, weil seine Anweisung nie ausgeführt würde. Weil der Compiler diese
Konsequenz der Versiegelung von Figur kennt, ist trotz der bestehenden Exhaustivitätsforderung
für die switch-Anweisung kein default-Fall erforderlich.
Um den Effekt der Versiegelung auf die Möglichkeiten des Compilers zur Fehlererkennung zu de-
monstrieren, ist ein Vorgriff auf das Kapitel 9 über Schnittstellen erforderlich. Wenn eine Klasse
eine Schnittstelle implementiert, können ihre Objekte über Referenzvariablen vom Schnittstellentyp
verwaltet werden. Bei einem Objekt vom deklarierten Typ Quadrat (mit dem Modifikator non-
sealed) kann der Compiler nicht ausschließen, dass sein von Quadrat abgeleiteter Laufzeittyp die
Schnittstelle Comparable implementiert. Folglich muss der folgende Quellcode akzeptiert werden:
Quadrat q = new Quadrat(150.0, 110.0, 30.0);
Comparable cq = (Comparable) q; // Laufzeitfehler
Wenn der Laufzeittyp die Schnittstelle Comparable nicht implementiert, kommt es zu einer
ClassCastException:
Exception in thread "main" java.lang.ClassCastException: class
de.uni_trier.zimk.figuren.Quadrat cannot be cast to class java.lang.Comparable
(de.uni_trier.zimk.figuren.Quadrat is in unnamed module of loader 'app'; java.lang.Comparable
is in module java.base of loader 'bootstrap')
at FigurenDemo.main(FigurenDemo.java:21)
Weil die Klasse Rechteck versiegelt und ihre einzige zugelassene Ableitung RechteckZ finali-
siert ist, kann der Compiler hingegen den folgenden Typumwandlungsfehler erkennen:
Abschnitt 7.12 Übungsaufgaben zum Kapitel 7 415
2) Im folgenden Beispiel wird die Klasse Kreis aus der Klasse Figur abgeleitet:
// Datei Figur.java
package de.uni_trier.zimk.figuren;
// Datei Kreis.java
package de.uni_trier.zimk.figuren.kreis;
import de.uni_trier.zimk.figuren.Figur;
4) Im Ordner
...\BspUeb\Vererbung und Polymorphie\abstract
finden Sie das Figurenbeispiel auf dem Entwicklungsstand von Abschnitt 7.8. Neben der im Manu-
skript diskutierten Kreis-Klasse ist die ebenfalls von Figur abgeleitete Klasse Rechteck vor-
handen mit …
• zusätzlichen Instanzvariablen für Breite und Höhe,
• einer wo() - Methode, die die geerbte Figur-Version überschreibt und
• einer meldeInhalt() - Methode, die die abstrakte Figur-Version implementiert.
In der Kreis-Klasse ist seit dem Abschnitt 7.2 die Methode abstand() vorhanden, die die Ent-
fernung einer bestimmten Position vom Kreismittelpunkt liefert. Implementieren Sie diese Methode
analog auch in der Klasse Rechteck. Damit die Methode polymorph verwendbar ist, muss sie in
der Basisklasse Figur vorhanden sein, wobei eine Implementation aber wohl nicht sinnvoll ist.
Erstellen Sie ein Testprogramm, das eine polymorphe Objektverwaltung und entsprechende Metho-
denaufrufe demonstriert.
5) Wird in einer Basisklasse die Implementation einer Methode verbessert, dann profitieren auch
alle abgeleiteten Klassen. Was muss geschehen, damit die Objekte einer abgeleiteten Klasse bei
einer geerbten Methode die verbesserte Variante benutzen?
a) Es genügt, die Basisklasse neu zu übersetzen und per Klassen- oder Modulpfad dafür zu
sorgen, dass die aktualisierte Basisklasse von der JVM geladen wird.
b) Man muss sowohl die Basisklasse als auch die abgeleitete Klasse neu übersetzen.
8 Generische Klassen und Methoden
In Java haben Variablen und Methodenparameter einen festen Datentyp, sodass der Compiler die
Typsicherheit garantieren, d .h. die Zuweisung von ungeeigneten Werten bzw. Objekten verhindern
kann. So sorgt der Compiler für Software-Stabilität und Kunden-Zufriedenheit. Allerdings werden
oft für unterschiedliche Datentypen völlig analog arbeitende Klassen oder Methoden benötigt, z. B.
eine Klasse zur Verwaltung einer geordneten Liste mit Elementen eines bestimmten, bei allen Ele-
menten identischen Typs. Statt die Definition für jeden in Frage kommenden Elementdatentyp zu
wiederholen, kann man die Klassendefinition seit Java 5 typgenerisch formulieren. Wird ein Objekt
einer generischen Listenklasse erzeugt, ist der Elementtyp konkret festzulegen. Im Ergebnis erhält
man durch eine Definition zahlreiche konkrete Klassen, wobei die Typsicherheit durch den Compi-
ler überwacht wird.
Mit Hilfe der typgenerischen Programmierung gelingt es also, denselben Code für unterschiedliche
Datentypen zu verwenden. Wir erhalten eine weitere Option zur Erstellung von wiederverwendba-
rem Quellcode. Weil diese Option z. B. in der Standardbibliothek intensiv genutzt wird, müssen wir
auf jeden Fall die Verwendung von generischen Typen und Methoden erlernen. Aber auch bei der
eigenen Software-Entwicklung sollten wir auf das Potential der Generizität nicht verzichten.
Wir werden in diesem Kapitel erste Erfahrungen mit typgenerischen Klassen und Methoden sam-
meln. Wegen der starken Verschränkung mit noch unbehandelten Themen (z. B. Interfaces, siehe
Kapitel 9) folgen später noch Ergänzungen zur Generizität.
Ein besonders erfolgreiches Anwendungsfeld für Typgenerizität sind die Klassen und Schnittstellen
zur Verwaltung von Listen, Mengen oder Schlüssel-Wert - Tabellen (Abbildungen) im Java Collec-
tions Framework, das im Kapitel 10 vorgestellt wird. Auf Beispiele aus dem Bereich der Kollekti-
onsverwaltung kann auch das aktuelle Kapitel nicht verzichten.
Weitere Details zu generischen Typen und Methoden in Java finden Sie z. B. bei Bloch1 (2018, Ka-
pitel 5), Bracha (2004) sowie Naftalin & Wadler (2007).
1
Joshua Bloch hat nicht nur ein lesenswertes Buch über Java verfasst (2018, Effective Java), sondern auch viele Klas-
sen im Java-API programmiert und insbesondere das Java Collections Framework entworfen.
418 Kapitel 8 Generische Klassen und Methoden
Dabei wurde der aus Kompatibilitätsgründen auch in der aktuellen Java-Version noch unterstützte,
sogenannte Rohtyp der generischen Klasse ArrayList genutzt. Diese veraltete und verbesserungs-
bedürftige Praxis ist hier noch einmal zu sehen, damit gleich im Kontrast die Vorteile der korrekten
Nutzung generischer Klassen deutlich werden.
Im Unterschied zu einem Array (siehe Abschnitt 5.1) bietet die Klasse ArrayList bei der eben vor-
geführten Verwendungsart:
• eine automatische Größenanpassung
• Typflexibilität bzw. -beliebigkeit
In der Praxis ist aber in der Regel ein Container mit automatischer Größenanpassung für Objekte
eines bestimmten, identischen Typs gefragt (z. B. zur Verwaltung von String-Objekten). Bei die-
sem Verwendungszweck stören zwei Nachteile der Typbeliebigkeit:
• Weil beliebige Objekte zugelassen sind, kann der Compiler keine Typsicherheit garantie-
ren. Er kann nicht sicherstellen, dass ausschließlich Objekte der gewünschten Klasse in den
Container eingefüllt werden. Viele Programmierfehler werden erst zur Laufzeit (womöglich
vom Kunden) entdeckt.
• Aus dem Container entnommene Objekte können erst nach einer expliziten Typumwand-
lung die Methoden ihrer Klasse ausführen. Die häufig benötigten Typumwandlungen sind
lästig und fehleranfällig.
Im folgenden Beispielprogramm sollen String-Objekte in einem Container mit dem ArrayList-
Rohtyp verwaltet werden:
import java.util.ArrayList;
class RawArrayList {
public static void main(String[] args) {
ArrayList al = new ArrayList();
// Bitte nur String-Objekte einfüllen!
al.add("Otto");
al.add("Rempremerding");
al.add('.');
int i = 0;
for (Object s: al)
System.out.printf("Länge von String %d: %d\n", ++i, ((String)s).length());
}
}
Bevor ein mutmaßliches String-Element des Containers nach seiner Länge befragt werden kann, ist
eine lästige Typumwandlung fällig, weil der Compiler nur die deklarierte Typzugehörigkeit Object
kennt, z. B.:
((String)s).length()
Beim dritten add() - Aufruf des Beispielprogramms wird ein Character-Objekt (per Autoboxing)
in den Container befördert. Weil der Container eigentlich zur Aufbewahrung von String-Objekten
gedacht war, liegt hier ein Programmierfehler vor, den der Compiler aber wegen der mangelhaften
Typsicherheit nicht verhindern kann. Beim Versuch, das Character-Objekt als String-Objekt zu
behandeln, scheitert das Programm am folgenden Ausnahmefehler vom Typ ClassCastException:
Exception in thread "main" java.lang.ClassCastException:
java.base/java.lang.Character cannot be cast to java.base/java.lang.String
at RawArrayList.main(RawArrayList.java:11)
In den ersten Jahren der Programmiersprache Java war die Verwendung von Kollektions-Rohtypen
wie ArrayList die Standardtechnik zur typ-generellen Programmierung. Seit Java 5 bieten aber die
generischen Klassen im Java Collections Framework (siehe Kapitel 10) weitaus bessere Lösungen.
Bei der Erstellung generischer Klassen kommt man aber weiterhin um die Verwendung von mög-
Abschnitt 8.1 Generische Klassen 419
lichst allgemeinen Datentypen wie Object für Felder und Parameter nicht herum (siehe Abschnitt
8.1.3).
1
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/ArrayList.html
420 Kapitel 8 Generische Klassen und Methoden
1
https://docs.oracle.com/javase/tutorial/java/generics/types.html,
http://www.angelikalanger.com/GenericsFAQ/FAQSections/ParameterizedTypes.html
Abschnitt 8.1 Generische Klassen 421
Während die Entwickler seit Java 5 (alias 1.5) generische Klassen verwenden und erstellen können,
weiß die JVM nichts von dieser Technik. Die damit zahlreich erforderlichen Typumwandlungen
fügt der Compiler automatisch in den Bytecode ein.
Weil Typformalparameter im Bytecode durch den generellsten zulässigen Datentyp, der stets ein
Referenztyp ist, ersetzt werden, muss für konkretisierende Typen Zuweisungskompatibilität zu die-
sem Datentyp bestehen. Aus einem unrestringierten Typformalparameter resultiert im Bytecode der
Typ Object, z. B. bei der Deklaration von Variablen. Solche Variablen können also keinen Wert
mit primitivem Datentyp aufnehmen. Dies hat zur Folge, dass zur Konkretisierung von Typformal-
parametern nur Referenztypen erlaubt sind. Ein primitiver Typ ist also durch die zugehörige Verpa-
ckungsklasse zu ersetzen.
Auf ihre Klassenzugehörigkeit befragt, nennen Objekte eines parametrisierten Typs stets den zuge-
hörigen Rohtyp, z. B.:
Quellcode Ausgabe
import java.util.ArrayList; class java.util.ArrayList
class Prog {
public static void main(String[] args) {
ArrayList<String> al = new ArrayList<>();
System.out.println(al.getClass());
}
}
Ist eine generische Klasse im Quellcode über ein sogenanntes Klassenliteral anzusprechen (Klas-
senname mit Suffix .class), dann muss der Rohtyp verwendet werden, z. B.:
if (al.getClass() == ArrayList.class)
System.out.println("Der Rohtyp von al ist ArrayList.");
Die Typlöschung ist auch bei Verwendung des im Abschnitt 7.5 vorgestellten instanceof-Operators
zu berücksichtigen, der die Zugehörigkeit eines Objekts zu einer bestimmten Klasse prüft. Er ak-
zeptiert keine parametrisierten Typen, sodass z. B. die folgende Anweisung nicht übersetzt werden
kann:
Anstelle des Rohtyps kann man auch auf den unrestringierten Wildcard-Datentyp (siehe Abschnitt
8.3.2) prüfen, was den Informationsgehalt der Abfrage aber nicht verändert:
System.out.println(al instanceof ArrayList<?>);
Von der strikten Empfehlung, den Rohtyp einer generischen Klasse im Quellcode eines Programms
zu vermeiden, müssen die folgenden Ausnahmen gemacht werden:
• import-Deklaration, z. B.:
import java.util.ArrayList;
• Klassenliteral, z. B.:
if (al.getClass() == ArrayList.class) ...
• instanceof-Operator, z. B.:
al instanceof ArrayList
• Namen von Konstruktoren (siehe Abschnitt 8.1.3).
• Besitzt eine generische Klasse eine statische Methode, ist beim Aufruf dieser Methode der
Rohtypname zu verwenden (siehe Abschnitt 8.4.2.1).
Schließlich taucht der Rohtyp einer Klasse noch im Dateinamen mit dem Quellcode auf. Der Quell-
code der Klasse ArrayList<E> steckt also in der Datei ArrayList.java.
422 Kapitel 8 Generische Klassen und Methoden
Als die Generizität in Java eingeführt wurde, existierte die Programmiersprache bereits ca. 10 Jahre,
sodass die Kooperation mit alten Java-Typen einen sehr hohen Stellenwert besaß und die aus heuti-
ger Sicht suboptimale Designentscheidung mit Typlöschung und Rohtyp erzwungen hat. Es war
z. B. unbedingt erforderlich, ein Objekt eines neu erstellten, parametrisierten Typs an eine vorhan-
dene Methode übergeben zu können.
Sofern man den Quellcode einer nicht-generischen Klasse besitzt, ist die Transformation in eine
generische Variante ohne großen Aufwand möglich. So werden die Vorteile der generischen Pro-
grammierung genutzt, ohne die Interoperabilität mit älteren Lösungen zu verlieren.
Java-Programmierer müssen lernen, mit der „latenten Gefahr“ des Rohtyps zu leben. Vor allem ist
die Deklaration einer Referenzvariablen vom Rohtyp (z. B. ArrayList) zu unterlassen, weil ihr
(versehentlich) ein Objekt eines parametrisierten Typs (z. B. ArrayList<String>) zugewiesen wer-
den könnte:
public static void main(String[] args) {
ArrayList<String> alString = new ArrayList<>(5);
ArrayList alObject = alString;
alObject.add(13);
System.out.println(alString.get(0).length());
}
Ein Aufruf dieser main() - Methode führt zu einer ClassCastException, weil das eingeschmuggelte
Integer-Objekt (Autoboxing!) keine length() - Methode beherrscht:
Exception in thread "main" java.lang.ClassCastException: java.base/java.lang.Integer
cannot be cast to java.base/java.lang.String
at Prog.main(Prog.java:7)
Ist ausnahmsweise ein „Gemischtwaren“ - Container gewünscht, sollte trotzdem nicht der Rohtyp
verwendet werden, sondern eine Konkretisierung mit dem Elementtyp Object, z. B.:
ArrayList<Object> alObject = new ArrayList<>();
Bei einer Referenzvariablen vom Typ ArrayList<Object> kann der eben beschriebene Fehler nicht
auftreten:
halten, weil die Klasse String eine Spezialisierung der Urahnklasse Object ist. Wie Sie im Kapitel
7 über Vererbung gelernt haben, können Objekte einer abgeleiteten Klasse über Referenzvariablen
der Basisklasse angesprochen werden. Der Compiler verbietet jedoch, ein Objekt der Klasse Ar-
rayList<String> über eine Referenzvariable vom Typ ArrayList<Object> anzusprechen, z. B.:
Ein Objekt der Klasse ArrayList<Object> kann als „Gemischtwarenladen“ Objekte von beliebi-
gem Typ aufnehmen, während in einem Objekt vom Typ ArrayList<String> nur String-Objekte
zugelassen sind. Ein Objekt vom Typ ArrayList<String> ist also nicht in der Lage, den Job eines
Objekts vom Typ ArrayList<Object> zu übernehmen. Dies ist aber von einer abgeleiteten Klasse
zu fordern (siehe Abschnitt 7.9). Die oben formulierte naive Spezialisierungsvermutung ist also
falsch.
Hinsichtlich der Zuweisungskompatibilität in Abhängigkeit vom Elementtyp (und damit bei der
Typsicherheit) besteht ein wichtiger Unterschied zwischen generischen Klassen und Arrays. Wäh-
rend der Compiler die Zuweisung
Bloch (2018, S. 129) empfiehlt, bei Schwierigkeiten mit der Kombination von Arrays und generi-
scher Programmierung die Arrays durch Listen (z. B. durch Objekte aus der API-Klasse Array-
List<E>) zu ersetzen:
As a rule, arrays and generics don't mix well. If you find yourself mixing them and getting com-
pile-time errors or warnings, your first impulse should be to replace the arrays with lists.
Ein ArrayList-Container mit Elementen vom parametrisierten Typ ArrayList<String> lässt sich
erstellen, z. B.:
ArrayList<ArrayList<String>> alals = new ArrayList<>();
Wie ein Blick in den Quellcode der Klasse ArrayList<E> zeigt, wird bei der Anwendung von
Blochs Empfehlung das Problem auf die Programmierer der API-Klasse aus dem Java Collection
Framework übertragen. Die Klasse ArrayList<E> speichert ihre Listenelemente intern in einem
Array, wobei der Typ Object[] verwendet wird.
Die im aktuellen Abschnitt beschriebene Einschränkung stört nicht bei der Verwendung der im Ja-
va-API und in anderen Bibliotheken zahlreich vorhandenen generischen Klassen. Bei der Definition
einer eigenen generischen Klasse lässt sich das Problem ohne allzu großen Aufwand lösen. Die
eben erwähnte Lösung aus der API-Klasse ArrayList<E> werden wir im Abschnitt 8.1.3 auch für
ein eigenes Beispiel verwenden.
Wer momentan nicht daran interessiert ist, warum in Java die generische Programmierung und die
Arrays mit etwas Aufwand zur Kooperation gebracht werden müssen, kann die Lektüre mit dem
Abschnitt 8.1.3 fortsetzen.
Wie das folgende Beispiel zeigt, könnte der Compiler bei einem Array mit einem parametrisierten
Elementtyp nicht für Typsicherheit sorgen.1 Er verhindert daher die Objektkreation:
1
Das Beispiel wurde übernommen von Bloch (2018, S. 127) bzw. Flanagan (2005, S. 166), wo es in weitgehend
identischer Form zu finden ist.
Abschnitt 8.1 Generische Klassen 425
Würde der Compiler die Objektkreation in Zeile 4 erlauben, käme es zur Laufzeit zu einer Class-
CastException:
• Die parametrisierten Typen ArrayList<String> und ArrayList<Integer> werden zur Lauf-
zeit durch den Rohtyp ArrayList ersetzt.
• Wegen der Kovarianz von Arrays ist ArrayList[] eine Spezialisierung des Typs Object[],
sodass der Compiler die Zeile 10 nicht beanstandet.
• In Zeile 11 wird ein ArrayList<Integer> - Objekt als Element 0 in den Object-Array ao
aufgenommen, was der Compiler erlauben muss, weil hier beliebige Objekte erlaubt sind.
• Auch zur Laufzeit würde die Zeile 11 kein Problem machen (keine ArrayStoreException
verursachen), obwohl der per ao angesprochene Array sehr wohl wüsste, dass seine Elemen-
te vom Rohtyp ArrayList sind. Schließlich hat das eingefügte Element alint ja genau die-
sen Rohtyp.
• In der Zeile 12 wird ausgenutzt, dass ein ArrayList<String> - Container nur Objekte vom
Typ String enthalten kann. Genau hier käme es zur ClassCastException, weil das Element
0 von aals kein ArrayList<String> - Container, sondern ein ArrayList<Integer> - Con-
tainer wäre.
Wegen der Kovarianz von Arrays muss ihr Elementtyp reifizierbar sein, d .h. zur Laufzeit darf
nicht weniger Information über den Typ zur Verfügung stehen als zur Übersetzungszeit (Bloch
2018, S. 127; siehe auch Gosling et al. 2021, Abschnitt 4.7). Bei parametrisierten Typen wie Ar-
rayList<String> sorgt aber die Typlöschung für eine solche Informationsreduktion. Folglich ver-
bietet der Compiler die Array-Kreation mit einem parametrisierten Elementtyp. Generell gilt, dass
kein Array mit einem nicht-reifizierbaren Elementtyp erstellt werden kann.
1
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/SafeVarargs.html
426 Kapitel 8 Generische Klassen und Methoden
List<Integer> eingefügte Integer-Objekt wird mit Hilfe des Referenzparameters als String-Objekt
behandelt, was eine ClassCastException zur Folge hat.
Der Java-Compiler im OpenJDK übersetzt das Programm, warnt aber wegen des Serienparameters
mit parametrisiertem Typ:
>javac Prog.java
Note: Prog.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.
Der kapitale Semantikfehler im Programm spielt bei der Compiler-Warnung keine Rolle. Die War-
nung erfolgt auch bei komplett leerem Rumpf. Bei einer Übersetzung mit der Option -Xlint wird
das Problem beschrieben:
>javac -Xlint Prog.java
Prog.java:3: warning: [unchecked] Possible heap pollution from parameterized
vararg type ArrayList<String>
private void genVarargs(ArrayList<String>... stringLists) {
^
Weil auch nützliche und sichere Methoden möglich sind, die einen Serienparameter mit parametri-
siertem Typ verwenden, haben die Java-Designer an dieser Stelle die Array-Kreation mit einem
nicht-reifizierbaren Elementtyp zugelassen, die ansonsten unterbunden wird (siehe Abschnitt
8.1.2.3). Mit der seit Java 7 verfügbaren Annotation @SafeVarargs (siehe Abschnitt 9.6.4) versi-
chert man dem Compiler, dass eine Methode mit ihren Serienparameter von einem konkretisierten
generischen Typ keine unsicheren Operationen ausführt, sodass der Compiler auf die Warnung ver-
zichtet. In Java 7 und 8 ist die Annotation @SafeVarargs nur erlaubt bei
• finalen Methoden
• statischen Methoden
• Konstruktoren
In dieser Liste befinden sich nur Methoden, die beim Vererben nicht überschrieben werden können,
denn für überschreibbare Methoden könnte kein Programmierer eine Garantie geben. Ein Über-
schreiben scheidet aber auch bei privaten Methoden aus, und seit Java 9 ist daher die Annotation
@SafeVarargs auch bei privaten Methoden erlaubt. In der folgenden sinn- und harmlosen Variante
des Beispielprogramms wird mit der @SafeVarargs - Annotation eine unbegründete Compiler-
Warnung verhindert:
import java.util.ArrayList;
class Prog {
// Kein Schreibzugriff auf den varargs-Array, keine Weitergabe der Adresse
@SafeVarargs
private void genVarargs(ArrayList<String>... stringLists) {
System.out.println(stringLists.length);
}
public static void main(String[] args) {
Prog p = new Prog();
p.genVarargs(new ArrayList<String>());
}
}
Nach Bloch (2018, S. 147) kann und sollte die @SafeVarargs - Annotation zur Unterdrückung von
irrelevanten Warnungen verwendet werden, wenn ...
• in der Methode keine Schreibzugriffe auf den Serienparameter stattfinden,
• keine Referenz auf das Array-Objekt des Serienparameters weitergegeben wird (z. B. per
Rückgabe).
Abschnitt 8.1 Generische Klassen 427
public SimpleList() {
initSize = DEF_INIT_SIZE;
elements = new Object[DEF_INIT_SIZE];
}
public void add(E element) {
if (size == elements.length)
elements = Arrays.copyOf(elements, elements.length + initSize);
elements[size++] = element;
}
1
Hier ist ein Beispiel zu finden: https://jaxenter.com/java-14-records-deep-dive-169879.html
428 Kapitel 8 Generische Klassen und Methoden
Die generische Klasse SimpleList<E> verwendet intern zur Ablage ihrer Elemente einen Array
namens elements. Aufgrund der im Abschnitt 8.1.2.1 erläuterten Typlöschung kann jedoch kein
Array mit Elementen vom Typ E erzeugt werden. Ein entsprechender Versuch führt zu einer Feh-
lermeldung wie im folgenden Beispiel:
Der Elementtyp des Arrays kann also leider nicht über den Typformalparameter bestimmt werden.
Auf dieses Problem stößt man regelmäßig bei der Definition einer Kollektionsklasse, die im Hinter-
grund einen Array zur Datenverwaltung verwendet.
In der Definition einer generischen Klasse mit dem Typformalparameter E ist es aufgrund der
Typlöschung auch nicht möglich, ein einzelnes Objekt vom Typ E zu erstellen, z. B.:
Die JVM weiß nicht, durch welche Klasse der Typformalparater E konkretisiert wird, und kann
folglich den zugehörigen Konstruktor nicht aufrufen.
Wir müssen bei der Array-Kreation als Elementtyp den generellsten zulässigen Datentyp für Kon-
kretisierungen von E verwenden: den Urahntyp Object.
Abschnitt 8.1 Generische Klassen 429
Beim Datentyp für die Referenzvariable elements haben wir zwei, letztlich äquivalente Alternati-
ven, die an unterschiedlichen Stellen in der Klassendefinition explizite Typumwandlungen erfor-
dern, deren Korrektheit der Compiler nicht sicherstellen kann, sodass der Programmierer die Ver-
antwortung übernehmen muss:
• Object[]
• E[]
Bei der Klasse SimpleList<E> wählen wir den ersten Weg. Weil also elements vom deklarier-
ten Typ Object[] ist, muss in der Methode get(), die ihren Rückgabetyp per Typparameter defi-
niert, eine Typumwandlung vorgenommen werden:
return (E) elements[index];
IntelliJ übermittelt die Warnung des Compilers vor einer ungeprüften Umwandlung:
Im aktuellen Beispiel kann ausgeschlossen werden, dass ein Element in den privaten Array
elements gelangt, das nicht vom Typ E ist, weil die einzige Möglichkeit zum Einfügen eines
Elements in der Verwendung der Methode add() besteht:
public void add(E element) {...}
Folglich kann bei der Typwandlung in get() nichts schiefgehen.
Nachweislich irrelevante Compiler-Warnungen sollten unterdrückt werden, damit wir uns nicht
durch häufige unbegründete Warnungen angewöhnen, Warnungen zu ignorieren. Um den Compiler
anzuweisen, eine Warnung zu unterlassen, fügt man eine sogenannte Annotation vom Typ
SuppressWarnings in den Quellcode ein und gibt in Klammern den Namen der zu ignorierenden
Warnung an (siehe Abschnitt 9.5). Eine Annotation vom Typ SuppressWarnings kann sich u. a.
auf eine Variable, Methode oder Klasse beziehen, z. B.:
// Casting erforderlich, weil kein Array vom Typ E erstellt werden kann.
// elements kann nur Objekte vom Typ E enthalten.
@SuppressWarnings("unchecked")
public E get(int index) {
if (index >= 0 && index < size)
return (E) elements[index];
else
return null;
}
Das Unterdrücken von Warnungen sollte mit einem möglichst begrenzten Gültigkeitsbereich erfol-
gen und außerdem kommentiert werden. In der anschließend vorgestellten Lösung wird es über eine
Hilfsvariable vermieden, die Unterdrückung auf die gesamte Methode zu beziehen:
public E get(int index) {
if (index >= 0 && index < size) {
// Casting erforderlich, weil kein Array vom Typ E erstellt werden kann.
// elements kann nur Objekte vom Typ E enthalten.
@SuppressWarnings("unchecked")
E result = (E) elements[index];
return result;
} else
return null;
}
430 Kapitel 8 Generische Klassen und Methoden
Wie oben erwähnt, ist es durchaus möglich, für die Referenzvariable elements den Datentyp E[]
zu verwenden und so die Typwandlung in der Methode get() zu vermeiden:
private E[] elements;
Allerdings muss man trotzdem einen Array vom Typ Object[] erzeugen, und die Typwandlung ist
nun an anderer Stelle fällig, z. B.:
elements = (E[]) new Object[DEF_INIT_SIZE];
Die eben beschriebene, letztlich äquivalente Technik wird im Abschnitt 8.1.3.2 bei einem ver-
gleichbaren Beispiel demonstriert.
Den Rohtyp zur Klasse SimpleList<E> kann man sich ungefähr so vorstellen:
package de.uni_trier.zimk.util.coll;
import java.util.Arrays;
Nachdem wir uns zuletzt mit Komplikationen der Generizitätslösung in Java herumschlagen muss-
ten, können wir uns nun bei der Beschäftigung mit einigen Details der Klasse SimpleList<E>
entspannen. Für den intern zur Datenspeicherung verwendeten Array wird als Länge der Voreinstel-
lungswert DEF_INIT_SIZE oder die per Konstruktorparameter festgelegte initiale Listenlänge
verwendet. In der Methode add() wird bei Bedarf mit Hilfe der statischen Arrays-Methode
copyOf() ein größerer Array erzeugt, der die Elemente des Vorgängers übernimmt. Solange die
Klasse SimpleList<E> keine Methode zum Löschen von Elementen bietet, müssen wir uns um
eine automatische Größenreduktion keine Gedanken machen. Das folgende Testprogramm demons-
triert u. a. die automatische Vergrößerung des privaten Arrays:
Abschnitt 8.1 Generische Klassen 431
Quellcode Ausgabe
import de.uni_trier.zimk.util.coll.SimpleList; Länge: 2, Kapazität: 3
Länge: 4, Kapazität: 6
class SimpleListTest { Otto
public static void main(String[] args) { Rempremmerding
SimpleList<String> sls = new SimpleList<>(3); Hans
sls.add("Otto"); Brgl
sls.add("Rempremmerding");
System.out.println("Länge: " + sls.size() +
", Kapazität: " + sls.capacity());
sls.add("Hans");
sls.add("Brgl");
System.out.println("Länge: "+sls.size() +
", Kapazität: " + sls.capacity());
for (int i = 0; i < sls.size(); i++)
System.out.println(sls.get(i));
}
}
Die API-Klasse HashMap<K,V> (siehe Abschnitt 10.7), die eine Tabelle mit Schüssel-Wert - Paa-
ren verwaltet, ist ein Beispiel für eine generische Klasse mit zwei Typformalparametern:1
Module java.base
Package java.util
Class HashMap<K,V>
java.lang.Object
java.util.AbstractMap<K,V>
java.util.HashMap<K,V>
Type Parameters:
K - the type of keys maintained by this map
V - the type of mapped values
Ein unbeschränkter Typformalparameter kann sogar durch die (von Object abstammende) Klasse
Void konkretisiert werden, die als Pseudo-Typ zum Schlüsselwort void dient. Einer Variablen bzw.
einem Parameter von diesem Typ kann nur das Referenzliteral null zugewiesen werden, was im
folgenden Programm geschieht:
Quellcode Ausgabe
class Prog<T> { null
T givePar(T t) {
return t;
}
Im Abschnitt 15.5.1 wird uns mit der parametrisierten Klasse ForkJoin<Void> ein relevantes Bei-
spiel für die Verwendung der Klasse Void begegnen.
1
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/HashMap.html
432 Kapitel 8 Generische Klassen und Methoden
@SuppressWarnings("unchecked")
// Casting erforderlich, weil kein Array vom Typ E erstellt werden kann.
// elements kann nur Objekte vom Typ E enthalten.
public SimpleSortedList(int len) {
if (len > 0) {
initSize = len;
elements = (E[]) new Comparable[len];
} else {
initSize = DEF_INIT_SIZE;
elements = (E[]) new Comparable[DEF_INIT_SIZE];
}
}
@SuppressWarnings("unchecked")
// Casting erforderlich, weil kein Array vom Typ E erstellt werden kann.
// elements kann nur Objekte vom Typ E enthalten.
public SimpleSortedList() {
initSize = DEF_INIT_SIZE;
elements = (E[]) new Comparable[DEF_INIT_SIZE];
}
1
Die Definition der Klasse wurde durch Paul Frischknecht entscheidend verbessert.
Abschnitt 8.1 Generische Klassen 433
Während im Abschnitt 8.1.3.1 (bei der Klasse SimpleList<E>) die erste Technik zum Einsatz
kam (mit dem Typ Object[] für den internen Array), wird im aktuellen Beispiel die zweite Technik
verwendet.
Abschnitt 8.2 Generische Methoden 435
Wie das folgende Testprogramm zeigt, hält ein Objekt einer Konkretisierung der Klasse
SimpleSortedList<E extends Comparable<E>> seine Elemente stets in sortiertem Zustand:
Quellcode Ausgabe
import de.uni_trier.zimk.util.coll.SimpleSortedList; Länge: 4 Kapazität: 6
1
class SimpleSortedListTest { 2
public static void main(String[] args) { 4
SimpleSortedList<Integer> si = new SimpleSortedList<>(3); 11
si.add(4); si.add(11); si.add(1); si.add(2);
System.out.println("Länge: " + si.size()+
" Kapazität: " + si.capacity());
for (int i = 0; i < si.size(); i++)
System.out.println(si.get(i));
}
}
Außerdem verhindert er das Konkretisieren des Typparameters durch eine Klasse, die die Typrest-
riktion nicht erfüllt, z. B.:
Man kann für einen Typformalparameter auch mehrere Beschränkungen (Restriktionen) definieren,
die mit dem &-Zeichen verknüpft werden. Im folgenden Beispiel
public class MultiRest<E extends SuperKlasse & Comparable<E>> {...}
steht E für einen Datentyp, der …
• direkt oder indirekt von SuperKlasse abstammt, wobei auch die SuperKlasse selbst zu-
gelassen ist,
• die Schnittstelle Comparable<E> implementiert.
In Bezug auf die Typlöschung (vgl. Abschnitt 8.1.2.1) ist zu beachten, dass sich die obere Schranke
bei multiplen Restriktionen ausschließlich an der ersten Restriktion orientiert, sodass im letzten
Beispiel der Typ SuperKlasse resultiert (siehe Naftalin & Wadler 2007, S. 55).
Quellcode Ausgabe
class Prog { String-max: def
static <T extends Comparable<T>> T max(T x, T y) { int-max: 4711
return x.compareTo(y) >= 0 ? x : y;
}
In der Definition einer generischen Methode befindet sich unmittelbar vor dem Rückgabetyp zwi-
schen spitzen Klammern mindestens ein Typformalparameter. Mehrere Typparameter werden durch
Kommata getrennt. Sie sind als Datentypen für den Rückgabewert, für Parameter und für lokale
Variablen erlaubt. Zur Formulierung von Typrestriktionen verwendet man wie bei den generischen
Klassen das Schlüsselwort extends (siehe Beispiel, vgl. Abschnitt 8.1.3.2).
Verwendet eine Methode einer generischen Klasse einen Typparameter der Klasse als Formalpara-
meter- oder Rückgabetyp, dann spricht man nicht von einer generischen Methode, weil keine eige-
nen Typparameter definiert werden, z. B. bei der Methode add() der im Abschnitt 8.1.3 beschrie-
benen Klasse SimpleList<E>:
public void add(E element) {
. . .
}
Wie bei generischen Klassen sind auch bei generischen Methoden als Konkretisierung für einen
Typformalparameter nur Referenztypen zugelassen. Zwar werden über Verpackungsklassen und
Auto(un)boxing auch primitive Typen unterstützt (siehe obiges Beispiel), doch sollte eine große
Zahl von Auto(un)boxing-Operationen wegen des damit verbundenen Zeitaufwandes vermieden
werden (siehe unten).
Beim Aufruf einer generischen Methode kann der Compiler fast immer aus den Datentypen der
Aktualparameter die passende Konkretisierung ermitteln (Typinferenz). Daher konnte im obigen
Beispiel an Stelle der kompletten Syntax
System.out.println("int-max:\t" + Prog.<Integer>max(12, 4711));
die folgende Kurzschreibweise verwendet werden:
System.out.println("int-max:\t" + max(12, 4711));
Bei seiner Bytecode-Produktion erstellt der Compiler eine Methode und ersetzt dabei die Typpara-
meter jeweils durch den generellsten erlaubten Typ (z. B. Comparable). Eine im Quellcode mehr-
fach konkretisierte generische Methode landet also nur einmal im Bytecode. Die gelöschten Typ-
konkretisierungen werden vom Compiler durch Typumwandlungen ersetzt.
Bei generischen Methoden sind Überladungen erlaubt, auch unter Beteiligung von gewöhnlichen
Methoden, z. B.:
Abschnitt 8.2 Generische Methoden 437
Quellcode Ausgabe
class Prog { String-max: def
static <T extends Comparable<T>> T max(T x, T y) { int-max: 4711
return x.compareTo(y) > 0 ? x : y;
}
Der Compiler ermittelt zu einem konkreten Aufruf die am besten passende Methode und beschwert
sich in Zweifelsfällen.
Wie oben erwähnt, kann es sich lohnen, eine generische Methode durch eine Überladungsfamilie
von Methoden zur Unterstützung primitiver Typen zu ergänzen, um den Zeitaufwand von Au-
to(un)boxing-Operationen zu vermeiden. Im folgenden Programm finden jeweils 1 Million Aufrufe
einer generischen und einer konventionellen Methode zur Bestimmung des Maximums von zwei
int-Werten statt:
class Prog {
static final int VERGL = 1_000_000;
start = System.currentTimeMillis();
for (int i = 0; i < VERGL; i++)
imax(12, 4711);
System.out.println("Zeit für " + VERGL + " Aufrufe der traditionellen Methode: \t"+
(System.currentTimeMillis() - start));
}
}
Dabei verursacht die generische Methode einen deutlich höheren Zeitaufwand (in Millisekunden):
Zeit für 1000000 Aufrufe der generischen Methode: 21
Zeit für 1000000 Aufrufe der traditionellen Methode: 3
438 Kapitel 8 Generische Klassen und Methoden
Während die generische max() - Methode für zwei einzelne Argumente dank Autoboxing auch mit
primitiven Konkretisierungen arbeitet, lässt sich eine analoge generische Methode zur Bestimmung
eines maximalen Array-Elements
static <T extends Comparable<T>> T max(T[] ar) {
. . .
}
nicht für Arrays mit einem primitiven Typ nutzen. Z. B. lässt sich der folgende Aufruf mit einem
Aktualparameter vom Typ int[] nicht übersetzen:
Der Java-Compiler nimmt kein Autoboxing auf Array-Ebene vor, ersetzt also z. B. keinesfalls int[]
durch Integer[]. Genau das wäre zur Nutzung der generischen Methode aber erforderlich, weil der
Typformalparameter nur durch Referenztypen konkretisiert werden darf. Soll eine max() - Metho-
de auch Arrays mit primitiven Elementtypen als Aktualparameter unterstützen, dann muss man ent-
sprechende Überladungen erstellen.
Im letzten Beispiel kann man sich durch die explizite Verwendung eines Integer[] -Arrays helfen:
System.out.println("Max. von int-Serie: " + max(new Integer[] {4, 777, 11, 81}));
Bei einer generischen Methode, die das maximale Element zu einer beliebig langen Serie von Ar-
gumenten zurückgibt,
public static <T extends Comparable<T>> T max(T... ar) {
. . .
}
klappt das Autoboxing und damit die Nutzung durch Argumente mit primitivem Typ, z. B.:
System.out.println("Max. von int-Serie: " + max(4, 777, 11, 81));
Offenbar verfährt der Compiler hier analog zu einer Array-Initalisierungsliste, z. B.:
Integer[] ar2 = {4, 777, 11, 81};
8.3 Wildcard-Datentypen
Generische Klassen verhalten sich invariant bei der Übertragung der Spezialisierungsrelation von
den Elementdatentypen auf die parametrisierten Klassen (vgl. Abschnitt 8.1.2.2), sodass z. B. der
parametrisierte Datentyp SimpleList<Integer> keine Spezialisierung des parametrisierten Typs
SimpleList<Number> ist, obwohl die numerischen Verpackungsklassen Integer, Double etc.
(vgl. Abschnitt 5.3) von der Klasse Number abstammen:
java.lang.Object
java.lang.Number
java.lang.Double java.lang.Integer
Abschnitt 8.3 Wildcard-Datentypen 439
Folglich ist z. B. bei einem Methodenformalparameter vom Typ SimpleList<Number> als Aktu-
alparameter keine Referenz auf ein Objekt vom Typ SimpleList<Integer> zugelassen.
Es ist jedoch oft wünschenswert, für einen Methodenparameter einen generischen Datentyp zu ver-
einbaren und dabei unterschiedliche (geeignet restringierte) Konkretisierungen der Typformalpara-
meter zu erlauben. Genau dies ermöglicht Java über die mit Hilfe eines Fragezeichens definierten
Wildcard-Datentypen.
Dem folgenden unbeschränkten Wildcard-Typ
SimpleList<?>
genügt jede Konkretisierung der generischen Klasse SimpleList<E>. Verwendet eine Methode
diesen Wildcard-Typ für einen Formalparameter, dann kann als Aktualparameter ein Objekt aus
einer beliebigen Konkretisierung von SimpleList<E> übergeben werden (siehe Abschnitt 8.3.2).
Häufiger als der unbeschränkte Wildcard-Datentyp wird die beschränkte Variante benötigt, wobei
z. B. als Konkretisierungen für einen Typformalparameter eine Basisklasse und deren Ableitungen
erlaubt sind. Mit diesem praxisrelevanten Fall werden wir uns zuerst beschäftigen.
Wir halten fest, dass es sich bei den Wildcard-Typen um spezielle, partiell offene parametrisierte
Datentypen handelt, die hauptsächlich bei Methodendefinitionen (aber nicht nur dort) Verwendung
finden.
Aufgrund der Invarianz generischer Klassen (keine Übertragung der Ableitungsbeziehung von
Typparameterkonkretisierungen auf die parametrisierten Klassen) ist SimpleList<Integer>
keine Spezialisierung von SimpleList<Number>.
Das Problem ist mit einem nach oben beschränkten Wildcard-Datentyp (engl.: upper bound) für
den Parameter der Methode addList() zu lösen, wobei SimpleList<E> - Konkretisierungen mit
dem Typ E oder mit einer Ableitung von E erlaubt sind:
public void addList(SimpleList<? extends E> list) {
. . .
}
Mit der verbesserten Methode kann eine Integer-Liste komplett in eine Number-Liste aufgenom-
men werden, was im folgenden Programm demonstriert wird:
Quellcode Ausgabe
import de.uni_trier.zimk.util.coll.SimpleList; Element Typ
13 java.lang.Integer
1.13 java.lang.Double
class SimpleListWildcardTest {
101 java.lang.Integer
public static void main(String[] args) { 102 java.lang.Integer
SimpleList<Number> sln = new SimpleList<>(3); 103 java.lang.Integer
sln.add(13); sln.add(1.13);
SimpleList<Integer> sli = new SimpleList<>(3);
sli.add(101); sli.add(102); sli.add(103);
sln.addList(sli);
System.out.println("Element\tTyp");
for (int i=0; i < sln.size(); i++)
System.out.println(sln.get(i) +
" \t" + sln.get(i).getClass().getName());
}
}
Nur selten verwendet man Wildcard-Datentypen für lokale Variablen und Felder. Als Rückgabetyp
von Methoden sind sie zwar erlaubt, aber nicht empfehlenswert, weil die Benutzung einer derarti-
gen Methode zur Verwendung eines Wildcard-Datentyps zwingen würde (Bloch 2018, S. 142).
Das ist aber keine wesentliche Einschränkung, weil eigenständig generische Methoden erlaubt sind,
z. B.:
Abschnitt 8.4 Einschränkungen der Generizitätslösung in Java 443
class Gent<E> {
static <T extends Comparable<T>> T max(T x, T y) {
return x.compareTo(y) >= 0 ? x : y;
}
. . .
}
class Prog {
public static void main(String[] args) {
System.out.println("String-max:\t" + Gent.max("abc", "def"));
}
}
Wie das Beispiel zeigt, richtet man sich beim Aufruf einer statischen Methode in einer generischen
Klasse an den Rohtyp.
8.4.2.2 Member aus einer per Typparameter bestimmten Klasse sind verboten
Weil zur Laufzeit alle Typformalparameter durch ihre obere Schranke (z. B. Object) ersetzt sind,
kann der Typ eines zu erzeugenden Objekts nicht über Typformalparameter festgelegt werden.
Wird z. B. eine generische Klasse unter Verwendung des Typformalparameters E definiert, lässt
sich in der Klassendefinition kein Objekt vom Typ E erzeugen:
Die JVM weiß schlicht nicht, welchen Konstruktor sie aufrufen soll.
Damit lässt sich natürlich auch kein Array mit einem generisch bestimmten Elementtyp erstellen,
was bei Kollektionsklassen mit interner Array-Datenablage zu Lücken in der vom Compiler garan-
tierten Typsicherheit führt. Davon ist aber nur die Definition einer generischen Klasse betroffen,
nicht ihre Verwendung. Bei der Definition kann und muss der Entwickler durch gerechtfertigte Ty-
pumwandlungen für generische und stabile Klassen sorgen. Im Abschnitt 8.1.3.1 haben wir die ge-
nerische Kollektionsklasse SimpleList<E> definiert und zur internen Verwaltung der Listenele-
mente einen Object-Array verwendet:
private Object[] elements;
private final static int DEF_INIT_SIZE = 16;
. . .
public SimpleList() {
initSize = DEF_INIT_SIZE;
elements = new Object[DEF_INIT_SIZE];
}
In der SimpleList<E> - Methode get(), die ihren Rückgabetyp per Typparameter definiert, war
daher eine explizite Typumwandlung erforderlich:
public E get(int index) {
if (index >= 0 && index < size) {
// Casting erforderlich, weil kein Array vom Typ E erstellt werden kann.
// elements kann nur Objekte vom Typ E enthalten.
@SuppressWarnings("unchecked")
E result = (E) elements[index];
return result;
} else
return null;
}
Weil im Rohtyp der Typformalparameter durch die obere Schranke Object ersetzt ist, muss durch
eine explizite Typumwandlung unter der Verantwortung des Klassendesigners dafür gesorgt wer-
den, dass die Methode get() eine Referenz vom erwarteten Typ abliefert. Der Compiler macht mit
444 Kapitel 8 Generische Klassen und Methoden
8.4.2.3 Member aus einer per Typparameter konkretisierten generischen Klasse sind erlaubt
In einer generischen Klasse oder Methode kann kein Objekt einer per Typformalparameter be-
stimmten Klasse erstellt werden, weil die JVM die Klasse nicht kennt und folglich nicht weiß, wel-
cher Konstruktor aufzurufen ist. Es ist aber möglich, ein Objekt einer per Typformalparameter kon-
kretisierten generischen Klasse zu erstellen. Zu dieser Leistung ist die JVM trotz Typlöschung fä-
hig, weil sie nur ein Objekt des Rohtyps zu erstellen hat.
Im folgenden Beispiel verwendet die generische Klasse Genni<E> intern ein Objekt der Klasse
ArrayList<E>, das sie problemlos unter Verwendung des Typformalparameters erzeugen kann:
1
Mit dem per implements-Schlüsselwort zum Ausdruck gebrachten Implementieren von Schnittstellen beschäftigen
wir uns im Kapitel 9, und den Modifikator transient lernen wir im Zusammenhang mit der Serialisierung kennen.
Die bis Java 7 in der ArrayList<E> - Klassendefinition verwendete strikte Datenkapselung
private transient Object[] elementData;
ist seit Java 8 durch die voreingestellte Sichtbarkeit Paket ersetzt werden:
transient Object[] elementData;
Der für Mitgliedsklassen garantierte Zugriff auf private Member (vgl. Abschnitt 4.8.1) wird mit einem Zusatzauf-
wand erkauft, der nun durch eine Reduktion der Datenkapselung eingespart wird. Das geht gut, solange im Paket ja-
va.util keine Klasse die Zugriffsrechte auf elementData missbraucht.
Abschnitt 8.5 Übungsaufgaben zum Kapitel 8 445
import java.util.ArrayList;
2) Das folgende, bei Bloch (2018, S. 119) gefundene, Programm wird fehlerfrei übersetzt:
import java.util.ArrayList;
class Prog {
private static void addElement(ArrayList list, Object o) {
list.add(o);
}
3) Warum sollte man als Datentyp für „Gemischtwaren“ - Kollektionsobjekte die Parametrisierung
mit dem Elementtyp Object (z. B. ArrayList<Object>) gegenüber dem Rohtyp (z. B. ArrayList)
bevorzugen?
9 Interfaces
Ein Interface (dt.: eine Schnittstelle) kann in erster Näherung als Referenzdatentyp mit ausschließ-
lich abstrakten Methodendefinitionen beschrieben werden.1 Wenn eine instanzierbare (nicht abs-
trakte) Klasse von sich behaupten möchte, ein Interface zu implementieren, muss sie für jede abs-
trakte Methode im Interface eine konkrete Realisation (mit Methodenrumpf) besitzen. Objekte einer
implementierenden Klasse werden vom Compiler überall dort akzeptiert, wo für eine Referenzvari-
able oder für einen Referenzparameter das Interface als Datentyp vorgeschrieben ist. Auf diese
Weise erhöhen Interface-Datentypen die Flexibilität bei der Software-Entwicklung:
• Statt ein Member-Objekt oder einen Methodenparameter auf eine bestimmte Klasse (und ih-
re Ableitungen) festzulegen, wird über einen Interface-Datentyp lediglich das benötigte
Verhalten vorgeschrieben. Wenn z. B. für ein Member-Objekt statt der Kollektionsklasse
ArrayList<E> die Schnittstelle List<E> verwendet wird, kann beim Instanziieren situati-
onsadäquat eine Kollektion mit Array-Unterbau oder eine verkettetet Liste gewählt werden
(siehe z. B. Abschnitt 10.4.3).
• Polymorphie ist nicht mehr von einer gemeinsamen Basisklasse abhängig, sondern kann viel
flexibler über ein gemeinsam implementiertes Interface realisiert werden.
Mehr als ein Drittel aller Klassen im Java SE - API implementieren mindestens ein Interface.
9.1 Überblick
Zunächst wird an einem Beispiel erläutert, was wir über eine Klasse durch die Liste der von ihr im-
plementierten Interfaces erfahren. Dann beschäftigen wir uns mit dem primären Verwendungs-
zweck von Schnittstellen und mit den möglichen Bestandteilen einer Schnittstellendefinition.
9.1.1 Beispiel
Wer das Manuskript mit seinen zahlreichen, meist unvermeidlichen Vorgriffen auf das aktuelle Ka-
pitel aufmerksam gelesen hat, wird sich wohl kaum noch fragen müssen, was mit den Implemented
Interfaces gemeint ist, die in der Dokumentation zu zahlreichen API-Klassen an prominenter Stelle
angegeben werden, z. B. zur Verpackungsklasse Double im Paket java.lang (vgl. Abschnitt 5.3):2
Module java.base
Package java.lang
Class Double
java.lang.Object
java.lang.Number
java.lang.Double
All Implemented Interfaces:
Serializable, Comparable<Double>, Constable, ConstantDesc
Im konkreten Fall erfährt man, dass die Klasse Double die folgenden Schnittstellen implementiert:
1
Im Abschnitt 1.1.1 haben wir von der Schnittstelle einer Klasse gesprochen und dabei die Sammlung ihrer öffentli-
chen Methoden gemeint. In der Bezeichnung API steckt das englische Wort Interface mit derselben Bedeutung. Im
aktuellen Kapitel wird die Bezeichnung Schnittstelle bzw. Interface in einer deutlich anderen Bedeutung für einen
Bestandteil der Programmiersprache Java verwendet.
2
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Double.html
448 Kapitel 9 Interfaces
• Serializable
Weil die Klasse Double das Interface Serializable im Paket java.io implementiert, können
Double-Objekte auf bequeme Weise in eine Datei gespeichert und von dort eingelesen wer-
den. Diese beeindruckende, leider durch Sicherheitsprobleme belastete und zum Glück nicht
alternativlose Option werden wir im Kapitel 14 über die Ein- und Ausgabe kennenlernen.
Die Schnittstelle Serializable enthält keinerlei Methoden, und durch das Implementieren
signalisiert eine Klasse, dass sie nichts gegen das Serialisieren ihrer Objekte einzuwenden
hat.
• Comparable<Double>
Analog zu generischen Klassen (vgl. Abschnitt 8.1) unterstützt Java seit der Version 5 auch
Interfaces mit Typparametern (z. B. Comparable<T>). Weil die Klasse Double das para-
metrisierte Interface Comparable<Double> im Paket java.lang implementiert, ist für ihre
Objekte eine Anordnung definiert. Das hat z. B. zur Folge, dass die Objekte in einem Dou-
ble-Array mit der statischen Methode java.util.Arrays.sort() sortiert werden können, z. B.:
Double[] da = {15.3, 4.0, 78.1, 12.9};
java.util.Arrays.sort(da);
Um das parametrisierte Interface Comparable<Double> zu implementieren, muss die Klas-
se Double eine Methode mit dem folgenden Definitionskopf besitzen:
public int compareTo(Double d)
Wie Sie aus dem Abschnitt 5.2.1 wissen, beherrscht auch die Klasse String eine analoge
Methode. Das Beispiel der Klasse String lehrt uns, dass eine „vernünftige“ compareTo() -
Realisation keinen beliebigen int-Wert abliefern darf, sondern das Vergleichsergebnis fol-
gendermaßen mitteilen muss:
o Wenn das angesprochene Objekt in der Anordnung vor dem Parameterobjekt steht
(kleiner ist), dann wird ein negativer Rückgabewert geliefert.
o Wenn beide hinsichtlich der Anordnung gleich sind, dann wird die Rückgabe 0 ge-
liefert.
o Wenn das angesprochene Objekt in der Anordnung hinter dem Parameterobjekt steht
(größer ist), dann wird ein positiver Rückgabewert geliefert.
Weitere Details zum Vertrag der Methode compareTo() liefert Bloch (2018, S. 66ff).
• Constable, ConstantDesc
Diese mit Java 12 eingeführten Schnittstellen sind nur relevant für Personen, die einen
Compiler mit Bytecode-Ergebnis, eine virtuelle Maschine oder ein Code-Analyse - Werk-
zeug programmieren wollen.1 Wir werden uns also nicht mit diesen Schnittstellen beschäfti-
gen.
1
https://coderanch.com/t/734952/java/Constable-ConstantDesc-Interfaces-Introduced-Java
Abschnitt 9.1 Überblick 449
• Im Interface ist eine aus der Sicht der Klasse akzeptable default-Implementierung vorhan-
den.
• Es wird eine Implementierung von der direkten oder von einer indirekten Basisklasse geerbt.
Die mit einer Schnittstelle verbundene Verpflichtungserklärung ist in der Regel durch die Definiti-
onsköpfe der abstrakten Methoden nicht erschöpfend definiert. Meist beschreiben die Schnittstel-
lendesigner in der begleitenden Dokumentation das geforderte Verhalten der Methoden (siehe Bei-
spiel Comparable<T> im letzten Abschnitt).
Wenn sich eine Klasse zu einem Interface bekennt und die daraus resultierenden Verpflichtungen
erfüllt, dann wird ihr vom Compiler die Eignung für den Datentyp der Schnittstelle zuerkannt. Es
lassen sich zwar keine Objekte von einem Interface-Datentyp erzeugen, aber Referenzvariablen von
diesem Typ sind erlaubt und als Abstraktionsmittel sehr nützlich. Diese Variablen dürfen auf Ob-
jekte aus allen Klassen zeigen, die die Schnittstelle implementieren. Somit können Objekte unab-
hängig von den Vererbungsbeziehungen ihrer Typen gemeinsam verwaltet werden, wobei Metho-
denaufrufe polymorph erfolgen (d .h. mit später bzw. dynamischer Bindung, siehe Abschnitt 7.7).
Mit einer Schnittstelle sind für eine implementierende Klasse also Pflichten und Rechte verbunden:
• Die Klasse muss die im Interface enthaltenen und nicht mit einer default-Implementierung
ausgestatteten Instanzmethoden definieren (oder erben), wenn keine abstrakte Klasse entste-
hen soll (vgl. Abschnitt 7.8).
• Objekte der Klasse werden vom Compiler überall dort akzeptiert, wo der Interface-Datentyp
vorgeschrieben ist.
Im Programmieralltag kommen wir auf unterschiedliche Weise mit Schnittstellen in Kontakt, z. B.:
• Verwendung von vorhandenen Schnittstellen als Datentypen
In einer Methodendefinition kann es sinnvoll sein, Parameterdatentypen über Schnittstellen
festzulegen. In den Anweisungen der Methode werden Verhaltenskompetenzen der Parame-
terobjekte genutzt, die durch Schnittstellenverpflichtungen garantiert sind. Damit wird die
Typsicherheit ohne überflüssige Einengung erreicht.
Beispiel: Wenn man als Datentyp für eine Zeichenfolge das Interface CharSequence an-
gibt, kann der Methode beim Aufruf alternativ ein Objekt aus den implementie-
renden Klassen String, StringBuilder oder StringBuffer übergeben werden (sie-
he Abschnitt 9.4).
Sind bei der Definition einer generischen Klasse für einen beschränkten Typformalparame-
ter bestimmte Verhaltenskompetenzen zu fordern, dann gelingt das oft am besten per
Schnittstellendatentyp (siehe Abschnitt 8.1.3.2).
• Implementierung von vorhandenen Schnittstellen in einer eigenen Klassendefinition
Damit werden Variablen dieses Typs vom Compiler überall dort akzeptiert (z. B. als Aktu-
alparameter), wo die jeweiligen Schnittstellenkompetenzen gefordert sind.
Beispiel: Wenn unsere Klasse Bruch (siehe z. B. Abschnitt 4.1.3) das Interface Compara-
ble<Bruch> implementiert, dann können wir die bequeme Methode
Arrays.sort() verwenden, um einen Array mit Bruch-Objekten zu sortieren.
• Definition von eigenen Schnittstellen
Beim Entwurf eines Software-Systems, das als Halbfertigprodukt (oder Programmgerüst)
für verschiedene Aufgabenstellungen durch spezielle Klassen mit bestimmten Verhaltens-
kompetenzen zu einem lauffähigen Programm komplettiert werden soll, definiert man eige-
ne Schnittstellen, um die Interoperabilität der Klassen sicherzustellen. In diesem Fall spricht
man von einem Framework (z. B. Java Collections Framework, Java Persistence
Framework). Auch bei einem Entwurfsmuster (engl.: design pattern), das für eine konkre-
te Aufgabe bewährte Lösungsverfahren vorschreibt, spielen Schnittstellen oft eine wichtige
Rolle.
450 Kapitel 9 Interfaces
1
Sie finden diese Definition in der Datei Comparable.java, die wiederum im Archiv src.zip mit den API-
Quelltexten steckt. Das Quelltextarchiv landet bei der OpenJDK-Installation auf die Festplatte Ihres PCs (siehe Ab-
schnitt 3.1.5). Wenn im Manuskript zu einem API-Quellcode keine Java-Version angegeben wird, dann ist der
Quellcode in den Versionen 8 und 17 (essentiell) identisch.
Abschnitt 9.2 Interfaces definieren 451
Hinsichtlich der Dateiverwaltung gilt analog zu Klassen, dass ein public-Interface in einer eigenen
Datei gespeichert werden muss, wobei der Schnittstellenname für die Datei übernommen und die
Namenserweiterung .java angehängt wird. In der Regel wendet man diese Praxis bei allen Schnitt-
stellen an, die nicht in andere Typen eingeschachtelt sind.
Auch Schnittstellen werden in der Regel in ein benanntes Paket aufgenommen, sodass am Anfang
der Quellcodedatei eine package-Deklaration steht. Danach folgen nötigenfalls import-Deklaratio-
nen.2
Analog zu Klassen können Schnittstellen nicht nur auf Paketebene definiert werden, sondern ...
• auch innerhalb von Klassen oder anderen Schnittstellen (vgl. Abschnitt 4.8.1)
• sowie innerhalb von Methoden (vgl. Abschnitt 4.8.2).
Durch die Erweiterung der Java-Schnittstellen um ...
• Instanzmethoden mit default-Implementation
• statischen Methoden
• privaten Methoden
ist das ursprünglich klare und einfache Schnittstellen-Konzept von Java komplex geworden, sodass
bei der Lektüre des restlichen Abschnitts 9.2 keine Vergnügungssteuer anfällt. Mit dem Argument,
vorläufig keine eigenen Schnittstellen definieren zu wollen, können Einsteiger es wagen, zum Ab-
schnitt 9.3 zu springen.
1
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Comparable.html
2
Die in der API-Datei Comparable.java vorhandene import-Deklaration
import java.util.*;
(siehe Quellcode am Anfang des aktuellen Abschnitts) ist für die Interface-Definition nicht erforderlich und insofern
irritierend. Sie wird allerdings für den in Comparable.java vorhandenen Dokumentationskommentar benötigt.
452 Kapitel 9 Interfaces
9.2.3 Schnittstellen-Methoden
Die Methoden einer Schnittstelle sind per Voreinstellung öffentlich, und das Schlüsselwort public
kann weggelassen werden. In der Java Language Specification findet sich die folgende Empfehlung
(Gosling et al. 2021, Abschnitt 9.4):
It is permitted, but discouraged as a matter of style, to redundantly specify the public modifier
for a method declared in an interface.
Im Quellcode der wichtigen Java-API - Schnittstelle Comparable<T> findet sich allerdings zur
einzigen Methode compareTo() die folgende Definition:
public int compareTo(T o);
Seit Java 9 dürfen Schnittstellenmethoden auch als private deklariert werden.
Zwar dienen die meisten Schnittstellen dazu, Verhaltenskompetenzen von Klassen über abstrakte
Methodendefinitionen vorzuschreiben, doch sind für spezielle Zwecke auch Schnittstellen ohne
Methoden erlaubt (siehe unten).
Während bis Java 7 in Schnittstellen ausschließlich abstrakte Instanzmethoden erlaubt waren, sind
seit Java 8 auch möglich:
• Instanzmethoden mit default-Implementierung
• statische Methoden, wobei hier eine Implementierung vorgeschrieben ist.
In den nächsten Abschnitten werden die verschiedenen Varianten beschrieben.
Wenn eine Klasse mehrere Interfaces implementiert (siehe Abschnitt 9.3 zum Implementieren) und
dabei ein Konflikt mit Signatur-gleichen default-Methoden auftritt, dann verweigert der Compiler
die Übersetzung, z. B.:
Abschnitt 9.2 Interfaces definieren 455
Das Problem ist dadurch zu lösen, dass die betroffene Klasse die kritische Methode implementiert
oder als abstract definiert.
Ein analoges Problem tritt auf, wenn eine Schnittstelle von zwei anderen Schnittstellen Signatur-
gleiche default-Methoden erbt, z. B.:
Um das Problem zu lösen, muss die abgeleitete Schnittstelle die kritische Methode entweder (impli-
zit) als abstract deklarieren oder eine default-Implementierung vornehmen.
Eine default-Methode wird grundsätzlich ignoriert, wenn in einer implementierenden Klasse eine
Signatur-identische Methode vorhanden ist. Gegen eine in der Urahnklasse Object definierte Me-
thode (z. B. equals(), hashCode(), toString()) kann eine default-Methode also nie gewinnen, weil
sich die Object-Methode im (geerbten) Handlungsrepertoire aller Klassen befindet. Folglich ver-
hindert der Compiler die Definition einer solchen default-Methode, z. B.:
Nun wird das implementierte Interface um eine default-Methode namens tuWas() mit einem Pa-
rameter vom Typ int erweitert:
interface Kuckuck {
void sagWas();
void sagA();
Im Unterschied zu den statischen Methoden von Klassen werden die statischen Interface-Methoden
nicht vererbt, weder an erweiternde Schnittstellen, noch an implementierende Klassen. Wenn eine
Klasse ein Interface mit statischer Methode implementiert, gelangt diese Methode also nicht in das
statische Handlungsrepertoire der Klasse. Im Rahmen bestehender Zugriffsrechte kann die statische
Schnittstellenmethode jedoch wie eine statische Methode einer fremden Klasse genutzt werden,
z. B.:
class Impl1 implements WinterFace1 {
public void sagA() {
System.out.println("A");
}
1
https://docs.oracle.com/javase/tutorial/java/IandI/defaultmethods.html
458 Kapitel 9 Interfaces
interface WinterFace1 {
private static void achtung() {
System.out.print("Achtung Durchsage:");
}
9.2.4 Konstanten
Neben Methoden sind in einer Schnittstellendefinition auch Felder erlaubt, wobei diese implizit als
public, final und static deklariert sind, also initialisiert werden müssen. Die Demo-Schnittstelle im
folgenden Beispiel enthält eine int-Konstante namens ONE und verlangt das Implementieren einer
Methode namens sayOne():
public interface Demo {
int ONE = 1;
int sayOne();
}
Implementierende Klassen können auf eine Konstante ohne Angabe des Schnittstellennamens zu-
greifen. Auch nicht implementierende Klassen dürfen eine Interface-Konstante verwenden, müssen
aber den Interface-Namen samt Punkt voranstellen. Implementiert eine Klasse zwei Schnittstellen
mit namensgleichen Konstanten, dann muss beim Zugriff zur Beseitigung der Zweideutigkeit der
jeweilige Schnittstellenname vorangestellt werden.
Eine früher verbreitete, auch im Java-API anzutreffende, heute aber kritisch beurteilte Praxis be-
steht darin, Schnittstellen mit dem einzigen Zweck der Aufbewahrung von Konstanten zu definie-
ren, z. B.:
public interface DiesUndDas {
int KW = 4711;
double PIHALBE = 1.5707963267948966;
}
Bloch (2018, S. 107f) plädiert dafür, Schnittstellen ausschließlich als Datentypen zu verwenden und
nur eng mit diesem Zweck gekoppelte Konstanten in die Definition aufzunehmen. Als (nicht allzu
dramatische) Nachteile der Verwendung von Schnittstellen als Konstanten-Container nennt Bloch:
• Dass eine Klasse bestimmte Konstanten verwendet, ist ein Implementierungsdetail, das nicht
in die Öffentlichkeit gehört. Welche Schnittstellen eine Klasse implementiert, ist aber öf-
fentlich zu dokumentieren.
• Eine Klasse vererbt ihre Schnittstellen-Implementationen, sodass die vererbten Schnittstel-
len-Konstanten auch den Namensraum einer abgeleiteten Klasse belasten.
Abschnitt 9.2 Interfaces definieren 459
Als Konstanten-Container sollten anstelle von Schnittstellen besser Klassen verwendet werden.
Wenn dort ausschließlich statische Mitglieder vorhanden sind, sollte das Instanziieren verhindert
werden (z. B. durch die Schutzstufe private für alle Konstruktoren). Wenn ein Klassenname allzu
oft in Kombination mit den Namen von Konstanten im Quellcode auftaucht, kann über den stati-
schen Import für eine Vereinfachung gesorgt werden (siehe Abschnitt 6.1.2.2).
Mitglieds-Schnittstellen können auch in Klassen definiert werden und sind dann ebenfalls implizit
statisch (siehe Gosling et al. 2021, Abschnitt 8.5.1).
460 Kapitel 9 Interfaces
package fimpack;
Alle abstrakten Methoden einer im Klassenkopf angemeldeten Schnittstelle, die nicht von einer
Basisklasse geerbt werden, müssen im Rumpf der Klassendefinition implementiert werden, wenn
keine abstrakte Klasse entstehen soll. Nach der im Abschnitt 9.2 wiedergegebenen
Comparable<T> - Definition ist also im aktuellen Beispiel eine Methode mit dem folgenden Defi-
nitionskopf erforderlich:1
public int compareTo(Figur fig)
In semantischer Hinsicht soll sie eine Figur beauftragen, sich mit dem per Aktualparameter be-
stimmten Artgenossen zu vergleichen. Bei obiger Realisation werden Figuren nach der X-
Koordinate ihrer Position verglichen:
• Hat die angesprochene Figur eine kleinere X-Koordinate als der Vergleichspartner, dann
wird der Wert -1 zurückgemeldet.
• Haben beide Figuren dieselbe X-Koordinate, dann lautet die Antwort 0.
• Ansonsten wird der Wert 1 gemeldet.
Damit ist eine Anordnung der Figur-Objekte definiert, und einem erfolgreichen Sortieren (z. B.
mit Hilfe der statischen Methode sort() in der Klasse java.util.Arrays) steht nichts mehr im Weg.
Weil die zu implementierenden Methoden einer Schnittstelle grundsätzlich als public definiert sind,
und beim Implementieren eine Einschränkungen der Schutzstufe verboten ist, muss beim Definieren
von implementierenden Methoden die Schutzstufe public verwendet werden, wobei der Modifika-
tor wie bei jeder Methodendefinition explizit anzugeben ist.
Wenn eine implementierende Klasse eine abstrakte Schnittstellenmethode weglässt (oder abstrakt
implementiert), dann entsteht eine abstrakte Klasse, die auch als solche deklariert werden muss (vgl.
Abschnitt 7.8).
1
Es ist erlaubt und sinnvoll, aber nicht strikt empfohlen, beim Implementieren von Interface-Methoden wie beim
Überschreiben von Instanzmethoden (siehe Abschnitt 7.4.1) die Absicht gegenüber dem Compiler durch die Anno-
tation @Override zu bekunden. Das ist zum frühzeitigen Entdecken von Fehler meist nicht erforderlich, weil eine
durch Tippfehler im Methodennamen gescheiterte Implementation von Compiler als fehlend reklamiert wird. Es
kann aber z. B. passieren, dass eine Implementation (unbeachtet) geerbt wird, und für eine vermeintliche Implemen-
tation der Tippfehler im Methodennamen erst durch die Annotation @Override entlarvt wird. IntelliJ dekoriert je-
denfalls per Voreinstellung die als QuickFix eingefügten implementierenden Methoden mit der Annotation
@Override.
462 Kapitel 9 Interfaces
Über den instanceof-Operator kann man nicht nur prüfen, ob ein Objekt zu einer Klasse gehört,
sondern auch feststellen, ob seine Klasse ein bestimmtes Interface implementiert. Im Fall einer ge-
nerischen Schnittstelle ist der Rohtyp (vgl. Abschnitt 8.1.2.1) anzugeben, z. B.:
System.out.println(fig instanceof Comparable);
Auch die mit Java 16 eingeführten Record-Klassen (siehe Abschnitt 5.5) können Schnittstellen im-
plementieren.
Wenn dabei ein Konflikt mit Signatur-gleichen default-Methoden aus verschiedenen Schnittstellen
auftritt, verweigert der Compiler die Übersetzung. Das Problem ist dadurch zu lösen, dass die Klas-
se die kritische Methode selbst implementiert.
Es ist kein Problem, wenn zwei implementierte Schnittstellen über abstrakte Methoden mit identi-
schem Definitionskopf verfügen, weil keine konkurrierenden Realisationen geerbt werden, sondern
von der implementierenden Klasse eine eigene Realisation erstellt werden muss.
Implementiert eine Klasse eine Schnittstelle mit (direkten und indirekten) Basisschnittstellen, dann
muss sie die Methoden aller Schnittstellen im Stammbaum realisieren. Weil z. B. die Klasse
TreeSet<E> aus dem Java Collections Framework (siehe Abschnitt 10.6.4) neben den Schnittstel-
len Cloneable und Serializable auch die Schnittstelle NavigatableSet<E> implementiert (siehe
oben), sammelt sich einiges an Lasten an, denn NavigatableSet<E> erweitert die Schnittstelle
SortedSet<E>,
public interface NavigableSet<E> extends SortedSet<E> { . . . }
die ihrerseits die Schnittstelle Set<E> erweitert:
public interface SortedSet<E> extends Set<E> { . . . }
Das Interface Set<E> basiert auf dem Interface Collection<E>,
public interface Set<E> extends Collection<E> { . . . }
das wiederum die Schnittstelle Iterable<E> erweitert:
public interface Collection<E> extends Iterable<E> { . . . }
Wer als Programmierer wissen möchte, welche Datentypen eine API-Klasse direkt oder indirekt
erfüllt, muss aber keine Ahnenforschung betreiben, sondern wird in der API-Dokumentation zur
Klasse komplett informiert, z. B. bei der Klasse TreeSet<E>:1
1
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/TreeSet.html
Abschnitt 9.3 Interfaces implementieren 463
Module java.base
Package java.util
Class TreeSet<E>
java.lang.Object
java.util.AbstractCollection<E>
java.util.AbstractSet<E>
java.util.TreeSet<E>
Type Parameters:
E - the type of elements maintained by this set
All Implemented Interfaces:
Serializable, Cloneable, Iterable<E>, Collection<E>, NavigableSet<E>,
Set<E>, SortedSet<E>
Wenn es im Beispiel für den TreeSet<E> - Programmierer gut gelaufen ist, dann hat der
AbstractSet<E> - Programmierer bereits einige Schnittstellenmethoden implementiert (siehe Ab-
schnitt 9.3.2 zu geerbten Interface-Implementationen).
Auch Schnittstellen ändern nichts daran, dass für Java-Klassen eine Mehrfachvererbung (vgl. Ab-
schnitt 7) ausgeschlossen ist. Allerdings erlauben Schnittstellen in vielen Fällen eine Ersatzlösung,
denn:
• Eine Klasse darf beliebig viele Schnittstellen implementieren.
• Bei Schnittstellen ist die Mehrfachvererbung erlaubt.
Die mit einer Mehrfachvererbung verbundenen Risiken, die beim Java-Design bewusst vermieden
wurden, bleiben aber ausgeschlossen: In Schnittstellen sind Felder generell statisch (siehe Abschnitt
9.2.4). Folglich können Instanzvariablen nur von der Basisklasse (also nur von einer Klasse) über-
nommen werden, und der sogenannte Deadly Diamond of Death ist ausgeschlossen (siehe Kreft &
Langer 2014).
Quellcode Ausgabe
import fimpack.Kreis; A B C
class ImpleDemo {
public static void main(String[] args) {
Kreis[] ka = new Kreis[3];
ka[0] = new Kreis("C", 70, 20, 10);
ka[1] = new Kreis("B", 60, 20, 30);
ka[2] = new Kreis("A", 50, 20, 50);
java.util.Arrays.sort(ka);
for (Kreis ko : ka) System.out.print(ko.name + " ");
}
}
Die Schnittstelle Comparable<Kreis> befindet sich weder im Erbe der Kreis-Klasse noch darf
sie hier zusätzlich implementiert werden:
Es ist erlaubt, in der Kreis-Klasse die folgende Überladung der Methode compareTo() zu definie-
ren:
public int compareTo(Kreis kr) {
if (xpos + radius < kr.xpos + kr.radius)
return -1;
else if (xpos + radius == kr.xpos + kr.radius)
return 0;
else
return 1;
}
Die kommt aber im Beispielprogramm zum Sortieren der Kreis-Objekte nicht zum Einsatz, weil
die Klasse Kreis die Schnittstelle Comparable<Figur> implementiert und daher von
Arrays.sort() die zugehörige Methode verwendet wird, nämlich:
public compareTo(Figur fig)
Man könnte in der Klasse Kreis die Methode compareTo() (mit dem Parameter vom Datentyp
Figur) z. B. so überschreiben, dass beim Vergleich von zwei Kreisen der Radius berücksichtigt
wird, beim Vergleich eines Kreises mit einer Figur jedoch nicht. Diese „Lösung“ soll aber nicht
vorgeführt werden, weil sie beim Sortieren eines Arrays mit Kreisen und Figuren ein kaum brauch-
bares Ergebnis liefern würde.
Bei der folgenden Lösung kommt eine Überladung der Arrays-Methode sort() zum Einsatz, die
zum Sortieren nicht die Methode compareTo() der Klasse Kreis verwendet, sondern ein Objekt
aus einer Klasse engagiert, die das Interface Comparator<Kreis> implementiert und daher die
Methode compare(Kreis o1, Kreis o2) beherrscht. Als zweiter Aktualparameter der sort() - Me-
thode wird per new-Operator ein Objekt aus einer anonymen Klasse (siehe Abschnitt 12.1.1.2) mit
der erforderlichen Instanzmethode erzeugt:
Abschnitt 9.3 Interfaces implementieren 465
Quellcode Ausgabe
import fimpack.Kreis; C B A
import java.util.Comparator;
class ImpleDemo {
public static void main(String[] args) {
Kreis[] ka = new Kreis[3];
ka[0] = new Kreis("C", 70, 20, 10);
ka[1] = new Kreis("B", 60, 20, 30);
ka[2] = new Kreis("A", 50, 20, 50);
Im selben Paket wird die Klasse InTell definiert, die zwar für die paketinterne Verwendung ge-
dacht ist, aber die Schnittstelle IFace implementiert:
package meinpaket;
void tellPint() {
System.out.println("Eigene Methode von Klasse Intell");
}
}
466 Kapitel 9 Interfaces
Nach diesen Vorbereitungen lässt sich ein Objekt der Klasse InTell auch in Methoden fremder
Pakete einsetzen, z. B.:
package sichtbarkeit;
import meinpaket.*;
class Intereferenz {
public static void main(String[] args) {
Quatsch[] demintiar = {new Ritter(), new Wolf()};
for (Quatsch di : demintiar)
di.sagWas();
}
}
Abschnitt 9.5 Versiegelte Interfaces 467
Damit wird es z. B. möglich, Objekte aus beliebigen Klassen (z. B. Ritter und Wolf) in einem
Array gemeinsam zu verwalten, sofern alle Klassen dasselbe Interface implementieren. Zwar lässt
sich derselbe Zweck auch mit Object-Referenzen erreichen, doch leidet unter so viel Liberalität die
Typsicherheit. Mit einem Interface als Elementdatentyp ist sichergestellt, dass alle Elemente be-
stimmte Verhaltenskompetenzen besitzen (im Beispiel: die Methode sagWas()). Folglich kann
diese Funktionalität ohne lästige und fehleranfällige Typwandlungen abgerufen werden.
Im Beispiel werden ein Ritter und ein Wolf über den Datentyp einer gemeinsam implementierten
Schnittstelle angesprochen. Sie führen die Schnittstellenmethode sagWas() auf ihre klasseneigene
Art aus, zeigen also polymorphes Verhalten (vgl. Abschnitt 7.7).
Nach dem etwas verspielten Beispiel für die Verwendung eines Schnittstellendatentyps folgt noch
ein praxisrelevantes Beispiel. Implementiert eine Klasse das Interface CharSequence, dann taugen
ihre Objekte zur Repräsentation einer geordneten Folge von Zeichen und beherrschen lesende
Zugriffsmethoden, z. B. die Methode charAt() mit dem folgenden Definitionskopf:
public char charAt(int index)
Sie liefert das Zeichen an der angegebenen Indexposition. Das Interface CharSequence erlaubt bei
Verwendung als Formalparameterdatentyp die Definition von Methoden, die als Aktualparameter-
datentyp sowohl die Klasse String (optimiert für konstante Zeichenfolgen, vgl. Abschnitt 5.2.1) als
auch die Klassen StringBuilder und StringBuffer (optimiert für veränderliche Zeichenketten, vgl.
Abschnitt 5.2.2) akzeptieren. Solange man mit den im Interface CharSequence definierten lesen-
den Zugriffsmethoden auskommt, hat man an Flexibilität gewonnen. Ein Beispiel ist die Methode
replace() der Klasse String:
public String replace(CharSequence target, CharSequence replacement)
Von den implementierenden Klassen bzw. erweiternden Schnittstellen einer versiegelten Schnitt-
stelle sind die folgenden Bedingungen einzuhalten:1
• Bei der Übersetzung der versiegelten Schnittstelle muss der Compiler Zugang zu den Quell-
codedateien mit den implementierenden Klassen bzw. erweiternden Schnittstellen haben.2
• In der Definition einer zugelassenen implementierenden Klasse muss einer von den Modifi-
katoren final, sealed oder non-sealed angegeben werden (vgl. Abschnitt 7.11.3):
• Wenn sich die versiegelte Schnittstelle in einem benannten Modul befindet, dann müssen
auch die zugelassenen implementierenden Klassen bzw. erweiternden Schnittstellen zu die-
sem Modul gehören. Wenn sich die versiegelte Schnittstelle im unbenannten Modul befindet,
dann müssen die implementierenden bzw. erweiternden Typen zum selben Paket gehören wie
die versiegelte Schnittstelle.
Bei den in Java 17 für switch-Anweisungen und switch-Ausdrücke eingeführten, zunächst im Vor-
schaumodus verfügbaren Mustervergleichen (siehe Abschnitt 3.7.2.5) berücksichtigt der Compiler
versiegelte Typen, sodass ggf. kein default-Fall erforderlich ist, um die Exhaustivität herzustellen.
Im folgenden Beispielprogramm verwendet ein switch-Ausdruck einen steuernden Ausdruck vom
Typ der oben beschriebenen versiegelten Schnittstelle:
package de.uni_trier.zimk.sealdemo;
class Nonsense {
static int bewerte(Quatsch q) {
return switch (q) {
case Wolf w -> 500;
case Ritter r -> 5;
};
}
9.6 Annotationen
An Pakete, Typen (Klassen, Schnittstellen), Methoden, Konstruktoren, Parameter und lokale Vari-
ablen lassen sich Annotationen anheften, um zusätzliche Metainformationen bereit zu stellen, die
…
• zur Entwicklungs- bzw. Übersetzungszeit
• und/oder zur Laufzeit
berücksichtigt werden können.3 Sie ergänzen die im Java - Sprachumfang verankerten Modifika-
toren für Typen, Methoden etc. und bieten dabei eine enorme Flexibilität. Bei einfachen Annotatio-
nen besteht die Information über den Träger in der An- bzw. Abwesenheit einer Eigenschaft (z. B.
1
https://docs.oracle.com/en/java/javase/17/language/sealed-classes-and-interfaces.html
2
Bei der finalisierten Klasse Ritter genügte in einem Test die Verfügbarkeit der class-Datei.
3
Wer die Programmiersprache C# kennt, fühlt sich zu Recht an die dortigen Attribute erinnert.
Abschnitt 9.6 Annotationen 469
Deklaration einer Methode als überschreibend), jedoch kann eine Annotation auch Detailinformati-
onen enthalten.
Annotationen mit Relevanz für die Entwicklungs- bzw. Übersetzungszeit beeinflussen das Verhalten
des Compilers, der z. B. durch die Annotation Deprecated zur Ausgabe einer Warnung veranlasst
wird. Neben dem Compiler kommen aber auch Entwicklungswerkzeuge als Adressaten für Quell-
code-Annotationen in Frage. Diese können z. B. den Quellcode analysieren und aufgrund von An-
notationen zusätzlichen Code generieren, um dem Programmierer lästige und fehleranfällige Routi-
nearbeiten abzunehmen. So bieten die Annotationen eine Option zur deklarativen Programmierung.
Annotationen mit Relevanz für die Laufzeit beeinflussen ein Programm über ihre Signalwirkung auf
Methoden, welche sich über die Existenz bzw. Ausgestaltung der Annotation informieren und ihr
Verhalten daran orientieren (siehe Abschnitt 9.6.3). Wir lernen hier eine weitere Technik zur Kom-
munikation zwischen Programmbestandteilen kennen. In komplexen objektorientierten Software-
Systemen spielt die als Reflexion (engl.: reflection) bezeichnete Ermittlung von Informationen über
Typen zur Laufzeit eine wichtige Rolle. Dabei leisten Annotationen einen wichtigen Beitrag.
Neben den im Java-API enthalten Annotationen (z. B. Deprecated für veraltete, nicht mehr zu ver-
wendende Typen oder Member) lassen sich auch eigene Exemplare definieren. Dabei ist eine an
Schnittstellen erinnernde Syntax zu verwenden (siehe Abschnitt 9.6.1), und der Compiler erzeugt
tatsächlich aus jeder Annotationsdefinition, die nicht auf den Quellcode beschränkt bleiben soll
(siehe Abschnitt 9.6.4), ein Interface.
Annotationen mit Sichtbarkeit public benötigen wie andere öffentliche Schnittstellen eine eigene
Quellcodedatei.
9.6.1 Definition
Wir starten mit der (im typischen Programmieralltag nur selten erforderlichen) Definition von An-
notationen und werden dabei ohne großen Aufwand einen guten Einblick in die Technik gewinnen.
Als erstes Beispiel betrachten wir die im Abschnitt 7.4.1 behandelte API-Annotation Override (Pa-
ket java.lang), die dem Compiler signalisiert, dass durch eine Methodendefinition eine Basisklas-
senvariante oder eine Schnittstellenmethode überschrieben werden soll. Sie enthält keine Annotati-
onselemente (siehe unten) und gehört daher zu den Marker-Annotationen:
public @interface Override {
}
Hinter dem optionalen Zugriffsmodifikator public steht das Schlüsselwort interface mit dem Präfix
@ zur Unterscheidung von gewöhnlichen Schnittstellendefinitionen. Dann folgen der Typname und
der (bei einer Marker-Annotation leere) Definitionsrumpf.
Im Allgemeinen enthält der Definitionsrumpf einer Annotation sogenannte Annotationselemente,
damit bei der Zuweisung einer Annotation (siehe Abschnitt 9.6.2) Detailinformationen durch (Na-
me-Wert) - Paare übergeben werden können. In der Definition wird ein Annotationselement als
abstrakte Interface-Methode realisiert mit:
• einem Namen
• einem Rückgabetyp für den bei der Zuweisung festzulegenden Wert
Über die folgende, selbst entworfene Annotation VersionInfos können bei der Zuweisung Versi-
onsinformationen an Programmbestandteile geheftet werden:
470 Kapitel 9 Interfaces
Wie das Beispiel Override zeigt, kann auch eine Annotation (wie jeder andere Typ) Träger von
Annotationen werden (im Beispiel: Target und Retention), wobei man von Meta-Annotationen
spricht. Die drei im Beispiel auftauchenden API-Annotationen werden im Abschnitt 9.6.4 näher
beschrieben.
Die Ableitung von einem Basistyp ist bei Annotationen nicht möglich.
Abschnitt 9.6 Annotationen 471
9.6.2 Zuweisung
Eine zu vergebende Annotation wird im Quellcode dem Träger vorangestellt. In der Regel setzt
man die Annotationen vor sonstige Dekorationen (also Modifikatoren), doch ist auch ein Mix er-
laubt. Eine Annotation besteht aus einem Namen samt Präfix „@“ und einer durch runde Klammern
begrenzte Elementenliste mit (Name = Wert) - Paaren. Im folgenden Beispiel wird einer Methode
die Annotation VersionInfos zugewiesen, deren Definition im Abschnitt 9.6.1 zu sehen war:
@VersionInfos(version="7.1.4", build=3124, contributors={"Häcker", "Kwikki"})
public static void meth() {
// Not yet implemented
}
package annoreflection;
import java.lang.reflect.Method;
class AnnoReflection {
@VersionInfos(version = "7.1.4", build = 3124, contributors = {"Häcker","Kwikki"})
public static void meth() {
// Not yet implemented
}
9.6.4 API-Annotationen
Anschließend werden wichtige Annotationen aus dem Java-API beschrieben, die Sie teilweise be-
reits kennen. Im Paket java.lang finden sich u. a. die folgenden, an den Compiler oder an Entwick-
lungswerkzeuge gerichteten Annotationen:
Abschnitt 9.6 Annotationen 473
• Deprecated
Diese Annotation wird an veraltete (überholte, abgewertete) API-Bestandteile (z. B. Typen
oder Methoden) geheftet, um Programmierer von ihrer weiteren Verwendung abzuhalten.
Eventuell hat sich die Verwendung des API-Bestandteils als problematisch herausgestellt,
oder es ist eine bessere Lösung entwickelt worden. Im Kapitel 15 über das Multithreading
ist z. B. zu erfahren, dass die Methode stop() nicht mehr zum Stoppen von Threads verwen-
det werden sollte. Wie der Quellcode zur Klasse Thread zeigt, hat die Methode stop() die
Annotation Deprecated erhalten:
@Deprecated(since="1.2")
public final void stop() {
. . .
}
In der Deprecated-Definition wird durch die Meta-Annotation Documented (siehe unten)
empfohlen, die Vergabe der Annotation Deprecated durch einen Dokumentationskommen-
tar (vgl. Abschnitt 3.1.5) mit dem Tag @deprecated (kleiner Anfangsbuchstabe!) zu erläu-
tern, was bei der Thread-Methode stop() auch geschehen ist:
/**
* Forces the thread to stop executing.
. . .
* @deprecated This method is inherently unsafe. Stopping a thread with
* Thread.stop causes it to unlock all of the monitors that it
* has locked (as a natural consequence of the unchecked
* <code>ThreadDeath</code> exception propagating up the stack). If
* any of the objects previously protected by these monitors were in
* an inconsistent state, the damaged objects become visible to
* other threads, potentially resulting in arbitrary behavior. Many
* uses of <code>stop</code> should be replaced by code that simply
* modifies some variable to indicate that the target thread should
* stop running. ...
*/
Unsere Entwicklungsumgebung IntelliJ warnt vor der Verwendung von abgewerteten API-
Bestandteilen, indem die Bezeichnung im Editor durchgestrichen angezeigt wird, z. B.:
protected void finalize() throws Throwable {
super.finalize();
System.out.println(this + " finalisiert");
}
Die Annotation Deprecated kennt folgende Annotationselemente:
String since() default "";
boolean forRemoval() default false;
• Override
Mit dieser Marker-Annotation kann man seine Absicht bekunden, bei einer Methodendefini-
tion eine Basisklassenvariante oder eine Schnittstellenmethode zu überschreiben (siehe Ab-
schnitt 7.4.1), z. B.:
@Override
public void wo() {
super.wo();
System.out.println("Unten rechts: (" + (xpos+2*radius) +
", " + (ypos+2*radius) + ")");
}
Misslingt dieser Plan z. B. wegen eines Tippfehlers im Methodennamen, dann warnt der
Compiler.
474 Kapitel 9 Interfaces
• SuppressWarnings
Mit dieser Annotation überredet man den Compiler, Warnungen aus bestimmtem Anlass zu
unterdrücken. Sie kann auf diverse Programmbestandteile bezogen werden (auf Typen, Fel-
der, Methoden, Konstruktoren, Parameter, lokale Variablen). Es ist anzustreben, den Gültig-
keitsbereich der Unterdrückung so klein wie möglich zu halten. Im folgenden Beispiel aus
dem Abschnitt 8.1.3.1 wird die Warnung vor einer vom Compiler nicht kontrollierbaren Ty-
pumwandlung abgeschaltet, was stets kommentiert werden sollte:
public E get(int index) {
if (index >= 0 && index < size) {
// Casting erforderlich, weil kein Array vom Typ E erstellt werden kann.
// elements kann nur Objekte vom Typ E enthalten.
@SuppressWarnings("unchecked")
E result = (E) elements[index];
return result;
} else
return null;
}
Die Annotation SuppressWarnings kennt ein Annotationselement, das den Namen der zu
unterdrückenden Warnung nennt:
String[] value();
Laut Java-Sprachbeschreibung (Gosling et al. 2021, Abschnitt 9.6.4.5) haben drei Warnun-
gen einen festgelegten Namen: unchecked, deprecation, removal. Weitere Namen sind
von den Compiler-Herstellern abhängig:
Vendors are encouraged to cooperate to ensure that the same names work across multiple
compilers.
• SafeVarargs
Mit dieser seit Java 7 verfügbaren Marker-Annotation versichert man dem Compiler, dass
eine Methode mit ihren Serienparameter von einem konkretisierten generischen Typ keine
unsicheren Operationen ausführt, sodass der Compiler auf die Warnung verzichtet (siehe
Abschnitt 8.1.2.4), z. B.:
@SafeVarargs
private void genVarargs(Method(ArrayList<String>... stringLists) {
System.out.println(stringLists.length);
}
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.RECORD_COMPONENT, ElementType.METHOD,
ElementType.FIELD, ElementType.PARAMETER})
public @interface RecordAnno {}
mit der Record-Klasse Point (vgl. Abschnitt 5.5)
476 Kapitel 9 Interfaces
System.out.println("\nAnnotierte Record-Komponenten:");
for (var rcomp : Point.class.getRecordComponents()) {
da = rcomp.getAnnotation(RecordAnno.class);
if (da != null)
System.out.println(" " + rcomp.getName());
}
System.out.println("\nAnnotierte Methoden:");
for (var meth : Point.class.getMethods()) {
da = meth.getAnnotation(RecordAnno.class);
if (da != null)
System.out.println(" " + meth.getName()+"()");
}
System.out.println("\nAnnotierte Felder:");
for (var field : Point.class.getDeclaredFields()) {
da = field.getAnnotation(RecordAnno.class);
if (da != null)
System.out.println(" " + field.getName());
}
System.out.println("\nAnnotierte Konstruktorparameter:");
for (var cpar : Point.class.getConstructors()[0].getParameters()) {
da = cpar.getAnnotation(RecordAnno.class);
if (da != null)
System.out.println(" " + cpar.getName());
}
}
}
Annotierte Record-Komponenten:
x
Annotierte Methoden:
x()
Annotierte Felder:
x
Annotierte Konstruktorparameter:
x
2) Erstellen Sie zur Klasse Bruch, die im Kapitel 4 als zentrales Beispiel diente, eine Variante, die
die Schnittstelle Comparable<Bruch> implementiert, sodass ein Bruch-Array z. B. mit der stati-
schen Methode sort() aus der Klasse Arrays sortiert werden kann.
Nachdem wir uns im Kapitel 6 mit Paketen beschäftigt haben, sollte die Klasse Bruch in ein Paket
(z. B. de.uni_trier.zimk.matrain.br) eingefügt und die in der Bruch-Definition verwende-
te Klasse Simput aus dem Paket de.uni_trier.zimk.util.conio bezogen werden (vgl. Ab-
schnitt 6.2.7.1). Allerdings können Sie der Einfachheit halber auf die mit Java 9 eingeführte Modul-
technik verzichten und die modulare jar-Datei de.uni_trier.zimk.util-1.0.jar mit dem Modul
de.uni_trier.zimk.util, das u. a. das Paket de.uni_trier.zimk.util.conio enthält,
wie eine traditionelle jar-Datei behandeln. Diese jar-Datei (oder der Ordner mit dieser jar-Datei)
kann z. B. in IntelliJ (nach dem Menübefehl File > Project Structure) als globale Bibliothek
vereinbart
Weil die Klasse Simput mit Java 17 übersetzt worden ist (vgl. Abschnitt 6.2.7.1), muss ein passen-
des Projekt-SDK gewählt werden.
3) Definieren Sie eine generische Methode mit einem Parameter, dessen Typ von einer bestimmten
Klasse abstammen und zwei Interfaces implementieren muss.
10 Java Collections Framework
Die in diesem Kapitel vorgestellten Typen zur Verwaltung von Listen, Mengen, (Schlüssel-Wert) -
Tabellen (Abbildungen) oder Warteschlangen gehören zu dem mit Java 2 (alias 1.2) eingeführten
Java Collections Framework (JCF). Diese Sammlung von Schnittstellen und Klassen aus den
Paketen java.util und java.util.concurrent wird von praktisch jedem Java-Programmierer in vielen
Anwendungen intensiv zur Datenverwaltung genutzt.
Das JCF hat enorm von der in Java 5 (alias 1.5) eingeführten Generizität profitiert und war ein pri-
märer Grund für die Erweiterung der Programmiersprache Java um die Generizität (Naftalin &
Wadler 2007). Im Abschnitt 8.1.1 haben Sie einen Eindruck davon erhalten, welchen Fortschritt
eine generische Listenverwaltungsklasse wie ArrayList<E> gegenüber der auf unsicheren Object-
Referenzen und expliziten Typumwandlungen basierenden Vorgängerlösung bei der häufig benötig-
ten Verwaltung einer Liste mit Elementen desselben Typs darstellt.
Wer nach der Lektüre von Kapitel 8 noch Zweifel am Nutzen der generischen Typen und Methoden
hatte, lernt nun zahlreiche generische Interfaces und Klassen mit hohem praktischem Nutzwert ken-
nen, was im Hinblick auf die Generizität zu einem Erfahrungs- und Motivationsgewinn führen soll-
te. Zugleich wird allgemein belegt, dass auch scheinbar abstrakte Java-Sprachmerkmale (wie die
Generizität) die Praxis enorm erleichtern.
Für die Objekte der im aktuellen Kapitel vorzustellenden Klassen wird im Manuskript alternativ zur
offiziellen Bezeichnung Kollektionen aus sprachlichen Gründen gelegentlich auch die Bezeichnung
Container verwendet.
In diesem Kapitel beschäftigen wir uns nicht damit, eigene generische Kollektionstypen zu definie-
ren (siehe einfache Beispiele im Abschnitt 8.1.3), sondern wir konzentrieren und darauf, die im JCF
zahlreich vorhandenen Typen zu nutzen. Diese Typen besitzen ausgefeilte Handlungskompetenzen
(Methoden) für typische Aufgabenstellungen (z. B. Vereinigung von zwei Mengen ohne Entstehung
von Dubletten), damit Programmierer möglichst selten „das Rad neu erfinden müssen“. Sind doch
eigene Kollektionsklassen erforderlich, dann eignen sich als Basis die abstrakten Klassen Abstract-
Collection<E>, AbstractList<E>, AbstractSet<E>, AbstractMap<K,V> und
AbstractQueue<E> im JCF.
Weitere Details zum Java Collections Framework liefern z. B. bei Evans & Flanagan (2015) sowie
Naftalin & Wadler (2007).
Mit thread-sicheren Kollektionen werden wir uns im Abschnitt 15.6 beschäftigen.
Das JCF bietet hingegen Listenverwaltungsklassen, die eine automatische Vergrößerung sowie ein
performantes Einfügen und Löschen beherrschen.
Sind für Elementsammlungen häufige Existenzprüfungen erforderlich, dann bietet ein Array wenig
Unterstützung. Sind seine Elemente nicht sortiert, dann muss für jedes Element geprüft werden, ob
es mit dem gesuchten Element übereinstimmt. JCF-Kollektionen zur Verwaltung von Mengen bie-
ten hingegen schnelle Detektionsmöglichkeiten und verhindern außerdem identische Elemente
(Dubletten).
In der Praxis ist oft eine Menge von (Schlüssel-Wert) - Paaren (Abbildungen) zu verwalten, z. B.
eine Tabelle mit den bei einem Web-Dienst aktuell angemeldeten Benutzern, wobei eine eindeutige
Kennung als Schlüssel fungiert und auf ein Objekt mit den Eigenschaften des Benutzers zeigt.
Eventuell stammen die Eigenschaften aus einer Datenbankzeile, die nach der Anmeldung des Be-
nutzers von einem Datenbankserver bezogen und dann zum schnellen Zugriff im Hauptspeicher
aufbewahrt wird. Es melden sich ständig Benutzer an oder ab, und beim Versuch, eine solche Da-
tenstruktur mit einem Array zu verwalten, treten die eben beschriebenen Probleme auf:
• feste Anzahl von Elementen
• umständliches Einfügen und Löschen
• aufwändige Existenzprüfungen
• fehlende Unterstützung bei der meist erforderlichen Eindeutigkeit der Schlüssel.
Im zu modellierenden Aufgabenbereich einer Anwendung treten oft Datenstrukturen vom Typ Lis-
te, Menge, (Schlüssel-Wert) - Tabelle oder Warteschlange auf, und im Java Collections Framework
finden sich passende Typen, sodass im Vergleich zur Verwendung von Arrays eine bessere Model-
lierung und ein besser lesbarer Quellcode resultieren.
Manche JCF-Kollektionsklassen verwenden im Hintergrund einen Array zur Datenspeicherung
(z. B. ArrayList<E>), was in bestimmten Anwendungsfällen zu einer performanten Lösung führt
(siehe z. B. Abschnitt 10.4.4 mit Einsatzempfehlungen für die Listenverwaltung. Diese Kollekti-
onsklassen ersetzten den Hintergrund-Array bei Bedarf automatisch durch ein größeres Exemplar.
Im Abschnitt 8.1.2.2 hat sich herausgestellt, dass die sogenannte Kovarianz von Arrays regelrecht
als Defekt angesehen werden muss. Während der Compiler z. B. ArrayList<String> nicht als Spe-
zialisierung von ArrayList<Object> akzeptiert, übersetzt er leider die folgenden Anweisungen
ohne jede Kritik:
Object[] arrObject = new String[5];
arrObject[0] = 13;
Zur Laufzeit ärgern sich die Benutzer über einen Fehler vom Typ ArrayStoreException.
Trotz der guten Argumente für die Kollektionstypen gibt es aber weiterhin berechtigte Einsatzzwe-
cke für Arrays, und das nicht nur als Hintergrundspeicher von Kollektionsklassen wie Array-
List<E>. Dass in Kollektionsklassen als Elemente nur Objekte erlaubt sind, wird bei der Verwal-
tung einer großen Anzahl primitiver Werte zum Nachteil:
• Das Auto(un)boxing verursacht einen spürbaren Zeitaufwand (für das Erstellen und Entsor-
gen von Objekten).
• Der Speicherbedarf für die Objekte ist relativ hoch.
In einem Array kann man demgegenüber auch eine große Anzahl primitiver Werte zeit- und platz-
sparend abgelegen.
Wenn es erforderlich ist, zwischen einem Array mit Referenz-Elementtyp und einer Liste umzustei-
gen, bietet das Java-API geeignete Methoden:
Abschnitt 10.2 Zur Rolle von Schnittstellen beim JCF-Design 481
1
Man erhält ein Objekt der in Arrays enthaltenen statischen Mitgliedsklasse ArrayList<E> (nicht zu verwechseln
mit der Top-Level-Klasse ArrayList<E>), die das Interface List<E> erfüllt, z. B.:
String[] sar = {"a", "b", "c"};
List<String> li = Arrays.asList(sar);
Die resultierende Liste speichert ihre Daten intern in einem Array und hat eine feste Länge, was bei Kollektionen
ungewöhnlich ist. Auf den Versuch, ein weiteres Element einzufügen, reagiert sie mit einer Ausnahme vom Typ
UnsupportedOperationException. Ist eine erweiterbare Liste das Ziel, kann man einem Vorschlag von Evans &
Flanagan (2015, S. 257) folgend die asList() - Rückgabe z. B. an einen ArrayList<E> - Konstruktor weiterreichen:
List<String> li = new ArrayList<>(Arrays.asList(sar));
2
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Collection.html
482 Kapitel 10 Java Collections Framework
Will eine Klasse von sich behaupten, das Interface Collection<E> zu implementieren, dann muss
sie alle abstrakten Interface-Methoden realisieren oder erben. Hinzu kommen noch die Methoden
im Interface Iterable<E>, das von Collection<E> erweitert wird.
Ein Interface ist aber nicht nur ein Pflichtenheft, sondern auch ein Datentyp. Wird ein Interface
z. B. als Datentyp für einen Formalparameter einer Methode vorgeschrieben, dann ist beim Metho-
denaufruf als Aktualparameter-Datentyp jede Klasse erlaubt, die das Interface implementiert. So
kann die Methode mit diversen, z. B. auch mit später definierten Klassen zusammenarbeiten. Damit
leisten Interfaces einen wichtigen Beitrag zur Realisation von Software nach dem Open-Closed -
Prinzips (vgl. Abschnitt 4.1.1.3): Neue Anforderungen können durch zusätzliche Klassen realisiert
werden, ohne dass die vorhandene Code-Basis geändert werden muss. Um diese Flexibilität zu er-
zielen, sollten Interface-Datentypen intensiv verwendet werden (z. B. für Formalparameter von Me-
thoden und für Instanzvariablen von Klassen).
Analog zur Erweiterung einer Klasse durch abgeleitete Klassen lassen sich zu einem Interface er-
weiterte (abgeleitete) Varianten definieren, die von implementierenden Klassen zusätzliche Metho-
den verlangen. Für das Java Collections Framework ist so eine Interface-Hierarchie entstanden, die
einen guten Eindruck von den Kompetenzprofilen der im Framework enthaltenen Klassen vermit-
telt. In der folgenden Abbildung sind die JCF-Schnittstellen zu sehen, die im weiteren Verlauf des
Kapitels zusammen mit implementierenden Klassen behandelt werden:
Iterable<E>
Collection<E>
NavigableSet<E> NavigableMap<K,V>
In der Abbildung ist auch das (nicht dem JCF zugerechnete) Interface Iterable<T> enthalten, das
von Collection<E> erweitert wird (siehe Abschnitte 10.3 und 10.5).
Abschnitt 10.3 Das Interface Collection<E> 483
1
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Collection.html
2
Mit der Ausnahmebehandlung werden wir uns bald im Kapitel 11 beschäftigen.
3
Ein Objekt wird in Java bekanntlich vom Garbage Collector gelöscht, wenn im gesamten Programm keine Referenz
auf das Objekt mehr vorhanden ist, und die JVM wegen eines Mangels an Heap-Speicher eine Aufräumaktion ver-
anlasst.
484 Kapitel 10 Java Collections Framework
Verwendet eine Kollektionsklasse intern einen Array zum Speichern ihrer Elemente, dann ist die
maximale Kapazität begrenzt durch den größtmöglichen Array-Index (= Integer.MAX_VALUE =
2147483647).
1
https://stackoverflow.com/questions/4269147/why-does-java-mapk-v-take-an-untyped-parameter-for-the-get-and-
remove-methods
486 Kapitel 10 Java Collections Framework
Mit dem Zusatz optional operation will der Schnittstellendesigner keinesfalls vorschlagen, eine
betroffene Methode beim Implementieren wegzulassen, was zu einem Protest des Compilers führen
würde. Es wird vielmehr eine Implementation wie im folgenden Beispiel (entnommen aus dem
Quellcode der API-Klasse AbstractCollection<E>) verbunden mit einer entsprechenden Doku-
mentation als akzeptabel dargestellt:
public boolean add(E e) {
throw new UnsupportedOperationException();
}
Diese Methode führt keine Aufträge aus, sondern meldet nur per Ausnahmeobjekt: „Ich kann das
nicht.“
Die merkwürdige Lösung mit „optionalen“ Schnittstellenmethoden und Pseudoimplementationen
dient beim Java Collections Framework dazu, die folgenden Ziele gemeinsam zu realisieren:
• Die Zahl der Schnittstellen soll möglichst klein gehalten werden.
• Spezielle Kollektionsklassen sollen eine Schnittstelle (z. B. Collection<E>) erfüllen, aber
keine Änderungen (z. B. durch Aufnahme neuer Elemente) erlauben (z. B. die im Abschnitt
10.10 erwähnten unveränderlichen Kollektionen).
Mit dieser Besonderheit befindet sich das JCF zwar nicht syntaktisch, aber doch semantisch in par-
tiellem Widerspruch zu den Erläuterungen über Schnittstellen als Verpflichtungserklärungen (vgl.
z. B. Abschnitt 9.1.2).
1
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Collection.html
Abschnitt 10.4 Listen 487
• Reflexivität
Für jede nicht auf null zeigende Referenzvariable x muss x.equals(x) den Wert true lie-
fern.
• Symmetrie
Für zwei nicht auf null zeigende Referenzvariablen x und y muss gelten: x.equals(y) lie-
fert den Wert true genau dann, wenn y.equals(x) den Wert true liefert.
• Transitivität
Für drei nicht auf null zeigende Referenzvariablen x, y und z muss gelten: Aus
x.equals(y) und y.equals(z) folgt x.equals(z).
Außerdem muss eine sinnvolle equals() - Überschreibung die folgenden Bedingungen erfüllen:
• Konsistenz
Für zwei nicht auf null zeigende Referenzvariablen x und y muss x.equals(y) bei jedem
Aufruf den konstanten Wert true oder false liefern, solange bei den Objekten keine für den
Vergleich relevante Änderung stattgefunden hat.
• Für jede nicht auf null zeigende Referenzvariable x muss x.equals(null) den Wert false
liefern.
In der API-Klasse Integer (Paket java.lang) ist z. B. eine geeignete equals() - Überschreibung vor-
handen:
public boolean equals(Object obj) {
if (obj instanceof Integer) {
return value == ((Integer)obj).intValue();
}
return false;
}
Wenn eine Klasse die Object-Methode equals() überschreibt, dann muss sie auch die Object-
Methode hashCode() überschreiben (Bloch 2018, Item 11, S. 50ff). Ansonsten taugt die Klasse
nicht zur Konkretisierung des ...
• Typformalparameters E der Mengenverwaltungsklasse HashSet<E> (siehe Abschnitt
10.6.3)
• Typformalparameters K der Abbildungsverwaltungsklasse HashMap<K,V> (siehe Ab-
schnitt 10.7.2)
Weitere Anforderungen an die hashCode() - Überschreibung werden im Abschnitt 10.6.3 beschrie-
ben.
Die Mengenverwaltungsklasse TreeSet<E>, die u. a. das von Collection<E> abgeleitete Interface
Set<E> implementiert, verwendet bei Übereinstimmungsprüfungen die Methode compareTo() des
Elementtyps oder die Methode compare() einer Comparator<E> - Implementation (siehe Ab-
schnitt 10.6.4). Trotzdem benötigt auch bei einer parametrisierten TreeSet<E> - Klasse die E-
Konkretisierung eine sinnvolle Überschreibung der Methode equals(), die zudem konsistent mit der
Methode compareTo() bzw. compare() sein muss. Anderenfalls verletzt die parametrisierte Klasse
den Vertrag der Schnittstelle Set<E>.
10.4 Listen
Eine JCF-Liste enthält eine Sequenz von Elementen (Objektreferenzen) desselben Typs mit einer
definierten Reihenfolge. Man kann auf die Elemente ...
• entweder sequentiell (über einen Iterator, siehe Abschnitt 10.5)
• oder wahlfrei (über einen nullbasierten Index)
488 Kapitel 10 Java Collections Framework
zugreifen. Es ist also wie bei einem Array möglich, das Element an einer bestimmten Position abzu-
rufen oder zu verändern.1
Im Unterschied zu einem Array wird eine Liste bei Bedarf automatisch vergrößert. Wir haben also
einen größendynamischen Container zur Verfügung, der dank Typgenerizität Elemente von einem
wählbaren Referenztyp sortenrein (mit Compiler-Typsicherheit) verwaltet.
Für Listen finden sich sehr viele Einsatzmöglichkeiten bei der Software-Entwicklung. Man verwen-
det sie z. B. für ...
• die aktuell von einem Steuerelement der Bedienoberfläche angebotenen Optionen,
• die Bestellungen eines Kunden, der aus einer Datenbank geladen wurde,
• die aus einem Text sukzessiv extrahierten Wörter.
Die Elemente einer Liste müssen (im Unterschied zu den Elementen einer Menge, vgl. Abschnitt
10.6) nicht verschieden sein, d .h.:
• Mehrere Elemente können dasselbe Objekt referenzieren (also dieselbe Adresse enthalten).
• Mehrere Referenzziele können im Sinn der equals() - Methode der Elementklasse inhalts-
gleich sein.
Im späteren Kapitel 13 über die Programmierung von grafischen Bedienoberflächen mit JavaFX-
Technik werden beobachtbare Listen behandelt. Diese können durch ein ListView<E> - Steue-
relement präsentiert werden. Außerdem lassen sich bei einer solchen Liste Beobachter registrieren,
die über bestimmte Veränderungen (z. B. Aufnahme neuer Elemente, Auftreten bestimmter Werte
bei einem Element) informiert werden wollen (siehe Abschnitt 13.5.3.3).
1
Man kann ein adressiertes Element ersetzen oder verändern, sofern seine Klasse Änderungen erlaubt.
Abschnitt 10.4 Listen 489
Seit Java 9 besitzt die Schnittstelle List<E> eine statische Fabrikmethode namens of(), mit der sich
auf einfache Weise unveränderliche List<E> - Objekte erstellen lassen, z. B.:1
Quellcode Ausgabe
import java.util.*; [a, b, c, d]
class Prog {
public static void main(String[] args) {
List<String> los = List.of("a", "b", "c", "d");
System.out.println(los);
// los.add("f"); // verursacht eine UnsupportedOperationException
}
}
10.4.2 Beispiel
In einem Beispielprogramm sollen einige List<E> - Methoden unter Verwendung der bereits be-
kannten parametrisierten Klasse ArrayList<String> erprobt werden:
import java.util.*;
class Listen {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
List<String> namen = new ArrayList<>();
1
Wenn von List<E> - Objekten die Rede ist, dann sind Objekt aus einer das Interface List<E> implementierenden
Klasse gemeint. Um mit einem von of() gelieferten Objekt erfolgreich arbeiten zu können, müssen wir den Namen
seiner Klasse nicht kennen.
Abschnitt 10.4 Listen 491
while (true) {
System.out.print("Zu suchender Name oder leere Eingabe zum Beenden: ");
String s = scanner.nextLine();
if (s.length() == 0)
break;
System.out.println("Position: " + namen.indexOf(s));
}
10.4.3 Listenarchitekturen
Eine Liste verwendet zum Speichern ihrer Elemente entweder einen Array oder verlinkte Knoten.
Listen mit einem Array-Unterbau sind beim wahlfreien Zugriff auf Elemente an bestimmten Positi-
onen im Vorteil, während eine aus verlinkten Knoten aufgebaute Liste beim Einfügen und Löschen
von inneren Elementen überlegen ist. Bei der Suche nach einem Element mit einem bestimmten
Inhalt muss eine Liste unabhängig von der verwendeten Architektur zeitaufwändig linear durchlau-
fen werden.
1
Man sollte von einer bedingten Thread-Sicherheit sprechen (siehe Naftalin & Wadler 2007, Abschnitt 8.6). Soll
z. B. ein Element nur dann in eine Liste eingefügt werden, wenn es dort noch nicht vorhanden ist, dann muss durch
eine explizite Synchronisierung verhindert werden, dass zwischen der Existenzprüfung und dem Einfügen andere
Threads auf die Liste zugreifen.
Abschnitt 10.4 Listen 493
kann, soll hier eine Option zum Erstellen thread-sicherer Listenverwaltungsklassen erwähnt werden.
Die Service-Klasse Collections (mit einem s am Ende des Namens, siehe Abschnitt 10.10) liefert
über die statische Methode synchronizedList() zu einer das Interface List<E> implementierenden
Klasse eine synchronisierte Hüllenklasse, z. B.:
List<String> sal = Collections.synchronizedList(new ArrayList<String>());
Diese Hüllenklasse kann allerdings bei der Verarbeitungsgeschwindigkeit nicht ganz mit der Klasse
Vector<E> mithalten.1 Mit weiteren thread-sicheren Listenverwaltungsklassen werden wir uns im
Abschnitt 15.6 beschäftigen.
Die Vorteile einer Liste aus verlinkten Knoten im Vergleich zu einer Liste mit Array-Unterbau sind:
• Beim Einfügen oder Löschen von inneren Elementen müssen keine anderen Elemente ver-
schoben werden. Es genügt, die Adressketten neu zu verknüpfen. Der Aufwand ist unabhän-
gig von der aktuellen Listenlänge und von der bearbeiteten Position immer gleich.
• Es kann nicht passieren, dass bei einer Erschöpfung der aktuellen Kapazität aufwändige
Maßnahmen erforderlich werden (Kopieren der Elemente in einen größeren Array).
Um ein Listenelement mit einem bestimmtem Indexwert anzusteuern, muss eine verkettete Liste
allerdings ausgehend vom ersten oder letzten Element durchlaufen werden. Folglich ist die verkette-
te Liste beim wahlfreien Zugriff auf vorhandene Elemente einer Liste mit Array-Technik deutlich
unterlegen, weil die Elemente eines Arrays im Speicher hintereinander liegen und nach einer einfa-
chen Adressberechnung direkt angesprochen werden können. Außerdem benötigt eine verkettete
Liste mehr Speicher. Zum sequentiellen Aufsuchen der Listenelemente muss bei der Klasse
LinkedList<E> aus Performanzgründen an Stelle des Indexzugriffs (per get(int index)) unbedingt
ein Iterator-Objekt verwendet werden (siehe Abschnitt 10.5).
Insgesamt sind verlinkte Listen gut geeignet für Algorithmen, die …
• häufig Elemente einfügen oder entfernen und sich dabei nicht auf das Listenende beschrän-
ken,
• Elemente überwiegend sequentiell aufsuchen.
1
Quelle: http://docs.oracle.com/javase/tutorial/collections/implementations/list.html
494 Kapitel 10 Java Collections Framework
Weil die Klasse LinkedList<E> neben der Schnittstelle List<E> auch die Schnittstelle Deque<E>
implementiert, beherrscht sie auch die dort geforderten Methoden (siehe Abschnitt 10.9) und kann
folglich auch als Warteschlange oder Stapel verwendet werden.
Wie die Klasse ArrayList<E> bietet auch die Klasse LinkedList<E> aus Performanzgründen kei-
ne Thread-Sicherheit. Von der Collections-Methode synchronizedList() erhält man aber zu jedem
List<E> - Objekt eine thread-sichere (synchronisierte) Verpackung (siehe Abschnitte 10.4.3.1 und
10.10).
class Listen {
static final int ANZ = 40_000;
// Füllen
System.out.println("\nKollektionsklasse:\t" + plis.getClass());
long start = System.currentTimeMillis();
for (int i = 0; i < ANZ; i++) {
sb.delete(0, 6);
for (int j = 0; j < 5; j++)
sb.append((char) (65 + ran.nextInt(26)));
plis.add(sb.toString());
}
System.out.printf(" %-50s %7d\n", "Zeit zum Füllen:",
(System.currentTimeMillis() - start));
// Einfügen am Listenanfang
start = System.currentTimeMillis();
for (int i = 0; i < ANZ; i++)
plis.add(0, "neu");
System.out.printf(" %-50s %7d\n", "Zeit zum Einfügen am Anfang:",
(System.currentTimeMillis() - start));
Abschnitt 10.4 Listen 495
// Löschen am Listenanfang
start = System.currentTimeMillis();
for (int i = 0; i < ANZ; i++)
plis.remove(0);
System.out.printf(" %-50s %7d\n", "Zeit zum Löschen am Anfang:",
(System.currentTimeMillis() - start));
}
1
Die Zeiten stammen von einem PC unter Windows 10 (64 Bit) mit Intel-CPU Core i3 (3,2 GHz) mit vier virtuellen
Kernen.
496 Kapitel 10 Java Collections Framework
Wir beobachten:
• Das Befüllen verläuft bei allen Klassen recht flott. Die thread-sichere Klasse Vec-
tor<String> benötigt nicht mehr Zeit als die anderen Klassen, wobei dieses Ergebnis nicht
generalisiert werden darf.
• Beim Abrufen von Werten sind die Array-basierten Klassen erheblich schneller als die ver-
kettete Liste.
• Beim Einfügen und Löschen am Listenanfang ist die verkettete Liste überlegen. Allerdings
hat sie dabei einen Wettbewerbsvorteil: Weil das Einfügen und Löschen stets am Listenan-
fang stattfindet, müssen keine Adressen per Listenverfolgung ermittelt werden.
• Beim Einfügen an einer zufällig gewählten, per Index definierten Position ist die verkettete
Liste deutlich unterlegen.
Das Beispielprogramm macht sich zu Nutze, dass eine Schnittstelle als Datentyp zugelassen ist, und
dass eine entsprechende Referenzvariable auf ein Objekt aus einer beliebigen implementierenden
Klasse zeigen kann (siehe die Definition der Methode testList() und deren Aufrufe in der Me-
thode main()).1
10.5 Iteratoren
Die Schnittstelle Collection<E> erweitert die Schnittstelle Iterable<E>,2
public interface Iterable<T> {
Iterator<T> iterator();
}
sodass eine implementierende Kollektionsklasse eine Methode namens iterator() beherrschen
muss, die ein Objekt liefert, das die Schnittstelle Iterator<E>3
public interface Iterator<E> {
boolean hasNext();
E next();
default void remove() {
throw new UnsupportedOperationException("remove");
}
}
erfüllt und daher u. a. die folgenden Methoden beherrscht, um das Iterieren durch die Elemente der
Kollektion zu ermöglichen:
• public boolean hasNext()
Befindet sich hinter der aktuellen Iterator-Position noch ein weiteres Element, wird der
Rückgabewert true geliefert, sonst false.
Position des Iterators (|) hasNext()-Rückgabe
X|YZ true
XYZ| false
1
Im vorliegenden Fall hätten wir als Datentyp für den testList() - Parameter auch die Klasse AbstractList<E>
verwenden können, weil diese gemeinsame Basisklasse von ArrayList<E>, Vector<E> und LinkedList<E> eben-
falls das Interface List<E> implementiert und somit die benötigten Methoden beherrscht (vgl. Abschnitt 7.5).
2
Der Einfachheit halber wurden die beiden in Java 8 ergänzten default-Methoden forEach() und spliterator() weg-
gelassen.
3
Der Einfachheit halber wurde die in Java 8 ergänzte default-Methode forEachRemaining() weggelassen.
Abschnitt 10.5 Iteratoren 497
• public E next()
Diese Methode liefert das nächste Element hinter dem Iterator und verschiebt den Iterator
um eine Position nach rechts:
Position des Iterators (|) vor next() Position des Iterators (|) nach next()
X|YZ XY|Z
Gibt es kein nächstes Element, wirft die Methode eine Ausnahme vom Typ
NoSuchElementException.
• public void remove()
Ein remove() - Aufruf entfernt das zuletzt per next() abgerufene Listenelement. Einem re-
move() - Aufruf muss also ein erfolgreicher next() - Aufruf vorangehen, der noch nicht
durch einen anderen remove() - Aufruf verwertet worden ist. Die Methode remove() ist in
der API-Dokumentation durch den Zusatz optional operation markiert (vgl. Abschnitt 9.2).
Es ist einer Klasse erlaubt, sich bei der Implementation dieser Methode auf das Werfen einer
UnsupportedOperationException zu beschränken. Es wird allerdings von jeder imple-
mentierenden Klasse erwartet, es in der Dokumentation offenzulegen, wenn nur eine Pseu-
do-Implementation oder die default-Methode der Schnittstelle Iterator<E>
default void remove() {
throw new UnsupportedOperationException("remove");
}
vorhanden ist.
Wird nach der Erstellung des Iterators die zugehörige Liste auf andere Weise als durch einen Aufruf
der Iterator-Methode remove() geändert, dann darf der Iterator anschließend nicht mehr verwendet
werden, weil sein Verhalten unspezifiziert ist. Bei Bedarf muss durch einen erneuten Aufruf der
Iterable<E> - Methode iterator() ein frischer Iterator erstellt werden.
Das folgende Programm demonstriert die Verwendung des Iterators zu einem LinkedList<String>
- Objekt:
Quellcode Ausgabe
import java.util.*; Otto
class Prog { Luise
public static void main(String[] args) { Rainer
List<String> ls = new LinkedList<>(List.of("Otto", "Luise",
"Rainer")); Rest der Liste:
Iterator<String> ist = ls.iterator(); Otto
while (ist.hasNext()) Luise
System.out.println(ist.next());
ist.remove(); // Letzte next() - Rückgabe entfernen
System.out.println("\nRest der Liste:");
for (String s : ls)
System.out.println(s);
}
}
auch bei den Klassen zur Verwaltung von Mengen und Abbildungen verwendbar (vgl. Abschnitte
10.6 und 10.7).
Iteratoren werden meist implizit und bequem über die for-Schleife für Kollektionen genutzt (auch
als erweiterte for-Schleife oder for-each - Schleife bezeichnet, vgl. Abschnitt 3.7.3.2):
for (Elementtyp Iterationsvariable : Serie)
Anweisung
Die vom Interface List<E> geforderte Methode listIterator() liefert ein Objekt, welches das vom
Interface Iterator<E> abstammende Interface ListIterator<E> implementiert. Es enthält zusätzli-
che Methoden, die u. a. bidirektionale Listenpassagen und die Aufnahme von neuen Elementen un-
terstützen:
• public boolean hasPrevious()
Befindet sich vor der aktuellen Iterator-Position noch ein Listenelement, dann wird der
Rückgabewert true geliefert, sonst false.
• public E previous()
Diese Methode liefert das nächste Listenelement vor dem Iterator und verschiebt den Itera-
tor um eine Position nach links. Gibt es kein vorheriges Element, wirft die Methode eine
Ausnahme vom Typ NoSuchElementException.
• public int previousIndex()
Von dieser Methode erfährt man den Index des Elements, das ein nachfolgender previous()
- Aufruf liefern würde, oder den Wert -1, wenn sich der Iterator am Listenanfang befindet.
• public void add(E e) (optionale Operation)
Das Parameterobjekt wird in die Liste aufgenommen und vor dasjenige Element gesetzt, das
von next() geliefert würde.
• public void set(E element) (optionale Operation)
Das zuletzt von next() oder previous() gelieferte Element wird durch das Parameterobjekt
ersetzt.
Bei den List<E> - Implementationen ArrayList<E>, LinkedList<E> und Vector<E> sind auch
die listIterator() - Rückgaben fail-fast. Sie bemühen sich also, eine nach der Erstellung des Itera-
tors vorgenommene, unzulässige strukturelle Listenänderung zu erkennen und ggf. mit einer Aus-
nahme zu reagieren, um erratisches Verhalten zu verhindern. Strukturelle Listenänderungen durch
die Methoden add() und remove() sind zulässig und damit kein Anlass für eine Ausnahme.
10.6 Mengen
Zur Verwaltung einer Menge von Elementen, die im Unterschied zu einer Liste keine Dubletten (im
Sinne der equals() - Methode der Elementklasse) aufweisen darf, enthält das Java Collections
Framework im Paket java.util u. a. die generischen Klassen HashSet<E>, LinkedHashSet<E>
und TreeSet<E>. Sie implementieren wie ihre gemeinsame (direkte oder indirekte) abstrakte Ba-
sisklasse AbstractSet<E> das Interface Set<E>. Diese Klassen sind nützlich, wenn Mengen im
Sinne der Mathematik zu modellieren sind und entsprechende Operationen benötigt werden (z. B.
Durchschnitt, Vereinigung oder Differenz von zwei Mengen). Im Vergleich zu anderen Kollekti-
onsklassen können sie Mengenzugehörigkeitsprüfungen sehr schnell ausführen, was sie unverzicht-
bar macht, wenn derartige Prüfungen in großer Zahl auftreten.
Einige Mengenverwaltungsklassen unterstützen eine Anordnung der Elemente, die entweder auf der
Einfügereihenfolge (bei LinkedHashSet<E>) oder auf einer Ordnung des Elementtyps (bei
TreeSet<E>) basiert. Von den Listen (siehe Abschnitt 10.4) unterscheiden sich auch die Mengen-
verwaltungsklassen mit geordneten Elementen durch die Forderung, dass zur Existenzprüfung ein
Abschnitt 10.6 Mengen 499
Algorithmus verfügbar sein muss, dessen Aufwand im ungünstigsten Fall über eine logarithmische
Funktion von der Anzahl k der Elemente abhängt, also z. B. von der Ordnung O(log2 k) ist (siehe
Abschnitt 10.6.4).
1
Wenn von Set<E> - Objekten die Rede ist, sind Objekt aus einer das Interface Set<E> implementierenden Klasse
gemeint. Um mit einem von of() gelieferten Objekt erfolgreich arbeiten zu können, müssen wir den Namen seiner
Klasse nicht kennen.
500 Kapitel 10 Java Collections Framework
Quellcode Ausgabe
import java.util.*; [d, a, c, b]
class Prog {
public static void main(String[] args) {
Set<String> sos = Set.of("a", "b", "c", "d");
System.out.println(sos);
// sos.add("f");
}
}
Den Aufruf der statischen, generischen Methode of() richtet man an den Rohtyp der generischen
Schnittstelle Set<E>. Wegen der Typinferenz-Fähigkeiten des Compilers ist es nicht erforderlich,
beim Aufruf der generischen Methode den Typaktualparameter anzugeben:
Set<String> sos = Set.<String>of("a", "b", "c", "d");
Wie die Ausgabe des Programms zeigt, besitzen die Elemente in einem Set<E> - Container keine
definierte Anordnung. Die auskommentierte Anweisung hätte einen Laufzeitfehler vom Typ
UnsupportedOperationException zur Folge, weil die of() - Produkte unveränderlich sind.
Man sollte nach Möglichkeit für Variablen und Parameter, die auf eine Menge zeigen, den Inter-
face-Datentyp Set<E> (oder eine geordnete Variante, siehe Abschnitt 10.6.5) verwenden, damit zur
Lösung einer konkreten Aufgabe die optimale Set<E> - Implementierung im OCP-Sinn (Open-
Closed - Prinzip, vgl. Abschnitt 4.1.1.3), also praktisch ohne Quellcode-Änderungen, genutzt wer-
den kann.
Einen Indexzugriff auf ihre Elemente bieten die Kollektionsklassen zur Mengenverwaltung nicht,
ein Iterator (vgl. Abschnitt 10.5) ist jedoch verfügbar.
Eine Set<E> - Implementierung kann Dubletten nicht verhindern, wenn Objekte nach Aufnahme in
die Menge geändert werden. Bei manchen API-Klassen ist eine Änderung von Objekten grundsätz-
lich ausgeschlossen (z. B. String, alle Verpackungsklassen für primitive Datentypen), was sich im
augenblicklichen Kontext (und nicht nur dort) als vorteilhaft erweist. In der Regel sind Objekte aber
veränderbar, z. B. bei der Klasse Mint, die Sie in einer Übungsaufgabe als int-Hüllenklasse ent-
worfen haben (vgl. Abschnitt 5.3). Im folgenden Programm entsteht ein HashSet<Mint> - Contai-
ner mit Dublette:
Quellcode Ausgabe
import java.util.*; [1, 2]
[1, 1]
class Dubletten {
public static void main(String[] args) {
Set<Mint> mint = new HashSet<>();
Mint m1 = new Mint(1);
Mint m2 = new Mint(2);
mint.add(m1); mint.add(m1);
mint.add(m2);
System.out.println(mint);
m2.val = 1;
System.out.println(mint);
}
}
thode synchronizedSet() zu einer das Interface Set<E> implementierenden Klasse eine synchroni-
sierte Hüllenklasse, z. B.:
Set<String> shs = Collections.synchronizedSet(new HashSet<String>());
Mit weiteren thread-sicheren Mengenverwaltungsklassen werden wir uns im Abschnitt 15.6 be-
schäftigen.
10.6.3 Hashtabellen
Benötigt ein Algorithmus zahlreiche Mengenzugehörigkeitsprüfungen, sind Kollektionen mit Lis-
tenbauform wenig geeignet, weil ein fragliches Element potentiell mit jedem Listenelement über
einen equals() - Methodenaufruf verglichen werden muss. Um diese Aufgabe schneller lösen zu
können, kommt bei der Klasse HashSet<E> eine sogenannte Hashtabelle zum Einsatz. Die zentrale
Designidee besteht darin, zur Datenablage einen Array mit sogenannten Buckets zu verwenden,
wobei es sich um einfach verkettete Listen handelt. Eine effiziente Existenzprüfung ist möglich,
weil ...
• für ein aufzunehmendes oder auf Existenz zu prüfendes Element der Array-Index des zuge-
hörigen Buckets direkt berechnet werden kann,
• die Buckets nach Möglichkeit einelementig sind, sodass kaum Listenoperationen anfallen.
Bei leicht vereinfachter Darstellung sieht ein Bucket-Array so aus:2
1
Die Zeiten stammen von einem PC unter Windows 10 (64 Bit) mit Intel-CPU Core i3 (3,2 GHz).
2
Es wird mal wieder aus didaktischen Gründen ein wenig gemogelt. Ein Blick in den API-Quellcode zeigt, dass die
Klasse HashSet<E> intern ein HashMap<E,V> - Objekt (vgl. Abschnitt 10.7.2) zur Datenablage verwendet, so-
dass die realen Buckets etwas anders aussehen.
502 Kapitel 10 Java Collections Framework
Bei der Aufnahme eines neuen Elements per add() und auch bei der Existenzprüfung (erforderlich
beim Aufruf von contains() oder remove()) wird der Bucket-Index über die typspezifische Imple-
mentierung der bereits in der Urahnklasse Object definierten Instanzmethode hashCode() ermittelt:
public int hashCode()
Beim Einfügen eines neuen Elements ist die Liste zum berechneten Index idealerweise noch leer.
Anderenfalls spricht man von einer Hash-Kollision, und es entsteht ein kleiner Zusatzaufwand. We-
gen der folgenden Anforderungen an eine zum Befüllen einer Hashtabelle einzusetzende
hashCode() - Methode (bzw. an die in dieser Methode realisierte Hash-Funktion) ist in der Regel in
der E-Konkretisierungsklasse das Object-Erbstück durch eine sinnvolle Implementierung zu über-
schreiben:
• Während eines Programmlaufs müssen alle Methodenaufrufe für ein Objekt denselben Wert
liefern, solange bei diesem Objekt keine Veränderungen mit Relevanz für die equals() - Me-
thode auftreten.
• Sind zwei Objekte identisch im Sinne der equals() - Methode, dann müssen sie denselben
hashCode() - Wert erhalten. Daher muss für jede Klasse, die die equals() - Methode über-
schreibt, auch die hashCode() - Methode überschrieben werden.
• Sind zwei Objekte verschieden im Sinne der equals() - Methode, dann müssen sie nicht un-
bedingt verschiede hashCode() - Werte erhalten. Allerdings leidet die Performanz von
Hashtabellen, wenn es oft zu sogenannten Hash-Kollisionen kommt (gleiche Hash-Werte
für equals() - verschiedene Objekte.
• Die hashCode() - Werte sollten dazu taugen, Array-Elemente zu indizieren und dazu mög-
lichst gleichmäßig verteilt sein.
Bei den Klassen im Java-API kann man von korrekten Implementationen der Methoden equals()
und hashCode() ausgehen. Bei der Urahnklasse Object ist die Konsistenz der Methoden z. B.
dadurch gewährleistet, dass beide auf der Speicheradresse eines Objekts basieren.
Aus dem Hashcode eines Objekts und der Hashtabellen-Kapazität wird der Array-Index per Mo-
dulo-Operation ermittelt:
Array-Index = Hashcode % Kapazität
Abschnitt 10.6 Mengen 503
Bei der API-Klasse String kommt z. B. die folgende Hash-Funktion zum Einsatz:
u(0) 31n-1 + u(1) 31n-2 + ... + u(n-1)
Dabei steht u(i) für die Unicode-Nummer des Zeichens an der (nullbasierten) Position i und n für
die Länge der Zeichenfolge. Für die Zeichenfolge "Theo" erhält man z. B.:
84 ˑ 313 + 104 ˑ 312 + 101 ˑ 311 + 111 = 2605630
Bei einer Hashtabellen-Kapazität von 1024 resultiert der Array-Index
2605630 % 1024 = 574
Weil bei der Klasse String die equals() - und die hashCode() - Methode beide auf der Sequenz der
Zeichen basieren, ist die Konsistenz der Methoden gewährleistet.
Um für ein Objekt mit der Methode contains() festzustellen, ob es bereits in der Hashtabelle (Men-
ge) enthalten ist, muss es nicht über equals() - Aufrufe mit allen Insassen verglichen werden. Statt-
dessen wird sein Hashcode berechnet und sein Array-Index ermittelt. Befindet sich hier noch keine
Listenadresse, dann ist die Existenzfrage geklärt (contains() - Rückmeldung false). Anderenfalls ist
nur für die Objekte der im Array-Element adressierten Liste eine equals() - Untersuchung erforder-
lich.
Damit es selten zu Hash-Kollisionen kommt, sollte die Array-Größe ungefähr das 1,5 - fache der
Anzahl aufzunehmender Elemente betragen (Horstmann & Cornell, 2002, S. 137). Über den La-
dungsfaktor der Hashtabelle legt man fest, bei welchem Füllungsgrad in einen neuen, ca. doppelt
so großen Array umgezogen werden soll (Voreinstellung: 0,75). In diesem Fall werden alle Array-
Indizes nach der oben angegebenen Regel
Array-Index = Hashcode % Kapazität
neu berechnet, sodass eine komplett andere Anordnung der Elemente resultiert.
Weil die Klasse HashSet<E> das Interface Collection<E> (siehe Abschnitt 10.3) und das syntak-
tisch äquivalente Interface Set<E> (siehe Abschnitt 10.6.1) implementiert, kann sie (als Rückgabe
der Methode iterator()) einen Iterator (siehe Abschnitt 10.5) zur Verfügung stellen, der sukzessive
alle Elemente aufsucht und dabei erwartungsgemäß eine zufällig wirkende Reihenfolge verwendet.
Mit der Klasse LinkedHashSet<E> steht eine HashSet<E> - Ableitung zur Verfügung, deren Ob-
jekte sich die Einfügereihenfolge der Elemente merken. Dies wird durch den Zusatzaufwand einer
doppelt verlinkten Liste realisiert. Die Elemente merken sich ...
• das nächste Element im selben Bucket
• und das als nächstes eingefügte Element.
Im Ergebnis erhalten wir einen Iterator, der die Einfügereihenfolge verwendet und außerdem flotter
arbeitet als die HashSet<E> - Variante, weil die leeren Buckets nicht aufgesucht werden müssen
(Naftalin & Wadler 2007, S. 181).
Fritz
Berta Ludwig
Rudi
Bei einem balancierten Binärbaum kommen Forderungen zur maximalen Entfernung zwischen der
Wurzel und einem Element (also zur Anzahl der Ebenen) hinzu, um den Aufwand beim Suchen und
Einfügen von Elementen zu begrenzen. Der bisher betrachtete Namensbaum ist gut balanciert (mit
vier Ebenen), während in der folgenden Abbildung eine extrem unbalancierte Anordnung derselben
Elemente zu sehen ist (mit acht Ebenen), die offenbar aus einer ungünstigen Wahl des Wurzelele-
ments resultiert:
Anton
Berta
Charly
Fritz
Lars
Ludwig
Norbert
Rudi
Zur Beurteilung des Aufwands bei der Suche nach einem Element (oder bei der Neuaufnahme eines
Elements) gehen wir von einem balancierten und vollständig gefüllten Binärbaum aus. Hier haben
alle Knoten, die keine Endknoten sind, genau zwei Nachfolger. In der ersten Variante des Namens-
baums lag diese Situation vor der Aufnahme von Rudi vor. Der maximale Aufwand bei einer Exis-
tenzprüfung oder Neuaufnahme ist identisch mit der Zahl m der Ebenen, weil pro Ebene ein Anord-
nungsvergleich vorgenommen werden muss. Wir schätzen nun ab, wie viele Ebenen ein balancierter
Binärbaum zur Aufnahme von k Elementen benötigt.
Aus der Anzahl m der Ebenen kann nach der folgenden Formel die Anzahl der k der enthaltenen
Elemente berechnet werden:
k = 2m − 1
Abschnitt 10.6 Mengen 505
Bei m = 3 Ebenen resultieren z. B. 7 Elemente (siehe Beispiel). Man erhält k als Partialsumme der
geometrischen Reihe:1
m −1
1 − 2m
k = 2 = i
= 2m − 1
i =0 1− 2
Für hinreichend großes k kann man die Beziehung zwischen k und m vereinfachen und dann durch
Anwendung der Logarithmus-Funktion nach m auflösen, um die zur Verwaltung von k Elementen
erforderliche Anzahl von Ebenen zu ermitteln:
k = 2m
log 2 (k ) = m
Für hinreichend großes k sind also log2(k) Algorithmusschritte erforderlich, um ein Element zu su-
chen oder die Position für ein neues Element zu bestimmen. Man sagt unter Verwendung einer No-
tation mit dem griechischen Großbuchstaben O (Omikron), der Algorithmus sei von der Ordnung
O(log2 k). Weil das monotone Wachstum der Logarithmus-Funktion relativ flach verläuft, steigt der
Aufwand nur langsam mit der Anzahl k der Elemente an:2
13
12
11
log2(x)
10
9
8
7
Bei einer Hashtabelle wächst der Aufwand einer Existenzprüfung nicht mit der Anzahl der Elemen-
te, und es resultiert die günstigere Ordnung O(1). Bei einer Liste hingegen ist der Aufwand einer
Existenzprüfung direkt proportional zur Anzahl der Elemente, und es resultiert die ungünstige Ord-
nung O(k). Insgesamt verursacht bei einem Binärbaum die Anordnung der Elemente keine allzu
großen Kosten für die Mengenoperationen.
Im Java Collections Framework nutzt die Klasse TreeSet<E> das Prinzip des Binärbaums, um ihre
Elemente in aufsteigender Sortierordnung zu halten. Dabei wird durch die sogenannte Rot-Schwarz
-Architektur sichergestellt, dass der Binärbaum immer balanciert ist.
Der Iterator zu einem TreeSet<E> - Objekt, den wir meist implizit im Rahmen der erweiterten for-
Schleife (vgl. Abschnitt 3.7.3.2) benutzen, durchläuft die Elemente in aufsteigender Ordnung, z. B.:
1
Der mit elementaren Mitteln zu führende Beweis ist z. B. hier zu finden:
https://de.wikibooks.org/wiki/Mathe_f%C3%BCr_Nicht-Freaks:_Geometrische_Reihe
2
Der Funktionsplot wurde mit R 3.6 erstellt über den Funktionsaufruf:
curve(log2(x), 0, 10000, col="blue", lwd=5)
506 Kapitel 10 Java Collections Framework
Quellcode Ausgabe
import java.util.*; Anton
class Prog { Berta
public static void main(String[] args) { Charly
Set<String> tsi = new TreeSet<>(Set.of("Fritz", "Lars", "Berta", Fritz
"Charly", "Ludwig", "Anton", "Norbert", "Rudi")); Lars
for(String s : tsi) Ludwig
System.out.println(s); Norbert
} Rudi
}
Am Rand sei noch erwähnt, dass seit Java 7 in eine Kollektion vom Typ TreeSet<E> kein Element
mit dem Referenzziel null eingefügt werden kann. Ein Versuch endet mit einer NullPointerExcep-
tion. Bei der Klasse HashSet<E> und bei den meisten List<E> - Implementationen lässt sich eine
null-Referenz jedoch ungestraft einfügen.
SortedSet<E>
NavigableSet<E>
Dem TreeSet<String> - Konstruktor wird ein Set<String> - Objekt übergeben, das mit
Hilfe der seit Java 9 verfügbaren statischen Methode of() aus dem Interface Set<E> erstellt
wird. Aus der von Set.of() gelieferten unveränderlichen Menge (vgl. Abschnitt 10.6.1) er-
stellt der TreeSet<E> - Konstruktor eine veränderliche Kollektion.
• public SortedSet<E> tailSet(E untereSchranke)
Man erhält ein View-Objekt mit allen Elementen der angesprochenen Kollektion auswirken,
die mindestens so groß sind wie die untere Schranke. Alle Methoden des View-Objekts wir-
ken sich auf die Originalkollektion aus, z. B.:
1
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Comparator.html
508 Kapitel 10 Java Collections Framework
Quellcode Ausgabe
import java.util.*; [c, d]
class Prog { [a, b]
public static void main(String[] args) {
SortedSet<String> ts=new TreeSet<>(Set.of("a","b","c","d"));
SortedSet<String> sos = ts.tailSet("c");
System.out.println(sos);
sos.clear();
System.out.println(ts);
}
}
// Datei ComparatorTest
import java.util.*;
class ComparatorTest {
public static void main(String[] args) {
SortedSet<String> tss = new TreeSet<>(
Set.of("Werner", "Ludwig", "Otto"));
System.out.println(tss);
Eine geordnete Menge kann wie eine Liste als Sequenz bezeichnet werden, doch es bestehen u. a.
die folgenden Unterschiede zwischen den beiden geordneten Kollektionen:
• In einer Menge sind keine Dubletten erlaubt, während die Eindeutigkeitsgarantie bzw. -
restriktion bei Listen nicht besteht.
• Bei einer Liste kann der Programmierer die Position jedes einzelnen Elements uneinge-
schränkt kontrollieren und z. B. ein neues Element per add() - Methodenaufruf an einer frei
wählbaren Stelle einfügen. Bei einem geordneten Menge wird hingegen die Position aller
Elemente durch eine compareTo() - oder eine compare() - Methode diktiert.
• Eine Liste bietet im Unterschied zu einer geordneten Menge den Indexzugriff auf die Ele-
mente.
• Eine geordnete Menge kann Existenzprüfungen sehr viel schneller ausführen.
• Während in eine Liste auch ein Element mit null-Referenz eingefügt werden kann, führt der
Versuch bei einem Objekt der Klasse TreeSet<E> zu einer NullPointerException.
10.7 Abbildungen
Zur Verwaltung einer Menge von (Schlüssel-Wert) - Paaren stellt das Java Collections Framework
Klassen zur Verfügung, die das generische Interface Map<K,V> erfüllen und als Abbildungen be-
zeichnet werden. Die Schlüssel (mit einer Konkretisierung des Typformalparameters K als Daten-
typ) werden wie eine Menge verwaltet, sodass also Eindeutigkeit herrscht (ohne Dubletten). Über
einen Schlüssel ist sein Wert ansprechbar (mit einer Konkretisierung des Typformalparameters V
als Datentyp). Man könnte z. B. in einem Programm zur Personalverwaltung eine Abbildung ver-
wenden mit ...
• einer eindeutigen Personalnummer (Typ Integer als K-Konkretisierung)
• und einer geeigneten Klasse Personal (mit Instanzvariablen für den Namen, die Mail-
Adresse etc.) als V- Konkretisierung.
Statt der etwas sperrigen Bezeichnung (Schlüssel-Wert) - Paar verwenden wir gelegentlich die Be-
zeichnung Assoziation.
Abschnitt 10.7 Abbildungen 511
Hinsichtlich der zur Schlüsselverwaltung eingesetzten Technik unterscheiden sich die beiden be-
kanntesten, das Interface Map<K,V> implementierenden Klassen:
• HashMap<K,V>
Die Schlüssel werden in einer Hashtabelle verwaltet (vgl. Abschnitt 10.6.3), sind also sehr
schnell auffindbar, aber unsortiert.
• TreeMap<K,V>
Die Schlüssel werden in einen balancierten Binärbaum verwaltet (vgl. Abschnitt 10.6.4),
sind also nicht ganz so schnell auffindbar, aber stets sortiert (im Sinne der natürlichen K-
Ordnung oder per Comparator<K>).
Die Klassen HashMap<K,V> und TreeMap<K,V> sind aus Performanzgründen nicht thread-
sicher. Allerdings liefert die Klasse Collections über die statische Methode synchronizedMap() zu
einer das Interface Map<K,V> implementierenden Klasse eine synchronisierte Hüllenklasse, z. B.:
Map<String,Person> shm =
Collections.synchronizedMap(new HashMap<Integer,Person>());
Daneben bietet das ebenfalls mit Java 5 (alias 1.5) eingeführte Paket java.util.concurrent Schnitt-
stellen und Klassen zur Multithreading-Unterstützung bei Abbildungs-Kollektionen, die aus Per-
formanzgründen gegenüber den synchronizedMap() - Rückgaben zu bevorzugen sind (siehe Ab-
schnitt 15.6).
Die traditionsreiche Klasse Hashtable<K,V> (kleines t im Namen der Klasse!), die mittlerweile
ebenfalls das Interface Map<K,V> implementiert, steht (analog zur Listenverwaltungsklasse
Vector<E>, siehe Abschnitt 10.4.3) trotz ihrer Thread-Sicherheit mittlerweile nicht mehr auf der
Best Practice - Empfehlungsliste für Java-Entwickler. Sie enthält neben den Methoden aus dem
Interface Map<K,V> noch weitere Methoden, die nicht mehr verwendet werden sollten, weil sie
den Wechsel zu einer alternativen Map<K,V> - Implementation verhindern, also die Flexibilität
und Wiederverwendung der Software erschweren.
1
https://stackoverflow.com/questions/4269147/why-does-java-mapk-v-take-an-untyped-parameter-for-the-get-and-
remove-methods
Abschnitt 10.7 Abbildungen 513
Quellcode Ausgabe
import java.util.*; {1=A, 2=B}
class Prog { {2=B}
public static void main(String[] args) {
Map<Integer,String> mis = new HashMap<>();
mis.put(1, "A"); mis.put(2, "B");
System.out.println(mis);
Map<?,?> wc = mis;
wc.remove(1);
System.out.println(mis);
}
}
Die Map<K,V> - Methoden keySet(), values() und entrySet() liefern jeweils eine Sicht (engl.:
View) auf die Abbildung:
1
Mitglieds-Schnittstellen von Schnittstellen sind implizit öffentlich und statisch, werden also wie Top-Level-
Schnittstellen behandelt, müssen aber einen Doppelnamen führen (siehe Abschnitt 9.2.5, vgl. Gosling et al. 2021,
Abschnitt 9.5).
Abschnitt 10.7 Abbildungen 515
Alle zu einer Änderung der Kollektion führenden Methoden (z. B. put(), putAll(), clear(), re-
move() usw.) sind in der API-Dokumentation durch den Zusatz optional operation markiert. Es ist
einer Klasse erlaubt, sich bei der Implementation solcher Methoden auf das Werfen einer
UnsupportedOperationException zu beschränken. Es wird allerdings von jeder implementieren-
den Klasse erwartet, in der Dokumentation offenzulegen, für welche Methoden nur eine Pseudo-
Implementation vorhanden ist.
Man sollte nach Möglichkeit für Variablen und Parameter, die auf eine Abbildung zeigen, den Inter-
face-Datentyp Map<K,V> (oder eine Variante mit geordnetem Schlüssel, siehe Abschnitt 10.7.3)
verwenden, damit zur Lösung einer konkreten Aufgabe die optimale Map<K,V> - Implementie-
rung im OCP-Sinn (Open-Closed - Prinzip, vgl. Abschnitt 4.1.1.3), also praktisch ohne Quellcode-
Änderungen, genutzt werden kann.
Seit Java 9 besitzt die Schnittstelle Map<K,V> eine statische und generische Fabrikmethode na-
mens of(), mit der sich auf einfache Weise unveränderliche Abbildungen erstellen lassen, z. B.:
Quellcode Ausgabe
import java.util.*; {1=a, 3=c, 2=b}
class Prog {
public static void main(String[] args) {
Map<Integer,String> mis = Map.of(1,"a", 2,"b", 3,"c");
System.out.println(mis);
// mis.put(4, "d");
}
}
class Prog {
public static void main(String[] args) {
Map<Integer,String> mis = Map.ofEntries(
entry(1,"a"), entry(2,"b"), entry(3,"c"));
System.out.println(mis);
}
}
516 Kapitel 10 Java Collections Framework
Wie die im Abschnitt 10.6.3 beschriebene Klasse HashSet<E> arbeitet auch die Klasse Hash-
Map<K,V> mit einer Hashtabelle, verwendet also einen Array mit einfach verketteten Listen
(Buckets) als Einträgen.1 Folglich muss die K-Konkretisierungsklasse eine hashCode() - Imple-
mentierung besitzen, die die im Abschnitt 10.6.3 angegebenen Bedingungen erfüllt.
1
Ein Blick in den API-Quellcode von Java 17 zeigt übrigens, dass die Klasse HashSet<E> intern eine
HashMap<E,Object> - Kollektion zur Datenablage verwendet und alle Elemente mit einem Dummy-Objekt als V-
Wert anlegt:
Abschnitt 10.7 Abbildungen 517
@java.io.Serial
static final long serialVersionUID = -5024744406713321676L;
/**
* Constructs a new, empty set; the backing {@code HashMap} instance has
* default initial capacity (16) and load factor (0.75).
*/
public HashSet() {
map = new HashMap<>();
}
. . .
}
518 Kapitel 10 Java Collections Framework
SortedMap<K,V>
NavigableMap<K,V>
Es gibt zwei Möglichkeiten, die Ordnung der von einer SortedMap<K,V> - Implementierung ver-
walteten Elemente zu begründen:
• Der Schlüsseltyp K erfüllt das Interface Comparable<K>, besitzt also eine Instanzmethode
compareTo() und somit eine natürliche Ordnung.
• Man übergibt dem Konstruktor ein Objekt, das die Schnittstelle Comparator<K> erfüllt
und folglich für den Typ K oder für eine Basisklasse B von K eine Vergleichsmethode mit
dem folgenden Definitionskopf bietet:
public int compare(K k1, K k2) bzw. public int compare(B e1, B e2)
Die Methoden compareTo() bzw. compare() müssen konsistent mit der equals() - Methode des
Schlüsseltyps sein, denn:
• Im Interface Map<K,V> basieren die Verträge vieler Methoden (z.B. containsKey()) auf
der equals() - Methode
• Eine SortedMap<K,V> - Implementation benutzt für alle Schlüsselvergleiche und insbe-
sondere auch für Übereinstimmungsprüfungen die Methoden compareTo() bzw. com-
pare().
Das Interface SortedMap<K,V> fordert von implementierenden Klassen u. a. die folgenden Me-
thoden:
• public Comparator<? super K> comparator()
Es wird das bei der Konstruktion übergebene Comparator-Objekt geliefert oder null, wenn
die natürliche Ordnung der Schlüsselklasse K genutzt wird. In diesem Fall muss K das In-
terface Comparable<K> implementieren.
• public K firstKey()
Liefert den ersten (kleinsten) Schlüssel in der sortierten Abbildung
• public K lastKey()
Liefert den letzten (größten) Schlüssel in der sortierten Abbildung
Abschnitt 10.7 Abbildungen 519
dann sind die Elemente der Rückgabe gemäß der compareTo() - Implementierung in der Klasse
Character sortiert:
L --> 1
O --> 1
e --> 1
i --> 1
l --> 1
o --> 3
p --> 1
s --> 1
t --> 5
tms.pollFirstEntry();
System.out.println(tms);
System.out.println("\nceilingKey(3) = "+tms.ceilingKey(3));
System.out.println("floorKey(3) = "+tms.floorKey(3));
System.out.println("heigherKey(4) = "+tms.higherKey(4));
System.out.println("lowerKey(4) = "+tms.lowerKey(3));
}
}
10.9 Warteschlangen
Seltener als Listen, Mengen und Abbildungen werden Kollektionen mit einer Warteschlagen- oder
Stapel-Architektur benötigt (Evans & Flanagan 2015, S. 253). Die von einer Warteschlange erwar-
teten Verhaltenskompetenzen werden im Interface Queue<E> beschrieben, das vom Interface Coll-
ection<E> abstammt:
Collection<E>
Queue<E>
Deque<E>
Eine Warteschlange ist wie eine Liste linear organisiert, doch können nur am Kopfende Elemente
entnommen und nur am Schwanzende Elemente eingefügt werden, sodass die Elemente nach dem
FIFO-Prinzip (First In First Out) bedient werden. Anders als bei Listen ist es nicht möglich, das
Element an einer bestimmten Position abzurufen. Im Abschnitt 15.2.4 über Verfahren zur automati-
sierten Thread-Koordination für Produzenten-Konsumenten - Konstellationen wird das von
Queue<E> abgeleitete Interface BlockingQueue<E> vorgestellt.
Im Pflichtenheft der Schnittstelle Queue<E> befinden sich u. a. die folgenden Methoden:
• public boolean add(E e)
Es wird ein Element in die Warteschlange eingefügt, wenn deren Kapazität noch nicht er-
schöpft ist. Über den Rückgabewert informiert die Methode darüber, ob die Warteschlange
geändert worden ist.
• public E remove()
Das erste Element in der Warteschlange wird abgerufen und entfernt. Ist die Warteschlange
leer, wird eine NoSuchElementException geworfen.
Bei der ebenfalls linear organisierten, das Interface Deque<E> realisierenden Doppelschlange
können an beiden Enden Elemente eingefügt und entnommen werden. Man verwendet diese Kol-
lektion häufig im einseitigen Betrieb als Stapel, wobei nach dem LIFO-Prinzip (Last In First Out)
das zuletzt eingefügt (bzw. aufgelegte) Element zuerst bedient wird.
Im Pflichtenheft der Schnittstelle Deque<E> befinden sich u. a. die folgenden Methoden:
• public E getFirst()
Die Warteschlange liefert ggf. das erste Element.
• public E getLast()
Die Warteschlange liefert ggf. das letzte Element.
• public E removeFirst()
Ggf. wird das erste Element aus der Warteschlange entfernt und als Rückgabe geliefert.
• public E removeLast()
Ggf. wird das letzte Element aus der Warteschlange entfernt und als Rückgabe geliefert.
• public void addFirst(E element)
public void push(E element)
Durch die funktionsgleichen Methoden addFirst() und push() wird das Parameterobjekt an
den Anfang der Warteschlage gesetzt bzw. auf den Stapel aufgelegt, der durch die Warte-
schlange repräsentiert wird.
Abschnitt 10.10 Nützliche Methoden in der Klasse Collections 523
• public E pop()
Hebt das oberste (erste) Element vom Stapel ab, der durch die Warteschlange repräsentiert
wird.
• public void addLast(E element)
Setzt das Parameterobjekt an das Ende der Warteschlage.
Ist die Doppelschlange leer, dann werfen die Methoden getFirst(), getLast(), removeFirst() und
removeLast() eine NoSuchElementException.
Häufig als Stapel verwendete Deque<E> - Implementation sind die Klassen:
• ArrayDeque<E>
Diese Klasse speichert ihre Elemente in einem Array.
• LinkedList<E>
Diese aus dem Abschnitt 10.4.3.2 bekannte Klassse arbeitet mit verlinkten Knoten.
Von der zur Verwaltung von LIFO-Stapeln einst populären Klasse Stack<E> wird mittlerweile
abgeraten, weil sie die Schnittstelle Deque<E> nicht implementiert.1
1
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Stack.html
2
Diese Erklärung stammt von der Webseite:
http://www.angelikalanger.com/GenericsFAQ/FAQSections/ProgrammingIdioms.html#FAQ104
524 Kapitel 10 Java Collections Framework
1
https://stackoverflow.com/questions/16748030/difference-between-arrays-aslistarray-and-new-
arraylistintegerarrays-aslist
Abschnitt 10.11 Übungsaufgaben zum Kapitel 10 525
Quellcode Ausgabe
import java.util.*; Original: [Otto, Luise, Rainer]
class Prog { Sortiert: [Luise, Otto, Rainer]
public static void main(String[] args) { Index(Otto): 1
List<String> ls = new LinkedList<>(); Invertiert: [Rainer, Otto, Luise]
ls.add("Otto"); ls.add("Luise"); ls.add("Rainer"); Verwirbelt: [Otto, Luise, Rainer]
System.out.println("Original: \t"+ls); Minimum: Luise
Collections.sort(ls); Maximum: Rainer
System.out.println("Sortiert: \t"+ls);
System.out.println("Index(Otto): \t"
+Collections.binarySearch(ls,"Otto"));
Collections.reverse(ls);
System.out.println("Invertiert: \t"+ls);
Collections.shuffle(ls);
System.out.println("Verwirbelt: \t"+ls);
System.out.println("Minimum: \t"+Collections.min(ls));
System.out.println("Maximum: \t"+Collections.max(ls));
}
}
2) Erweitern Sie die als Aufgabe zu Abschnitt 5.2 in Auftrag gegebene Klasse StringUtil um
statische Methoden mit den folgenden Leistungen:
• public static List<String> getWordList(CharSequence text)
Diese Methode soll für die Parameterzeichenfolge eine Liste mit den enthaltenen Wörtern in
der Reihenfolge ihres Auftretens liefern.
• public static NavigableMap<Character,Integer>
getStartCharFreqs(CharSequence text)
Diese Methode soll zur Parameterzeichenfolge als TreeMap<Character,Integer> - Objekt
eine sortierte Tabelle liefern, die für jeden Buchstaben (die Groß-/Kleinschreibung ignorie-
rend) angibt, wie viele Wörter mit diesem Buchstaben beginnen.
Im folgenden Programm wird die Verwendung der Methoden demonstriert:
import java.util.*;
import de.uni_trier.zimk.util.strings.StringUtil;
class StringUtilTest {
public static void main(String[] args) {
String s = "In diesem Satz kommt der Anfangsbuchstabe a zweimal vor.";
System.out.println(StringUtil.getWordList(s));
Anfangsbuchstabe Häufigkeit
a 2
d 2
i 1
k 1
s 1
v 1
z 1
Weil Sie seit dem Abschnitt 5.2 viel dazugelernt haben, sollte die Klasse StringUtil modernisiert
werden:
• Die Klasse StringUtil sollte in ein explizites Paket aufgenommen werden, damit ihre
Verwendbarkeit nicht länger auf das Standardpaket beschränkt ist (vgl. Kapitel 6).
• In vorhandenen Methoden sollte die Klasse String als Parameterdatentyp durch das Inter-
face CharSequence ersetzt werden (siehe Kapitel 9).
3) Erstellen Sie eine Klasse mit generischen, statischen und öffentlichen Methoden für elementare
Operationen aus dem Bereich der Mengenlehre. Realisieren Sie zumindest den Schnitt, die Vereini-
gung und die Differenz von zwei Mengen (Kollektionsobjekten gem. Abschnitt 10.6) mit identi-
schem (ansonsten beliebigem) Referenztyp. Für zwei Mengen
A = {'a', 'b', 'c'}, B = {'b', 'c', 'd'}
sollte das Testprogramm
import java.util.*;
import de.uni_trier.zimk.matrain.ml.Sets;
class Mengenlehre {
public static void main(String[] args) {
Set<Character> set1 = new TreeSet<>(Set.of('a', 'b', 'c'));
System.out.println("Menge A");
for (Character c : set1)
System.out.println(c);
Set<Character> set2 = new TreeSet<>(Set.of('b', 'c', 'd'));
System.out.println("\nMenge B");
for (Character c : set2)
System.out.println(c);
System.out.println("\nDurchschnitt von A und B");
for (Character c : Sets.intersection(set1, set2))
System.out.println(c);
System.out.println("\nVereinigung von A und B");
for (Character c : Sets.union(set1, set2))
System.out.println(c);
System.out.println("\nDifferenz von A und B");
for (Character c : Sets.difference(set1, set2))
System.out.println(c);
}
}
Menge B
b
c
d
Abschnitt 10.11 Übungsaufgaben zum Kapitel 10 527
4) Erweitern Sie das als Aufgabe zu Abschnitt 5.1 in Auftrag gegebene Programm zur deskriptiven
Analyse einer Datenmatrix mit double-Elementen. Für jedes Merkmal (für jede Spalte) in der Da-
tenmatrix soll eine Tabelle mit den absoluten Häufigkeiten der aufsteigend sortierten Merkmalsaus-
prägungen erstellt werden, z. B.:
Datenmatrix mit 5 Fällen und 3 Merkmalen:
1,00 2,00 4,00
1,00 2,00 5,00
2,00 2,00 6,00
2,00 1,00 5,00
3,00 1,00 4,00
Häufigkeiten Merkmal 0:
Wert N
1,00 2
2,00 2
3,00 1
Häufigkeiten Merkmal 1:
Wert N
1,00 2
2,00 3
Häufigkeiten Merkmal 2:
Wert N
4,00 2
5,00 2
6,00 1
11 Ausnahmebehandlung
Durch Programmierfehler (z. B. versuchter Array-Zugriff mit ungültigem Indexwert) oder durch
besondere Umstände (z. B. fehlerhafte Eingabedaten, Speichermangel, unterbrochene Netzwerkver-
bindungen) kann die reguläre Ausführung einer Methode scheitern. In diesem Kapitel geht es um
die Behandlung von Ausnahmesituationen, die während der Laufzeit auftreten. Java bietet ein leis-
tungsfähiges Verfahren zur Meldung und Behandlung von Laufzeitproblemen: An der Unfallstelle
wird ein Ausnahmeobjekt aus der Klasse java.lang.Throwable oder aus einer problemspezifischen
Unterklasse erzeugt und der unmittelbar verantwortlichen Methode „zugeworfen“. Diese Methode
wird somit über das Problem informiert und mit Daten für die Behandlung des Problems versorgt.
Die Initiative beim Auslösen einer Ausnahme kann ausgehen …
• von der JVM
Sie wirft z. B. ein Ausnahmeobjekt aus der Klasse ArithmeticException bei einer versuch-
ten Ganzzahldivision durch null.
• vom Programm, wozu auch die verwendeten Bibliotheksklassen gehören
In jeder Methode und in jedem Konstruktor kann mit der throw-Anweisung (siehe Ab-
schnitt 11.6) eine Ausnahme geworfen werden (z. B. wegen ungeeigneter Aktualparameter-
werte).
Die unmittelbar von einer Ausnahme betroffene Methode steht in der Regel am Ende einer Sequenz
verschachtelter Methodenaufrufe, und entlang der Aufrufersequenz haben die beteiligten Methoden
jeweils die folgenden Reaktionsmöglichkeiten:
• das Ausnahmeobjekt abfangen und das Problem behandeln
Im tatsächlichen Programmablauf fliegen natürlich keine Objekte durch die Gegend, die mit
irgendwelchen Gerätschaften eingefangen werden. Stattdessen überprüft die Laufzeitumge-
bung, ob die betroffene Methode geeigneten Code zur Behandlung des Ausnahmeobjekts
(einen sogenannten Exception-Handler) enthält. Gegebenenfalls wird dieser Exception-
Handler ausgeführt und erhält quasi als Aktualparameter das Ausnahmeobjekt mit Informa-
tionen über das Problem. Nach der Ausnahmebehandlung kann die Methode ...
o entweder ihre Tätigkeit mit einem angepassten Handlungsplan fortsetzen
o oder ihrerseits ein Ausnahmeobjekt werfen (entweder das ursprüngliche oder ein in-
formativeres) und somit die Kontrolle an ihren Aufrufer zurückgeben.
• das Ausnahmeobjekt ignorieren
In diesem Fall besitzt eine Methode keinen zum Ausnahmeobjekt passenden Exception-
Handler. Die Methode wird beendet, und das Ausnahmeobjekt wird dem Vorgänger in der
Aufrufersequenz überlassen.
Wir werden uns anhand verschiedener Versionen eines Beispielprogramms damit beschäftigen,
• was bei unbehandelten Ausnahmen geschieht,
• wie man eine Methode auf Ausnahmen vorbereitet, um diese abfangen zu können,
• wie man in einer Methode selbst Ausnahmen wirft,
• wie man eigene Ausnahmeklassen definiert.
Man kann von keinem Programm erwarten, dass es unter allen widrigen Umständen normal funkti-
oniert. Doch müssen Schäden (z. B. Datenverluste) nach Möglichkeit verhindert werden, und der
Benutzer sollte eine nützliche Information zum aufgetretenen Problem erhalten. Bei vielen Metho-
denaufrufen ist es realistisch und erforderlich, auf Störungen des normalen Ablaufs vorbereitet zu
sein. Dies folgt schon aus Murphy’s Law (zitiert nach Wikipedia):1
1
https://de.wikipedia.org/wiki/Murphys_Gesetz
530 Kapitel 11 Ausnahmebehandlung
if (args.length > 0)
argument = convertInput(args[0]);
else {
System.out.println("Kein Argument angegeben");
System.exit(1);
}
if (argument != -1) {
double fakul = 1.0;
for (int i = 1; i <= argument; i++)
fakul = fakul * i;
System.out.printf("%s! = %.0f", args[0], fakul);
} else
System.out.printf("Keine ganze Zahl im Intervall [0, 170]: " + args[0]);
}
}
der Methode System.exit(), wobei als Aktualparameter ein Exitcode übergeben wird. Dieser landet
beim Betriebssystem und steht unter Windows nach dem Programmende in der Umgebungsvariab-
len ERRORLEVEL zur Verfügung, z. B.:
>java Fakul
Kein Argument angegeben
>echo %ERRORLEVEL%
1
Diese Reaktion auf ein fehlendes Programmargument kann als akzeptabel gelten. An Stelle der für
Benutzer irritierenden und wenig hilfreichen Ausnahmemeldung durch das Laufzeitsystem
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 0
at Fakul.main(Fakul.java:13)
erscheint eine kurze, leichter verstehende Information:
Kein Argument angegeben
Manche API-Klassen bieten für kritische Methodenaufrufe eine Prüfung der Realisierbarkeit, so-
dass scheiternde Aufrufe mit dem Ergebnis eines Ausnahmefehlers vermieden werden können. In
der Klasse Scanner, die sich auch dazu eignet, aus einer Textdatei Werte primitiver Datentypen zu
lesen (vgl. Abschnitt 14.5), befinden sich z. B. die beiden folgenden Methoden:
• public double nextDouble()
Es wird versucht, aus der Eingabedatei eine abgegrenzte Zeichenfolge zu ermitteln und als
double-Zahl zu interpretieren. Wenn dies misslingt, wirft die Methode eine Ausnahme.
• public boolean hasNextDouble()
Es wird überprüft, ob das eben beschriebene Unterfangen realisierbar ist.
Weil es bei einem nextDouble() - Aufruf leicht zu Problemen kommen kann (Ende der Eingabeda-
tei erreicht, Fehler bei der Interpretation), empfiehlt sich eine vorherige Kontrolle, z. B.:
while (input.hasNextDouble()) {
sum += input.nextDouble();
n++;
}
Zu den präventiven Maßnahmen kann auch die im Abschnitt 11.5.1 beschriebene assert-Anweisung
gerechnet werden, die per Voreinstellung nur während der Programmentwicklung bzw. -testung
wirksam ist.
Präventive Maßnahmen zur Vermeidung von Ausnahmefehlern stoßen auf Grenzen (Eck 2021, S.
405):
• Die Vielfalt der möglichen Ausnahmefehler bei der Ausführung einer Methode ist oft so
groß, dass keine vollständige Prävention möglich ist.
• Durch überbordende Kontrollmaßnahmen kann ein eleganter Algorithmus zu einer schwer
verständlichen Ansammlung von if-Anweisungen mutieren.
Am Beispielprogramm zur Fakultätsberechnung ist im Hinblick auf das Thema des aktuellen Kapi-
tels neben der oben beschriebenen, vor dem convertInput() - Methodenaufruf durchgeführten ...
• präventiven Maßnahme zur Vermeidung eines Ausnahmeobjekts auch die
• Kommunikation eines Fehler bzw. Problems per Rückgabewert
(also auf traditionelle Weise, ohne Ausnahmeobjekt) von Interesse. Die Methode
convertInput() überprüft, ob die aus dem übergebenen String-Parameter ermittelte int-Zahl
außerhalb des zulässigen Wertebereichs für eine Fakultätsberechnung mit double-Ergebniswert
liegt, und meldet ggf. den Wert -1 als Fehlerindikator zurück. Weil der Ergebnistyp double ver-
532 Kapitel 11 Ausnahmebehandlung
wendet wird, sind nur Argumente bis zum maximalen Wert 170 erlaubt.1 Die Methode main()
kennt die spezielle Bedeutung der Rückgabe -1, sodass die unsinnige Fakultätsberechnung für ein
negatives Argument und der wenig hilfreiche Ergebniswert Unendlich für ein Argument größer 170
vermieden werden.
Diese traditionelle Fehlerbehandlung per Rückgabewert (engl.: return code) ist nicht grundsätzlich
als überholt und ineffizient zu bezeichnen, aber in vielen Situationen doch der im aktuellen Kapitel
behandelten Kommunikation über Ausnahmeobjekte unterlegen (siehe Abschnitt 11.4 zum Ver-
gleich von Fehlerrückmeldung und Ausnahmebehandlung).
convertInput()
Integer.parseInt() NumberFormatException
Weil die JVM im Beispielprogramm (mit der bislang von uns verwendeten Single-Thread - Archi-
tektur) entlang der Aufrufersequenz bis hinauf zur main() - Methode keinen passenden Exception-
Handler findet, bringt sie den im Ausnahmeobjekt enthaltenen Unfallbericht auf die Konsole und
beendet dann das Programm, z. B.:3
Exception in thread "main" java.lang.NumberFormatException: For input string: "vier"
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
at java.lang.Integer.parseInt(Integer.java:580)
at java.lang.Integer.parseInt(Integer.java:615)
at Fakul.convertInput(Fakul.java:3)
at Fakul.main(Fakul.java:14)
1
Durch Verwendung der Klasse BigDecimal ist es möglich, für beliebig große Argumente die Fakultät zu bestim-
men, und mit Hilfe der in Java 8 eingeführten Stromoperationen kann ohne nennenswerten Programmieraufwand die
für große Argumente erforderliche Rechenzeit durch parallele Ausführung in mehreren Threads begrenzt werden
(siehe Abschnitt 12.2.5.4.2). Wir verzichten im aktuellen Kapitel auf diese Verbesserungen, um ein möglichst einfa-
ches Beispiel zur Demonstration der Ausnahmebehandlung zu erhalten. Später werden Sie in einer Übungsaufgabe
zum Kapitel 12 ein Programm erstellen, das für beliebige positive Ganzzahlen die Fakultät berechnet und dabei alle
verfügbaren CPU-Kerne nutzt.
2
Aufrufverschachtelungen innerhalb der Standardbibliothek ignorieren wir an dieser Stelle. Im Abschnitt 11.3.3 wird
die Angelegenheit mit Hilfe des API-Quellcodes genauer untersucht.
3
Genau genommen, ist das Geschehen in Folge einer nicht abgefangenen Ausnahme komplexer:
• Zunächst wird nur der Thread (Ausführungsfaden) beendet, in dem die Ausnahme aufgetreten ist. Existiert
(wie bei unseren bisherigen Konsolenprogrammen) kein weiterer Benutzer-Thread, dann endet das Programm.
• Der zu terminierende Thread wird von der JVM über die statische Thread-Methode
getUncaughtExceptionHandler() nach seinem UncaughtExceptionHandler befragt. Dieses Objekt enthält
einen Aufruf der Methode uncaughtException(), und diese Methode fordert das per Aktualparameter überge-
bene Exception-Objekt auf, die Methode printStackTrace() auszuführen.
Abschnitt 11.3 Ausnahmen abfangen 533
try {
Überwachter Block mit Anweisungen für den regulären Ablauf
}
catch (Ausnahmeklassenliste1 parameter1) {
Anweisungen für die Behandlung einer Ausnahme aus einer aufgelisteten Klasse
oder aus einer daraus abgeleiteten Klasse
}
// Optional können weitere Ausnahmeklassen abgefangen werden:
catch (Ausnahmeklassenliste2 parameter2) {
Anweisungen für die Behandlung einer Ausnahme aus einer aufgelisteten Klasse
oder aus einer daraus abgeleiteten Klasse
}
...
// Optionaler finally-Block mit Abschlussarbeiten.
// Besitzt eine try-Anweisung einen finally-Block, muss kein catch-Block vorhanden sein.
finally {
Anweisungen, die unabhängig vom Auftreten einer Ausnahme ausgeführt werden sollen
}
Die Anweisungen für den ungestörten Ablauf setzt man in den try-Block. Nachdem eine Anwei-
sung des try-Blocks eine Ausnahme verursacht oder aktiv geworfen hat, werden die weiteren An-
weisungen des try-Blocks nicht mehr ausgeführt.
534 Kapitel 11 Ausnahmebehandlung
Treten bei der Ausführung die überwachten Blocks keine Fehler auf, dann wird die Methode hinter
der try-Anweisung fortgesetzt, wobei ggf. vorher noch der finally-Block ausgeführt wird (siehe
Abschnitt 11.3.1.2).
Java erlaubt die folgenden Varianten der try - Anweisung:
• try-catch
• try-finally
• try-catch-finally
Ein try-, catch- oder finally-Block benötigt auch dann ein einrahmendes Paar geschweifter Klam-
mern, wenn nur eine Anweisung enthalten ist.
Weil es der obigen Syntaxbeschreibung im Quellcodedesign trotz Unterstützung durch Kommentare
an Präzision fehlt, sollen Sie in einer Übungsaufgabe ein Syntaxdiagramm zur try - Anweisung
erstellen (siehe Abschnitt 11.11).
fangen. Der catch-Block beendet die Methodenausführung mit dem Rückgabewert -2, der als Feh-
lerindikator zu verstehen ist:
static int convertInput(String instr) {
int arg;
try {
arg = Integer.parseInt(instr);
} catch (NumberFormatException e) {
return -2;
}
if (arg < 0 || arg > 170) {
return -1;
} else
return arg;
}
Wie die API-Dokumentation zu Java 17 zeigt, sind von parseInt() keine Ausnahmen aus anderen
Klassen zu erwarten:
if (args.length > 0)
argument = convertInput(args[0]);
else {
System.out.println("Kein Argument angegeben");
System.exit(1);
}
switch (argument) {
case -1: System.out.print("Keine ganze Zahl im Intervall [0, 170]: " + args[0]);
break;
case -2: System.out.printf("Fehler beim Konvertieren von: \"%s\"", args[0]);
break;
default: double fakul = 1.0;
for (int i = 1; i <= argument; i++)
fakul = fakul * i;
System.out.printf("%s! = %.0f", args[0], fakul);
}
}
536 Kapitel 11 Ausnahmebehandlung
Programmablauf bei der Ausnahmebehandlung werden in den Abschnitten 11.3.2 und 11.8 be-
schrieben.
Die eventuell im überwachten try-Block auf die Anweisung, die zur Ausnahme geführt hat, noch
folgenden Anweisungen werden nicht ausgeführt. Java verwendet also in Bezug auf den betroffenen
try-Block eine terminierende Form der Ausnahmebehandlung, wobei aber nicht die gesamte Me-
thode und erst recht nicht die ganze Anwendung enden müssen. Nach einer Ausnahmebehandlung
kann der Benutzer z. B. die Gelegenheit erhalten, den gescheiterten Vorgang (z. B. einen Netzwerk-
zugriff) zu wiederholen (z. B. nach erfolgter WLAN-Aktivierung). Viele Ausnahmebehandlungen
bestehen darin, den Benutzer über das aufgetretene Problem zu informieren und bei einem erneuten
Versuch zu unterstützen.
void abheben() {
Scanner input = new Scanner(System.in);
int amount;
while (true) {
try {
Thread.sleep(3000);
} catch (InterruptedException ignored) {
// Die Ausnahme darf ignoriert werden, weil der Thread nicht abgebrochen wird.
}
lock.lock();
try {
System.out.print(
"\n\nWelcher Betrag soll abgehoben werden (Beenden mit Betrag < 0): ");
amount = Integer.parseInt(input.nextLine());
if (amount < 0) {
System.exit(0);
}
konto -= amount;
System.out.println("Neuer Kontostand: " + konto);
} catch(NumberFormatException e) {
System.out.println("Kein gültiger Betrag!");
} finally {
lock.unlock();
}
}
}
Es ist sicherzustellen, dass die ggf. von einem Ausnahmeobjekt betroffene Methode abheben()
unter allen Umständen (also auch bei gestörter Ausführung) das Sperrobjekt wieder freigibt, damit
weitere Einzahlungen durch den zweiten Thread möglich sind. Daher wird der erforderliche un-
lock() - Aufruf in einen finally-Block platziert.
Ansonsten demonstriert die Methode abheben(), dass nach der Behandlung einer Ausnahme
durchaus der Normalbetrieb wieder aufgenommen werden kann:
Kontostand erhöht auf: 24
Kontostand erhöht auf: 36
Kontostand erhöht auf: 63
Kontostand erhöht auf: 64
Welcher Betrag soll abgehoben werden (Beenden mit Betrag < 0): vier
Kein gültiger Betrag!
Welcher Betrag soll abgehoben werden (Beenden mit Betrag < 0): 4
Neuer Kontostand: 108
zeitsystem z. B. bei einer versuchten Ganzzahldivision durch null auslöst. calc() benutzt die
Klassenmethode Integer.parseInt() sowie den Modulo-Operator in einem try-Block, wobei nur die
potentiell von Integer.parseInt() zu erwartende NumberFormatException abgefangen wird.
Wir betrachten einige Konstellationen mit ihren Konsequenzen für den Programmablauf:
a) Normaler Ablauf
b) Exception in calc(), die dort auch behandelt wird
c) Exception in calc(), die in main() behandelt wird
d) Exception in main(), die nirgends behandelt wird
a) Normaler Ablauf
Beim Programmablauf ohne Ausnahmen (hier mit Kommandozeilen-Argument „8“) werden die
try- und die finally-Blöcke von main() und calc()ausgeführt. Es kommt zu folgenden Ausgaben:
try-Block von main()
try-Block von calc()
finally-Block von calc()
Nach try-Anweisung in calc()
10 % 8 = 2
finally-Block von main()
Nach try-Anweisung in main()
kommt es, weil die NumberFormatException in calc() nicht sinnvoll behandelt wird. Wenn ein
catch-Block lediglich eine Fehlermeldung ausgibt und/oder einen Logdateieintrag schreibt, sollte er
in der Regel die aufgefangene Ausnahme erneut werfen oder stattdessen eine informativere Aus-
nahme werfen. Das aktuelle Beispiel soll nur dazu dienen, Programmabläufe bei der Ausnahmebe-
handlung zu demonstrieren.
Im Abschnitt 11.8 beschäftigen wir uns erneut mit Varianten bei der Ausnahmebehandlung. Im Un-
terschied zum aktuellen Abschnitt werden wir dabei nur eine try-catch-finally - Anweisung be-
trachten (also keine verschachtelten Methoden). Allerdings werden die möglichen Varianten bei der
Ausführung einer try-catch-finally - Anweisung vollständig behandeln, z. B. inklusive der Mög-
lichkeit, dass es ...
• in einem catch-Block
• und/oder im finally-Block
zu einer weiteren Ausnahme kommt.
Das Ergebnis enthält den Namen der Ausnahmeklasse und eine Fehlermeldung zur näheren Be-
schreibung der Ausnahme, falls eine solche beim Erstellen des Ausnahmeobjekts an den Konstruk-
tor übergeben wurde, z. B.:
java.lang.NumberFormatException: For input string: "vier"
Wer nur die Fehlermeldung, aber nicht den Namen der Ausnahmeklasse sehen möchte, verwendet
die Throwable-Methode getMessage(), z. B.:
System.out.println(e.getMessage());
In Beispiel erscheint nur noch:
For input string: "vier"
Eine weitere nützliche Information, die ein Ausnahmeobjekt parat hat, ist die Aufrufersequenz
(engl.: stack trace) von der main() - Methode bis zur Unfallstelle. Mit der Throwable-Methode
printStackTrace()befördert man den Namen der Ausnahmeklasse, die Fehlermeldung und die Auf-
rufersequenz zur Standardfehlerausgabe (System.err), die per Voreinstellung (ohne Umleitung) mit
der Standardausgabe (System.out) identisch ist, z. B.:
catch (NumberFormatException e) {
e.printStackTrace();
. . .
}
Im Beispiel erscheint:
java.lang.NumberFormatException: For input string: "vier"
at java.base/java.lang.NumberFormatException.forInputString(Unknown Source)
at java.base/java.lang.Integer.parseInt(Unknown Source)
at java.base/java.lang.Integer.parseInt(Unknown Source)
at Sequenzen.calc(Sequenzen.java:6)
at Sequenzen.main(Sequenzen.java:23)
Bleibt ein Ausnahmeobjekt unbehandelt, dann erhält es von der JVM die Aufforderung printStack-
Trace(), bevor das Programm beendet wird.1 Daher haben wir schon mehrfach das Ergebnis eines
printStackTrace() - Aufrufs gesehen.
Vielleicht wundern Sie sich darüber, dass in der zuletzt präsentierten Aufrufersequenz gleich zwei
Integer-Methoden namens parseInt() auftauchen. Ein Blick in den API-Quellcode zeigt, dass die
von unserer Methode convertInput() aufgerufene parseInt() - Überladung mit einem Parameter
vom Typ String
public static int parseInt(String s) throws NumberFormatException {
return parseInt(s, 10);
}
die eigentliche Arbeit einer Überladung mit einem zusätzlichen Parameter für die Basis des Zahlen-
systems überlässt, die schließlich auf das Problem stößt und die NumberFormatException wirft:
public static int parseInt(String s, int radix)
throws NumberFormatException {
. . .
}
1
Genau genommen, verläuft die Kommunikation etwas komplizierter: Der zu terminierende Thread wird von der
JVM über die statische Thread-Methode getUncaughtExceptionHandler() nach seinem
UncaughtExceptionHandler befragt. Dieses Objekt enthält einen Aufruf der Methode uncaughtException(), und
diese Methode fordert das per Aktualparameter übergebene Exception-Objekt auf, die Methode printStackTrace()
auszuführen.
Abschnitt 11.4 Ausnahmeobjekte im Vergleich zur Fehlerkommunikation per Rückgabe 543
returncode = m1();
// Behandlung für diverse m1() - Fehler
if (returncode == 1) {
// ...
System.exit(1);
}
// ...
returncode = m2();
// Behandlung für diverse m2() - Fehler
if (returncode == 1) {
// ...
System.exit(2);
}
544 Kapitel 11 Ausnahmebehandlung
// ...
returncode = m3();
// Behandlung für diverse m3() - Fehler
if (returncode == 1) {
// ...
System.exit(3);
}
// ...
}
Mit Hilfe der Ausnahmetechnik bleibt hingegen beim Kernalgorithmus die Übersichtlichkeit erhal-
ten. Wir nehmen nun an, dass die drei Methoden m1(), m2() und m3() durch Ausnahmeobjekte
über Fehler informieren:
public static void main(String[] args) {
try {
m1();
m2();
m3();
} catch (ExA a) {
// Behandlung von Ausnahmen aus der Klasse ExA
} catch (ExB b) {
// Behandlung von Ausnahmen aus der Klasse ExB
} catch (ExC c) {
// Behandlung von Ausnahmen aus der Klasse ExC
}
}
Es ist zu beachten, dass z. B. nach der Behandlung einer durch die Methode m1() verursachten
Ausnahme die weiteren Anweisungen des überwachten try-Blocks nicht mehr ausgeführt werden.
Das traditionelle Verfahren der Fehlerrückmeldung hat neben dem unübersichtlichen Quellcode
noch weitere Nachteile:
• Ungesicherte Beachtung von Rückgabewerten
Gute gesetzte Rückgabewerte nutzten nichts, wenn sich der Aufrufer nicht darum kümmert.
• Umständliche Weiterleitung von Fehlern
Wenn ein Fehler nicht an Ort und Stelle behandelt werden soll, dann muss die Fehlerinfor-
mation aufwändig entlang der Aufrufersequenz nach oben gemeldet werden.
Wenn eine Methode per Rückgabewert eine Nutzinformation (z. B. ein Berechnungsergebnis)
übermitteln soll, und bei einer ungestörten Methodenausführung jeder Wert des Rückgabetyps auf-
treten kann, dann sind keine Werte als Fehlerindikatoren verfügbar. In diesem Fall verwendet die
klassische Fehlersignalisierung einen per Methodenaufruf oder Variable zugänglichen Fehlerstatus
als Kommunikationsmittel, wobei die Beachtung ebenso wenig garantiert ist wie bei einem Return-
code. Auch die Klasse Simput, die wir zur Vereinfachung der Werteingabe in zahlreichen Konso-
lenprogrammen verwendet haben (vgl. Abschnitt 3.4), informiert per Fehlerstatus bei solchen Me-
thoden, die keine Ausnahmen werfen (z. B. gint() zum Erfassen eines int-Werts). Die Methode
frage() unserer Demonstrationsklasse Bruch (siehe z. B. Abschnitt 1.1.2) verwendet die Metho-
de Simput.gint() und überprüft den Erfolg eines Aufrufs über die statische Methode
Simput.checkError():1
1
Weil Simput der Einfachheit halber mit statischen Methoden arbeitet, darf die Klasse nicht simultan durch mehrere
Threads verwendet werden. Ansonsten könnte das checkError() - Ergebnis auf die zwischenzeitliche Tätigkeit
eines anderen Threads zurückgehen. Mit dem Multithreading werden wir uns in Kapitel 15 beschäftigen.
Abschnitt 11.4 Ausnahmeobjekte im Vergleich zur Fehlerkommunikation per Rückgabe 545
do {
System.out.print("Zähler: ");
setzeZaehler(Simput.gint());
} while (Simput.checkError());
Auch die Methoden der zur Ausgabe in Textdateien geeigneten API-Klasse PrintWriter (siehe
Abschnitt 14.4.1.5) werfen keine IOException, sondern setzen ein Fehlersignal, das mit einer Me-
thode namens checkError() abgefragt werden kann.
Gegenüber der konventionellen Fehlerbehandlung hat die Kommunikation über Ausnahmeobjekte
u. a. folgende Vorteile:
• Garantierte Beachtung von Ausnahmen
Im Unterschied zu einem Returncode oder einem Fehlerstatus können Ausnahmen nicht ig-
noriert werden. Ist ein Ausnahmeobjekt (gleich aus welcher Ausnahmeklasse) erst einmal
geworfen, muss es behandelt werden. Anderenfalls wird das Programm (genauer: der be-
troffenen Thread) vom Laufzeitsystem beendet. Leider gibt es eine Möglichkeit, die Absich-
ten der Java-Designer zu durchkreuzen und Ausnahmen doch zu ignorieren. Sollte dieses
Vorgehen ausnahmsweise akzeptabel sein, muss es im Quellcode kommentiert werden, z. B.
(vgl. Abschnitt 11.3.1.2):
try {
Thread.sleep(3000);
} catch (InterruptedException ignored) {
// Die Ausnahme darf ignoriert werden, weil der Thread nicht abgebr. wird.
}
• Obligatorische Vorbereitung auf Ausnahmen
In Java wird zwischen der obligatorischen und der freiwilligen Ausnahmebehandlung unter-
schieden (siehe Abschnitt 11.5.2). Beim Einsatz von Methoden, von denen behandlungs-
pflichtige Ausnahmen zu erwarten sind, muss sich der Aufrufer vorbereiten (z. B. durch eine
try-Anweisung mit geeignetem catch-Block). Unabhängig von der Pflicht zur Vorbereitung,
muss jede geworfene Ausnahme behandelt werden, um die Beendigung des Programms (ge-
nauer: des betroffenen Threads) zu verhindern.1
• Automatische Weitermeldung bis zur bestgerüsteten Methode
Manchmal ist der unmittelbare Verursacher nicht gut gerüstet zur Behandlung einer Aus-
nahme, z. B. nach dem vergeblichen Öffnen einer Datei. Dann sollte eine „höhere“ Methode
über das weitere Vorgehen entscheiden und z. B. beim Benutzer eine alternative Datei erfra-
gen. Gelegentlich kann die unmittelbar betroffene Methode ihren Aufrufer bei der Prob-
lemlösung unterstützen, indem sie das primäre Ausnahmeobjekt durch einen besser ver-
ständlichen Fehlerbericht ersetzt.
• Bessere Lesbarkeit des Quellcodes
Mit Hilfe einer try-catch-finally - Anweisung erreicht man eine bessere Trennung zwischen
den Anweisungen für den normalen Programmablauf und den diversen Ausnahmebehand-
lungen, sodass der Quellcode übersichtlich bleibt.
• Umfangreiche Fehlerinformationen für den Aufrufer
Über ein Exception-Objekt kann der Aufrufer beliebig genau über einen aufgetretenen Feh-
ler informiert werden, was bei einem traditionellen Rückgabewert nicht der Fall ist.
1
Durch eine unbehandelte Ausnahme wird zunächst nur der betroffene Thread beendet. Wenn ein Programm keine
Benutzer-Threads mehr besitzt, sondern nur noch sogenannte Daemon-Threads, die mit niedriger Priorität im Hin-
tergrund arbeiten und ein Programm nicht am Leben erhalten können, dann wird das Programm beendet (siehe Ab-
schnitt 15.10.1). In unseren Konsolenprogrammen ist nur ein Benutzer-Thread vorhanden. Wenn dort ein unbehan-
delter Ausnahmefehler auftritt, dann wird das Programm beendet.
546 Kapitel 11 Ausnahmebehandlung
Allerdings ist die Fehlermeldung per Rückgabewert oder Fehlerstatus nicht in jedem Fall der mo-
derneren Kommunikation per Ausnahmeobjekt unterlegen. Die Verwendung der traditionellen
Technik im Beispielprogramm von Abschnitt 11.3 kann z. B. als akzeptabel gelten. Im weiteren
Verlauf von Kapitel 11 wird eine alternative Variante der Methode convertInput() zu sehen
sein, die ihren Aufrufer durch das Werfen von Ausnahmeobjekten über Probleme informiert. Bei
der Entscheidung für eine Technik zur Fehlerkommunikation ist u. a. die Wahrscheinlichkeit für das
Auftreten des Fehlers relevant:
• Wenn ein Problem mit erheblicher Wahrscheinlichkeit auftritt, dann sollte eine routine-
mäßige, aktive Kontrolle stattfinden. Daher sollte eine Methode, die ein solches Problem zu
melden hat, davon ausgehen, dass der Aufrufer mit dem Problem rechnet und per Rückga-
bewert oder Fehlerstatus kommunizieren. Über ein mit erheblicher Wahrscheinlichkeit auf-
tretendes Problem per Ausnahmeobjekt zu informieren, wäre eine unangemessen aufwändi-
ge Kommunikationstechnik. Die Ausnahmebehandlung sollte nicht zum Bestandteil der
Programmablaufsteuerung werden.
• Bei außergewöhnlichen Problemen (mit einer geringen Auftretenswahrscheinlichkeit)
haben jedoch häufige, meist überflüssige Kontrollen eine Leistungseinbuße zur Folge. Hier
sollte man es besser auf eine Ausnahme ankommen lassen. Eine Überwachung über die
Ausnahmetechnik verursacht praktisch nur dann Kosten, wenn tatsächlich eine Ausnahme
geworfen wird. Diese Kosten sind allerdings deutlich größer als bei einer Fehleridentifikati-
on auf traditionelle Art.
Wenn ...
• der max() - Parameter gleich null ist,
• oder die Parameterkollektion leer ist,
• oder alle Elemente in der Parameterkollektion gleich null sind,
dann erhält der Aufrufer als Rückgabe ein leeres Optional<E> - Objekt:
Quellcode Ausgabe
import java.util.*; Optional[d]
class Prog { Optional.empty
static <E extends Comparable<E>> Optional<E> max(Collection<E> c) { Optional[a]
if(c == null || c.isEmpty())
return Optional.empty();
E result = null;
for(E e : c)
if (e != null)
if (result == null)
result = e;
else if (e.compareTo(result) > 0)
result = e;
return Optional.ofNullable(result);
}
public static void main(String[] args) {
List<String> los = List.of("b", "d", "c", "a");
System.out.println(max(los));
List<String> elos = List.of();
System.out.println(max(elos));
List<String> lon = Arrays.asList(null, "a", null);
System.out.println(max(lon));
}
}
Object
Throwable
Error Exception
LinkageError VirtualMachineError
NoClassDefFoundError
NumberFormatException
StringIndexOutOfBoundsExce
ption
ArrayIndexOutOfBoundsException StringIndexOutOfBoundsException
StringIndexOutOfBoundsException
Wo im bisherigen Kursverlauf von Ausnahmeobjekten die Rede war, hätte also eigentlich von
Throwable-Objekten gesprochen werden müssen, um neben den Exception-Objekten auch die
Error-Objekte einzubeziehen.
Ein Behandlungszwang (siehe Abschnitte 11.5.1 und 11.5.2) besteht nur für die Exception-
Ableitungen, die nicht von RuntimeException abstammen (in der Abbildung gelb hinterlegt).
In einem catch-Block einer try-Anweisung können auch mehrere Ausnahmesorten durch Wahl
einer entsprechend breiten Ausnahmeklasse abgefangen werden.
Sind mehrere catch-Blöcke vorhanden, dann werden diese beim Auftreten einer Ausnahme sequen-
tiell von oben nach unten auf Zuständigkeit untersucht, wobei pro Ausnahmeobjekt nur eine Be-
handlung stattfindet. Folglich müssen speziellere Ausnahmeklassen vor allgemeineren stehen, was
der Compiler sicherstellt.
11.5.1 Error
Durch Error-Objekte werden gravierende Probleme signalisiert, vor denen laut API-
Dokumentation ein Programm kapitulieren sollte:1
An Error is a subclass of Throwable that indicates serious problems that a reasonable applica-
tion should not try to catch.
Typische Beispiele sind:
1
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Error.html
Abschnitt 11.5 Ausnahmen und Fehler 549
• NoClassDefFoundError
Kann die JVM eine für den Programmablauf benötigte Klasse nicht finden, meldet sie einen
NoClassDefFoundError, z. B.:1
Exception in thread "main" java.lang.NoClassDefFoundError: demopack/A
at PackDemo.main(packdemo.java:7)
• OutOfMemoryError
Fordert ein Programm zu viel Heap-Speicher an (z. B. für einen sehr großen Array), dann
meldet die JVM einen OutOfMemoryError, z. B.:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at Prog.main(Prog.java:8)
Um das per Voreinstellung am verfügbaren Speicher orientierte maximale Heap-Volumen
eines Programms kann zu erhöhen, fordert man beim Programmstart mit der Kommandozei-
lenoption -Xmx mehr Speicher an. Im folgenden Beispiel wird 1 GB (=1024 MB) verlangt:
>java -Xmx1g Prog
Zwischen -Xmx und dem gewünschten Volumen darf kein Leerzeichen stehen. Die Einheit
(Megabyte bzw. Gigabyte) kann durch einen Kleinbuchstaben (m bzw. g) oder durch einen
Großbuchstaben (M bzw. G) gesetzt werden. In IntelliJ ist per Ausführungskonfiguration
eine VM option zu setzen, z. B.:
• StackOverflowError
Wird z. B. bei einem fehlerhaften rekursiven Algorithmus die Anzahl der verschachtelten
Methodenaufrufe zu groß, dann meldet die JVM einen StackOverflowError, z. B.:
Exception in thread "main" java.lang.StackOverflowError
Es ist durchaus möglich, einen Error per try-catch - Anweisung abzufangen:
1
Die von LinkageError abstammenden Ausnahmeklasse NoClassDefFoundError wird verwendet, wenn eine im
Quellcode über Ihren Namen angesprochene
Katze cat = new Katze();
und beim Übersetzen auch vorhandene Klasse zur Laufzeit fehlt. Daneben kennt Java die von Exception abstam-
mende Ausnahmeklasse ClassNotFoundException. Diese Ausnahme wird von der (abgewerteten) Class-Methode
newInstance() geworfen, wenn eine im Quellcode per Zeichenfolge identifizierte Klasse nicht zu finden ist, z. B.:
Object obj = Class.forName("Katze").newInstance();
Beim Übersetzen wird nicht geprüft, ob zur angegebenen Zeichenfolge eine Klasse existiert.
550 Kapitel 11 Ausnahmebehandlung
• Man kann eine Meldung ausgeben oder einen Logeintrag schreiben und die Anwendung an-
schließend beenden.
• Eventuell ist nur eine irrelevante Funktion des Programms betroffen, und das Programm
kann fortgesetzt werden. Eine Anwendung nach einem Error fortzusetzen, ist aber riskant
und nur akzeptabel, wenn man die Ursache des Fehlers mit sehr hoher Wahrscheinlichkeit
kennt.
Einen Error zu werfen, sollte der JVM vorbehalten bleiben. Eine Ausnahme stellt die Verwendung
der zur Unterstützung der Fehlersuche in Java 1.4 eingeführten assert-Anweisung dar, die eine
notwendige Bedingung für die reguläre Programmausführung überprüft und bei negativem Ergebnis
einen AssertionError wirft. Nach dem Schlüsselwort assert gibt man einen logischen Ausdruck
mit der Bedingung an, unter der das AssertionError-Objekt geworfen werden soll. In der Regel
lässt man einen Doppelpunkt und eine an den AssertionError-Konstruktor zu übergebende Feh-
lermeldung folgen, wobei ein beliebiger Ausdruck mit Wert erlaubt, also ein Methodenaufruf mit
Rückgabe void verboten ist:
assert-Anweisung
Man kann eine eigene Klasse von Error ableiten, sollte es aber besser nicht tun (siehe Bloch 2018,
S. 297).
• Error
Hier wäre eine Behandlung aussichtslos.
• RuntimeException
Hier wird ein Programmierfehler angenommen. Befindet sich der Fehler in einer API-Klasse
statt, dann wird von einer nutzenden Klasse (vom sogenannten Klientencode) nicht erwartet,
das Problem kompensieren zu können.1 Statt sich gegen die Programmierfehler im eigenen
Code mit einer Ausnahmebehandlung abzusichern, sollte man solche Fehler durch eine sorg-
fältige Programmierung vermeiden (Eck 2021, S. 409). Bei manchen Klassen aus der
RuntimeException-Hierarchie ist die Klassifikation als Folge eines Programmierfehlers al-
lerdings nicht überzeugend. Die NumberFormatException wird z. B. oft durch fehlerhafte
Eingabedaten verursacht.
Im folgenden Programm soll mit der Methode read() der Klasse InputStream, zu der auch das
Standardeingabe-Objekt System.in gehört, ein Zeichen (bzw. ein Byte) von der Tastatur gelesen
werden. Weil die von read() potentiell zu erwartende java.io.IOException behandlungspflichtig
ist, muss sie entweder im Kopf der Methode main() angekündigt oder in einem catch-Block abge-
fangen werden:
Da wir mittlerweile die try-Anweisung beherrschen, ist das Problem leicht zu lösen (mit oder ohne
Hilfe der Entwicklungsumgebung):
class ChEx {
public static void main(String[] args) {
int key = 0;
System.out.print("Beliebige Taste + Return: ");
try {
key = System.in.read();
} catch(java.io.IOException e) {
System.out.println("Fehler beim Lesen von der Konsole: " + e);
System.exit(1);
}
System.out.println(key);
}
}
Allerdings ist der Compiler nicht in der Lage, eine wirksame Ausnahmebehandlung einzufordern
und akzeptiert z. B. auch Exception-Handler mit einem leeren Anweisungsblock, z. B.:
try {
Thread.sleep(3000);
} catch (InterruptedException ignored) {
// Die Ausnahme darf ignoriert werden, weil der Thread nicht abgebrochen wird.
}
1
https://docs.oracle.com/javase/tutorial/essential/exceptions/runtime.html
Abschnitt 11.5 Ausnahmen und Fehler 553
Wie im Abschnitt 11.3.1.1 erläutert, ist diese Praxis nur mit einer triftigen Begründung erlaubt, die
am besten im Quellcode dokumentiert wird.
Eine beim Aufruf einer Methode zu erwartende Ausnahme per throws-Klausel (vgl. Abschnitt
11.6.1) an den eigenen Aufrufer weiterzuleiten, ist eine bequeme Möglichkeit, z. B.:
class ChEx {
public static void main(String[] args) throws java.io.IOException {
System.out.print("Beliebige Taste + Return: ");
int key = System.in.read();
System.out.println(key);
}
}
Bei der Methode main() ist gegen diese Praxis nicht viel einzuwenden, weil main() nicht von ande-
ren Methoden aufgerufen wird. Folglich wird kein Aufrufer durch die weitergeleitete Ausnahme
belastet. Allerdings ist eine Ausnahmebehandlung per catch-Block für die Benutzer oft günstiger
als die Beendigung des Programms durch die JVM mit der schwer verständlichen Ausgabe der Me-
thode printStackTrace() (siehe Abschnitt 11.3.3).
bares Problem). Tatsächlich stammt die Klasse NumberFormatException aber von der Klasse
RuntimeException ab, und der Compiler verlangt daher vom Aufrufer der Methode Integer.parse-
Int() keine Ausnahmebehandlung. Hier wird offenbar der Standpunkt vertreten, dass bei einem
parseInt() - Aufruf mit ungeeignetem Argument ein Fehler des Programmierers vorliegt, der das
Argument hätte prüfen müssen.
Die Schwierigkeiten bei der Differenzierung zwischen geprüften und ungeprüften Ausnahmen mö-
gen der Grund dafür gewesen sein, warum praktisch alle anderen Programmiersprachen auf diese
Differenzierung verzichten.
Die freiwillige Deklaration einer Ausnahme hat keinen Behandlungszwang durch den Aufrufer zur
Folge.
1
Zu sehen ist die statische Methode newBufferedReader() der Klasse Files (im Paket java.nio.file), die wir im Ab-
schnitt 14.4.2.4 verwenden werden. Sie leitet die von InputStream.newInputStream() zu erwartende IOException
weiter.
556 Kapitel 11 Ausnahmebehandlung
Es hat sich offenbar die Auffassung etabliert, dass die freiwillige Deklaration von ungeprüften Aus-
nahmen keine gute Praxis sei.1 Joshua Bloch (2018, S. 304) argumentiert, die freiwillige Deklarati-
on würde den Unterschied zwischen ungeprüften und geprüften Ausnahmen verwischen, und emp-
fiehlt unmissverständlich:
Use the Javadoc @throws tag to document each unchecked exception that a method can throw,
but do not use the throws keyword to include unchecked exceptions in the method declaration.
Die Java-Sprachspezifikation (Gosling et al. 2021, Abschnitt 8.4.6) sagt dazu:
It is permitted but not required to mention unchecked exception classes (§11.1.1) in a throws
clause.
Der schon im Abschnitt 11.3.1.1 präsentierte Definitionskopf der API-Methode Integer.parseInt()
enthält eine throws-Klausel mit der ungeprüften NumberFormatException:
public static int parseInt(String s) throws NumberFormatException { ... }
Aus dem Auftritt einer Ausnahmeklasse in der throws-Klausel einer API-Methode kann man also
nicht schließen, dass es sich um eine geprüfte Ausnahme handelt.
In der Dokumentation zu einer Methode sollten auf jeden Fall alle von ihr zu erwartenden Ausnah-
men erscheinen (geprüft der ungeprüft). So erfahren andere Programmierer, welche Fehler zu einer
Ausnahme führen und zu vermeiden sind.
Um das nunmehr von convertInput() zu erwartende Ausnahmeobjekt aus der Klasse Illegal-
ArgumentException behandeln zu können, muss die Methode im Rahmen einer try-Anweisung
aufgerufen werden, z. B.:
try {
argument = convertInput(args[0]);
} catch (IllegalArgumentException iae) {
System.out.println(iae.getMessage());
if (iae.getCause() != null)
System.out.println(" Ursache: " + iae.getCause().getMessage());
System.exit(1);
}
Dass eine Methode selbst geworfene Ausnahmen auch wieder auffängt, ist nicht unbedingt der
Standardfall, aber in manchen Situationen eine praktische Möglichkeit, von verschiedenen poten-
tiellen Schadstellen aus zur selben Ausnahmebehandlung zu verzweigen. Wir könnten z. B. in der
main() - Methode unseres Fakultätsprogramms beliebige Argumentprobleme (nicht vorhanden,
nicht konvertierbar, außerhalb des legitimes Wertebereichs) zentral behandeln:
try {
if (args.length == 0)
throw new IllegalArgumentException ("Kein Argument angegeben");
argument = convertInput(args[0]);
} catch (IllegalArgumentException iae) {
System.out.println(iae.getMessage());
if (iae.getCause() != null)
System.out.println(" Ursache: " + iae.getCause().getMessage());
System.exit(1);
}
Im Zusammenhang mit dem Überschreiben von Instanzmethoden (siehe Abschnitt 7.4.1) ist noch zu
beachten, dass eine überschreibende Methode keine geprüfte Ausnahme per throws-Klausel ankün-
digen darf, die breiter ist als eine von der überschriebenen Methode angekündigte geprüfte Aus-
nahme. Eine analoge Aussage gilt für das Implementieren einer Schnittstellenmethode.
1
https://stackoverflow.com/questions/25743574/java-best-practice-for-declaring-unchecked-exception
Abschnitt 11.6 Ausnahmen in einer eigenen Methode werfen und ankündigen 557
Im Abschnitt 11.5.2 haben Sie erfahren, dass man beim Aufruf einer Methode auf zu erwartende
geprüfte Ausnahmen (checked exceptions) vorbereitet sein muss. In der Regel ist es empfehlens-
wert, die kritischen Aufrufe in einem try-Block vorzunehmen und Ausnahmen in einem catch-
Block zu behandeln. Es ist aber auch erlaubt, über das Schlüsselwort throws im Definitionskopf der
aufrufenden Methode die Verantwortung auf den Vorgänger in der Aufrufhierarchie abzuschieben
(siehe Beispiel im Abschnitt 11.5.2). Man kann also mit throws nicht nur selbst geworfene Aus-
nahmen anmelden (siehe Abschnitt 11.6.1), sondern auch von aufgerufenen Methoden stammende
Ausnahmen weiterleiten. Im Falle von geprüften Ausnahmen kann man sich so der Behandlungs-
pflicht entledigen.
Unbehandelte Ausnahmen sollten aber nicht an den Aufrufer weitergeleitet werden, wenn sie dort
nur schlecht zu verstehen sind. Stattdessen sollte man sich in einer eigenen Ausnahmebehandlung
als Informationsvermittler bemühen, dem Aufrufer einen leichter verständlichen Unfallbericht zu
liefern (siehe oben).
1
Übernommen von:
https://docs.oracle.com/javase/8/docs/technotes/guides/language/catch-multiple.html
558 Kapitel 11 Ausnahmebehandlung
Seit Java 7 kann man allerdings auch mit dem Multi-Catch - Block (vgl. Abschnitt 11.3.1.1) beide
Nachteile vermeiden, wobei der Schreibaufwand im Vergleich zur obigen Lösung nur unwesentlich
ansteigt:
catch (FirstException | SecondException e) {
e.printStackTrace(System.out);
throw e;
}
Vermutlich kommen nur wenige Programmierer jemals auf die Idee, einen finally-Block mit einer
return-Anweisung zu beenden, und das unerwünschte „Neutralisieren“ von Ausnahmen wird meist
anders realisiert (z. B. durch einen leeren catch-Block).
import java.io.*;
class FinallyClose {
static void mean(String eingabe) {
DataInputStream dis = null;
try {
dis = new DataInputStream(new FileInputStream(eingabe));
double sum = 0.0;
int n = 0;
while (dis.available() > 0) {
n++;
sum += dis.readDouble();
}
System.out.println("Mittelwert zur Datei " + eingabe + ": " + sum/n);
} catch (IOException ioe) {
ioe.printStackTrace();
} finally {
if (dis != null)
try {
dis.close();
} catch (IOException ioc) {
ioc.printStackTrace();
};
}
}
Methoden zur Dateibearbeitung müssen in der Regel in einer try-Anweisung mit passendem catch-
Block aufgerufen werden, weil sie über Ausnahmeobjekte aus der Klasse IOException (im Paket
java.io) oder aus einer daraus abgeleiteten Klasse (z. B. FileNotFoundException, EOFException)
kommunizieren, auf die sich ein Aufrufer obligatorisch vorbereiten muss (vgl. Abschnitt 11.5.2). Im
Beispiel sind der FileInputStream-Konstruktor und die DataInputStream-Methode readDouble()
betroffen. Es könnte z. B. passieren, dass sich die Eingabedatei öffnen lässt, aber später beim Lesen
eine IOException auftritt.
Im Beispiel wird der gesamte Algorithmus in einem try-Block ausgeführt. Damit das möglichst
frühe Schließen der Datei auch im Ausnahmefall (z. B. EOFException beim Lesen) sichergestellt
ist, findet der erforderliche close() - Aufruf im finally-Block der try-Anweisung statt. Stünde er
z. B. am Ende des try-Blocks, bliebe die Datei im Ausnahmefall bis zu einem Garbage Collector -
Einsatz oder bis zum Programmende geöffnet.1
Ist bereits das Öffnen der Datei im FileInputStream-Konstruktor misslungen, existieren keine zu
schließende Datei und kein Adressat für den close() - Aufruf. Das Programm unterlässt den Fehl-
versuch, der eine NullPointerException zur Folge hätte.
Weil auch die close() - Methode eine IOException werfen kann, und Ausnahmeobjekte aus dieser
Klasse entweder behandelt oder angemeldet werden müssen, findet der close() - Aufruf in einer try-
catch - Anweisung stattfinden, und es resultiert eine try-Verschachtelung.
1
In der Klasse FileInputStream ist eine finalize() - Methode definiert, die ggf. vom Garbage Collector aufgerufen
wird und für das Schließen der Datei sorgt.
Abschnitt 11.10 Freigabe von Ressourcen 565
Die im catch- oder im finally-Block möglichen Ausnahmen sollten vor Ort abgefangen werden,
weil ansonsten eine zuvor im try-Block aufgetretene und an den Aufrufer zu übermittelnde unbe-
handelte Ausnahme verdeckt würde (siehe Abschnitt 11.8).
Neben dem nicht ganz unerheblichen Aufwand besteht ein weiterer Nachteil der traditionellen Lö-
sung darin, dass die DataInputStream-Variable nicht im try-Block deklariert werden kann, weil
sie sonst im finally-Block unbekannt wäre. In dem allgemeineren, umgebenden Block ist sie aber
einem leicht erhöhten Fehlerrisiko ausgesetzt.
class TryWithResources {
static void mean(String eingabe) {
try (DataInputStream dis = new DataInputStream(new FileInputStream(eingabe))) {
double sum = 0.0;
int n = 0;
while (dis.available() > 0) {
n++;
sum += dis.readDouble();
}
System.out.println("Mittelwert zur Datei " + eingabe + ": " + sum/n);
} catch (IOException ioe) {
ioe.printStackTrace();
}
}
Bis Java 8 ist in einem try-with-resources - Block eine außerhalb des Blocks definierte Ressource
nur über eine redundante lokale Variable zu verwenden, z. B.:
566 Kapitel 11 Ausnahmebehandlung
2) Erstellen Sie ein Syntaxdiagramm zur try-catch-finally - Anweisung (vgl. Abschnitt 11.3.1). Die
im Abschnitt 11.10.2 vorgestellte try-Variante mit automatisierter Ressourcen-Freigabe muss dabei
nicht berücksichtigt werden.
Abschnitt 11.11 Übungsaufgaben zum Kapitel 11 567
3) Erstellen Sie ausnahmsweise ein Programm, das eine NullPointerException auslöst, indem es
auf ein nicht existentes Objekt zugreift.
Erstellen Sie eine Variante, die bei ungeeigneten Argumenten eine IllegalArgumentException
wirft.
1
Für positive Zahlen a und b ist der Logarithmus von a zur Basis b definiert durch:
log( a )
log b ( a ) :=
log( b )
Dabei steht log() für den natürlichen Logarithmus zur Basis e (Eulersche Zahl).
12 Funktionales Programmieren
Java 8 hat als wesentlichen Fortschritt die Unterstützung der funktionalen Programmierung ge-
bracht, was Horstmann (2014b) so formuliert:
The principal enhancement in Java 8 is the addition of functional programming constructs to its
object-oriented roots.
Zur Unterstützung der funktionalen Programmierung wurden in Java 8 eingeführt:
• Lambda-Ausdrücke
Ein Lambda-Ausdruck ist ein Stück Code (bestehend aus einem einzelnen Ausdruck oder
aus einem Anweisungsblock) zusammen mit den vom Code erwarteten Parametern, also
letztlich eine Methode. Es wird sich noch zeigen, dass tatsächlich ein Objekt im Spiel ist,
das die Methode ausführt (siehe Abschnitt 12.1.1.3). Man kann den Lambda-Ausdruck aber
auch als Funktion bezeichnen. Er wird z. B. an eine andere Methode zur Ausführung über-
geben, um deren Verhalten zu komplettieren oder zu konfigurieren. Im folgenden Beispiel
erhält eine Methode namens filter() einen Aktualparameter vom Interface-Datentyp
Predicate<String> (siehe Abschnitt 12.1.1.1):
filter(s -> s.length() == 4)
Der Lambda-Ausdruck empfängt einen Parameter vom Typ String und liefert eine Rückga-
be vom Typ boolean, die genau dann true ist, wenn die Parameterzeichenfolge die Länge
vier besitzt. Was die Methode filter() unter Verwendung des Lambda-Funktionsobjekts tut,
ist gleich anschließend zu sehen.
Bei den Lambda-Ausdrücken handelt es sich um eine Erweiterung der Programmierspra-
che, die den Umgang mit Funktionsobjekten erleichtert.
• Ströme
Ein Strom ist eine Sequenz von Elementen aus einer Quelle (z. B. Kollektion, Array, Datei)
und unterstützt Operationen zur sequentiellen oder parallelen Massenabfertigung der Ele-
mente (engl.: bulk operations oder aggregate operations). Ein wesentliches, aber nicht das
einzige Ziel beim Design der Stromverarbeitung in Java 8 war die bequeme (und damit tat-
sächlich genutzte) Parallelisierung von Operationen bei Sequenzen mit dem Ergebnis guter
Leistungswerte auf Multi-Core - Systemen.
Bei den Java 8 - Strömen handelt es sich um eine Erweiterung der Standardbibliothek,
die datenbankartige Operationen (z. B. Filtern, Gruppieren, Auswerten) mit Kollektionsob-
jekten erleichtert. Im folgenden Beispiel entsteht aus einem List<String> - Objekt durch ei-
nen Aufruf seiner stream() - Methode ein Stream<String> - Objekt mit einer Sequenz von
Namen. Um die Anzahl der Namen mit vier Zeichen zu ermitteln, werden durch die Strom-
operation filter() die Namen mit einer abweichenden Länge ausgefiltert. Dann wird über die
Stromoperation count() die Anzahl der verbliebenen Stromelemente ermittelt:
List<String> als = List.of("Rudolf", "Emma", "Otto", "Kurt","Walter");
long n4 = als.stream()
.filter(s -> s.length() == 4)
.count();
12.1 Lambda-Ausdrücke
Wer zu einem Buch mit Unterstützung für Java 8 greift, wird mit einer bis zu Java 7 völlig unge-
wohnten Syntax wie im folgenden Aufruf der Methode setOnAction() konfrontiert:
570 Kapitel 12 Funktionales Programmieren
1
Diese Registrierung wird durch einen Aufruf der ButtonBase-Methode setOnAction() erledigt (siehe oben). Wie im
Kapitel 13 über die JavaFX-Ereignisbehandlung zu erfahren ist, wird dabei der Wert des Property-Objekts onAction
im Befehlsschalter gesetzt. Wird die JavaFX-Bedienoberfläche deklarativ per FXML-Datei gestaltet, dann passiert
einiges im Verborgenen. Wie die IntelliJ-Erläuterung zum Property-Objekt onAction im Element Button aus der
FXML-Datei zu unserem Beispielprogramm im Abschnitt 4.9 zeigt, ändert sich aber nichts an der Grundlogik der
JavaFX-Ereignisbehandlung:
Abschnitt 12.1 Lambda-Ausdrücke 571
In Java 8 wurde die Standardbibliothek um das Paket java.util.function erweitert, das über 40
funktionale Schnittstellen enthält.
Um dem Compiler für ein als funktional konzipiertes Interface die Kontrolle der eben beschriebe-
nen Regel (essentiell genau eine abstrakte Methode) zu ermöglichen, kann man der Definition die
Marker-Annotation @FunctionalInterface voranstellen. Mit dieser im Java-API oft verwendeten
Annotation ist z. B. das generische Interface Predicate<T> im Paket java.util.function dekoriert,
das eine abstrakte Methode namens test() mit einem Parameter vom Typ T und einer Rückgabe
vom Typ boolean verlangt:
package java.util.function;
import java.util.Objects;
@FunctionalInterface
public interface Predicate<T> {
1
Man hat die implizit ohnehin in jeder Schnittstellen-Implementation vorhandene Methode equals() deshalb explizit
deklariert, um das erwartete Verhalten in der API-Dokumentation beschreiben zu können. Dort ist lesen, dass die
equals() - Methode so überschrieben werden kann, dass sie den Wert true liefert für eine per Parameter bestimmte
Comparator<T> - Implementation, die im folgenden Sinne mit der angesprochenen Implementation kompatibel ist:
Die compare(T o1, T o2) - Methoden der beiden Implementationen liefern für alle Paare von Objekten des Typs T
eine übereinstimmende Beurteilung (dasselbe Vorzeichen oder den übereinstimmenden Wert null). Die equals() -
Methode der Klasse Object liefert nur dann den Wert true, wenn das Parameterobjekt mit dem angesprochenen Ob-
jekt identisch ist. Die Dokumentation einer Klasse oder Schnittstellen ist als Bestandteil des Vertrags zu diesem Typ
aufzufassen.
572 Kapitel 12 Funktionales Programmieren
1
https://de.wikipedia.org/wiki/Strategie_(Entwurfsmuster)
Abschnitt 12.1 Lambda-Ausdrücke 573
Wie ein Blick in den Quellcode des Programms zeigt, wird seine Bedienoberfläche komplett durch
Anweisungen erzeugt, was in JavaFX nach wie möglich und bei anderen GUI-Techniken (z. B.
Swing) alternativlos ist:
import javafx.application.Application;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
. . .
import javafx.scene.layout.VBox;
button.setOnAction(new EventHandler<ActionEvent>() {
@Override
public void handle(ActionEvent event) {
label.setText("Hallo JavaFX");
}
});
niert. Einen Namen erhält die nur lokal benötigte Klasse nicht, und es resultiert eine sogenannte
anonyme Klasse.1
Seit Java 9 kann der sogenannte Diamond Operator (siehe Abschnitt 8.1.1.2) auch bei anonymen
Klassen verwendet werden, sodass sich der setOnAction() - Aufruf im letzten Beispiel leicht ver-
einfachen lässt:
button.setOnAction(new EventHandler<>() {
@Override
public void handle(ActionEvent event) {
label.setText("Hallo JavaFX");
}
});
Eine anonyme Klasse kann als Aktualparameter verwendet oder einer Variablen zugewiesen wer-
den. Sie hat Zugriff auf Variablen und Methoden aus dem jeweiligen Kontext. Im folgenden Pro-
gramm, das wegen der Nutzung des Diamond-Operators mindestens Java 9 voraussetzt, werden drei
Umgebungsvarianten
• statisches Feld
• lokale Variable in einer Instanzmethode
• lokale Variable in einer statischen Methode
und die jeweils möglichen Zugriffe auf Kontextvariablen demonstriert:
import java.util.function.ToIntFunction;
class Umgebungen {
static java.util.List<String> ls = java.util.Arrays.asList("1", "22", "333");
static String statEnv = "Stat";
String instEnv = "Inst";
void instMeth() {
String methEnv = "Meth";
1
Man kann die anonyme Klasse als spezielle lokale Klasse betrachten (vgl. Abschnitt 4.8.2). Dieser Standpunkt wird
auch im Java-Tutorial der Firma Oracle vertreten, siehe
https://docs.oracle.com/javase/tutorial/java/javaOO/anonymousclasses.html
Abschnitt 12.1 Lambda-Ausdrücke 575
Die Funktionsobjekte aus den anonymen Klassen werden jeweils einer Variablen vom Typ
ToIntFunction<String> zugewiesen, die als Parameter für die Stream<String> - Methode
mapToInt() dient, die wir im Abschnitt 12.2 als intermediäre Operation für Ströme kennenlernen
werden. Um praxisrelevante Anwendungsfälle für anonyme Klassen und Lambda-Ausdrücke zu
erhalten, verwenden wir im aktuellen Abschnitt in möglichst einfacher Form die im Abschnitt 12.2
vorzustellenden Stream-Typen. Im Augenblick interessiert am Beispielprogramm ausschließlich,
dass die anonymen Klassen auf Variablen in ihrer jeweiligen Umgebung zugreifen dürfen.
Wichtige Eigenschaften von anonymen Klassen:
• Definition und Instanzierung finden in einem new-Operanden statt, wobei im Konstruktor-
aufruf der fehlende Klassenname durch den Namen der implementierten Schnittstelle oder
der beerbten Basisklasse vertreten wird. Es folgt ein Klassendefinitionsblock, der wie üblich
durch geschweifte Klammern zu begrenzen ist. Im folgenden Beispiel
button.setOnAction(new EventHandler<ActionEvent>() {
@Override
public void handle(ActionEvent event) {
label.setText("Hallo JavaFX");
}
});
wird die Schnittstelle EventHandler<ActionEvent> angegeben und deren einzige Methode
handle() implementiert. Es kann nur eine einzige Instanz der anonymen Klasse erzeugt wer-
den. Werden mehrere Instanzen benötigt, ist eine alternative Lösung zu verwenden (lokale
Klasse, Mitgliedsklasse oder Top-Level - Klasse).
• Weil der Klassenname fehlt, sind keine Konstruktoren möglich. Über die Instanzinitialisie-
rer (vgl. Abschnitt 4.4.4) ist jedoch eine Ersatzlösung verfügbar.
• Vor Java 16 waren in einer anonymen Klasse keine statischen Methoden erlaubt, und stati-
sche Felder mussten finalisiert sein. Seit Java 16 sind diese Beschränkungen entfallen.
• Eine anonyme Klasse kann auf die finalisierten lokalen Variablen der umgebenden Methode
zugreifen. Seit Java 8 wird nur noch die effektive Finalität verlangt. Diese besteht, wenn
nach der Initialisierung keine Wertveränderung stattfindet. Der Modifikator final ist also
nicht mehr erforderlich. Seit Java 8 kann eine anonyme Klasse auch auf effektiv finale Pa-
rameter der umgebenden Methode zugreifen. Weil der Zugriff auf effektiv finale Variablen
bzw. Parameter beschränkt ist, sind natürlich nur lesende Zugriffe erlaubt. Über eine Refe-
renzvariable in der lokalen Umgebung sind aber durchaus Schreibzugriffe auf das ansprech-
bare Objekt möglich, weil sich die Referenzvariable dabei ja nicht ändert.
576 Kapitel 12 Funktionales Programmieren
• Außerdem kann eine anonyme Klasse auf die statischen Variablen und Methoden der umge-
benden Klasse zugreifen. Wird eine anonyme Klasse in einer Instanzmethode definiert, kann
sie auch auf die Instanzvariablen und -methoden des handelnden Objekts zugreifen. Alle ge-
nannten Zugriffe sind auch bei Methoden und Feldern mit private-Deklaration möglich. Auf
Felder kann lesend und schreibend zugegriffen werden. Es ist Vorsicht geboten, wenn der
Code einer anonymen Klasse in verschiedenen Threads ausgeführt wird.
• Felder und lokale Variablen der anonymen Klasse überdecken gleichnamige Variablen der
Umgebung. Überdeckte statische Variablen der umgebenden Klasse können in der anony-
men Klasse über den Klassennamen als Präfix angesprochen werden, z. B. bei einer umge-
benden Klasse namens Aussen mit der statischen Variablen statEnv:
Aussen.statEnv
Überdeckte Instanzvariablen eines umgebenden Objekts können in der anonymen Klasse
über einen Präfix aus dem Klassennamen und dem Schlüsselwort this angesprochen werden,
z. B. bei einer umgebenden Klasse namens Aussen mit der Instanzvariablen instEnv:
Aussen.this.instEnv
• Der Compiler erzeugt auch für eine anonyme Klasse eine eigene class-Datei, in deren Na-
men der Bezeichner für die umgebende Klasse eingeht, z. B.: Main$1.class.
Die gleich vorzustellenden Lambda-Ausdrücke können oft statt einer anonymen Klasse verwendet
werden und dabei für einen besser lesbaren Quelltext sorgen. In anderen Fällen sind anonyme Klas-
sen aber weiterhin zu bevorzugen, weil sie aufgrund der reichhaltigeren syntaktischen Optionen
u. a. die folgenden Vorteile gegenüber Lambda-Ausdrücken haben:
• Aus einem Lambda-Ausdruck resultiert stets ein Objekt, das genau eine Methode beherrscht.
Im Unterschied dazu kann eine anonyme Klasse beliebig viele Instanzmethoden besitzen.
• Ein Lambda-Objekt muss auf Instanzvariablen verzichten, während diese bei einem anony-
men Objekt verfügbar sind.
• Eine anonyme Klasse kann eine Schnittstelle implementieren oder eine Basisklasse erwei-
tern. Letzteres ist bei Lambda-Ausdrücken nicht möglich.
• Ein Lambda-Objekt kann sich nicht selbst (mit dem Schlüsselwort this) ansprechen, was bei
anonymen Klassen genauso klappt wie bei Top-Level - Klassen.
• Die vom Lambda-Code produzierte Rückgabe muss vom passenden Typ sein. Im Beispiel
hat die Interface-Methode handle() den Rückgabetyp void, und eine return-Anweisung mit
Rückgabe als Bestandteil des Lambda-Ausdrucks würde zum Übersetzungsfehler führen,
z. B.:
Mit einem Lambda-Ausdruck gelingt es in der Regel, die benötigte Funktionalität auf sehr kompak-
te Weise zu formulieren. Gelegentlich ist aber doch eine direkt definierte anonyme Klasse wegen
der folgenden (schon am Ende von Abschnitt 12.1.1.2 aufgelisteten) Vorteile zu bevorzugen:
578 Kapitel 12 Funktionales Programmieren
• Aus einem Lambda-Ausdruck resultiert stets ein Objekt, das genau eine Methode beherrscht.
Im Unterschied dazu kann eine anonyme Klasse beliebig viele Instanzmethoden besitzen.
• Ein Lambda-Ausdruck besteht aus einer Methoden-Implementation, sodass keine Instanzva-
riablen definiert werden können. Ein Lambda-Objekt muss also auf Instanzvariablen ver-
zichten, während diese bei einem anonymen Objekt verfügbar sind. Wenn z. B. ein
ActionEvent-Handler zu einem GUI-Bedienelement über die einzelnen Aufrufe hinweg Da-
ten speichern muss, ist ein „vollwertiges“ Objekt erforderlich. Um ein Objekt einer anony-
men Klasse mehrfach verwenden zu können, muss man seine Adresse natürlich in einer Re-
ferenzvariablen aufbewahren.
• Eine anonyme Klasse kann eine Schnittstelle implementieren oder eine Basisklasse erwei-
tern. Letzteres ist bei Lambda-Ausdrücken nicht möglich.
• Ein Lambda-Objekt kann sich nicht selbst (mit dem Schlüsselwort this) ansprechen, was bei
anonymen Klassen genauso klappt wie bei Top-Level - Klassen.
Der zuverlässige Java-Ratgeber Joshua Bloch (2018, Item 42) rät, Lambda-Ausdrücke gegenüber
anonymen Klassen zu bevorzugen, solange der Lambda-Code kurz (max. 3 Zeilen lang) und selbst-
erklärend bleibt. Gegen einen unübersichtlichen Lambda-Code lässt sich leicht etwas unternehmen,
weil ein Lambda-Ausdruck (wie eine anonyme Klasse) auf (statische) Methoden der umgebenden
Klasse zugreifen kann.
Während zu einer anonymen Klasse beim Übersetzen des Quellcodes eine eigene Bytecode-Datei
entsteht (vgl. Abschnitt 12.1.1.2), wird die zu einem Lambda-Ausdruck gehörige Klasse zur Lauf-
zeit bei Bedarf dynamisch erstellt (Goetz 2012).
Der Rückgabetyp, den der Compiler aus der vom Lambda-Ausdruck implementierten Schnittstel-
lenmethode kennt, ist nicht anzugeben.
12.1.2.1 Formalparameterliste
Grundsätzlich gilt für die Formalparameterliste eines Lambda-Ausdrucks wie für die Formalpara-
meterliste einer Methode (vgl. Abschnitt 4.3.1.4):
• Die Formalparameterliste wird durch ein Paar runder Klammern begrenzt.
• Für jeden Formalparameter sind ein Datentyp und ein Name anzugeben.
• Die Formalparameter sind durch ein Komma voneinander zu trennen.
• Am Ende kann ein Serienparameter stehen.
• Die Parameter können als final deklariert werden.
Der Compiler erlaubt allerdings bei der Formalparameterliste eines Lambda-Ausdrucks einige Ver-
einfachungen:
Abschnitt 12.1 Lambda-Ausdrücke 579
• Man kann auf die Angabe der Parametertypen verzichten, weil sich diese aus dem zu erfül-
lenden Interface zwingend ergeben. Es ist zu beachten, dass der Datentyp für alle Parameter
einheitlich entweder anzugeben oder wegzulassen ist. Im folgenden Beispiel
IntBinaryOperator absMax = (a, b) -> Math.abs(a) >= Math.abs(b) ? a : b;
wird per Lambda-Ausdruck ein Objekt namens absMax erstellt, dessen Klasse das Interface
IntBinaryOperator (im Paket java.util.function) erfüllt:
public interface IntBinaryOperator {
int applyAsInt(int left, int right);
}
Der Lambda-Ausdruck liefert zu zwei int-Werten die Zahl mit dem größten Betrag. Er kann
z. B. als Argument der Stream-Methode reduce() verwendet werden, um aus einem
IntStream-Objekt das Element mit dem größten Betrag zu fischen:
IntStream is = IntStream.of(-3, 7, -12, 5);
OptionalInt amax = is.reduce(absMax);
Die Methode reduce() liefert ein Objekt der Klasse OptionalInt, das die betrags-maximale
Zahl aus dem Strom enthält, oder (bei einem leeren Strom) keinen Wert besitzt (siehe Ab-
schnitt 11.4.2). Ausführliche Erläuterungen zur Methode reduce() und zu anderen Strom-
operationen folgen im Abschnitt 12.2.5.
• Bei einem einzelnen, implizit typisierten Parameter kann man die runden Klammern weglas-
sen. Das Beispiel
Predicate<String> ps = (String s) -> s.length() >= 5;
kann also kompakter notiert werden:
Predicate<String> ps = s -> s.length() >= 5;
Wie bei einer Methodendefinition muss im Falle einer leeren Parameterliste ein paar runder Klam-
mern angegeben werden, z. B.:
() -> 1
Dieser scheinbar sinnlose Lambda-Ausdruck eignet sich übrigens als Parameter der IntStream-
Methode generate() dazu, einen unendlich langen Strom mit Einsen zu erzeugen, der per limit() -
Aufruf die tatsächlich benötigte Länge erhält, z. B.:
IntStream one = IntStream.generate(() -> 1).limit(10);
Weil die Ausführung der Strommethoden im Java generell ökonomisch bzw. faul (engl.: lazy) er-
folgt, wird keinesfalls ein „unendlich“ langer Strom erzeugt und anschließend gekappt. Stattdessen
entstehen genau die benötigten 10 Elemente.
12.1.2.2 Rumpf
Der Lambda-Rumpf kann aus einem geschweift eingeklammerten Anweisungsblock bestehen
IntBinaryOperator absMax = (a, b) -> {
if (Math.abs(a) >= Math.abs(b))
return a;
else
return b;
};
oder aus einem einzelnen Ausdruck (im Sinn von Abschnitt 3.5):
IntBinaryOperator absMax = (a, b) -> Math.abs(a) >= Math.abs(b) ? a : b;
Ist der Lambda-Rumpf ein Anweisungsblock und der Rückgabetyp der zu erfüllenden Interface-
Methode von void verschieden, dann muss für jeden möglichen Ausführungspfad per return-
Anweisung ein Rückgabewert vom passenden Typ geliefert werden (siehe erstes Beispiel).
580 Kapitel 12 Funktionales Programmieren
Wenn im Anweisungsblock eines Lambda-Ausdrucks eine Methode aufgerufen wird, die eine ge-
prüfte Ausnahme (vgl. Abschnitt 11.5.2) werfen kann, und diese Ausnahme in der implementierten
abstrakten Interface-Methode nicht deklariert wird, dann muss der Lambda-Block die Ausnahme in
einer try-Anweisung mit geeignetem catch-Block abfangen (vgl. Abschnitt 11.3).
Im folgenden Beispielprogramm
interface Tester<T> {
boolean test(T t) throws Exception;
}
class Prog {
public static void main(String[] args) throws Exception {
java.util.function.Predicate<String> pstr = s ->
{if (s.length() == 13) throw new RuntimeException(); return s.length() >= 5;};
12.1.2.3 Definitionsumgebungen
Ein Lambda-Ausdruck kann in verschiedenen Umgebungen eingesetzt werden und hat dementspre-
chend Zugriff auf unterschiedliche Variablen und Methoden der Umgebung, z. B.:
• Wird ein Lambda-Ausdruck als Wert für eine lokale Variable oder einen Parameter in einer
(statischen) Methode oder in einem Konstruktor definiert, dann hat er Zugriff auf effektiv
finale Variablen und Parameter der umgebenden Methode bzw. des umgebenden Konstruk-
tors. Eine lokale Variable ist effektiv final, wenn ihr Wert nach der ersten Zuweisung unver-
ändert bleibt. Ein Parameter ist effektiv final, wenn sein Wert in der Methode bzw. im Kon-
struktor nicht verändert wird. Weil der Zugriff auf (effektiv) finale Variablen bzw. Parame-
ter beschränkt ist, sind natürlich nur lesende Zugriffe erlaubt. Über eine Referenzvariable in
der lokalen Umgebung sind aber durchaus Schreibzugriffe auf das ansprechbare Objekt
möglich, weil sich die Referenzvariable dabei ja nicht ändert (siehe Beispiel unten).
• Wird ein Lambda-Ausdruck als Wert für eine Instanzvariable verwendet (bei der Deklarati-
on, in einem Konstruktor oder in einem Instanzinitialisierer), dann hat er Zugriff auf ...
o die Instanzvariablen und -methoden des umgebenden Objekts,
o die statischen Felder und Methoden der umgebenden Klasse.
Abschnitt 12.1 Lambda-Ausdrücke 581
• Wird ein Lambda-Ausdruck als Wert für ein statisches Feld verwendet (bei der Deklaration
oder in einem statischen Initialisierer), dann hat er Zugriff auf die statischen Felder und Me-
thoden der umgebenden Klasse.
Lokale Variablen eines Lambda-Ausdrucks überdecken (statische) Felder der Umgebung. Über-
deckte statische Felder der umgebenden Klasse können im Lambda-Ausdruck über den Klassenna-
men als Präfix angesprochen werden, z. B. bei einer umgebenden Klasse namens Aussen mit der
statischen Variablen statEnv:
Aussen.statEnv
Überdeckte Instanzvariablen eines umgebenden Objekts können im Lambda-Ausdruck über das
Schlüsselwort this angesprochen werden, z. B. bei der Instanzvariablen instEnv:
this.instEnv
Weil sich ein methodenintern definierter Lambda-Ausdruck im Gültigkeitsbereich der umgebenden
Methode befindet, sind für lokale Variablen des Lambda-Ausdrucks keine Namen erlaubt, die be-
reits für lokale Variablen der umgebenden Methode genutzt werden.
Auf Instanz- und Klassenvariablen der Umgebung kann in einem Lambda-Ausdruck auch schrei-
bend zugegriffen werden. Es ist Vorsicht geboten, wenn der Code eines Lambda-Ausdrucks in ver-
schiedenen Threads ausgeführt wird.
Das folgende Beispielprogramm demonstriert für einen im Konstruktor definierten Lambda-
Ausdruck die erlaubten Zugriffe auf Umgebungsvariablen:
Quellcode Ausgabe
import java.util.function.Supplier; 1 11 13 101
2 12 13 102
class LambdaScoping { 3 13 13 103
static int statEnv = 0;
int instEnv = 10;
Supplier<String> sups;
LambdaScoping() {
int locEnv = 13;
int[] locEnvArr = {100};
sups = () -> {
// int locEnv = 14; // Verboten
return String.valueOf(++statEnv)+" "+
String.valueOf(++instEnv)+" "+
String.valueOf(locEnv)+" "+
String.valueOf(++locEnvArr[0]);
};
}
void prot() {
System.out.println(sups.get());
}
Die Zusammenfassung eines Lambda-Ausdrucks mit den „eingefangenen“ Variablen aus der Um-
gebung wird als Abschluss (engl. closure) bezeichnet.
582 Kapitel 12 Funktionales Programmieren
Beim Zugriff auf Umgebungsvariablen gelten für anonyme Klassen und Lambda-Ausdruck weitge-
hend identische Regeln mit den folgenden Ausnahmen:
• Eine anonyme Klasse begründet einen eigenen Gültigkeitsbereich, und in ihren Methoden
dürfen lokale Variablen mit einem Namen angelegt werden, den auch lokale Variablen einer
umgebenden Methode verwenden. Dabei werden die Umgebungsvariablen überdeckt. Ein
Lambda-Ausdruck gehört hingegen wie ein gewöhnlicher eingeschachtelter Block zum Gül-
tigkeitsbereich einer umgebenden Methode, sodass die dortigen lokalen Variablennamen im
Lambda-Ausdruck nicht verwendet werden dürfen. In der englischsprachigen Literatur wird
dafür die Bezeichnung lexical scoping verwendet.
• Sowohl in einer anonymen Klasse als auch in einem Lambda-Ausdruck werden Instanzvari-
ablen eines umgebenden Objekts durch lokale Variablen überdeckt. Um die Instanzvariablen
des umgebenden Objekts weiterhin ansprechen zu können, genügt im Lambda-Ausdruck das
Schlüsselwort this, das sich hier auf das umgebende Objekt bezieht, z. B.:
String.valueOf(this.instEnv)
Daraus ergibt sich allerdings die in seltenen Fällen relevante Einschränkung, dass sich das
Lambda-Objekt nicht selbst ansprechen kann. In einer anonymen Klasse bezieht sich this
hingegen auf das anonyme Objekt, und zum Zugriff auf eine überdeckte Instanzvariable des
umgebenden Objekts ist dem Schlüsselwort der Klassenname voranzustellen, z. B.:
String.valueOf(Aussen.this.instEnv)
12.1.3.1 Methodenreferenzen
Wenn zu einem geplanten Lambda-Ausdruck eine Methode existiert, bei der die Formalparameter-
liste und der Rückgabetyp exakt passen, dann kann der Lambda-Ausdruck durch eine sogenannte
Methodenreferenz ersetzt werden:
Methodenreferenz
objektreferenz :: instanzmethode
Klassenname :: instanzmethode
Klassenname :: klassenmethode
Bei einer Instanzmethode wird der Auftragnehmer entweder durch eine konkrete Objektreferenz
(z. B. System.out) oder durch eine Klasse angegeben. Bei einer statischen Methode ist der Klassen-
name anzugeben. Hinter den Auftragnehmer ist der :: - Operator zu setzen. Schließlich folgt der
Methodenname ohne Parameterliste, womit prinzipiell überflüssige Code-Bestandteile eingespart
werden, was Anhänger eines kompakten Programmierstils erfreut (siehe z. B. Bloch 2018, S. 197f).
Gibt man eine Klasse zusammen mit einer Instanzmethode an (Fall 2 im Syntaxdiagramm), dann
wird der erste Parameter der zu implementierenden Schnittstellenmethode zum Ansprechpartner für
den Aufruf der Instanzmethode, und die restlichen Parameter der zu implementierenden Methode
müssen zu den Parametern der Instanzmethode passen. Ebenso muss der Rückgabetyp kompatibel
sein. Eine Methodenreferenz von der mittleren Bauart aus dem obigen Syntaxdiagramm ist also
Abschnitt 12.1 Lambda-Ausdrücke 583
genau dann zulässig, wenn der Typ des ersten Parameters in der zu implementierenden Schnittstel-
lenmethode eine Instanzmethode beherrscht, welche genau die restlichen Parameter aus der zu im-
plementierenden Schnittstellenmethode verarbeitet und einen passenden Rückgabewert liefert.
Im folgenden Beispiel wird für die String-Objekte in einer Liste die mittlere Länge berechnet, wo-
bei ein Stromobjekt vom Typ Stream<String> zum Einsatz kommt (vgl. Abschnitt 12.2). Der
Stream<String> - Methode mapToInt() wird als Parameter vom Interface-Typ ToIntFunction<?
super String> ein Lambda-Ausdruck übergeben:
OptionalDouble ml =
List.of("Viktor","Otto","Emma","Kurt","Isolde","Frank")
.stream()
.mapToInt(s -> s.length())
.average();
Von mapToInt() wird die Schnittstellenmethode
public int applyAsInt(String value)
mit jedem Stromelement als Aktualparameter aufgerufen. Ein Lambda-Ausdruck verwendet diesel-
be Formalparameterliste wie die zu implementierende Schnittstellenmethode. Im Beispiel wird der
erste (und einzige) Parameter (Typ String) zum Ansprechpartner für den Aufruf der String-
Instanzmethode length(). Hier passt eine Instanzmethodenreferenz gemäß Fall 2 aus dem obigen
Syntaxdiagramm:
• Aus dem ersten (und einzigen) applyAsInt() - Parameter wird die Klassenangabe String.
• Bei der String-Instanzmethode length() passen die restliche Parameterliste und der Rückga-
betyp int.
Folglich kann der Lambda-Ausdruck durch eine Instanzmethodenreferenz mit der Methode length()
ersetzt werden:
OptionalDouble ml =
List.of("Viktor", "Otto", "Emma", "Kurt", "Isolde", "Frank")
.stream()
.mapToInt(String::length)
.average();
Man darf sich vorstellen, dass der Compiler aus der Methodenreferenz eine anonyme Klasse synthe-
tisiert:
OptionalDouble ml =
List.of("Viktor", "Otto", "Emma", "Kurt", "Isolde", "Frank")
.stream()
.mapToInt(new ToIntFunction<String>() {
public int applyAsInt(String s) {
return s.length();
}
})
.average();
Ist der Auftragnehmer ein konkretes Objekt (z. B. System.out) oder eine Klasse, dann werden alle
Parameter der zu implementierenden Schnittstellenmethode auf die Parameter der Instanz- oder
Klassenmethode abgebildet. Im folgenden Beispiel wird an die Stream<String> - Methode
forEach() ein Objekt übergeben, das die Schnittstelle Consumer<? super String> im Paket
java.util.function implementiert:
List.of("Viktor", "Otto", "Emma", "Kurt", "Isolde", "Frank")
.stream()
.forEach(s -> System.out.println(s));
Der Lambda-Ausdruck
584 Kapitel 12 Funktionales Programmieren
s -> System.out.println(s)
sorgt für die Ausgabe der Stromelemente. Er hat einen Parameter vom Typ String sowie den Rück-
gabetyp void und kann daher durch die folgende Methodenreferenz
System.out::println
ersetzt werden:
List.of("Viktor", "Otto", "Emma", "Kurt", "Isolde", "Frank")
.stream()
.forEach(System.out::println);
Ein Beispielprogramm aus dem Abschnitt 10.6.5, das ein Comparator<String> - Objekt als Para-
meter für den TreeSet<String> - Konstruktor verwendet, um eine geordnete Namenssammlung mit
bevorzugter Einordnung von „Otto“ zu erstellen, lässt sich leicht zur Demonstration einer Metho-
denreferenz vom statischen Typ umbauen, wobei ausnahmsweise kein Vorgriff auf die im Abschnitt
12.2 vorzustellenden Stream-Klassen stattfindet:
Quellcode Ausgabe
import java.util.*; [Otto, Ludwig, Werner]
class StatMethRef {
public static int compare(String s1, String s2) {
if (s1.equals("Otto"))
return -1;
if (s2.equals("Otto"))
return 1;
return s1.compareTo(s2);
}
12.1.3.2 Konstruktorreferenzen
Wenn ein Lambda-Ausdruck nichts anderes tut, als ein Objekt per Konstruktoraufruf zu instanziie-
ren, dann kann der Lambda-Ausdruck durch eine sogenannte Konstruktorreferenz ersetzt werden:
Konstruktorreferenz
Klassenname :: new
Abschnitt 12.2 Ströme 585
Eine Konstruktorreferenz unterscheidet sich von einer Methodenreferenz (siehe Abschnitt 12.1.3.1)
dadurch, dass ein Konstruktor statt einer Methode aufgerufen wird, was syntaktisch folgende Kon-
sequenzen hat:
• Vor dem :: - Operator befindet sich stets ein Klassenname.
• An der Stelle des Methodennamens befindet sich das Schlüsselwort new.
Im folgenden Beispiel sollen String-Objekte in Objekte der Klasse BigDecimal gewandelt werden.
Wir erstellen aus einem Kollektionsobjekt der Klasse List<String> ein Stromobjekt vom Typ
Stream<String> und verwenden dessen Methode map(), um daraus ein Stromobjekt vom Typ
Stream<BigDecimal> zu erzeugen. Die Methode map() erwartet als Parameter ein Funktionsob-
jekt vom Interface-Typ Function<String,BigDecimal>, der die Methode apply() vorschreibt:
public BigDecimal apply(String s)
Die folgende Überladung des BigDecimal - Konstruktors
public BigDecimal(String val)
erfüllt den Job und kann daher per Konstruktorreferenz an map() übergeben werden:
Quellcode Ausgabe
import java.math.BigDecimal; 3.14
import java.util.Arrays; 9.99
47.11
class KonstruktorReferenzen {
public static void main(String[] args) {
List.of("3.14", "9.99", "47.11")
.stream()
.map(BigDecimal::new)
.forEach(System.out::println);
}
Die Konstruktorreferenz
BigDecimal::new
ist äquivalent zum Lambda-Ausdruck:
s -> new BigDecimal(s)
12.2 Ströme
In günstigen Fällen gelingt es, von der sequenziellen Verarbeitung mit geringem Aufwand auf die
parallele, mehrere Prozessorkerne nutzende Verarbeitung umzustellen. Bei parallelen Stromopera-
tionen werden ...
• die Daten in Teilmengen zerlegt,
• die Teilmengen in eigenständigen Threads parallel verarbeitet,
• und die Teilergebnisse am Ende zusammengeführt.
In vielen Situationen kann man sich eine eigene Multithreading-Lösung, die typischerweise mit
Aufwand und Fehlerrisiko verbunden ist, ersparen und die parallelisierte Strombearbeitung den
ausgefeilten Methoden der Systembibliothek überlassen. Dabei kommt im Hintergrund das Fork-
Join - Framework zum Einsatz, das im Abschnitt 15.5.1 vorgestellt wird.
Bevor es zu abstrakt wird, betrachten wir ein Beispiel. Die Aufgabe besteht darin, für eine Sequenz
von Namen die mittlere Länge aller Namen mit einer geraden Anzahl von Buchstaben zu ermitteln.
List<String> als = List.of("Viktor", "Anton", "Urs", "Emma", "Tom", "Thilo");
OptionalDouble mleven = als.stream()
.filter(s -> s.length() %2 == 0)
.mapToInt(s -> s.length())
.average();
System.out.println(mleven);
Ausgehend von einer Liste mit String-Objekten, erstellt von der statischen Methode of() aus dem
Interface List<E>, wird über die (im Abschnitt 10.3 erwähnte) Collection<T> - Methode stream()
ein Objekt vom Typ Stream<String> erstellt.
Daraus entsteht durch Anwendung der Operation filter() ein neues Stromobjekt vom selben Typ,
das nur noch die String-Objekte mit einer geraden Anzahl von Zeichen enthält. Zur Bewertung der
Zeichenfolgen im ursprünglichen Strom dient ein Funktionsobjekt aus einer anonymen, das Inter-
face Predicate<String> implementierenden Klasse, die per Lambda-Ausdruck definiert wird und
die Instanzmethode
public boolean test(String s)
besitzt.
Wie das Beispiel zeigt, bietet die Stream-Bibliothek ein Fluent API, weil ihre Methoden ein flüssi-
ges Programmieren durch Verketten von Aufrufen erlauben.
Über die Operation mapToInt() erhält man durch die elementweise Abbildung ein Stromobjekt
vom Typ IntStream. Für die Produktion des int-Werts zu einer Zeichenfolge ist ein Funktionsob-
jekt aus einer anonymen, das Interface ToIntFunction<String> implementierenden Klasse zustän-
dig, die per Lambda-Ausdruck definiert wird und die Instanzmethode
public int applyAsInt(T value)
beherrscht.
Auf das IntStream-Objekt wird die Stromoperation average() angewendet, um ein Ergebnisobjekt
vom Typ OptionalDouble zu produzieren, das bei einem nicht-leeren Strom die gesuchte Durch-
schnittslänge als double-Wert enthält und nach Aufforderung durch getAsDouble() ausliefert. In
der folgenden Abbildung ist die gesamte Stromverarbeitung dargestellt:
Abschnitt 12.2 Ströme 587
List<String>
stream()
Stream<String>
filter()
Stream<String>
mapToInt()
IntStream
average()
OptionalDouble
Ein wesentliches Kennzeichen der in Java 8 eingeführten Stromoperationen besteht darin, dass Ite-
rationen in der Standardbibliothek gekapselt, also aus dem Anwendungs-Code ferngehalten werden.
Bei den mit interner Iteration arbeitenden Stromoperationen ...
• legt der Programmierer fest, was geschehen soll (z. B. eine Summenbildung) und überlässt
die Implementierungsdetails der Standardbibliothek,
• erfordert der Wechsel vom Single- in den Multithread-Betrieb nur eine simple Änderung bei
der Erstellung des Stromobjekts. Dieser Wechsel muss allerdings mit Bedacht geschehen,
weil er auch zu einer verschlechterten Performanz und sogar zu einem fehlerhaften Pro-
gramm führen kann (Bloch 2018, S. 225).
Im folgenden Programm werden die Elemente eines Arrays aufsummiert. Dazu wird ...
• zunächst die vertraute externe Iteration per for-Schleife
• und anschließend die Stromoperation sum()
verwendet:
Quellcode Ausgabe
import java.util.Arrays; 37
37
public class Prog {
// Externe Iteration
int sumex = 0;
for(int wert : daten)
sumex += wert;
// Interne Iteration
int sumint = Arrays.stream(daten).sum();
System.out.println(sumex+"\n"+sumint);
}
}
In der strombasierten Lösung ist vom Initialisieren und vom wiederholten Verändern einer Sum-
menvariablen nichts zu sehen, sodass Aufwand und Fehlergefahr entfallen.
Spätestens bei der Parallelisierung ist die traditionelle Technik hoffnungslos unterlegen, weil die
moderne Konkurrenz im Beispiel dazu lediglich einen zusätzlichen Methodenaufruf benötigt, der
aus dem seriell arbeitenden Strom einen parallel arbeitenden erstellt:
int sumint = Arrays.stream(daten)
.parallel()
.sum();
Eine das Interface Collection<E> implementierende Kollektion bietet bei der Erstellung eines
Stromobjekts die Wahl zwischen der Methode stream(), die einen seriell arbeitenden Strom liefert,
und der Methode parallelStream(), die einen parallel arbeitenden Strom erstellt.
Wegen der unvermeidlichen Fixkosten einer Multithreading-Lösung wird im konkreten Beispiel
(mit der Summe 37) allerdings der parallele Strom deutlich mehr Zeit benötigen als der serielle.
Abschnitt 12.2 Ströme 589
12.2.4.2 Stromobjekt aus einem Array oder aus einer Serie von Werten (stream, of)
Um einen sequentiellen Strom aus einem Array zu erstellen, kann man die in diversen Überladun-
gen vorhandene statische Methode stream() aus der Klasse Arrays verwenden, z. B.:
Stream<String> sos = Arrays.stream(new String[] {"Emma", "Otto", "Kurt"});
IntStream is = Arrays.stream(new int[] {1, 4, 14, 39});
Über die in allen Stream-Interfaces vorhandene Methode parallel() lässt sich indirekt auch ein pa-
ralleler Strom aus einem Array erstellen, z. B.:
IntStream paris = Arrays.stream(new int[] {1, 4, 14, 39}).parallel();
Bei einer kleinen Serie von Werten ist die in allen Stream-Schnittstellen vorhandene statische Fab-
rikmethode of(), die einen Serienparameter besitzt, bequem einzusetzen, z. B.:
IntStream is = IntStream.of(1, 4, 14, 39);
Die Methode sum() liefert bei Strömen vom Typ IntStream, LongStream oder DoubleStream die
Summe der Elemente (siehe Abschnitt 12.2.5.4.3 zu weiteren Methoden für Ströme mit primitiven
Elementen).
Die statische Methode lines() der Klasse Files im Paket java.nio.file liefert ein Objekt der Klasse
Stream<String>, das die Verarbeitung der Zeilen in einer Textdatei erleichtert. Im folgenden Pro-
gramm werden mit der Stromoperation count() (siehe Abschnitt 12.2.5.4.3) die Zeilen in der Datei
gezählt:
Quellcode Ausgabe
import java.io.IOException; Anzahl der Zeilen: 5
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.stream.Stream;
class Prog {
public static void main(String[] args) {
try (Stream<String> sol = Files.lines(Paths.get("test.txt"))) {
System.out.println("Anzahl der Zeilen: " + sol.count());
} catch (IOException e) {
e.printStackTrace();
}
}
}
12.2.5 Stromoperationen
Java bietet seit der Version 8 viele aus funktionalen Programmiersprachen (z. B. Haskell, Clojure,
Scala) bekannte Operationen zur Listenbearbeitung. Die beteiligten Schnittstellen Stream<T>,
IntStream, LongStream und DoubleStream im Paket java.util.stream enthalten ähnliche, aber in
Details abweichende Methoden bzw. Operationen (siehe API-Dokumentation). Die wesentliche
Funktionserweiterung für die Software-Entwicklung mit Java besteht darin, dass datenbankartige
Operationen (z. B. Filtern, Gruppieren, Auswerten) mit Kollektionsobjekten möglich werden, wobei
die Listenbearbeitung in Vordergrund steht (Urma 2014).
in der Pipeline ab. Dank dieser als lazy (dt.: faul) bezeichneten Arbeitsweise sind Optimie-
rungen möglich (siehe Abschnitt 12.2.5.2).
• Terminale Operationen
Terminale Operationen liefern ein Ergebnis, das kein Strom ist (z. B. eine Zahl oder eine
Liste). Wichtige Beispiele sind:
o reduce()
Die Elemente im Strom werden durch iterative Anwendung einer binären Operation
auf einen Wert reduziert (z. B. auf eine Zahl). So kann man z. B. aus einem Strom
mit den natürlichen Zahlen von 1 bis k durch iterative Multiplikation die Fakultät
von k berechnen (siehe Abschnitt 12.2.5.4.2).
o average()
Für einen Strom mit Elementen vom Typ int, long oder double erhält man den
Durchschnittswert (siehe Abschnitt 12.2.5.4.3).
o collect()
Aus dem Strom kann man z. B. eine Liste oder eine Abbildung erstellen (siehe Ab-
schnitt 12.2.5.4.4).
Nach der Ausführung einer terminalen Operation sind die Stromobjekte in der Pipeline ver-
braucht und können keine weiteren Operationen mehr ausführen. Um aus der Quelle ein
weiteres Ergebnis zu ermitteln, muss eine neue Pipeline aufgebaut werden.
Bei den intermediären Operationen unterscheidet man:
• Zustandslose Operationen
Jedes Element kann unabhängig von allen anderen verarbeitet werden (Beispiele: filter(),
map()). Sind in einer Pipeline alle intermediären Operationen zustandslos, ist (bei serieller
oder paralleler) Verarbeitung nur ein Durchlauf erforderlich.
• Zustandsbehaftete Operationen
Bei der Verarbeitung eines Elementes muss eventuell der Zustand von früher verarbeiteten
Elementen berücksichtigt werden (Beispiele: distinct(), sorted()). Enthält eine Pipeline zu-
standsbehaftete intermediäre Operationen, sind bei paralleler Verarbeitung eventuell mehre-
re Durchläufe oder eine Speicherung von Zwischenergebnissen erforderlich.
Quellcode Ausgabe
import java.util.List; Filtern von Rudolf
import java.util.stream.Collectors; Abbilden von Rudolf
Filtern von Emma
class LacyOp { Filtern von Otto
Filtern von Agnes
public static void main(String[] args) { Abbilden von Agnes
List<String> als = List.of("Rudolf","Emma","Otto",
"Agnes","Kurt","Walter"); [RUDOLF, AGNES]
List<String> f2ge5 = als.stream()
.filter(s -> {
System.out.println("Filtern von " + s);
return s.length() >= 5;})
.map(s -> {
System.out.println(" Abbilden von " + s);
return s.toUpperCase();})
.limit(2)
.collect(Collectors.toList());
System.out.println("\n" + f2ge5);
}
}
Quellcode Ausgabe
import java.util.List; 3
class Filter {
public static void main(String[] args) {
List<String> als = List.of("Rudolf","Emma","Otto",
"Agnes","Kurt","Walter");
long n4 = als.stream()
.filter(s -> s.length() == 4)
.count();
System.out.println(n4);
}
}
Das benötigte Objekt vom Typ Predicate<? super String> wird per Lambda-Ausdruck realisiert:
s -> s.length() == 4
Die Strom-Klassen für primitive Elementtypen (IntStream, LongStream und DoubleStream) be-
sitzen Filteroperationen mit einem angepassten Parametertyp, z. B.:
public IntStream filter(IntPredicate predicate)
Eine weitere intermediäre Stromoperationen, die Elemente des Eingabestroms aufgrund eines über-
standenen Tests in den Ausgabestrom befördert, ist distinct(). Man erhält einen Ausgabestrom ohne
Dubletten. Im folgenden Programm wird zur Ausgabe des distinct() -Produktion die terminale Ope-
ration forEach() verwendet (siehe Abschnitt 12.2.5.4.1), die ihren IntConsumer-Parameter per
Methodenreferenz erhält (siehe Abschnitt 12.1.3.1):
Quellcode Ausgabe
import java.util.stream.IntStream; 1
2
class Filter { 3
public static void main(String[] args) { 4
5
IntStream.of(1, 1, 2, 3, 3, 4, 5, 5)
.distinct()
.forEach(System.out::println);
}
}
12.2.5.3.2 Eine Startsequenz zulassen oder ausschließen (limit, skip, takeWhile, dropWhile)
Im aktuellen Abschnitt werden intermediäre Stromoperationen beschrieben, die eine sinnvolle An-
ordnung der Elemente voraussetzen, die z. B. nach der Erstellung eines Stroms aus einer Menge
nicht besteht.
Durch die intermediären Stromoperationen limit() bzw. skip() werden Elemente des Eingabestroms
ab einer Position oder bzw. bis zu einer Position ausgeschlossen:
• limit(long n)
Man erhält einen neuen Strom mit den ersten n Elementen des alten Stroms.
• skip(long n)
Im neuen Strom fehlen die ersten n Elemente des alten Stroms.
Wenn bei einem Eingangsstrom die Zulässigkeitsprüfung beendet werden soll, sobald erstmals über
ein Element negativ entschieden wird, dann bietet sich die in Java 9 eingeführte zustandsbehaftete
intermediäre Operation takeWhile() an. Der Ergebnisstrom enthält die Startsequenz aus dem Ein-
596 Kapitel 12 Funktionales Programmieren
gangsstrom bis zum letzten positiv beurteilten Element, wobei auch ein leerer Ergebnisstrom ent-
stehen kann.
Die default-Methode takeWhile() ist sowohl in der generischen Schnittstelle Stream<T> als auch
in den Schnittstellen IntStream, LongStream und DoubleStream für Elemente vom primitiven
Typ int, long bzw. double vorhanden, z. B.:
default Stream<T> takeWhile(Predicate<? super T> predicate)
Im folgenden Beispiel enthält der Eingabestrom eine Serie von ganzen Zahlen. Die Weiterleitung
der Eingabewerte in den Ergebnisstrom stoppt, sobald erstmals ein Wert über 50 auftritt:
Quellcode Ausgabe
import java.util.stream.IntStream; Bis zur ersten
Beobachtung > 50
class TakeWhile { 48
public static void main(String[] args) { 8
IntStream instr = IntStream.of(48, 8, 35, 52, 82, 24); 35
IntStream whileLE50 = instr.takeWhile(i -> i <= 50);
System.out.println("\nBis zur ersten"+"\nBeobachtung > 50");
whileLE50.forEach(System.out::println);
}
}
Es ist zu beachten, dass der Wert 24 nicht in den Ergebnisstrom gelangt, obwohl er das Take-
Kriterium erfüllt, weil nach dem ersten negativen Prüfungsergebnis (52 > 50) keine weitere Prüfung
mehr stattfindet.
Sollen aus einem Eingangsstrom alle Elemente von der Weiterleitung in den Ergebnisstrom ausge-
schlossen werden, bis erstmals ein Wert mit einem positiven Prüfungsergebnis auftritt, dann bietet
sich (als Gegenstück zu takeWhile()) die ebenfalls in Java 9 eingeführte zustandsbehaftete inter-
mediäre Operation dropWhile() an. Der Ergebnisstrom enthält die Startsequenz aus dem Eingangs-
strom ab dem ersten positiv beurteilten Element, wobei auch ein leerer Ergebnisstrom entstehen
kann.
Die default-Methode dropWhile() ist sowohl in der generischen Schnittstelle Stream<T> als auch
in den Schnittstellen IntStream, LongStream und DoubleStream für Elemente vom primitiven
Typ int, long bzw. double vorhanden, z. B.:
default Stream<T> dropWhile (Predicate<? super T> predicate)
Im folgenden Beispiel enthält der Eingabestrom die schon im letzten Beispiel verwendete Serie von
ganzen Zahlen. Diesmal startet die Weiterleitung der Eingabewerte in den Ergebnisstrom, sobald
erstmals ein Wert über 50 auftritt:
Quellcode Ausgabe
import java.util.stream.IntStream; Ab der ersten
Beobachtung > 50
class DropWhile { 52
public static void main(String[] args) { 82
IntStream instr = IntStream.of(48, 8, 35, 52, 82, 24); 24
IntStream droppedLE50 = instr.dropWhile(i -> i <= 50);
System.out.println("\nAb der ersten"+"\nBeobachtung > 50");
droppedLE50.forEach(System.out::println);
}
}
Abschnitt 12.2 Ströme 597
Es ist zu beachten, dass der Wert 24 in den Ergebnisstrom gelangt, obwohl er das Drop-Kriterium
erfüllt, weil nach dem ersten positiven Prüfungsergebnis (52 > 50) keine weitere Kontrolle mehr
stattfindet.
class Mapping {
public static void main(String[] args) {
Stream.of("Rudolf", "Emma", "Otto", "Agnes", "Kurt")
.map(String::length)
.forEach(i -> System.out.print(i + " "));
}
}
Neben der generischen Methode map() für Ergebnisströme mit Objekten als Elementen existieren
im Interface Stream<T> noch Methoden für Ergebnisströme mit primitivem Elementtyp. Wenn für
die Namensliste im letzten Beispiel die Gesamtzahl der Buchstaben interessiert, bietet es sich an,
mit der Operation mapToInt() ein IntStream-Objekt zu erstellen. Mit den Elementen eines solchen
Stroms sind arithmetische Operationen wie die Addition ohne (Un-)boxing möglich, was der Per-
formanz zu Gute kommt. Außerdem existieren in den Schnittstellen für Ströme mit einem primiti-
ven Elementtyp einige Operationen, die Stromstatistiken mit einem einfachen Aufruf liefern (vgl.
Abschnitt 12.2.5.4.3). So kann man z. B. die Summe der int-Elemente von der Methode sum() er-
mitteln lassen, was im folgenden Beispiel geschieht:
598 Kapitel 12 Funktionales Programmieren
Quellcode Ausgabe
import java.util.stream.Stream; Summe: 23
class Mapping {
public static void main(String[] args) {
int n = Stream.of("Rudolf", "Emma", "Otto", "Agnes", "Kurt")
.mapToInt(String::length)
.sum();
System.out.println("Summe: " + n);
}
}
In den Schnittstellen für Ströme mit primitivem Elementtyp (z. B. IntStream) befinden sich:
• Die Methode map() für einen Ergebnisstrom mit demselben Elementtyp
• Methoden für Ergebnisströme mit einem anderen primitiven Elementtyp (z. B.
mapToDouble())
• Die Methode mapToObj() für einen Ergebnisstrom mit Objekten als Elementen
Die Schnittstellen IntStream, LongStream und DoubleStream für primitive Elementtypen enthal-
ten jeweils eine analoge arbeitende sorted() - Methode.
Das Interface Stream<T> enthält zusätzlich eine sorted() - Überladung mit einem Parameter vom
Typ Comparator<? super T>, sodass sich ein alternatives Sortierkriterium realisieren lässt:
public Stream<T> sorted(Comparator<? super T>)
Im folgenden Beispiel entsteht aus einem Strom mit Elementen vom Typ String ein absteigend
sortierter Strom mit denselben Elementen, wobei der Comparator per Lambda-Ausdruck realisiert
wird:
Abschnitt 12.2 Ströme 599
Quellcode Ausgabe
import java.util.stream.Stream; Rudolf
Otto
class Sorted { Kurt
public static void main(String[] args) { Emma
Stream.of("Rudolf", "Emma", "Otto", "Agnes", "Kurt") Agnes
.sorted((s1, s2) -> s2.compareTo(s1))
.forEach(System.out::println);
}
}
Eine terminale Operation produziert entweder ein Ergebnis oder einen Nebeneffekt, wobei
forEach() ein Nebeneffekt-Produzent ist. Man sollte Stromoperationen mit Nebeneffekten mög-
lichst vermeiden und insbesondere die Operation forEach() ausschließlich zu Reportzwecken ein-
setzen (Bloch 2018, S. 210ff).
Bei einem parallelen Strom ist nicht garantiert, dass die Consumer-Aktion der Reihe nach auf die
Stromelemente angewendet wird. Um diese sicherzustellen, verwendet man statt forEach() die Me-
thode forEachOrdered().
Mit der zur Fehlersuche konzipierten Stromoperation peek() lassen sich ebenfalls elementweise
Nebeneffekte produzieren, wobei jedoch die Pipeline nicht terminiert wird (siehe Abschnitt
12.2.5.3.5).
12.2.5.4.2 Reduktion eines Stroms auf einen Wert durch eine assoziative Funktion (reduce)
Über die Strommethode reduce() lässt sich eine beliebige assoziative Funktion von zwei Variablen
zum Reduzieren eines Stroms verwenden. Die Funktion wird so lange iterativ auf jeweils zwei be-
nachbarte Elemente angewendet, bis schließlich ein einzelner Wert resultiert.
Eine binäre Funktion f
f: (e1, e2) → c
ist genau dann assoziativ, wenn für beliebige Argumente e1, e2 und e3 gilt:
f(f(e1, e2), e3) = f(e1, f(e2, e3)
Es spielt also keine Rolle, ob die Funktion zuerst auf e1 und e2 oder zuerst auf e2 und e3 angewendet
wird. Folglich kann die Anwendung der Methode reduce() auf den gesamten Strom parallelisiert
werden, d .h. es ist eine parallele Ausführung durch mehrere Threads möglich. Um die Zusammen-
fassung der Teilergebnisse kümmert sich die Standardbibliothek.
Von reduce() wird die Funktion f zunächst auf die beiden ersten Stromelemente angewendet und
dann iterativ auf das aktuelle Zwischenergebnis ci und das aktuelle Stromelement ej. Bei einem
Strom mit den vier Elementen e1 bis e4 resultiert die folgende Verarbeitungskette:
Abschnitt 12.2 Ströme 601
e1 e2
c1 e3
c2 e4
c3
Wegen der iterativen Arbeitsweise wird eine Reduktion auch als Faltung (engl.: folding) bezeich-
net. Man kann sich vorstellen, dass bei einem langen Papierstreifen mit vielen Segmenten so lange
das jeweils erste Segment Richtung Ende gefaltet wird, bis nur noch ein (ziemlich dickes) Segment
übrig ist (siehe Urma 2014).
Von der anzuwendenden Funktion erfährt die Methode reduce() über einen Parameter vom Typ
einer funktionalen Schnittstelle. Die Methode reduce() der generischen Schnittstelle Stream<T>
erwartet einen Parameter vom Typ BinaryOperator<T>:
public Optional<T> reduce(BinaryOperator<T> op)
Man erhält von reduce() als Rückgabe ein Objekt vom Typ Optional<T>. Dieses Objekt enthält
nach einer erfolgreichen Stromreduktion das Ergebnis und liefert es nach Aufforderung per get() ab.
Ob ein Wert vorhanden ist, erfährt man über die boolesche Rückgabe der Methode isPresent().
Das als reduce() - Parameter erwartete Funktionsobjekt vom Typ BinaryOperator<T> muss die
folgende Methode beherrschen:
public T apply(T first, T second)
In der Schnittstelle IntStream ist eine analoge reduce() - Methode
public OptionalInt reduce(IntBinaryOperator op)
vorgeschrieben, die als Parameter ein Funktionsobjekt vom Typ IntBinaryOperator mit der fol-
genden Methode
public int applyAsInt(int left, int right)
erwartet und als Rückgabe ein Objekt vom Typ OptionalInt liefert. Dieses Objekt enthält nach
einer erfolgreichen Stromreduktion das Ergebnis und liefert es nach Aufforderung per getAsInt()
ab. Ob ein Wert vorhanden ist, erfährt man über die boolesche Rückgabe der Methode isPresent().
Im folgenden Beispiel wird ein Strom vom Typ IntStream mit Elementen vom primitiven Typ int
durch die statische IntStream-Methode range() erzeugt, die einen inklusiven Startwert und einen
exklusiven Endwert vom Typ int erwartet und die zugehörige Sequenz von ganzen Zahlen produ-
ziert:
Quellcode Ausgabe
import java.util.OptionalInt; 14
import java.util.stream.IntStream;
class Reduce {
public static void main(String[] args) {
OptionalInt sq = IntStream.range(1,4)
.map(i -> i*i)
.reduce((c,i) -> c+i);
System.out.println((sq.isPresent() ? sq.getAsInt() : "Fehler"));
}
}
Per map() - Operation mit dem Lambda-Ausdruck (i -> i*i) als IntUnaryOperator resultiert
ein neuer Strom mit den quadrierten ganzen Zahlen.
602 Kapitel 12 Funktionales Programmieren
Weil wir mit einem IntStream arbeiten, erwartet reduce() in der Rolle der assoziativen Funktion
einen IntBinaryOperator, und wir liefern den Lambda-Ausdruck:1
(c,x) -> c+x
Als Ergebnis erhalten wir die Summe der quadrierten ganzen Zahlen von 1 bis 3.
Wir betrachten noch eine zweite reduce() - Überladung, die im ersten Parameter das neutrale Ele-
ment z der assoziativen Funktion im folgenden Sinn
f(z, a) = a
erwartet, sodass für den Typ Stream<T> der folgende Methodendefinitionskopf resultiert:
public T reduce(T identity, BinaryOperator<T> op)
Diesmal erhalten wir eine Rückgabe vom Typ T, die bei einem leeren Strom mit dem ersten re-
duce() - Parameter identisch ist, sodass auf jeden Fall eine Rückgabe vom Typ T resultiert, und der
Aufwand mit einer Optional<T> - Rückgabe entfällt.
Für eine IntStream-Implementation sieht der Definitionskopf der reduce() - Überladung mit einem
Parameter für das neutrale Element so aus:
public int reduce(int identity, IntBinaryOperator op)
Im nächsten Beispiel wird die Fakultät einer natürlichen Zahl mit Stromoperationen berechnet. Um
dies für große Argumente zu ermöglichen, kommt der Datentyp BigDecimal zum Einsatz. Zunächst
entsteht ein Strom vom Typ IntStream mit Elementen vom primitiven Typ int mit Hilfe der stati-
schen IntStream-Methode rangeClosed(), die eine Sequenz ganzer Zahlen von einem Start- bis zu
einem Endwert (beide inklusive) produziert. Mit der generischen IntStream-Methode mapToObj()
wird der IntStream in einen Stream<BigDecimal> gewandelt. Ein Aufruf der entsprechend para-
metrisierten Methode kann folgendermaßen aussehen (mit expliziter Konkretisierung des Typfor-
malparameters, vgl. Abschnitt 8.2):
Stream<BigDecimal> sbd = IntStream.rangeClosed(1, 500)
.<BigDecimal>mapToObj(new IntFunction<BigDecimal>() {
public BigDecimal apply(int i) {
return new BigDecimal(i);
}
});
Als Aktualparameter dient ein Objekt einer anonymen Klasse, welche das funktionale Interface
IntFunction<BigDecimal> erfüllt. Dazu implementiert die Klasse eine Methode namens apply()
mit einem int-Parameter und einer Rückgabe vom Typ BigDecimal, die von einem Konstruktor der
Klasse BigDecimal produziert wird. Per Konstruktorreferenz (vgl. Abschnitt 12.1.3.2) lässt sich der
Aktualparameterausdruck drastisch vereinfachen, wobei dank Typinferenz auch auf die explizite
Typkonkretisierung im Methodennamen verzichtet werden kann:
Stream<BigDecimal> sbd = IntStream.rangeClosed(1, 500).mapToObj(BigDecimal::new);
Nach der Stromkonstruktion und -transformation kommt es zum Reduktionsschritt unter Verwen-
dung der reduce() - Überladung mit einem neutralen Element im ersten und einer assoziativen
Funktion im zweiten Parameter. Die Eins als neutrales Element der Multiplikation kann in der Klas-
se BigDecimal so notiert werden:
BigDecimal.ONE
1
Weil wir gerade mit einem IntStream arbeiten und lediglich eine Summenbildung benötigen, steht als deutlich
bequemere Alternative zur reduce() - Operation mit IntBinaryOperator-Parameter die spezielle Stromoperation
sum() zur Verfügung (siehe Abschnitt 12.2.5.4.3). Wir verwenden trotzdem die umständlichere Lösung, um mit ei-
nem besonders einfachen IntBinaryOperator arbeiten zu können (Addition).
Abschnitt 12.2 Ströme 603
Zur Multiplikation von zwei BigDecimal-Objekten beauftragt man den ersten Faktor mit der Me-
thode multiply() und übergibt per Parameter den zweiten Faktor:
public BigDecimal multiply(BigDecimal multiplicand)
Auf die recht lange Beschreibung folgt ein angenehm kurzes Programm, das den Binary-
Operator<BigDecimal> (die assoziative Funktion) per Methodenreferenz vereinbart (vgl. Ab-
schnitt 12.1.3.1):
Quellcode Ausgabe
import java.math.BigDecimal; 1,220137e+1134
import java.util.stream.IntStream;
class Reduce {
public static void main(String[] args) {
final int arg = 500;
BigDecimal fak = IntStream
.rangeClosed(1, arg)
.mapToObj(BigDecimal::new)
.reduce(BigDecimal.ONE, BigDecimal::multiply);
System.out.printf("%e", fak);
}
}
Mit dem aktuellen Beispiel lässt sich demonstrieren, wie leicht die serielle Strombearbeitung auf
eine parallele, mehrere Prozessorkerne nutzende Strombearbeitung umgestellt werden kann. Dazu
ist lediglich mit der IntStream-Methode parallel() aus dem seriellen Strom ein paralleler Strom zu
erstellen. Wir erweitern das letzte Beispielprogramm außerdem um eine Zeitmessung und erhalten:
Quellcode Ausgabe
import java.math.BigDecimal; Fakultät von 50000:
import java.util.stream.IntStream; 3,347321e+213236
Zeit in Millisek.: 438
class Prog {
public static void main(String[] args) {
long start = System.currentTimeMillis();
final int arg = 50_000;
BigDecimal fak = IntStream.rangeClosed(1, arg)
.parallel()
.mapToObj(BigDecimal::new)
.reduce(BigDecimal.ONE, BigDecimal::multiply);
System.out.printf("\nFakultät von %d:\n %e\nZeit in Millisek.: %d",
arg, fak, System.currentTimeMillis() - start);
}
}
Die Berechnung der Fakultät von 500 dauert nach der Parallelisierung länger (23 statt 11 Millise-
kunden). Offenbar wiegt bei dieser Problemgröße der durch Multithreading bedingte organisatori-
sche Zusatzaufwand den Gewinn durch Verwendung mehrerer Kerne mehr als auf. Zur Berechnung
der Fakultät von 50.000 benötigt der parallele Strom mit 438 Millisekunden allerdings deutlich we-
niger Zeit als der serielle, der erst nach 1904 Millisekunden zum Ergebnis kommt. Wir stellen fest:
• Multithreading ist bei der funktionalen Strombearbeitung in Java leicht zu realisieren.
• Die Beispielaufgabe ist gut parallelisierbar. Man darf die Erfahrung aber nicht auf beliebige
Stromverarbeitungsaufgaben übertragen.
• Weil die Erstellung und Koordination von mehreren Threads Zeitaufwand verursacht, ent-
scheidet die Problemgröße darüber, ob ein Nutzen zu erzielen ist. Im Beispiel liegt die Ge-
schwindigkeitssteigerung bei einer zeitaufwändigen Fakultätsberechnung ungefähr in dem
bei vier logischen Prozessoren zu erwartenden Bereich.
604 Kapitel 12 Funktionales Programmieren
Ströme, die einen primitiven Elementtyp besitzen, also das Interface IntStream, LongStream oder
DoubleStream implementieren, beherrschen Operationen zur Berechnung statistischer Kennwerte:
• sum(), average()
Man erhält die Summe bzw. den Mittelwert der Stromelemente, z. B.:
Quellcode-Segment Ausgabe
IntStream isp = IntStream.of(1, 4, 10, 20); 35
System.out.println(isp.sum());
• min(), max()
Diese Methoden liefern das kleinste bzw. größte Element, z. B.:
Quellcode-Segment Ausgabe
IntStream isp = IntStream.of(1, 4, 10, 20); OptionalInt[20]
System.out.println(isp.max());
Auch im Interface Stream<T> für Ströme mit Referenzelementtyp sind Methoden max() und
min() zur Ermittlung des größten bzw. kleinsten Elements vorhanden, wobei ein Parameterobjekt
vom Typ Comparator<? super T> zu übergeben ist.
Quellcode Ausgabe
import java.util.*; Typ von als2b:
import java.util.stream.Collectors; class java.util.ArrayList
import java.util.stream.Stream; Anette
Anton
public class Collect { Ben
public static void main(String[] args) { Berta
List<String> als2b = Stream.of("Charly", "Anton", "Berta", Charly
"Ben", "Anton", "Anette", Tom
"Charly", "Tom")
.distinct()
.sorted()
.collect(Collectors.toList());
System.out.println("Typ von als2b:\n" + als2b.getClass());
for(String s: als2b)
System.out.println(s);
}
}
Im nächsten Beispiel entsteht mit Hilfe der Collectors-Methode groupingBy() aus einem Strom
mit Vornamen nach dem Entfernen von Dubletten und dem Sortieren ein Kollektionsobjekt vom
Abbildungs-Typ Map<Character, List<String>>, das eine Gruppierung der Vornamen nach den
Anfangsbuchstaben leistet:
Quellcode Ausgabe
import java.util.*; A [Anette, Anton]
import java.util.stream.Collectors; B [Ben, Berta]
C [Charly, Clara]
public class GroupingBy {
public static void main(String[] args) {
List<String> als = List.of("Charly", "Anton",
"Berta", "Ben", "Clara", "Anton", "Anette", "Charly");
Map<Character, List<String>> map = als.stream()
.distinct()
.sorted()
.collect(Collectors.groupingBy(s -> s.charAt(0)));
for(Character c : map.keySet())
System.out.println(c + " " + map.get(c));
}
}
Man kann hier von einer Abbildung mit (Single Key - Multiple Values) - Struktur sprechen.
Mit dem Gespann aus collect() und Collectors lassen sich Stromelemente nicht nur in Kollektionen
sammeln, sondern auch zu anderen Resultaten verarbeiten (siehe API-Dokumentation). Im folgen-
den Beispiel werden String-Objekte mit der Collectors-Methode joining() unter Verwendung einer
Separatorzeichenfolge zu einer Ergebniszeichenfolge verkettet:
Abschnitt 12.2 Ströme 607
Quellcode Ausgabe
import java.util.*; Charly, Anton, Berta
import java.util.stream.Collectors;
Quellcode Ausgabe
import java.util.stream.Stream; true
class Matching {
public static void main(String[] args) {
boolean test = Stream.of("Rudolf","Emma","Otto","Agnes","Kurt")
.anyMatch(s -> s.length() == 5);
System.out.println(test);
}
}
Das Funktionsobjekt zur elemtbezogenen Bedingungsprüfung hat im Beispiel den Typ der
funktionalen Schnittstelle Predicate<String> und wird per Lambda-Ausdruck realisiert.
class FindAnyFirst {
static Stream<String> crFStream() {
return Stream.of("Rudolf", "Emma", "Otto",
"Agnes", "Kurt")
.filter(s -> s.length() == 4);
}
funktionale Lösung (abgesehen von der Ergebnisübergabe) hingegen keine. Dabei führt die funktio-
nale Lösung keinesfalls zu statischen Verhältnissen im Speicher. Es werden neue Objekte erzeugt
(z. B. vom Typ Stream<String>, IntStream, OptionalDouble), allerdings keine vorhandenen mo-
difiziert.
• Die Methode length() befindet sich im Handlungsrepertoire der Klasse String befindet.
• Der Lambda-Ausdruck liefert den korrekten Rückgabewert boolean.
• Damit lässt sich das benötigte Objekt aus einer passenden anonymen Klasse erstellen und an
filter() übergeben:
new Predicate<String>() {
public boolean test(String s) {
return s.length() >= 5;
}
}
Im Vergleich zur expliziten Verwendung eines Objekts aus einer (anonymen) Klasse besteht die
Neuerung eigentlich nur aus syntaktischer Bequemlichkeit. Trotzdem verwendet man eine neue
Begrifflichkeit, indem man von der Übergabe von Funktionen an Funktionen spricht.
Wenn im Beispiel aus dem Abschnitt 12.3.1.1 die mittlere Länge nicht nur für Vornamen mit der
Mindestlänge 5, sondern für mehrere Mindestlängen interessiert, dann ist es wenig attraktiv, ent-
sprechend viele Predicate<String> - Objekte bzw. Lambda-Ausdrücke zu erstellen. Stattdessen
definiert man eine Methode, die zu einer gewünschten Mindestlänge das passende Predi-
cate<String> - Objekt liefert. In der folgenden Lösung
import java.util.*;
import java.util.OptionalDouble;
import java.util.function.Predicate;
2) Ein Lambda-Ausdruck wird vom Compiler überall dort akzeptiert, wo eine Referenz vom Typ
einer funktionalen Schnittstelle erwartet wird. Er steht also für ein Objekt einer speziellen Klasse,
die im Java-Typsystem direkt oder indirekt von der Urahnklasse Object abstammt. Trotzdem kann
612 Kapitel 12 Funktionales Programmieren
Wir sind daran gewöhnt, dass in einer Object-Referenzvariablen die Adresse eines beliebigen Ob-
jekts abgelegt werden kann. Welche Gründe erzwingen eine Ausnahme bei der Zuweisungskompa-
tibilität?
3) Erstellen Sie ein Programm zur Fakultätsberechnung, das vom Benutzer per JOptionPane-
Standarddialog (vgl. Abschnitt 3.8) ein Argument entgegennimmt. Verwenden Sie den Datentyp
BigDecimal, um praktisch beliebig große Argumente erlauben zu können. Nutzen Sie je nach Prob-
lemgröße (Argument) einen seriell oder parallel arbeitenden Strom vom Typ LongStream. Die
Bedienoberfläche Ihres Programms könnte ungefähr so aussehen:
Geben Sie dem Benutzer unter Verwendung der im Kapitel 11 (über die Ausnahmebehandlung)
erlernten Techniken eine Möglichkeit, eine falsche Eingabe zu korrigieren.
13 GUI-Programmierung mit JavaFX
Mit den Eigenschaften und Vorteilen einer grafischen Benutzeroberfläche (engl.: Graphical User
Interface) sind Sie sicher sehr gut vertraut. Eine GUI-Anwendung präsentiert dem Anwender stan-
dardisierte Bedienelemente zur Datenpräsentation und Benutzerinteraktion, z. B.:
• Texteingabefelder
• Befehlsschalter
• Kontrollkästchen und Optionsfelder
• Schieberegler und Auswahllisten
• Baumansichten (z. B. für Dokumentenstrukturen)
• Tabellen zur Datenpräsentation
• Menüs
• Fortschrittsbalken
• Komponenten zur Präsentation von Bildern und audio-visuellen Medien
Die von einer GUI-Bibliothek (in unserem Fall von JavaFX) zur Verfügung gestellten Bedienele-
mente bezeichnet man oft als Komponenten, controls, Steuerelemente oder widgets.1
Von standardisierten Bedienelementen profitieren Entwickler und Anwender:
• Entwickler können dank fertiger und dabei auch noch flexibel konfigurierbarer Komponen-
ten die Bedienoberfläche einer Anwendung zügig aufbauen. Für eine weitere RAD-
Beschleunigung (Rapid Application Development) sorgen grafische GUI-Designer (z. B. der
Scene Builder zu JavaFX, den wir schon im Abschnitt 4.9 kennengelernt haben).
• Weil die Steuerelemente intuitiv (z. B. per Maus oder Finger) und in verschiedenen Pro-
grammen weitgehend konsistent zu bedienen sind, erleichtern sie dem Anwender den Um-
gang mit Software.
Die in einer leistungsfähigen GUI-Bibliothek wie JavaFX (alias OpenJFX) enthaltenen Standard-
komponenten erlauben durch ihre Vielfalt, ihre Konfigurierbarkeit und durch die Möglichkeiten zur
flexiblen (z. B. hierarchisch strukturierten) Anordnung die Erstellung von individuellen und ergo-
nomischen Bedienoberflächen für sehr viele Anwendungen. Bei manchen Programmen genügen die
Standardkomponenten aber nicht für die spezielle Präsentation und/oder Bearbeitung von zwei-
oder dreidimensionalen Daten (z. B. bei einem Editor für statistische Diagramme), und es wird eine
individuelle Grafikprogammierung erforderlich (siehe z. B. Eck 2021, S. 278ff). Wir beschränken
uns in diesem Kapitel auf einen Einstieg in die Erstellung von Bedienoberflächen mit Hilfe von
Standardkomponenten aus der JavaFX-Bibliothek.
13.1 Einordnung
1
Diese Wortkombination aus window und gadgets steht für ein praktisches Fenstergerät.
614 Kapitel 13 GUI-Programmierung mit JavaFX
oder (seltener) als Quelle von Ereignissen in erheblichem Maße den Ablauf mitbestimmt, indem es
Methoden der GUI-Applikation aufruft, z. B. zum Zeichnen von Fensterinhalten. Ausgelöst werden
die Ereignisse in der Regel vom Benutzer, der mit der Hilfe von Eingabegeräten wie Maus,
Tastatur, Touch Screen etc. praktisch permanent in der Lage ist, unterschiedliche Wünsche zu arti-
kulieren. Ein GUI-Programm präsentiert mehr oder weniger viele Bedienelemente, die dem An-
wender das Auslösen von Ereignissen ermöglichen. Das Programm wartet die meiste Zeit darauf,
auf ein vom Benutzer ausgelöstes Ereignis mit einer vorbereiteten Ereignisbehandlungsmethode zu
reagieren.
Im Vergleich zu einem Konsolenprogramm ist bei einem GUI-Programm die dominante Richtung
im Kontrollfluss zwischen Programm und Laufzeitsystem invertiert. Die Ereignisbehandlungsme-
thoden einer GUI-Anwendung sind Beispiele für sogenannte Call Back - Routinen. Man spricht
auch vom Hollywood-Prinzip, weil in dieser Gegend oft nach der Devise kommuniziert wird:
„Don’t call us. We call you“.
Während sich ein Konsolenprogramm gegenüber dem Anwender autoritär und gegenüber dem
Laufzeitsystem fordernd verhält, präsentiert ein GUI-Programm dem Anwender Service-Angebote
und befolgt die Anweisungen des Laufzeitsystems:
• Eine Konsolenanwendung diktiert den Ablauf und erlaubt dem Benutzer gelegentlich eine
Eingabe. Um seinen Job erledigen zu können, verlangt das Programm Dienstleistungen vom
Laufzeitsystem, z. B.: „Bitte den nächsten Tastendruck übermitteln.“ Das Laufzeitsystem er-
ledigt solche Anforderungen und gibt die Kontrolle dann wieder an die Konsolenanwendung
zurück. Eine Konsolenanwendung benimmt sich so, als wenn sie das einzige Anwendungs-
programm wäre und das Laufzeitsystem als Dienstleister zur Verfügung hätte.
• Eine GUI-Anwendung besteht hingegen aus einer Sammlung von Ereignisbehandlungsme-
thoden, wobei die zugehörigen Ereignisse vom Benutzer ausgelöst werden, indem er eines
der zahlreichen Bedienelemente benutzt. Die Ereignisse werden zunächst vom Laufzeitsys-
tem registriert, das daraufhin Methoden des GUI-Programms aufruft.
Betrachten wir zur Illustration eine Konsolen- und eine GUI-Anwendung zum Addieren von Brü-
chen. Bei der Konsolenanwendung (vgl. Abschnitt 1.1.4)
das Geschehen vom Benutzer diktiert, der die 5 Bedienelemente (vier Eingabefelder und eine
Schaltfläche) in beliebiger Reihenfolge verwenden kann, wobei das Programm mit seinen Ereignis-
behandlungsmethoden reagiert (benutzergesteuerter Ablauf).
Grundsätzlich ist das Erstellen einer GUI-Anwendung mit erheblichem Aufwand verbunden. Aller-
dings enthält das Java-API leistungsfähige Klassen zur GUI-Programmierung, und deren Verwen-
dung wird durch Hilfsmittel der Entwicklungsumgebungen (z. B. Fensterdesigner) sehr gut unter-
stützt.
Wie man mit statischen Methoden der Klasse JOptionPane einfache Swing-Standarddialoge er-
zeugt, um Nachrichten auszugeben oder Informationen abzufragen, wissen Sie schon seit dem Ab-
schnitt 3.8. Allerdings kommen nur wenige GUI-Anwendungen mit diesen elementaren Gestal-
tungs- bzw. Interaktionsmöglichkeiten aus. Wir beschäftigen uns in diesem Kapitel damit, individu-
elle Bedienoberflächen mit Hilfe der JavaFX-Bibliothek zu erstellen.
1
https://openjfx.io/
2
https://gluonhq.com/products/javafx/
3
https://docs.oracle.com/javafx/2/css_tutorial/jfxpub-css_tutorial.htm
Abschnitt 13.2 Einstieg in JavaFX 617
Im Abschnitt 4.9 wurde ein OpenJFX-Programm erstellt, das nur Java 8 voraussetzt und komforta-
bel (für Entwickler und Anwender) zusammen mit der OpenJDK 8 - Distribution aus dem Open
Source - Projekt ojdkbuild genutzt werden kann (siehe Abschnitte 1.2.1 und 2.1.2). Im aktuellen
Kapitel verwenden wir das OpenJFX 17 und überlassen es dem JavaFX-Plugin in unserer Entwick-
lungsumgebung IntelliJ, in Zusammenarbeit mit dem Entwicklungssystem Maven die aktuelle Ver-
sionen der JavaFX-Bibliothek herunterzuladen und in die Abhängigkeitsverwaltung von JavaFX-
Projekten einzubeziehen. Zur Bereitstellung der JavaFX-Bibliothek auf den Rechner eines Pro-
grammanwenders bestehen zwei Optionen (siehe Abschnitt 13.9):
• Es wird eine OpenJFX-SDK-17 - Installation (im Umfang von ca. 80 MB) als Ergänzung
zur OpenJDK-Installation vorausgesetzt (siehe Abschnitt 2.5).
• Die benötigten Bestandteile der OpenJFX-Bibliothek werden zusammen mit dem Programm
ausgeliefert, wobei das Volumen um ca. 9 MB steigt (siehe Abschnitt 13.9).
Um in IntelliJ ein JavaFX 17 - Projekt für das Programm zur Anwesenheitsüberwachung anzulegen,
starten wir mit dem Menübefehl
File > New > Project
618 Kapitel 13 GUI-Programmierung mit JavaFX
für ...
• ein JavaFX - Projekt
• mit dem Namen Attendance (dt.: Anwesenheit),
• und dem Projektordner U:\Eigene Dateien\Java\BspUeb\JavaFX\Attendance.
• Die Programmiersprache Java, das Erstellungssystem Maven und das Test framework
JUnit werden beibehalten.
• Damit das Paket zum Projekt Attendance den Namen
de.uni_trier.zimk.attendance erhält, ist in das Feld Group einzutragen:
de.uni_trier.zimk.
• In das Textfeld Artifact tragen wird den Namen Attendance ein, den die jar-Datei mit
dem auslieferungsbereiten Programm erhalten wird.
• Als Project SDK wählen wir das OpenJDK 17.
Auf die angebotenen Ergänzungen zur Verbesserung des OpenJFX-Funktionsumfangs bzw. zur
Aufwertung der Optik verzichten wir:
Abschnitt 13.2 Einstieg in JavaFX 619
IntelliJ legt für das entstehende Programm eine Hauptklasse mit dem Namen Main an, die von der
API-Klasse Application abstammt:
package de.uni_trier.de.attendance;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.stage.Stage;
import java.io.IOException;
erscheint ein Menü mit der Option zum Starten des Programms:
Das von IntelliJ erstellte Programm ist schon startfähig, zeigt aber bisher nur eine leere Szenerie:
620 Kapitel 13 GUI-Programmierung mit JavaFX
Im Zusammenhang mit dem ersten Start ist eine Ausführungskonfiguration mit den Namen der
Startklasse angelegt worden:
Wie das Project-Fenster zeigt, hat das Erstellungssystem Maven die benötigten Bestandteile der
JavaFX-Bibliothek bereitgestellt:
Maven legt die jar-Dateien mit den Java-Modulen der OpenJFX-Bibliothek unter Windows im Un-
terordner .m2 des Benutzerprofils ab, z. B.:
Abschnitt 13.2 Einstieg in JavaFX 621
Man kann das Verhalten des Erstellungssystems Maven über die im Projektordner vorhandene Da-
tei pom.xml konfigurieren, was aber in unserem Fall nicht erforderlich ist. Detaillierte Informatio-
nen zu Maven sind auf der Homepage des Open Source - Projekts
https://maven.apache.org/
und in zahlreichen Publikationen zu finden.
herausfindet, befindet sich die main() - Startmethode (sensu Abschnitt 1.1.4) zu einer JavaFX-
Anwendung in der Klasse LauncherHelper.FXHelper:
622 Kapitel 13 GUI-Programmierung mit JavaFX
Das JavaFX-Framework sorgt im Fall unserer Anwendung für den Aufruf der Attendance-
Methode main(). Fehlt diese Methode, dann sorgt das JavaFX-Framework für den Aufruf der Ap-
plication-Methode launch().
Im Unterschied zur main() - Methode der Anwendungsklasse ist deren start() - Methode unver-
zichtbar, weil hier die Bühne vorbereitet und schließlich der Vorhang gezogen wird:
@Override
public void start(Stage stage) throws IOException {
FXMLLoader fxmlLoader = new FXMLLoader(
Attendance.class.getResource("hello-view.fxml"));
Scene scene = new Scene(fxmlLoader.load(), 320, 240);
stage.setTitle("Hello!");
stage.setScene(scene);
stage.show();
}
Die vom IntelliJ-Assistenten erstellte start() - Implementation lädt die GUI-Deklaration
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.control.Button?>
<VBox alignment="CENTER" spacing="20.0" xmlns:fx="http://javafx.com/fxml"
fx:controller="de.uni_trier.de.attendance.attendance.HelloController">
<padding>
<Insets bottom="20.0" left="20.0" right="20.0" top="20.0"/>
</padding>
<Label fx:id="welcomeText"/>
<Button text="Hello!" onAction="#onHelloButtonClick"/>
</VBox>
mit Hilfe der Methode load() der Klasse FXMLLoader aus der Datei hello-view.fxml.
In der FXML-Datei wird ein VBox-Objekt als Wurzel-Container für das Anwendungsfenster ver-
wendet, und die Methode load() liefert als Rückgabe eine Referenz auf dieses Objekt mit dem Typ
Parent, von dem (indirekt) die JavaFX - Layout-Container abstammen (siehe Abschnitt 13.2.3).
Unter Verwendung des Parent-Objekts wird im Beispielprogramm ein Scene-Objekt in einer ge-
wünschten Größe erstellt:
Scene scene = new Scene(fxmlLoader.load(), 320, 240);
Abschnitt 13.2 Einstieg in JavaFX 623
Das der Methode start() per Parameter bekannte Objekt vom Typ Stage (dt.: Bühne) erhält den
Auftrag, diese Szene auf die Bühne zu bringen
stage.setScene(scene);
und sichtbar zu machen:
stage.show();
Außerdem ist das Stage-Objekt für den Fenstertitel verantwortlich, den wir im Beispielprogramm
anpassen:
stage.setTitle("Anwesenheitskontrolle");
Für Initialisierungsarbeiten, die vor dem eigentlichen Anwendungsstart stattfinden sollen, eignet
sich die Application-Methode init(), die ggf. nach dem Laden und Instanziieren der Anwendungs-
klasse ausgeführt wird:
public void init() throws Exception
Weil die Methode noch nicht im JavaFX-Anwendungs-Thread läuft, kann hier noch kein Stage-
oder Scene-Objekt erstellt werden. Nach Rückkehr von init() wird start() aufgerufen.
Im Vergleich zur älteren Swing - GUI-Technik ist anzumerken, dass beim Starten keine Vorsichts-
maßnahmen auf Seiten des Anwendungsprogrammierers erforderlich sind, um Multithreading-
Probleme zu verhindern.
Eine JavaFX-Anwendung endet, wenn ...
• alle Fenster (Bühnen) geschlossen werden
Während in der älteren GUI-Technik Swing durch spezielle Vorkehrungen (z. B. durch ei-
nen WindowListener) verhindert werden muss, dass nach dem Schließen aller Fenster im
Hintergrund ein unsichtbares Programm weiterläuft, endet eine JavaFX-Anwendung per
Voreinstellung nach dem Schließen ihrer Fenster.1
• die statischen Methode exit() der Klasse Platform aufgerufen wird:
Platform.exit();
Soll ein Programm in dieser Situation noch tätig werden, bietet die ggf. automatisch aufgerufene
Application-Methode stop() dazu Gelegenheit. Im folgenden Beispiel
@Override
public void stop() {
Alert alert = new Alert(Alert.AlertType.INFORMATION,
"Vielen Dank für die Verwendung dieser Software!");
alert.setHeaderText("Bis bald!");
alert.showAndWait();
}
wird noch ein JavaFX-Standarddialog angezeigt:
1
Allerdings kann in JavaFX mit der folgenden Anweisung
Platform.setImplicitExit(false);
in der start() - Methode das automatische Programmende nach dem Schließen der Fenster verhindert werden. Dann
muss man analog zu Swing für das Programmende sorgen.
624 Kapitel 13 GUI-Programmierung mit JavaFX
1
https://openjfx.io/javadoc/17/javafx.graphics/javafx/application/Application.html
Abschnitt 13.3 Anwendung mit All-In-One - Architektur 625
java.lang.Object
javafx.scene.Node
javafx.scene.shape.Shape javafx.scene.Parent
javafx.scene.layout.Region
javafx.scene.control.Control javafx.scene.layout.Pane
Die Spezialisierungen der Klasse javafx.scene.layout.Pane sind nicht nur aufnahmefähig für Kin-
delemente, sondern auch mit Kompetenzen zum Layout-Management ausgestattet. Sie sind daher
als Wurzelknoten des Szenengraphen besonders geeignet. Beim Erstellen einer Szene wird dem
Scene-Konstruktor ein Wurzelknoten bekanntgegeben, z. B.:
Scene scene = new Scene(fxmlLoader.load(), 320, 240);
Auch die von javafx.scene.control.Control abstammenden normalen Steuerelemente (z. B. Text-
Field, Button) können untergeordnete Knoten aufnehmen. Die von javafc.scene.shape.Shape ab-
stammenden Grafikelemente (z. B. Circle, Line) sind hingegen nur als Endknoten erlaubt.
In einem JavaFX-Szenegraphen können also drei Sorten von Knoten auftreten:
Wurzelknoten
Die folgende Abbildung sind die Abstammungsverhältnisse der Klassen Stage und Scene:
java.lang.Object
javafx.scene.Scene javafx.stage.Window
javafx.stage.Stage
Im Beispielprogramm ist als Wurzelknoten des Szenengraphen statt der von IntelliJ (in der Datei
hello-view.fxml) gewählten Klasse VBox die Alternative GridPane zu bevorzugen. Wir werden im
aktuellen Abschnitt das Layout durch Anweisungen definieren, um einen Einblick in die JavaFX-
Technik zu erhalten. Daher ignorieren wir die Datei hello-view.fxml und machen in der Methode
start() einen Layout-Neustart, wobei GridPane als Klasse des Wurzelknotens verwendet wird:
public void start(Stage stage) {
GridPane root = new GridPane();
Scene scene = new Scene(root, 450, 200);
stage.setScene(scene);
stage.setTitle("Anwesenheitskontrolle");
stage.show();
}
Weil in der neuen start() - Methode kein Zugriff auf eine FXML-Datei stattfindet, kann keine
IOException auftreten, sodass wird die vom IntelliJ-Assistenten erstellte throws-Klausel entfer-
nen.
Mit der Region-Methode setPadding() wird ein freizuhaltender Innenrahmen für den Container
vereinbart, und über die GridPane-Methoden setHgap() bzw. setVgap() ein horizontaler bzw. ver-
tikaler Abstand zwischen den Kindelementen festgelegt (siehe Abschnitt 13.7.1.2):
double dist = 10.0;
root.setPadding(new Insets(dist, dist, dist, dist));
root.setHgap(dist); root.setVgap(dist);
Zur Anzeige von Beschriftungen erstellen wir zwei Komponenten aus der Klasse Label im Paket
javafx.scene.control:
Label lblName = new Label("Name:");
Label lblPresent = new Label("Anwesend:");
Zur Aufnahme der Namen von Neuankömmlingen ist ein Objekt der Klasse TextField aus dem
Paket javafx.scene.control zuständig:
TextField tfName = new TextField();
Über zwei Befehlsschalter aus der Klasse Button sollen Personen in die Anwesenheitsliste aufge-
nommen bzw. aus dieser Liste entfernt werden:
Button btnAdd = new Button("Angekommen");
Button btnRemove = new Button("Gegangen");
Nun kommen wir zum „technischen Glanzstück“ der verhältnismäßig simplen Anwendung, einem
Objekt der Klasse ListView<String>, das die Elemente eines Objekts vom Typ Observable-
List<String> sortiert anzeigt und dynamisch auf Änderungen in der Zusammensetzung der be-
obachtbaren Liste reagiert:
String[] anwesend = new String[] {"Willi", "Otto", "Theo", "Irma", "Doro",
"Heiner", "Michael", "Ludger", "Ben"};
ObservableList<String> persons = FXCollections.observableArrayList(anwesend);
SortedList<String> perSorted = new SortedList<>(persons,
Comparator.naturalOrder());
ListView<String> lvPersons = new ListView<>(perSorted);
Mit beobachtbaren Kollektionen, die bei einer Änderung ihrer Zusammensetzung eine Mitteilung an
registrierte Beobachter versenden, werden wir uns später noch beschäftigen (siehe Abschnitt
13.5.3.3). Im Beispiel wird die generische Fabrikmethode observableArrayList() der Klasse
FXCollections dazu verwendet, ein Objekt aus einer Klasse zu erzeugen, die das Interface
ObservableList<String> implementiert und einen Array zur Aufbewahrung ihrer Elemente ver-
Abschnitt 13.3 Anwendung mit All-In-One - Architektur 627
wendet. Auf die Angabe des Elementtyps String kann beim Aufruf der generischen Methode dank
Typinferenz verzichtet werden.
Für die Sortierung sorgt eine Verpackung der ObservableList<String> durch ein Objekt der Klas-
se SortedList<String>, die ebenfalls das Interface ObservableList<String> implementiert. Dem
Konstruktor muss über seinen zweiten Parameter ein Comparator<? super String> zur Verfügung
gestellt werden. Wir lassen von der statischen Methode naturalOrder() der Schnittstelle Compar-
ator<T> ein Objekt der Klasse Comparator<String> erstellen. Das resultierende Objekt der Klas-
se SortedList<String> ...
• ist eine beobachtbare Kollektion
• und hält die Elemente im sortierten Zustand.
Dem ListView<String> - Steuerelement wird dieses Objekt per Konstruktorparameter bekanntge-
geben, sodass sich das Steuerelement dort als Beobachter registrieren kann. Im Ergebnis erhalten
wir ein Steuerelement, das automatisch die aktuellen Listenelemente im sortierten Zustand anzeigt.
Beim Aufnahmeschalter wird durch einen setOnAction() - Aufruf eine per Lambda-Ausdruck rea-
lisierte Ereignisbehandlungsmethode registriert:
btnAdd.setOnAction(event -> {
String s = tfName.getText();
if (s.length() > 0 && !persons.contains(s))
persons.add(s);
});
Wenn im Textfeld tfName ein Eintrag (mit Länge > 0) vorhanden ist, und sich diese Zeichenfolge
noch nicht in der beobachtbaren Liste befindet, dann wird der Name per add() - Aufruf in die be-
obachtbare Liste aufgenommen.
Analog erhält der Entlassungsschalter eine Klickbehandlungsmethode, die das im List-
View<String> gewählte Element ermittelt und es per remove() aus der beobachtbaren Liste ent-
fernt:
btnRemove.setOnAction(event ->
persons.remove(lvPersons.getSelectionModel().getSelectedItem()));
Bei jeder Änderung der beobachtbaren Liste aktualisiert das ListView<String> - Objekt automa-
tisch die Anzeige.
Über die GridPane-Methode add() mit Parametern für die nullbasierten Spalten- und Zeilennum-
mern platziert man die Bedienelemente in die passenden Matrixzellen, wobei der Spaltenindex zu-
erst anzugeben ist:
root.add(lblName, 0, 0);
root.add(tfName, 1, 0);
root.add(btnAdd, 2, 0);
root.add(lblPresent, 0, 1);
root.add(lvPersons, 1, 1);
root.add(btnRemove, 2, 1);
Abschließend sorgen wir noch dafür, dass die GridPane-Komponente den verfügbaren Platz im
Fenster stets vollständig nutzt, wobei in horizontaler Richtung das Texteingabefeld und in vertikaler
Richtung die Liste von einem wachsenden Platzangebot profitieren sollen:
GridPane.setHgrow(tfName, Priority.ALWAYS);
GridPane.setVgrow(lvPersons, Priority.ALWAYS);
Bei der Gestaltung eines GridPane-Containers kann man vorübergehend Gitterlinien aktivieren,
um das Design zu erleichtern:
root.setGridLinesVisible(true);
628 Kapitel 13 GUI-Programmierung mit JavaFX
Eine initiale Fenstergröße festzulegen, ist nicht unbedingt erforderlich. Bei Verwendung eines ein-
parametrischen Scene-Konstruktors
primaryStage.setScene(new Scene(root));
startet das Beispielprogramm so (ohne Gitterlinien zur Layout-Begutachtung):
Mit den Stage-Methoden setMinWidth() und setMinHeight() kann man den Benutzer daran hin-
dern, das Fenster unbenutzbar klein zu machen, z. B.:
stage.setMinWidth(400);
stage.setMinHeight(150);
Die Anwendungsklasse ist (trotz der GUI-Gestaltung durch Anweisungen) noch gut zu überblicken:
Abschnitt 13.3 Anwendung mit All-In-One - Architektur 629
package de.uni_trier.de.attendance;
import javafx.application.Application;
. . .
import java.util.Comparator;
btnAdd.setOnAction(event -> {
String s = tfName.getText();
if (s.length() > 0 && !persons.contains(s))
persons.add(s);
});
btnRemove.setOnAction(event ->
persons.remove(lvPersons.getSelectionModel().getSelectedItem()));
root.add(lblName, 0, 0);
root.add(tfName, 1, 0);
root.add(btnAdd, 2, 0);
root.add(lblPresent, 0, 1);
root.add(lvPersons, 1, 1);
root.add(btnRemove, 2, 1);
GridPane.setHgrow(tfName, Priority.ALWAYS);
GridPane.setVgrow(lvPersons, Priority.ALWAYS);
Bei komplexeren Bedienoberflächen ist die deklarative FXML-Alternative allerdings gegenüber der
programmatischen GUI-Definition zu bevorzugen. Wir haben die FXML-basierte Technik schon im
Abschnitt 4.9 verwendet und werden gleich im Abschnitt 13.4 weitere Erfahrungen damit sammeln.
Die fxml-Datei hello-view.fxml zur GUI-Deklaration und die Klasse HelloController sind im
aktuellen Projekt überflüssig und können gelöscht werden.
630 Kapitel 13 GUI-Programmierung mit JavaFX
Information
über Ereignisse
Controller View Interaktion mit
Anweisungen dem Benutzer
zur Darstellung
Model
13.4.3 Model
Im Beispielprogramm genügt ein Model-Objekt. Es soll ...
• zur Verwaltung der anwesenden Personen eine sortierte, beobachtbare Liste mit Elementen
vom Typ String verwenden (siehe Abschnitt 13.5.3.3),
• eine nichtmodifizierbare Sicht auf die Anwesenheitsliste über die öffentliche Methode
getSortedList() als Objekt vom Typ ObservableList<String> anbieten,
• über öffentliche Methoden namens add() und remove() das Einfügen bzw. Entfernen von
Listenelementen erlauben, wobei das Einfügen nicht zu Dubletten führen darf.
Wir öffnen im Project-Fenster das Kontextmenü zum Paket de.uni_trier.zimk.attendance
und wählen das Item New > Java Class, um die Klasse Model anzulegen:
Die folgende Implementation der Klasse Model enthält die im Abschnitt 13.3 beschriebene sortierte
und beobachtbare Liste:
Abschnitt 13.4 Anwendung mit Model-View-Controller - Architektur (MVC) 633
package de.uni_trier.zimk.attendance;
import java.util.Comparator;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.SortedList;
public Model() {
String[] anwesend = new String[] {"Willi", "Otto", "Theo", "Irma", "Doro",
"Heiner", "Michael", "Ludger", "Ben"};
persons = FXCollections.observableArrayList(anwesend);
perSorted = new SortedList<>(persons, Comparator.naturalOrder());
}
Das ListView<String> - Steuerelement der Bedienoberfläche benötigt eine Referenz auf die Anwe-
senheitsliste, um sich dort als Beobachter registrieren lassen zu können. Damit über diese Referenz
keine Änderungen an der Anwesenheitsliste möglich sind, liefert getSortedListe() eine nicht-
modifizierbare Sicht, die von der statischen Methode unmodifiableObservableList() der Klasse
FXCollections erstellt wird:
public ObservableList<String> getSortedList() {
return FXCollections.unmodifiableObservableList(perSorted);
}
Wir löschen in der Document-Zone (unten links, siehe Abschnitt 4.9.2) des Scene Builders den
vorhandenen VBox-Wurzelknoten und bewegen aus dem Container-Segment der Library-Zone
ein GridPane-Objekt in das Hierarchy-Segment der Document-Zone. Für das markierte
GridPane-Objekt setzen wir über das Layout-Segment der Inspector-Zone (am rechten Rand)
die bevorzugte Breite bzw. Höhe (Pref Width bzw. Pref Height) auf 450 bzw. 200.
Über das Kontextmenü zum neuen Wurzelknoten ergänzen wir die vorgegebene Matrix mit drei
Zeilen und zwei Spalten um eine dritte Spalte:
Grid Pane > Add Column Before
Dann öffnen wir in der Editing-Zone des Scene Builders das Kontextmenü zur überflüssigen drit-
ten Zeile und wählen dort das Item Delete.
Es ist zu beachten, dass die Zeilen-Spalten - Struktur eines GridPane-Containers nur dann in der
Editing-Zone angezeigt wird, wenn mindestens eine Zeile und mindestens eine Spalte vorhanden
sind.
Aus dem Controls-Segment der Library-Zone befördern wir zwei Label-Komponenten, zwei
Button-Komponenten, eine ListView-Komponente und eine TextField-Komponente in die ge-
wünschte Gitterzelle:
Die Label- und Button-Komponenten werden jeweils nach einem Doppelklick beschriftet.
Für die markierte TextField-Komponente wird im Layout-Segment der Inspector-Zone die Ei-
genschaft Hgrow auf den Wert Always gesetzt. Für die markierte ListView-Komponente wird im
Layout-Segment der Inspector-Zone die Eigenschaft Vgrow auf den Wert Always gesetzt. So
wird dafür gesorgt, dass von einer wachsender Fensterbreite das Texteingabefeld und von einer
wachsenden Fensterhöhe die Liste profitiert.
Um die Elemente im GridPane-Container vom Rand fernzuhalten, tragen wir bei markiertem Wur-
zelknoten im Layout-Segment der Inspector-Zone den Wert 10 in das erste Feld der Padding-
Zeile ein und klicken dann auf den unmittelbar rechts danebenstehenden Pfeil, um diesen Wert für
die restlichen Seiten zu übernehmen:
Abschnitt 13.4 Anwendung mit Model-View-Controller - Architektur (MVC) 635
Denselben Wert 10 tragen wir auch für die GridPane-Layout-Eigenschaften Hgap und Vgap ein,
um die Steuerelemente auf Abstand zu halten.
Wenn der Scene Builder als eigenständiges Programm läuft, kann man sich nach dem Menübefehl
Preview > Show Preview in Window
vom gelungenen Design überzeugen:
Um ein Steuerelement aus dem Szenegraphen später (z. B. zum Zweck der Ereignisbehandlung) mit
einer Instanzvariablen der Controller-Klasse verknüpfen zu können, muss man dem Steuerelement
eine Kennung, d .h. einen Wert für das FXML-Attribute fx:id zuordnen, was im Code-Segment der
Inspector-Zone möglich ist. Wir vergeben die folgenden Kennungen:
TextField-Komponente tfName
ListView-Komponente lvPersons
Button „Angekommen“ btnAdd
Button „Gegangen“ btnRemove
Über das Controller-Segment der Document-Zone wählen wir die Controller-Klasse zum gerade
entstehenden Fenster:
13.4.5 FXML
Wir beenden den als eigenständiges Programm verwendeten Scene Builder und sichern unsere Ar-
beit:
Im FXML-Editor von IntelliJ wird der aktuelle Stand der FXML-Datei angezeigt:
636 Kapitel 13 GUI-Programmierung mit JavaFX
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.ListView?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.ColumnConstraints?>
<?import javafx.scene.layout.GridPane?>
<?import javafx.scene.layout.RowConstraints?>
Außerdem sind hier xmlns-Attribute zur Deklaration von XML-Namensräumen vorhanden, wobei
die folgende Syntax verwendet wird:
xmlns[:prefix]="URI"
In einem Namensraum werden erlaubte Elemente und Attribute festgelegt, und zur Vermeidung von
Namenskollisionen kann zu einem Namensraum ein Präfix angegeben werden. Die Bezeichnung für
einen XML-Namensraum verwendet das URI-Format (Uniform Resource Identifier), um Eindeutig-
Abschnitt 13.4 Anwendung mit Model-View-Controller - Architektur (MVC) 637
keit sicherzustellen. Wie man sich leicht vergewissern kann, existiert aber im Internet kein Ort mit
diesem Namen.
Weil die Vereinbarung eines umlaufenden Innenrandes für den GridPane-Container nicht per
XML-Attribut erledigt werden kann, erscheint das eingeschachtelte Element padding:
<padding>
<Insets bottom="10.0" left="10.0" right="10.0" top="10.0" />
</padding>
13.4.6 Controller
Die Ereignisbehandlung in unserem Projekt soll durch ein Objekt der Klasse Controller erledigt
werden. Wir haben bisher lediglich den Namen der vom IntelliJ-Assistenten angelegten Klasse ver-
einfacht:
package de.uni_trier.zimk.attendance;
import javafx.fxml.FXML;
import javafx.scene.control.Label;
@FXML
protected void onHelloButtonClick() {
welcomeText.setText("Welcome to JavaFX Application!");
}
}
Im FXML-Code haben einige GUI-Komponenten eine Kennung (fx:id) erhalten, damit sie von der
Controller-Klasse angesprochen werden können. Um diese Ansprache zu ermöglichen, müssen in
der Controller-Klasse noch passend benannte Instanzvariablen angelegt werden. Beim Laden der
FXML-Datei durch das Laufzeitsystem (siehe Abschnitt 13.4.7) entsteht ein Objekt der Controller-
638 Kapitel 13 GUI-Programmierung mit JavaFX
Klasse, und es wird versucht, die in der FXML-Datei deklarierten und mit einer Kennung (fx:id)
ausgestatteten GUI-Objekte in die Controller-Klasse zu „injizieren“ (Horstmann 2014, S, 88).
Gehen Sie folgendermaßen vor, um von IntelliJ eine Unterstützung bei der Deklaration von Con-
troller-Instanzvariablen zu den GUI-Komponenten mit Kennung zu erhalten:
• Im Editorfenster der FXML-Datei sind die noch nicht zugeordneten Steuerelemente an der
Hintergrundfarbe zu erkennen, z. B.:
• Nach einem Klick auf den Link Create field 'btnAdd' in'Controller' wird das Con-
troller-Feld btnAdd zum Button-Steuerelement erstellt.
• Akzeptieren Sie für das neu angelegte Feld der Controller-Klasse
@FXML
protected void onHelloButtonClick() {
welcomeText.setText("Welcome to JavaFX Application!");
}
}
Wie es richtig gemacht wird, zeigt das Feld welcomeText, das IntelliJ bei der Erstellung des Pro-
jekts angelegt hat. Wir wählen zu den neuen Feldern die empfehlenswerte Schutzstufe private und
sorgen durch die Annotation @FXML dafür, dass die GUI-Komponenten über private Instanzvari-
ablen der Controller-Klasse angesprochen werden können:
public class Controller {
@FXML
private Button btnAdd;
@FXML
private Button btnRemove;
@FXML
private TextField tfName;
@FXML
private ListView<String> lvPersons;
}
Außerdem sollte unbedingt der von IntelliJ vorgeschlagenen Rohtyp ListView durch den parametri-
sierten Typ ListView<String> ersetzt werden.
Abschnitt 13.4 Anwendung mit Model-View-Controller - Architektur (MVC) 639
Zusammen mit dem eben noch als Vorbild nützlichen Feld welcomeText kann mit der Methode
onHelloButtonClick() noch ein weiterer Bestandteil der ursprünglichen Assistentenproduktion
gelöscht werden.
Wir ergänzen in der Controller-Klasse eine Instanzvariable vom Typ Model (vgl. Abschnitt
13.4.3), weil der Controller mit dem Model kommunizieren soll:
private Model am;
Zur Initialisierung des Controller-Objekts wird die parameterfreie Methode initialize() imple-
mentiert und mit @FXML annotiert, sodass sie beim Laden der FXML-Datei automatisch ausge-
führt wird:1
@FXML
public void initialize() {
am = Attendance.getModel();
lvPersons.setItems(am.getSortedList());
btnAdd.setOnAction(event -> {
String s = tfName.getText();
if (s.length() != 0)
am.add(s);
});
btnRemove.setOnAction(event ->
am.remove(lvPersons.getSelectionModel().getSelectedItem()));
}
In initialize() ermittelt der Controller mit der (noch zu implementierenden) statischen Atten-
dance-Methode getModel() das Model-Objekt und informiert die ListView<String> - Kompo-
nente durch einen setItems() - Aufruf darüber, welche Daten dargestellt werden sollen. Außerdem
werden ActionEvent-Behandlungsmethoden für die beiden Befehlsschalter vereinbart, wobei die
Model-Instanzmethoden add() und remove() zum Einsatz kommen.
Eine Besonderheit der Controller-Klasse besteht darin, dass in der Methode initialize() die (mitt-
lerweile privaten!) Felder mit Referenzen zu den Bedienelementen in der Annahme verwendet wer-
den, dass sie vom JavaFX-Framework (per Injektion) initialisiert worden sind.
Im Abschnitt 4.9.4 haben wir einen Befehlsschalter mit seiner ActionEvent-Behandlungsmethode
verknüpft, indem wir ...
• in der Controller-Klasse eine Instanzmethode erstellt
• und diese per Scene Builder als Wert des FXML-Attributs onAction zum Button-
Steuerelement festgelegt haben.
Wir könnten im aktuellen Beispiel analog vorgehen und den beiden Button-Steuerelementen im
Code-Segment der Inspector-Zone des Scene Builders eine ActionEvent-Behandlungsmethode
der Controller-Klasse zuweisen, z. B.:
1
In JavaFX 2.2 wurde von einer Controller-Klasse noch gefordert, das Interface Initializable zu implementieren
(siehe z. B. Horstmann 2014, S. 88). In der aktuellen OpenJFX-Dokumentation zu Initializable
(https://openjfx.io/javadoc/17/javafx.fxml/javafx/fxml/Initializable.html) heißt es dazu:
This interface has been superseded by automatic injection of location and resources properties into the
controller. FXMLLoader will now automatically call any suitably annotated no-arg initialize() method
defined by the controller.
Ab JavaFX 8 sollte also das im Beispiel demonstrierte Muster verwendet werden: Die Controller-Klasse besitzt eine
parameterfreie Methode initialize() mit void-Rückgabe und @FXML-Annotation (siehe z. B. Weaver et al 2014, S.
96).
640 Kapitel 13 GUI-Programmierung mit JavaFX
13.4.7 Anwendungsklasse
Der von IntelliJ erstellte Rohling der Anwendungsklasse muss noch überarbeitet werden:
package de.uni_trier.zimk.attendance;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.stage.Stage;
import java.io.IOException;
Wir sorgen dafür, dass beim Anwendungsstart ein Objekt der Klasse Model entsteht, das vom Con-
troller-Objekt verwendet werden kann. Dazu definieren wir in der Anwendungsklasse ein statisches
Feld vom Typ Model:
static private Model am;
In der start() - Methode der Anwendungsklasse legen wir das Model-Objekt an:
am = new Model();
Außerdem erstellen wird in der Anwendungsklasse noch eine statische Methode namens
getModel(), mit der sich das Controller-Objekt in seiner Methode initialize() eine Referenz zum
Model-Objekt besorgen kann:
static public AttendanceModel getModel() {
return am;
}
In der start() - Methode der Anwendungsklasse initialisieren wir eine Szene auf der primären Büh-
ne:
Abschnitt 13.4 Anwendung mit Model-View-Controller - Architektur (MVC) 641
stage.setScene(new Scene(fxmlLoader.load()));
stage.setTitle("Anwesenheitskontrolle");
stage.setMinWidth(400);
stage.setMinHeight(150);
stage.show();
Weil der Scene-Konstruktor keine Aktualparameterwerte zur Fenstergröße enthält, kommen Vor-
einstellungen zum Einsatz, die sich an den gewünschten Ausdehnungen der im Fenster enthaltenen
Komponenten orientieren.
Die Anwendungsklasse im Überblick:
package de.uni_trier.zimk.attendance;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.stage.Stage;
import java.io.IOException;
public class Attendance extends Application {
static private Model am;
@Override
public void start(Stage stage) throws IOException {
am = new Model();
FXMLLoader fxmlLoader = new FXMLLoader(
Attendance.class.getResource("attendance.fxml"));
stage.setScene(new Scene(fxmlLoader.load()));
stage.setTitle("Anwesenheitskontrolle");
stage.setMinWidth(400);
stage.setMinHeight(150);
stage.show();
}
Das auf den folgenden vier Dateien basierende Programm ist nun einsatzfähig:
• Attendance.java mit der Anwendungsklasse
• Model.java mit der Model-Klasse
• attendance.fxml mit der View-Deklaration
Darauf basiert wesentlich das in der start() - Methode der Anwendungsklasse erstellte Sce-
ne-Objekt, das an die Stage-Methode setScene() übergeben wird. Die View-Komponente ist
ein komplexes Gebilde unter Beteiligung des JavaFX-Frameworks, das z. B. die Bedienele-
mente erstellt und deren Adressen in die Controller-Klasse injiziert.
• Controller.java mit der Controller-Klasse
Bei der im Abschnitt 13.4 vorgeschlagenen MVC-Lösung wird die im FXML-Code deklarierte
Controller-Klasse (siehe das Attribute fx:controller im Element GridPane) verwendet. Es sind
viele andere Realisationen der MVC-Idee möglich, insbesondere bei einer GUI-Erstellung per Pro-
gramm (siehe z. B. Sharan 2015, Kapitel 11).
642 Kapitel 13 GUI-Programmierung mit JavaFX
1
Das Einführungsbeispiel im Abschnitt 1.1 enthält getter- und setter-Methoden, die sich wenig an den JavaBeans-
Konventionen orientieren:
• Es werden deutsche Namen verwendet (z. B. setzeNenner()).
Abschnitt 13.5 Properties mit Änderungssignalisierung und automatischer Synchronisation 643
• Die Methode setzeNenner() verwendet nicht den Rückgabetyp void, sondern signalisiert durch eine
Rückgabe vom Typ boolean, ob die gewünschte Wertzuweisung durchgeführt wurde. Fremde Program-
mierer ignorieren eventuell den unkonventionellen Rückgabewert. Bei einer für die Öffentlichkeit gedach-
ten Klasse sollte als Warnung vor einer nicht ausgeführten Wertzuweisung besser eine Ausnahme geworfen
werden vgl. Kapitel 11).
1
http://download.oracle.com/javase/tutorial/javabeans
644 Kapitel 13 GUI-Programmierung mit JavaFX
• ReadOnlyIntegerProperty
Diese abstrakte Basisklasse von IntegerProperty wird verwendet, wenn man eine int-
Property für den ausschließlich lesenden Zugriff durch fremde Klassen anbieten möchte.
Dann kann eine Property z. B. nicht zum Ziel einer Datenbindung gemacht werden (vgl. Ab-
schnitt 13.5.3).
• ReadOnlyIntegerWrapper
Diese konkrete Klasse stammt von SimpleIntegerProperty ab und realisiert eine modifi-
zierbare int-Property. Als Erweiterung im Vergleich zur Klasse SimpleIntegerProperty
kann über die Methode getReadOnlyProperty() ein Objekt der Klasse
ReadOnlyIntegerProperty erstellt werden, das automatisch mit dem internen
SimpleIntegerProperty-Objekt synchronisiert wird. Damit steht anbieter-intern (z.B. in ei-
ner Komponentenklasse) ein modifizierbares Property-Objekt zur Verfügung, und fremden
Klassen kann ein synchronisiertes ReadOnlyIntegerProperty-Objekt zum lesenden Zugriff
angeboten werden.
Weil in der Auflistung die Abstammungsverhältnisse der Klassen nicht leicht zu überblicken sind,
folgt noch eine grafische Darstellung:
ReadOnlyIntegerProperty
IntegerProperty
IntegerPropertyBase
SimpleIntegerProperty
ReadOnlyIntegerWrapper
• Für den Vor- und den Nachnamen sind die StringProperty-Objekte firstName und
lastName vorhanden.
• Für das Alter ist die IntegerProperty age vorhanden.
• Für die Personalnummer ist der ReadOnlyIntegerWrapper id vorhanden. Auf die Werte
dieser Eigenschaft soll die Außenwelt nur lesend zugreifen können.
Im folgenden Quellcode
public class Person {
private static int count = 0;
private ReadOnlyIntegerWrapper id = new ReadOnlyIntegerWrapper(this, "id", ++count);
private StringProperty firstName = new SimpleStringProperty(this, "firstName", "");
private StringProperty lastName = new SimpleStringProperty(this, "lastName", "");
private IntegerProperty age = new SimpleIntegerProperty(this, "age", -99);
Das folgende Programm zeigt die Verwendung der Person-Properties, kann aber noch nicht
demonstrieren, wozu der im Vergleich zu gewöhnlichen gekapselten Instanzvariablen höhere
Property-Aufwand taugt:
class PersonDemo {
public static void main(String[] args) {
Person p = new Person("Otto", "Rempremerding", 89);
System.out.println(p.getId() + "\n" + p.getFirstName()
+ "\n" + p.getLastName() + "\n" + p.getAge());
p.setFirstName("Ludwig");
p.setLastName("Thoma");
p.setAge(76);
// p.setId(2); // ReadOnlyIntegerProperty
}
}
Während der Lesezugriff bei allen Properties möglich ist, kann das ReadOnlyIntegerProperty-
Objekt im Unterschied zu den StringProperty-Objekten nicht verändert werden. In Person-
Methoden ist aber eine Veränderung des synchronen ReadOnlyIntegerWrapper-Objekts erlaubt.
Im weiteren Verlauf von Abschnitt 13.5 werden die Fähigkeiten von Property-Objekten zum Ver-
sand von Änderungsbenachrichtigungen und zur automatischen Synchronisation beschrieben.
Nach der Kreation ist ein Property-Objekt im gültigen Zustand. Bei einem Property-Objekt in gülti-
gem Zustand hat eine Wertveränderung folgende Effekte:
• Das Objekt wechselt in den ungültigen Zustand.
• Es informiert die Beobachter.
Bei einem ungültigen Property-Objekt haben weitere Wertveränderungen keine Invalidierungs-
ereignisse zur Folge. Eine Wertabfrage per get() - Aufruf bringt ein ungültiges Property-Objekt
wieder in den gültigen Zustand.
Das beschriebene Verhalten ist im folgenden Programm zu beobachten:
Quellcode Ausgabe
import javafx.beans.property.*; Wert nach Konstruktor: 0
Invalidated
class InvalidationListener { Wert = 2
public static void main(String[] args) { Invalidated
IntegerProperty inum = new SimpleIntegerProperty();
inum.addListener(obs->System.out.println("Invalidated"));
System.out.println("Wert nach Konstruktor: "+inum.get());
inum.set(1);
inum.set(2);
System.out.println("Wert = " + inum.get());
inum.set(3);
}
}
Wie das folgende Programm zeigt, informiert ein Property-Objekt registrierte Change-Listener
über jede Wertveränderung:
Quellcode Ausgabe
import javafx.beans.property.*; Wert nach Konstruktor: 0
Changed to: 1
public class ChangeListener { Changed to: 2
public static void main(String[] args) {
IntegerProperty inum = new SimpleIntegerProperty();
inum.addListener((obs, old, nev) ->
System.out.println("Changed to: " + nev));
System.out.println("Wert nach Konstruktor: "+inum.get());
inum.set(1);
inum.set(2);
}
}
class PropBiSync {
public static void main(String[] args) {
IntegerProperty i1 = new SimpleIntegerProperty(1);
IntegerProperty i2 = new SimpleIntegerProperty(2);
i1.bindBidirectional(i2);
i1 nun gleich 2
class PropUniSync {
public static void main(String[] args) {
IntegerProperty i1 = new SimpleIntegerProperty(1);
IntegerProperty i2 = new SimpleIntegerProperty(2);
i1.bind(i2);
System.out.println("\nÄnderung nach unidirektionaler Verbindung:");
// i1.set(11); //verboten
i2.set(22);
Der Typformalparameter T steht natürlich für einen Referenztyp, und die Klasse DoubleBinding
implementiert z. B. das Interface Binding<Number>.
Wenn eine Property (z. B. p) an ein Binding-Objekt (z. B. b) mit mehreren Abhängigkeiten bzw.
Quellen (z. B. q1, q2, q3) gebunden werden soll, dann kommen natürlich nur unidirektionale Bin-
dungen in Betracht:
q1
p b q2
q3
Ein Binding-Objekt verwendet im Hintergrund Invalidation-Listener für all seine Quellen und wird
ungültig, sobald eine Quelle ungültig wird. In dieser Situation löst das Binding-Objekt sein eigenes
Invalidierungs-Ereignis aus. Die Neuberechnung des Wertes erfolgt aus Performanzgründen erst bei
Bedarf (engl.: lacy execution). Ist ein Change-Listener bei einem Binding-Objekt registriert, dann
ist nach eingetretener Ungültigkeit die sofortige Neuberechnung des Werts erforderlich (engl.: ea-
ger execution).
Wer über die anschließenden kurz gefassten Erläuterungen hinaus weitere Informationen zum Bin-
ding-API benötigt, findet diese z. B. in Sharan (2015, S. 62ff).
Nun soll allerdings die Eigenschaft age nicht mehr separat geführt, sondern an die Eigenschaft
yearOfBirth gebunden werden. Im High-Level Binding-API stehen dazu zwei Lösungen bereit:
• Fluent API
In den Property- und den Binding-Klassen stehen Methoden bereit, die aus beobachtbaren
Variablen und konstanten Werten ein neues Binding-Objekt erstellen. Man spricht vom Flu-
ent API, weil die Methoden ein flüssiges Programmieren durch Verketten von Aufrufen er-
lauben(analog zu den Stromverarbeitungsmethoden, siehe Abschnitt 12.2.1). Im Beispiel
soll zur Berechnung des Alters vom aktuellen Jahr (ermittelt über die statische Methode
now() der Klasse Year) das Geburtsjahr abgezogen werden, was mit Hilfe der Methoden
subtract() und negate() gelingt:1
IntegerBinding ib = yearOfBirth.subtract(Year.now().getValue()).negate();
Es resultiert ein Objekt der Klasse IntegerBinding. An dieses Objekt wird die Property age
gebunden:
age.bind(ib);
1
Der Einfachheit halber wird nur das Geburtsjahr erfasst, sodass die Altersberechnung nicht ganz korrekt ist. Die
Methode negate() leistet eine Vorzeichenumkehr, wandelt also z. B. -15 in 15.
652 Kapitel 13 GUI-Programmierung mit JavaFX
Im folgenden Programm
class PersonDemo {
public static void main(String[] args) {
Person p = new Person("Otto", "Rempremerding", 1990);
System.out.println(p.getId() + ", " + p.getFirstName() +
" " + p.getLastName() + ", " + p.getAge());
p.ageProperty().addListener((obs, old, nev) ->
System.out.println("\nAlter geändert auf " + nev));
p.setYearOfBirth(1995);
}
}
wird die automatische Altersberechnung demonstriert:
1, Otto Rempremerding, 30
1
Die statische Methode subtract() der Klasse Bindings liefert in der Überladung mit Parametern von Typ int und
ObservableNumberValue ein Objekt vom Typ NumberBinding, der u. a. den Typ ObservableValue<Number>
erweitert. Die Methode bind() der Klasse IntegerProperty verlangt für ihren Parameter den Typ ObservableValu-
e<? extends Number>, sodass im Beispiel der bind() - Aufruf in Ordnung geht. Dabei wird ein Objekt vom Typ
der abstrakten Klasse IntegerProperty an ein Objekt vom Typ der Schnittstelle NumberBinding gebunden. In der
folgenden Anweisung liefert die verwendete subtract() - Überladung ein Ergebnis vom Typ DoubleBinding, und
bei der Bindung der IntegerProperty an die DoubleBinding - Quelle kommt es zu einer einschränkenden Typan-
passung:
age.bind(Bindings.subtract(10e200, yearOfBirth));
Dabei erhält die IntegerProperty als Ergebnis den maximal möglichen int-Wert (231-1 = 2147483647), wobei kein
alarmierendes Ausnahmeobjekt geworfen wird.
Abschnitt 13.5 Properties mit Änderungssignalisierung und automatischer Synchronisation 653
class LowLevelBinding {
public static void main(String[] args) {
IntegerProperty i1 = new SimpleIntegerProperty(1);
IntegerProperty i2 = new SimpleIntegerProperty(8);
Die anonyme Klasse überschreibt die abstrakte Basisklassenmethode computeValue() und reali-
siert dabei die Modulo-Variante des euklidischen Verfahrens zur Berechnung des größten gemein-
samen Teilers (siehe Übungsaufgabe auf Seite 193). Dass bei einer anonymen Klassendefinition auf
die lokalen Variablen der umgebenden Methode zugegriffen werden kann (vgl. Abschnitt 12.1.1.2),
erweist sich im Beispiel als sehr praktisch. Das Programm liefert die folgende Ausgabe:
Initialer Wert: 1
Automatische Wertanpassung bei ggt:
Neuer GGT: 2
Neuer GGT: 4
Neuer GGT: 8
Neuer GGT: 1
Das Property-Objekt ggt erhält einen (per Lambda-Ausdruck realisierten) Change-Listener, sodass
die Effekte der anschließend durchgeführten Änderungen bei der Quell-Property i1 beobachtet
werden können. Deren Ausgangswert von 1 wird mehrfach mit bzw. ohne Auswirkung auf den
größten geneinsamen Teiler von i1 und i2 verändert.
654 Kapitel 13 GUI-Programmierung mit JavaFX
java.util.List<E> javafx.beans.Observable
javafx.collections.ObservableList<E>
sodass eine implementierende Klasse neben den Funktionalitäten einer Liste auch die Observable-
Methoden addListener() und removeListener() anbieten muss, um Interessenten für Invalidie-
rungsereignisse zu verwalten:
• public void addListener(InvalidationListener listener)
• public void removeListener(InvalidationListener listener)
Außerdem verlangt das Interface ObservableList<E> auch Überladungen der Methoden
addListener() und removeListener() zur Verwaltung von Interessenten für Listenveränderungen:
• public void addListener(ListChangeListener<? super E> listener)
• public void removeListener(ListChangeListener<? super E> listener)
Implementierende Objekte zu den Schnittstellen für beobachtbare Kollektionen sind über statische
Fabrikmethoden der Klasse FXCollections verfügbar. Von der generischen Methode
observableArrayList() erhält man z. B. ein Objekt aus einer Klasse, die das Interface Observable-
Abschnitt 13.5 Properties mit Änderungssignalisierung und automatischer Synchronisation 655
List<E> implementiert und einen Array zur Aufbewahrung ihrer Listenelemente verwendet. Die
folgende Überladung erstellt eine leere beobachtbare Liste mit dem Elementtyp E:
public static <E> ObservableList<E> observableArrayList(E... items)
Im folgenden Beispiel kann beim Aufruf der generischen Methode dank Typinferenz auf die Anga-
be des Elementtyps String verzichtet werden:
ObservableList<String> ols = FXCollections.observableArrayList();
Ein ListChangeListener<E> muss die Methode onChanged() implementieren, die bei einer Lis-
tenveränderung aufgerufen wird und dabei ein Parameterobjekt vom Typ der innerhalb von
ListChangeListener<E> definierten statischen Mitgliedsklasse Change<E> erhält. Der im Chan-
ge<E> - Objekt enthaltene Bericht kann mehrere Teile umfassen, wenn von einer Veränderung
mehrere, nicht hintereinander liegende Elemente der beobachteten Liste betroffen sind. Im Beispiel
wird die Veränderungsverarbeitung in einer while-Schleife so lange fortgesetzt, bis der next() -
Aufruf im Schleifenkopf durch den Rückgabewert false signalisiert, dass im Change<E> - Objekt
keine weiteren Teilberichte vorhanden sind.
Über die (bei allen Teilberichten in einem Change<E> - Objekt identische) Veränderungsart in-
formieren die folgenden Change-Methoden:
• public boolean wasAdded()
Es wurden Elemente aufgenommen.
• public boolean wasPermutated()
Die Reihenfolge der Elemente wurde verändert.
• public boolean wasRemoved()
Es wurden Elemente entfernt.
• public boolean wasReplaced()
Es wurden Elemente ersetzt, d .h. es wurden alte Elemente entfernt und neue aufgenommen.
Wurde das Entfernen und die Aufnahme von Elementen verarbeitet, dann muss man sich
nicht zusätzlich um die Ersetzungen kümmern. Berichtet ein Change<E> - Objekt von einer
Ersetzung, dann liefern neben wasReplaced() auch die Methoden wasAdded() und
wasRemoved() die Rückgabe true.
656 Kapitel 13 GUI-Programmierung mit JavaFX
Bei der Aufnahme von 6 Elementen sowie beim Löschen von drei hintereinander liegenden Ele-
menten resultiert jeweils ein einteiliger Veränderungsbericht:
Elemente aufnehmen:
Added-Veränderung 1, betroffen: von 0 bis 5, ergänzt: [A, B, C, D, E, F]
Das ist auch beim Ersetzen eines Listenelementes der Fall, wobei diesmal die Methoden
wasRemoved() und wasAdded() beide die Rückgabe true liefern:
Ein Element ersetzen:
Removed-Veränderung 1, entfernt: [A]
Added-Veränderung 1, betroffen: von 0 bis 0, ergänzt: [A1]
Werden Listenelemente gelöscht, die nicht hintereinander liegen, dann enthält das Change-Objekt
einen mehrteiligen Veränderungsbericht:
Nicht hintereinander liegende Elemente entfernen:
Removed-Veränderung 1, entfernt: [A1]
Removed-Veränderung 2, entfernt: [C]
import javafx.beans.Observable;
import javafx.beans.property.*;
import javafx.collections.*;
class ObservableListUpdates {
public static void main(String[] args) {
ObservableList<StringProperty> ols =
FXCollections.observableArrayList(e -> new Observable[] {e});
ols.addListener(new ListChangeListener<StringProperty>() {
@Override
public void onChanged(ListChangeListener.Change<? extends StringProperty> c) {
while (c.next()) {
if (c.wasAdded())
System.out.println("Ergänzt: " + c.getAddedSubList());
if (c.wasUpdated()) {
int i = c.getFrom();
System.out.println("Neuer Wert von Element " + i + ": " +
c.getList().get(i).get());
}
}
}
});
System.out.println("\nElemente aufnehmen:");
ols.addAll(spa, spb);
13.6 Ereignisse
Die von Property-Objekten an registrierte Beobachter versendeten Mitteilungen über Invalidierun-
gen und/oder Veränderungen haben den Charakter von Ereignissen. Um die enorme Vielfalt der in
einer grafischen Bedienoberfläche auftretenden Ereignisse (z.B. Schalterbetätigungen, Mausklicks,
Tastendrücke, Drag & Drag - Bewegungen) zu verarbeiten, benötigt JavaFX aber eine generellere
Ereignisbehandlungstechnik.
13.6.1 Ereignishierarchie
Ereignisobjekte stammen in JavaFX von der Klasse Event im Paket javafx.event ab, zu der zahl-
reiche Spezialisierungen existieren:1
1
https://openjfx.io/javadoc/17/javafx.base/javafx/event/Event.html
Abschnitt 13.6 Ereignisse 659
java.lang.Object
javafx.event.Event
MouseEvent.MOUSE_PRESSED MouseEvent.MOUSE_RELEASED
Ist ein Funktionsobjekt z. B. zur Behandlung von MouseEvent.ANY - Ereignisse registriert, dann
wird es auch über die speziellen Ereignisvarianten informiert (z. B. MouseEvent.PRESSED und
MouseEvent.RELEASED).
13.6.2 Ereignisverarbeitung
Zu einem Ereignis gehört eine Route vom direkt betroffenen Knoten (dem sogenannten Ziel bzw.
target) im Szenengraphen bis zum Stage-Objekt des Fensters, und das Ereignis wird sukzessive
allen Knoten auf der Route angeboten. Die Route vom Stage-Objekt zum Zielobjekt wird zunächst
in absteigender und dann in aufsteigender Richtung durchlaufen, wenn die Behandlung nicht unter-
wegs gestoppt wird, was z. B. JavaFX-Steuerelemente bei Eingabeereignissen in der Regel tun.
Anschließend betrachten wir ein JavaFX-Beispielprogramm, das für sein Anwendungsfenster einen
VBox-Wurzelknoten verwenden, der die enthaltenen Knoten übereinander stapelt (siehe Abschnitt
13.7.3):
Stage
VBox
Label
Dabei werden registrierte Ereignisfilter (engl.: event filter), die das funktionale Interface
EventHandler<T extends Event> implementieren müssen, über das Ereignis informiert. Der Be-
griff Ereignisfilter ist nicht sonderlich glücklich. So wird in JavaFX ein Ereignisbehandlungsobjekt
auf der Top-Down - Route bezeichnet. Damit kann ein Elternknoten ...
• eine gemeinsame Ereignisbehandlung für mehrere Kindknoten durchführen
• oder eine Ereignisbehandlung beenden, also den Kindern das Ereignis vorenthalten.
Zum Registrieren eines Ereignisfilters dient die generische Window-Methode addEventFilter():
public final <E extends Event> void addEventFilter(EventType<E> eventType,
EventHandler<? super E> eventFilter)
Im Beispiel wird beim Stage-, beim VBox- und beim Label-Objekt ein Filter für das vom öffentli-
chen Feld MOUSE_PRESSED der Klasse MouseEvent
stage.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> presentEvent(event));
root.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> presentEvent(event));
lblText.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> presentEvent(event));
referenzierte Objekt der Klasse EventType<MouseEvent>
public static final EventType<MouseEvent> MOUSE_PRESSED =
new EventType<MouseEvent>(MouseEvent.ANY, "MOUSE_PRESSED");
registriert. Das Ereignis wird beim Drücken einer Maustaste gefeuert.
Der das Interface EventHandler<MouseEvent> implementierende Lambda-Ausdruck ruft die sta-
tische Methode presentEvent() auf, die das Ereignis in einem Standarddialog beschreibt:
private static void presentEvent(Event event) {
Alert alert = new Alert(Alert.AlertType.INFORMATION,
event.getEventType().toString());
alert.setHeaderText("Quelle: " + event.getSource().getClass().toString());
alert.showAndWait();
};
Nach einem Mausklick auf das Label-Objekt im Beispielprogramm erscheinen nacheinander drei
Informations-Dialoge:
Nachdem die Ereignisfilter entfernt worden sind, bewirkt ein Mausklick auf das Label-Objekt
nacheinander drei Informations-Dialoge:
Nach einem Mausklick auf das Button-Objekt im Beispielprogramm erscheint jedoch kein Informa-
tionsdialog, weil das Steuerelement das Eingabeereignis konsumiert und eine weitere Behandlung
verhindert.
Über die Window-Methode removeEventHandler() kann ein Ereignisbehandlungsobjekt entfernt
werden.
Man kann z. B. ein ActionEvent als aufbereitetes Ereignis bezeichnen, das aufgrund der Verarbei-
tung von elementaren Maus- und Tastatur-Ereignissen entsteht, wobei die Entwickler der Klasse
ButtonBase viel Arbeit investiert haben. Die JavaFX-Ereignisbehandlung bietet sehr einfache Lö-
sungen für Standardaufgaben und viel Flexibilität für spezielle Lösungen. Wir beschränken uns im
Manuskript meist auf die Verarbeitung von aufbereiteten Ereignissen. Wer sich für die Verarbei-
tung von elementaren Maus- und Tastatur-Ereignissen interessiert, findet z. B. in Eck (2021, S.
287ff) vertiefende Informationen.
664 Kapitel 13 GUI-Programmierung mit JavaFX
13.7 Layoutmanager
Die Spezialisierungen der Klasse javafx.scene.layout.Pane (siehe Klassenhierarchie im Abschnitt
13.2.3) sind in der Lage, den verfügbaren Platz sinnvoll auf die enthalten Kindelemente zu verteilen
und insbesondere bei einer Größenänderung die Ausdehnungs- und Anordnungswünsche der enthal-
tenen Kindelemente zu berücksichtigen. Wie Sie aus dem Abschnitt 13.3 wissen, kann man z. B.
festlegen, welche Komponente eine horizontale oder vertikale Größenzunahme konsumieren soll.
Anschließend wird zur Demonstration einiger JavaFX-Layoutmanager (Grid-, Anchor- und
BorderPane) die All-In-One - Variante des Beispielprogramms zur Anwesenheitskontrolle ver-
wendet (siehe Abschnitt 13.3). Um den Layoutmanager mit minimalem syntaktischem Aufwand
austauschen zu können, werden Instanzvariablen für die Steuerelemente angelegt:1
private Label lblName = new Label("Name:");
private Label lblPresent = new Label("Anwesend:");
private TextField tfName = new TextField();
private ListView<String> lvPersons;
private Button btnAdd = new Button("Angekommen");
private Button btnRemove = new Button("Gegangen");
In der start() - Methode verbleiben die layout-neutralen Programmbestandteile und ein Methoden-
aufruf zum jeweils verwendeten Layoutmanager, z. B.:
@Override
public void start(Stage primaryStage) {
String[] anwesend = new String[] {"Willi", "Otto", "Theo", "Irma", "Doro",
"Heiner", "Michael", "Ludger", "Ben"};
btnAdd.setOnAction(event -> {
String s = tfName.getText();
if (s.length() > 0 && !persons.contains(s))
persons.add(s);
});
btnRemove.setOnAction(event ->
persons.remove(lvPersons.getSelectionModel().getSelectedItem()));
primaryStage.setTitle("Anwesenheitskontrolle");
primaryStage.setScene(new Scene(root, 450, 200));
primaryStage.setMinWidth(400);
primaryStage.setMinHeight(150);
primaryStage.show();
}
1
Das IntelliJ-Projekt zur Demonstration diverser Layoutmanager ist im folgenden Ordner zu finden:
...\BspUeb\JavaFX\Layoutmanager\GridAnchorBorderPane
Abschnitt 13.7 Layoutmanager 665
kann aus Zeitgründen nur die erste berücksichtigt werden. Immerhin lernt man so die objektorien-
tierten Grundlagen (das API) von JavaFX kennen. API-Kenntnisse sind unverzichtbar, wenn im
laufenden Programm die Bedienoberfläche modifiziert werden soll.
13.7.1 GridPane
Der GridPane-Layoutmanager, mit dem wir schon etliche Erfahrungen gesammelt haben (siehe
z. B. die Abschnitte 4.9, 13.3 und 13.4), ordnet die Kindelemente in einer Matrix an, wobei sich ein
Element auch über mehrere Zellen ausdehnen darf.
13.7.1.1 Beispiel
Im folgenden Quellcode-Segment
double dist = 10.0;
GridPane.setHalignment(btnAdd, HPos.RIGHT);
GridPane.setHalignment(btnRemove, HPos.RIGHT);
GridPane.setHgrow(tfName, Priority.ALWAYS);
GridPane.setVgrow(lvPersons, Priority.ALWAYS);
wird ein GridPane-Objekt erzeugt
GridPane root = new GridPane();
und befüllt:
root.add(lblName, 0, 0); root.add(tfName, 1, 0); root.add(btnAdd, 2, 0);
root.add(lblPresent, 0, 1); root.add(lvPersons, 1, 1); root.add(btnRemove, 2, 1);
Wie das Beispiel zeigt, wird die Zahl der Spalten und Zeilen nicht beim Erstellen eines GridPane-
Objekts erwartet, sondern aus der tatsächlichen Verwendung ermittelt.
bei einer zugrunde gelegten Bildschirmauflösung von 96 dpi. Hat ein Display z. B. die doppelte
Auflösung von 192 dpi, dann werden alle Pixel-Werte automatisch verdoppelt.
Bei der Gestaltung eines GridPane-Containers kann man vorübergehend Gitterlinien aktivieren,
um das Design zu erleichtern:
root.setGridLinesVisible(true);
1
Weitere Details sind hier zu finden:
https://softwareengineering.stackexchange.com/questions/316643/why-does-javafxs-gridpane-attach-properties-of-
the-layout-to-the-components
Abschnitt 13.7 Layoutmanager 667
• columnSpan, rowSpan
Über diese Eigenschaften wird für ein Kindelement festgelegt, über wie viele Spalten bzw.
Zeilen es sich erstecken soll. In der folgenden Variante des Beispielprogramms zur Anwe-
senheitskontrolle
erstreckt sich das ListView<String> - Bedienelement über 3 Spalten, was sich durch den
folgenden Methodenaufruf vereinbaren lässt:
GridPane.setColumnSpan(lvPersons, 3);
Alternativ lässt sich die Erstreckung über mehr als eine Spalte bzw. Zeile auch schon bei der
Aufnahme eines Kindelements durch eine add() - Überladung erreichen, z. B.:
root.add(lvPersons, 1, 1, 3, 1);
• halignment, valignment
Hier geht es um die horizontale bzw. vertikale Ausrichtung eines Kindelements innerhalb
seiner Zelle bzw. Zone. Durch die folgende Anweisung wird dafür gesorgt, dass im Bei-
spielprogramm zur Anwesenheitskontrolle das Button-Objekt btnRemove innerhalb seiner
Zelle eine rechtsbündige horizontale Ausrichtung erhält:
GridPane.setHalignment(btnRemove, HPos.RIGHT);
• hgrow bzw. vgrow
Es kann ein Kindelement bestimmt werden, das von einem horizontalen bzw. vertikalen
Größenzuwachs des Containers profitieren soll, z. B.:
GridPane.setHgrow(tfName, Priority.ALWAYS);
GridPane.setVgrow(lvPersons, Priority.ALWAYS);
• margin
Für ein Kindelement lässt sich per Insets-Objekt ein Außenrand definieren (mit einer indi-
viduellen oberen, rechten, unteren und linken Breite), z. B.:
GridPane.setMargin(lvPersons, new Insets(dist, dist, dist, dist));
Um dieses Ziel zu erreichen, werden drei Objekte der Klasse ColumnConstraints erstellt, über die
Methode setPercentWidth() mit dem passenden Größenanteil versorgt und über die Methode
addAll() in die Liste mit den ColumnConstraints aufgenommen:
ColumnConstraints col1 = new ColumnConstraints();
col1.setPercentWidth(25);
ColumnConstraints col2 = new ColumnConstraints();
col2.setPercentWidth(50);
ColumnConstraints col3 = new ColumnConstraints();
col3.setPercentWidth(25);
root.getColumnConstraints().addAll(col1, col2, col3);
13.7.2 AnchorPane
Ein Kindelement dieses Layoutmanagers wird an 1, 2, 3 oder 4 Seiten des Containers in einem be-
stimmten Abstand verankert, der bei einer Änderung der Container-Größe erhalten bleibt. In der
folgenden AnchorPane-Layoutdefinition für das Beispielprogramm zur Anwesenheitskontrolle
AnchorPane root = new AnchorPane();
root.getChildren().addAll(lblName,lblPresent,tfName,lvPersons,btnAdd,btnRemove);
double bd = 10.0; double ldc2 = 100.0; double rdc2 = 120.0; double tdr2 = 50.0;
AnchorPane.setTopAnchor(lblName, bd); AnchorPane.setLeftAnchor(lblName, bd);
AnchorPane.setTopAnchor(lblPresent, tdr2); AnchorPane.setLeftAnchor(lblPresent,bd);
AnchorPane.setTopAnchor(btnAdd, bd); AnchorPane.setRightAnchor(btnAdd, bd);
AnchorPane.setTopAnchor(btnRemove, tdr2); AnchorPane.setRightAnchor(btnRemove, bd);
AnchorPane.setTopAnchor(tfName, bd); AnchorPane.setLeftAnchor(tfName, ldc2);
AnchorPane.setRightAnchor(tfName, rdc2);
AnchorPane.setTopAnchor(lvPersons, tdr2); AnchorPane.setBottomAnchor(lvPersons,bd);
AnchorPane.setLeftAnchor(lvPersons,ldc2);AnchorPane.setRightAnchor(lvPersons,rdc2);
wird ein Objekt der Klasse AnchorPane erstellt
AnchorPane root = new AnchorPane();
und mit den Steuerelementen befüllt:
root.getChildren().addAll(lblName,lblPresent,tfName,lvPersons,btnAdd,btnRemove);
Anschließend sorgen Aufrufe von statischen AnchorPane-Methoden zum Setzen von Anzeigeein-
stellungen (layout constraints) für Kindelemente (vgl. Abschnitt 13.7.1.3)
• public static void setTopAnchor (Node child, Double value)
• public static void setBottomAnchor (Node child, Double value)
• public static void setLeftAnchor (Node child, Double value)
• public static void setRightAnchor (Node child, Double value)
dafür, dass die Steuerelemente bei einer beliebigen Größe des Anwendungsfensters passende Posi-
tionen und Ausdehnungen erhalten. Das TextField-Steuerelement zur Aufnahme des Namens eines
Neuankömmlings erhält z. B. ...
• zum oberen Container-Rand den Abstand 10
• zum linken Container-Rand den Abstand 100
• zum rechten Container-Rand den Abstand 120
Bei einer Änderung der Container-Größe behält das oben, links und rechts verankerte TextField-
Steuerelement seine vertikale Position bei und passt seine horizontale Größe an den verfügbaren
Platz an:
Abschnitt 13.7 Layoutmanager 669
Im folgenden Quellcode-Segment
double dist = 10.0;
double lblw = 70.0;
double btnw = 100.0;
Insets insets = new Insets(dist,dist,dist,dist);
und befüllt:
top.getChildren().addAll(lblName, tfName, btnAdd);
Neben den beschriebenen Anzeigeeinstellungen (layout constraints) für Kindelemente werden im
Beispielprogramm die folgenden Eigenschaften modifiziert:
• alignment
Für den HBox-Container wird über seine Eigenschaft alignment eine vertikale Ausrichtung
der Elemente an den Elementbeschriftungen veranlasst:
top.setAlignment(Pos.BASELINE_LEFT);
Ohne diese Maßnahme stehen z. B. die Label- und die Button-Beschriftung nicht auf der-
selben Höhe:
• prefWith
Das Label- und das Button-Objekt erhalten über die Region-Eigenschaft prefWidth eine
Wunschbreite, um für eine Ausrichtung mit anderen Steuerelementen zu sorgen.
Per Konstruktorüberladung mit Abstandsparameter sorgt man für einen generellen horizontalen
Abstand (bei HBox) bzw. für einen generellen vertikalen Abstand (bei VBox) zwischen allen Kin-
delementen, z. B.:
HBox top = new HBox(10.0);
13.7.4 BorderPane
Dieser Layoutmanager eignet sich z. B. als Wurzelknoten für Fenster mit einem Layout bestehend
aus ...
• einem Menü am oberen Fensterrand,
• einer Statuszeile am unteren Fensterrand,
• einem Dokumentenbereich in der Mitte,
• der optional noch von Bedienelementen am linken und rechten Fensterrand flankiert wird.
Das folgende Programm demonstriert die BorderPane-Platzaufteilung:
• alignment
Hier geht es um die Ausrichtung eines Kindelements innerhalb seiner Zone. Durch die fol-
gende Anweisung wird dafür gesorgt, dass im Beispielprogramm zur Anwesenheitskontrolle
das Button-Objekt btnRemove innerhalb seiner Zone (Rechts) eine rechtsbündige horizon-
tale Ausrichtung und eine zentrierte vertikale Ausrichtung erhält:
BorderPane.setAlignment(btnRemove, Pos.CENTER_RIGHT);
• margin
Für ein Kindelement lässt sich per Insets-Objekt ein Außenrand definieren (mit einer indi-
viduellen oberen, rechten, unteren und linken Breite), z. B.:
double dist = 10.0;
Insets insets = new Insets(dist, dist, dist, dist);
BorderPane.setMargin(btnRemove, insets);
Im folgenden Quellcode-Segment wird der im Abschnitt 13.7.3 erstellte HBox-Container mit den
drei oberen Bedienelementen des Beispielprogramms einbezogen:
double dist = 10.0;
double lblw = 70.0;
double btnw = 100.0;
Insets insets = new Insets(dist, dist, dist, dist);
root.setTop(top);
root.setLeft(lblPresent);
root.setCenter(lvPersons);
root.setRight(btnRemove);
Wie zu Beginn des Abschnitts erwähnt, wurde die BorderPane-Klasse für ein anderes Anwen-
dungs-Layout konzipiert.
13.7.5 FlowPane
Je nach Orientierung eines FlowPane-Containers werden die Knoten neben bzw. untereinander
angeordnet, wobei nach Erreichen des Randes eine neue Zeile bzw. Spalte startet.
In der folgenden start() - Methode eines JavaFX-Programms nimmt ein FlowPane-Layoutmanager
mit der voreingestellten horizontalen Orientierung 10 Button-Objekte auf:
public void start(Stage stage) {
double dist = 10.0;
int nob = 10;
double csize = 40.0;
Der Layoutmanager erhält per setPadding() einen Innenrand. Mit setHgap() bzw. setVgap() wird
ein horizontaler bzw. vertikaler Abstand zwischen den enthaltenen Knoten vereinbart:
root.setPadding(new Insets(dist, dist, dist, dist));
root.setHgap(dist); root.setVgap(dist);
Per Voreinstellung werden die Kindelemente linksbündig angeordnet und nach Erreichen der Con-
tainer-Breite umgebrochen:
Abschnitt 13.7 Layoutmanager 673
13.7.6 StackPane
Von einem StackPane-Layoutmanager werden die Kindelemente übereinander gestapelt (senkrecht
zur Display-Ebene), sodass unten liegende Elemente (teilweise) überdeckt werden. In der folgenden
start() - Methode eines JavaFX-Programms nimmt ein StackPane-Layoutmanager 5 quadratische
Button-Objekte mit sukzessiv schrumpfender Kantenlänge auf:
public void start(Stage stage) {
int nob = 5;
double csize = 40.0;
stage.setTitle("StackPane");
stage.setScene(new Scene(root, 250, 250));
stage.show();
}
Die resultierende Schalterkombination kommt vielleicht in einer sehr speziellen Anwendung als
innovatives Bedienelement in Frage:
674 Kapitel 13 GUI-Programmierung mit JavaFX
javafc.scene.Node
javafc.scene.Parent
javafc.scene.layout.Region
javafc.scene.control.Control
RadioButton
13.8.1 Label
Mit Komponenten der Klasse Label aus dem Paket javafx.scene.control realisiert man Bedie-
nungshinweise in Schrift- und/oder Bildform. Im folgenden Beispielprogramm zum Zählen von
Vorkommnissen und Objekten aller Art
Button
Label mit Text Label mit Grafik
Abschnitt 13.8 Elementare Steuerelemente 675
befindet sich unter dem Befehlsschalter ein Label zur Anzeige des aktuellen Zählerstands, das fol-
gendermaßen instanziert wird:
String lblPrefix = "Zählerstand: ";
Label lblText = new Label(lblPrefix + "0");
Für Textänderungen im Programmablauf verwendet man die Label-Methode setText(), z. B.:
lblText.setText(lblPrefix + numClicks);
Das zweite Label im aktuellen Beispiel dient zur Anzeige von Bilddateien (Java-Maskottchen Duke
in verschiedenen Posen), die von Image-Objekten repräsentiert werden. Bevor ein Image-Objekt
mit der Label-Methode setGraphic() seiner Verwendung zugeführt werden kann, muss es noch in
ein ImageView-Objekt verpackt werden:
Image[] icons = new Image[3];
icons[0] = new Image(getClass().getResourceAsStream("duke.png"));
icons[1] = new Image(getClass().getResourceAsStream("fight.gif"));
icons[2] = new Image(getClass().getResourceAsStream("snooze.gif"));
ImageView imageView = new ImageView(icons[0]);
Label lblIcon = new Label();
lblIcon.setGraphic(imageView);
Als Grafikformate unterstützt die Klasse Image:
• BMP (Bitmap)
• GIF (Graphics Interchange Format)
• JPEG (Joint Photographic Experts Group)
• PNG (Portable Network Graphics).
Um das Icon im Programmablauf auszutauschen, erhält das ImageView-Objekt mit der Methode
setImage() eine neue Füllung, z. B.:
imageView.setImage(icons[iconInd]);
Neben Label-Objekten lassen sich auch diverse andere JavaFX-Komponenten mit Bildern verschö-
nern (z. B. Befehlsschalter).
Text und Grafik können auch gemeinsam auftreten, wobei die Eigenschaft ContentDisplay über
die relative Anordnung von Text und Grafik entscheidet. Im folgenden Codesegment
Label lblIcon = new Label("Duke");
lblIcon.setGraphic(imageView);
lblIcon.setContentDisplay(ContentDisplay.TOP);
sorgt ein Aufruf der Methode setContentDisplay() mit dem Parameterwert ContentDisplay.TOP
dafür, dass die Grafik über dem Text erscheint. Anschließend sind einige Anordnungen zu sehen:
ContentDisplay.LEFT
ContentDisplay.TOP ContentDisplay.BOTTOM
(= Voreinstellung)
lblIcon.setGraphicTextGap(10);
Mit der im Abschnitt 13.5.3.1 beschriebenen Technik zum Verbinden von JavaFX-Eigenschaften
kann im Beispiel dafür gesorgt werden, dass die Höhe des ImageView-Objekts (seine Eigenschaft
fitHeight) an die Höhe des BorderPane-Containers (an dessen Eigenschaft height) gebunden wird:
imageView.fitHeightProperty().bind(root.heightProperty().subtract(2*dist));
Im Beispiel sorgt die Methode subtract() aus dem High-Level Binding-API (siehe Abschnitt
13.5.3.2.1) dafür, dass der Innenrand des Containers berücksichtigt wird. Es resultiert eine Grafik
mit dynamisch angepasster Höhe:
13.8.2 Button
Befehlsschalter werden in JavaFX durch die Klasse Button aus dem Paket javafx.scene.control
realisiert. Die Syntax zum Deklarieren bzw. Erzeugen eines Schalters mit Beschriftung bietet kei-
nerlei Überraschungen:
Button btnAdd = new Button("Zählerstand erhöhen");
Das im Abschnitt 13.8.1 vorgestellte Mehrzweckzählprogramm besitzt einen Schalter, der nach
einem Klickereignis den Zählerstand erhöht, die Anzeige des Text-Labels aktualisiert und als Prä-
vention gegen die mögliche Ermüdung des zählenden Benutzers das Image-Objekt austauscht. Die-
se Arbeiten verrichtet aber nicht das Button-Objekt selbst, sondern ein dort per setOnAction() re-
gistriertes Objekt vom Typ EventHandler<ActionEvent>. Seit Java 8 kann man einen Ereignis-
empfänger per Lambda-Ausdruck realisieren (siehe Abschnitt 12.1):
btnAdd.setOnAction(event -> {
numClicks++;
lblText.setText(lblPrefix + numClicks);
if (iconInd < icons.length-1)
iconInd++;
else
iconInd = 0;
imageView.setImage(icons[iconInd]);
});
Ein Aufruf der Methode setMnemonicParsing() mit dem Parameterwert true sorgt bei Labeled-
Objekten (also insbesondere auch bei Button-Objekten) dafür, dass der erste Unterstrich in der Be-
schriftung eine besondere Bedeutung erhält:
Abschnitt 13.8 Elementare Steuerelemente 677
• Für das auf den ersten Unterstrich folgende Zeichen wird eine Alt-Tastenkombination ver-
einbart.
• Der erste Unterstrich wird im Programm nicht angezeigt.
Ist eine Alt-Tastenkombination vereinbart, dann wird im Programmablauf nach Betätigung der Alt-
Taste das auf den ersten Unterstrich folgende Zeichen unterstrichen. Solange dieser Zustand nicht
eine weitere Betätigung der Alt-Taste aufgehoben wird, hat die Eingabe des unterstrichenen Zei-
chens denselben Effekt wie ein Mausklick auf das Steuerelement.
Im folgenden Code-Segment
Button btnAdd = new Button("Zählerstand _erhöhen");
btnAdd.setMnemonicParsing(true);
wird für einen Befehlsschalter mit Unterstrich im Beschriftungstext die (per Voreinstellung inakti-
ve) Kurzwahldefinition aktiviert:
Besitzt ein Schalter den Eingabefokus (erkennbar an einem glimmenden Rand, siehe Beispiel),
dann kann per Voreinstellung sein Klickereignis auch mit der Enter- und der Leertaste ausgelöst
werden. Zu jedem Zeitpunkt besitzt in einem JavaFX-Programm genau ein Knoten den Eingabefo-
kus, und dieses Objekt wird zum Ziel für Tastaturereignisse. Mit der Node-Methode
requestFocus() kann ein Knoten den Eingabefokus anfordern:
public void requestFocus()
Im Beispielprogramm hat der Schalter den Fokus sicher, weil keine weiteren fokussierbaren Steue-
relemente vorhanden sind.
Für einen Schalter pro Fenster kann man das generelle Auslösen per Enter-Taste ermöglichen, in-
dem man ihn durch einen Aufruf der Methode setDefaultButton() zum Standardschalter ernennt,
z. B.:
btnAdd.setDefaultButton(true);
Dann wird das Klickereignis des Schalters auch dann per Enter-Taste ausgelöst, wenn ein anderes
Steuerelement den Eingabefokus besitzt. Während (bei Verwendung des voreingestellten JavaFX-
Designs) ein Schalter mit Eingabefokus an einem glimmenden Rand zu erkennen ist (siehe vorheri-
ges Bildschirmfoto), ist beim Standardschalter der Innenraum blau gefärbt. Aktuell sind im Bei-
spielprogramm für den einzigen vorhandenen Schalter beide Rollen (Fokusinhaber, Standardschal-
ter) und dementsprechend beide Hervorhebungen aktiv:
678 Kapitel 13 GUI-Programmierung mit JavaFX
Sie stammt wie die (im Manuskript nicht behandelte) Klasse TextArea zur Erfassung von mehrzei-
ligem Text von der Klasse TextInputControl ab:
TextInputControl
TextField TextArea
PasswordField
import javafx.application.Application;
. . .
import javafx.stage.Stage;
Label label=new Label("Bitte Vor- und Familiennamen eintragen und mit Enter quittieren:");
TextField name = new TextField();
root.getChildren().addAll(label, name);
root.setAlignment(Pos.CENTER);
name.setOnAction(e -> {
Alert alert = new Alert(AlertType.INFORMATION, "Sie heißen " + name.getText());
alert.showAndWait();
});
stage.setScene(new Scene(root));
stage.setTitle("TextField-Demo");
stage.show();
}
Das Tabulatorzeichen wird von einer TextField-Komponente nicht entgegengenommen, weil die
Tabulatortaste in der Regel den Eingabefokus zum nächsten Steuerelement bewegt. Ist hingegen
eine TextArea-Komponente der Fokusinhaber, dann wird durch dieselbe Taste ein Tabulatorzei-
chen in den Text eingefügt.
Drückt der Benutzer die Enter-Taste, während eine TextField-Komponente den Eingabefokus be-
sitzt, dann wird ein ActionEvent ausgelöst. Im Beispiel präsentiert der per Lambda-Ausdruck reali-
sierte Event Handler daraufhin einen Benachrichtigungs-Standarddialog mit dem erfassten Text,
den er über die TextInputControl-Methode getText() ermittelt:
Statt der voreingestellten und im Beispiel angemessenen linksbündigen Ausrichtung des Textfeld-
inhalts kann mit der TextField-Methode setAlignment() unter Verwendung eines Parameterobjekts
680 Kapitel 13 GUI-Programmierung mit JavaFX
vom Aufzählungstyp Pos (aus dem Paket javafx.geometry) auch eine zentrierte oder rechtsbündige
Ausrichtung gewählt werden, z. B.:
name.setAlignment(Pos.BASELINE_RIGHT);
Rechtsbündige Textfelder sind bei der Erfassung von Zahlen zu bevorzugen.
Mit setEditable(false) wird für eine TextField-Komponente festgelegt, dass ihr Text vom Benutzer
nicht geändert werden darf.
Zum Erfassen von Passwörtern steht die von TextField abstammende Klasse PasswordField be-
reit, die im Unterschied zu ihrer Basisklasse für jedes eingegebene Zeichen einen Punkt anzeigt,
z. B.:
13.8.4 Umschalter
In diesem Abschnitt werden zwei Klassen für Umschalter vorgestellt:
• Für Kontrollkästchen, die jeweils einen Zustand (de)aktivieren, steht die Klasse CheckBox
zur Verfügung.
• Für eine Gruppe von mehreren Schaltern, von denen zur Wahl einer Alternative aus mehre-
ren Optionen genau einer eingerastet werden kann, verwendet man Komponenten vom Typ
RadioButton.
Im folgenden Programm kann für den Text einer Label-Komponente über zwei Kontrollkästchen
die Schriftauszeichnung und über eine Radioschaltergruppe die Schriftart gewählt werden:
13.8.4.1 Kontrollkästchen
Im Beispielprogramm werden für die Schriftauszeichnungen fett und kursiv zwei CheckBox-
Komponenten verwendet, die im Konstruktoraufruf eine passende Beschriftung erhalten:
private CheckBox cbBold = new CheckBox("Fett"),
cbItalic = new CheckBox("Kursiv");
Es kommen Instanzvariablen zum Einsatz, weil die Komponenten nicht nur in der Application-
Methode start() angesprochen werden sollen.
Per Voreinstellung sind als Zustände eines Kontrollkästchens die beiden Werte der booleschen Ei-
genschaft selected relevant. Über die Methode setAllowIndeterminate() lässt sich zusätzlich die
Abschnitt 13.8 Elementare Steuerelemente 681
boolesche Eigenschaft indeterminate aktivieren, sodass ein Kontrollkästchen drei Zustände an-
nehmen kann, die nacheinander per Mausklick erreicht werden:
Zustand Wert der Eigenschaft Wert der Eigenschaft Anzeige
indeterminate selected
unbestimmt true false
gewählt false true
nicht gewählt false false
Im Beispiel wird auf den dritten Zustand verzichtet.
Aus Layout-Gründen werden die beiden Kontrollkästchen in einem eigenen VBox-Container unter-
gebracht, der am linken Rand des Fensters Platz nehmen soll. Als Top-Level-Container wird ein
GridPane-Objekt mit einer Zeile und drei Spalten verwendet:
GridPane root = new GridPane();
VBox vboxCheck = new VBox(dist);
root.add(vboxCheck, 0, 0);
vboxCheck.getChildren().addAll(cbBold, cbItalic);
Bei den selected-Eigenschaften der beiden CheckBox-Objekte registrieren wir einen per Metho-
denreferenz (siehe Abschnitt 12.1.3.1) realisierten Interessenten für Veränderungsereignisse:
cbBold.selectedProperty().addListener(this::cbChanged);
cbItalic.selectedProperty().addListener(this::cbChanged);
In der realisierenden Methode cbChanged() wird überprüft, zu welchem CheckBox-Objekt eine
selected-Änderung gemeldet wird. Dann wird in Anhängigkeit vom gemeldeten neuen Wert (true
oder false) die Instanzvariable fontWeight (Typ FontWeight, speichert die Fettauszeichnung)
bzw. die Instanzvariable fontPosture (Typ FontPosture, speichert die Kursivauszeichnung)
private FontWeight fontWeight = FontWeight.NORMAL;
private FontPosture fontPosture = FontPosture.REGULAR;
auf den neuen Wert gesetzt:
private void cbChanged(ObservableValue<? extends Boolean> obs,
Boolean old, Boolean nev) {
if (obs.equals(cbBold.selectedProperty()))
if (nev == true)
fontWeight = FontWeight.BOLD;
else
fontWeight = FontWeight.NORMAL;
else
if (nev == true)
fontPosture = FontPosture.ITALIC;
else
fontPosture = FontPosture.REGULAR;
lblBeispiel.setFont(Font.font(lblBeispiel.getFont().getFamily(),
fontWeight, fontPosture, fontSize));
}
Schließlich erhält die Label-Komponente per setFont() eine neue Schriftart. Das benötigte Objekt
aus der Klasse Font (im Paket javafx.scene.text) wird mit der statischen Font-Methode font() pro-
duziert. Wir verwenden eine font() - Überladung mit vier Parametern:
682 Kapitel 13 GUI-Programmierung mit JavaFX
• String family
Von den im lokalen System vorhandenen Schriftartfamilien wird die am besten passende
gewählt. Im Beispiel wird die vom Label-Objekt aktuell verwendete Schriftart mit
getFont() ermittelt und dann mit getFamily() nach ihrer Familienzugehörigkeit befragt.
• javafx.scene.text.FontWeight weight
Die Werte im Enumerationstyp FontWeight stehen für aufsteigend geordnete Schriftstärken
(THIN, EXTRA_LIGHT, LIGHT, NORMAL, MEDIUM, SEMI_BOLD, BOLD,
EXTRA_BOLD, BLACK). In der Regel sind nur die Schriftstärken NORMAL und BOLD
realisiert.
• javafx.scene.text.FontPosture posture
Die verfügbaren Werte im Enumerationstyp FontWeight sind REGULAR und ITALIC.
• double size
Die Schriftgröße wird in der Einheit Punkt (= 1/72 Zoll) angegeben.
13.8.4.2 Radioschalter
Die drei RadioButton-Objekte des Umschalter-Beispielprogramms (siehe Einstieg von Abschnitt
13.8.4) erhalten per Konstruktorparameter eine Beschriftung:
private RadioButton rbSans = new RadioButton("Sans Serif"),
rbSerif = new RadioButton("Serif"),
rbMono = new RadioButton("Monospaced");
Es kommen Instanzvariablen zum Einsatz, weil die Komponenten nicht nur in der Application-
Methode start() angesprochen werden sollen.
Im Beispielprogramm sind die Optionsschalter in einem eigenen VBox-Container untergebracht,
der sich in der mittleren Spalte des GridPane - Root-Containers befindet:
GridPane root = new GridPane();
VBox vboxRadio = new VBox(dist);
root.add(vboxRadio, 1, 0);
vboxRadio.getChildren().addAll(rbSans, rbSerif, rbMono);
Damit von den drei RadioButton-Objekten maximal eines ausgewählt sein kann, werden sie in ein
Objekt aus der Klasse ToggleGroup gesteckt:
ToggleGroup rbGroup = new ToggleGroup();
rbGroup.getToggles().addAll(rbSans, rbSerif, rbMono);
rbSans.setSelected(true);
Über die Methode getToggles() erhält man eine beobachtbare Liste (vgl. Abschnitt 13.5.3.3), in die
per addAll() die Optionsschalter aufgenommen werden. Mit der RadioButton-Methode
setSelected() wird im Beispielprogramm dafür gesorgt, dass beim Programmstart der Radioschalter
zur schnörkellosen Schriftart ausgewählt ist.
Das ausgewählte Element erfährt man von einem ToggleGroup-Objekt über seine Eigenschaft
selectedToggle, bei der wir einen per Methodenreferenz (siehe Abschnitt 12.1.3.1) implementierten
Change-Listener registrieren:
rbGroup.selectedToggleProperty().addListener(this::rbChanged);
In der realisierenden Methode rbChanged() wird passend zum ausgewählten Element die Schrift-
familie des Label-Objekts neu festgelegt:
Abschnitt 13.9 Modulare JavaFX-Anwendung ausliefern 683
private String ffSans = "Arial", ffSerif = "Times New Roman", ffMono = "Courier New";
. . .
private void rbChanged(ObservableValue<? extends Toggle> obs,
Toggle old, Toggle nev) {
String family = null;
if (nev == rbMono) {
family = ffMono;
} else if (nev == rbSerif) {
family = ffSerif;
} else if (nev == rbSans) {
family = ffSans;
}
lblBeispiel.setFont(Font.font(family, fontWeight, fontPosture, fontSize));
}
Nach einem Klick auf das Ordnersymbol wählen wir Attendance als Hauptklasse:
684 Kapitel 13 GUI-Programmierung mit JavaFX
Wird das Projekt anschließend z. B. über dem Symbol erstellt, dann erhält man im Ausgabeord-
ner
Ist auf dem Kundenrechner eine OpenJFX 17 - Installation vorhanden, dann muss nur die modulare
jar-Datei ausgeliefert werden, die z. B. unter dem gekürzten Namen Attendance.jar im Ordner
U:\Eigene Dateien\Java
abgelegt werden kann. Wenn ...
• sich der bin-Unterordner aus dem OpenJDK 17 im Windows-Pfad für ausführbare Pro-
gramme befindet,
• und OpenJFX im Ordner C:\Program Files\Java\OpenJFX-SDK-17 installiert ist,
dann kann das Programm gemäß Abschnitt 6.2.6 in jedem Konsolenfenster mit dem folgenden
Kommando gestartet werden:1
java -p "C:\Program Files\Java\OpenJFX-SDK-17\lib;U:\Eigene
Dateien\Java\Attendance.jar"
-m de.uni_trier.zimk.attendance/de.uni_trier.zimk.attendance.Attendance
Soll auf dem Kundenrechner keine OpenJFX 17 - Installation vorausgesetzt werden, dann muss der
von IntelliJ erstellte Ausgabeordner (im Umfang von ca. 9 MB) komplett (inklusive JavaFX) ausge-
liefert werden. Wenn ...
• sich der bin-Unterordner aus dem OpenJDK 17 im Windows-Pfad für ausführbare Pro-
gramme befindet,
• und das Programm im Ordner U:\Eigene Dateien\Java\Attendance abgelegt wurde,
dann kann das Programm in jedem Konsolenfenster mit dem folgenden Kommando gestartet wer-
den:
> java -p "U:\Eigene Dateien\Java\Attendance"
-m de.uni_trier.zimk.attendance/de.uni_trier.zimk.attendance.Attendance
1
Wenn beim Erstellen der modularen jar-Datei über die Option --main-class eine Hauptklasse deklariert wurde,
dann muss diese im Startkommando nicht genannt werden (vgl. Abschnitt 6.2.6). Es sollte gelingen, eine analoge
Option in die Maven-Konfigurationsdatei pom.xml einzubauen.
686 Kapitel 13 GUI-Programmierung mit JavaFX
2) Erstellen Sie ein Programm mit einem Schieberegler (Klasse Slider im Paket
javafx.scene.control), dessen Wert per unidirektionaler Bindung von einem Label angezeigt wird:
Hinweise:
• Im Slider-Konstruktor wählt man das Minimum, das Maximum und den Startwert.
• Den Abstand der Hauptunterteilungspunkte legt man mit der Slider-Methode
setMajorTickUnit() fest.
• Die Anzahl der Nebenunterteilungspunkte zwischen zwei Hauptunterteilungspunkten wählt
man mit der Slider-Methode setMinorTickCount().
• Mit der Slider-Methode setSnapToTicks() sorgt man dafür, dass nur die zu (Ne-
ben)unterteilungspunkten gehörigen Werte einstellbar sind.
• Mit der folgenden Anweisung
label.textProperty().bind(slider.valueProperty().asString("%4.2f"));
bindet man die text-Eigenschaft des Labels an die value-Eigenschaft des Schiebereglers,
wobei der Reglerwert unter Beachtung einer Formatvorschrift in ein String-Objekt gewan-
delt wird. Dank Property-Binding kommt man ohne Ereignisbehandlung aus.
Tipps:
• Für das Konvertierungsergebnis wird im Lösungsvorschlag eine Label-Komponente ver-
wendet:
lblErgebnis = new Label();
lblErgebnis.setStyle("-fx-border-color: lightblue");
int distErg = 5;
lblErgebnis.setPadding(new Insets(distErg, distErg, distErg, distErg));
lblErgebnis.setAlignment(Pos.BASELINE_RIGHT);
Der Rand wird mit Hilfe eines CSS-Stilattributs festgelegt. JavaFX erlaubt eine individuelle
Gestaltung der Optik über Cascading Style Sheets, die im Kurs leider nicht behandelt wer-
den kann.1
1
CSS-Tutorial: https://docs.oracle.com/javafx/2/css_tutorial/jfxpub-css_tutorial.htm
Farbnamen: https://openjfx.io/javadoc/17/javafx.graphics/javafx/scene/doc-files/cssref.html#typecolor
Abschnitt 13.10 Übungsaufgaben zum Kapitel 13 687
14.1 Grundlagen
14.1.1 Datenströme
In Java wird die sequentielle Datenein- und -ausgabe über sogenannte Ströme (engl. streams) abge-
wickelt. Ein Programm liest Bytes3 aus einem Eingabestrom, der aus einer Datenquelle (z. B. Da-
tei, Array, Eingabegerät, Netzwerkverbindung) gespeist wird:
1
Leider ist das Kapitel über die Datenbankprogrammierung mangels Zeit für die erforderliche Aktualisierung derzeit
nicht im Manuskript enthalten.
2
Die Channel-Technik setzt verstärkt auf innovative Ein-/Ausgabe - Kompetenzen des zugrundeliegenden Betriebs-
systems. Ein FileChannel-Objekt transportiert in der Regel Daten zwischen einer Datei und einem ByteBuffer-
Objekt, das als programminterner Zwischenspeicher dient. Wenn über die statischen Methoden newOutput-
Stream(), newInputStream(), newBufferedWriter() oder newBufferedReader() der Klasse Files ein Datenstrom-
objekt angefordert wird, dann kommt im Hintergrund die Channel-Technik zum Einsatz.
3
Wenn im Kapitel 14 der Namensteil Byte auftaucht, ist keine Java-Wrapper-Klasse gemeint, sondern eine 8 Bit
umfassende Informationseinheit der Datenverarbeitung.
690 Kapitel 14 Ein- und Ausgabe über Datenströme
0 1 2 3 4 5 6 7 Programm-
Quelle
Variablen
Ein Programm schreibt Bytes in einen Ausgabestrom, der die Werte von Programmvariablen zu
einer Datensenke befördert (z. B. Datei, Array, Ausgabegerät, Netzverbindung):
Programm- 0 1 2 3 4 5 6 7 Senke
Variablen
In der Regel kommen externe Quellen bzw. Senken zum Einsatz (Dateien, Geräte, Netzwerkverbin-
dungen). Gelegentlich werden aber programminterne Objekte per Datenstromtechnik angesprochen
(z. B. byte-Arrays, String-Objekte).
Mit dem Datenstromkonzept wird bezweckt, Anweisungen zur Ein- oder Ausgabe von Daten mög-
lichst unabhängig von den Besonderheiten konkreter Datenquellen und -senken formulieren zu
können.
Ein- bzw. Ausgabeströme werden in Java-Programmen durch Objekte aus Klassen des Pakets
java.io repräsentiert. Dort finden sich auch Datenstromklassen zum Transport von höheren Daten-
typen, die intern einen Byte-Strom mit direktem Kontakt zur Quelle bzw. Senke verwenden.
Datenströme, die von Beginn an zum Java-API gehören, haben zwar eine konzeptionelle Verwandt-
schaft mit den (z. B. auf den Elementen von Kollektionen basierenden) Strömen, die in Java 8 zur
Unterstützung der funktionalen Programmierung und der Parallelverarbeitung eingeführt wurden
(siehe Abschnitt 12.2), doch weichen die technischen Realisierungen stark voneinander ab.
14.1.2 Beispiel
Das folgende Programm schreibt einen byte-Array in eine Datei und liest die Daten anschließend
wieder zurück:
Quellcode Ausgabe
import java.io.*; 0
import java.nio.file.*; 1
2
class IOIntro { 3
public static void main(String[] args) throws IOException { 4
byte[] arro = {0, 1, 2, 3, 4, 5, 6, 7}; 5
byte[] arri = new byte[8]; 6
Path file = Paths.get("demo.bin"); 7
Zum Schreiben wird über die statische Methode newOutputStream() der Klasse Files im Paket
java.nio.file das Ausgabestromobjekt os mit einem von der Basisklasse OutputStream abstam-
menden Typ erzeugt und mit der vom Path-Parameterobjekt file repräsentierten Datei verbunden.
Nachdem das Schreiben durch die Methode write() erledigt ist, wird die Datei geschlossen. Dies
geschieht über die seit Java 7 verfügbare try-Anweisung mit automatischer Ressourcenfreigabe
(vgl. Abschnitt 11.10.2).
Zum Lesen wird auf analoge Weise das Eingabestromobjekt is mit einem von der Basisklasse
InputStream abstammenden Typ erzeugt und mit der zuvor gefüllten Datei verbunden. Eine try-
Anweisung mit automatischer Ressourcenfreigabe sorgt wieder dafür, dass nach dem Lesen per
read() - Methode die nicht mehr benötigte Datei schnell und garantiert (unter allen Umständen)
freigegeben wird (durch einen close() - Aufruf hinter den Kulissen).
Bei der Konstruktion eines Datenstromobjekts sowie beim Lesen bzw. Schreiben von Daten kann es
zu einer von IOException abstammenden Ausnahme kommen, die entweder in einer try-catch -
Anweisung abgefangen oder im Definitionskopf der betroffenen Methode deklariert werden muss
(vgl. Abschnitt 11.5.2). Das Beispielprogramm beschränkt sich der Einfachheit halber auf eine De-
klaration der Ausnahmeklasse IOException und wird infolgedessen im Fehlerfall nach einer Stack
Trace - Ausgabe von der JVM beendet (vgl. Abschnitt 11.2).
In realen Anwendungen werden statt Bytes in der Regel höhere Datentypen (z. B. Unicode-Zeichen,
double-Werte, beliebige Objekte) geschrieben oder gelesen. Trotzdem ist das Beispiel nicht über-
flüssig, weil es die Verwendung von byte-orientierten Datenströmen vorführt, auf denen die Ströme
für höhere Datentypen basieren.
ChannelInputStream
DataInputStream
PipedInputStream
BufferedInputStream
InputStream FilterInputStream
InflaterInputStream
ByteArrayInputStream
PushbackInputStream
SequenceInputStream
ObjectInputStream
In den Klassendiagrammen des aktuellen Kapitels werden die Ein- bzw. Ausgabeklassen mit einen
weißen, und die Transformationsklassen mit einem grauen Hintergrund dargestellt.
Programm
primitive Quelle
Daten- DataInput- Input-
Bytes von
typen, Stream Stream
Bytes
String
Wird für das DataInputStream-Objekt die close() - Methode aufgerufen (vgl. Abschnitt 14.1.5),
dann leitet es diese Botschaft an das verbundene InputStream-Objekt weiter.
Im folgenden Beispielprogramm kooperieren ein DataInputStream-Objekt und ein FileIn-
putStream-Objekt dabei, int-Werte aus einer Datei zu lesen. Zuvor werden diese int-Werte in die-
selbe Datei geschrieben, wobei ein Objekt der Ausgabetransformationsklasse DataOutputStream
und ein Objekt der Ausgabeklasse FileOutputStream kooperieren. Hier zerlegt der Filter die int-
Werte in einzelne Bytes und schiebt sie in den Ausgabestrom.
Quellcode Ausgabe
import java.io.*; 1024
import java.nio.file.*; 2048
4096
class Filterklassen { 8192
public static void main(String[] args) throws IOException {
Path file = Paths.get("demo.dat");
int[] iar = {1024, 2048, 4096, 8192};
1
https://docs.oracle.com/javase/tutorial/java/IandI/objectclass.html
Abschnitt 14.1 Grundlagen 695
Ein Transformationsobjekt gibt einen close() - Aufruf an den zugrunde liegenden Datenstrom wei-
ter, sodass bei Datenstromkopplungen von beliebiger Komplexität normalerweise ein close() - Auf-
ruf an das oberste Objekt genügt.1
Seit Java 7 stehen zwei Möglichkeiten zur Verfügung, für die garantierte Ausführung eines close() -
Aufrufs (auch bei Ausnahmefehlern) zu sorgen (vgl. Abschnitt 11.10). Die ältere Technik besteht
darin, kritische Ein- bzw. Ausgabemethoden im überwachten Block einer try-catch-finally - An-
weisung aufzurufen und die erforderlichen close() - Aufrufe im finally-Block vorzunehmen (vgl.
Abschnitt 11.10.1). Beim korrekten Schließen von Ressourcen kommt es im Handbetrieb leicht zu
Fehlern, und Evans & Flanagan (2015, S. 294) berichten von einer Einschätzung durch Software-
Entwickler der Firma Oracle, wonach in der ursprünglichen JDK-Version 6 ca. 60% des Codes zur
Behandlung von Ressourcen fehlerhaft war.
Seit Java 7 lässt sich das Schließen der in einem try-Block benötigten Ressourcen automatisieren
(try with resources). Dazu deklariert man Objekte, die baldmöglichst zu schließende Ressourcen
repräsentieren, nach dem Schlüsselwort try zwischen runden Klammern in einer ein- oder
mehrelementigen Liste (siehe Abschnitt 11.10.2).2 Beteiligte Klassen müssen das Interface
AutoCloseable implementieren, was bei den Datenstromklassen im Java-API (also bei den Ablei-
tungen der Klassen InputStream, OutputStream, Reader und Writer) der Fall ist. Der Compiler
sorgt dafür, dass erforderliche close() - Aufrufe hinter den Kulissen unter allen Umständen automa-
tisch und garantiert erfolgen.
Im folgenden Beispiel werden in der Methode mean() mit Hilfe eines Objekts vom Typ DataIn-
putStream, das intern ein Objekt vom InputStream verwendet, double-Werte aus einer Datei ge-
lesen, um den Mittelwert daraus zu berechnen:
import java.io.*;
import java.nio.file.*;
class TryWithResources {
static double mean(Path file) throws IOException {
double sum = 0.0;
int n = 0;
try (DataInputStream dis = new DataInputStream(Files.newInputStream(file))) {
while (dis.available() > 0) {
n++;
sum += dis.readDouble();
}
}
return sum/n;
}
1
Es kann allerdings der (mehr oder weniger unwahrscheinliche) Fall auftreten, dass nach dem erfolgreichen Öffnen
eines Ein- bzw. Ausgabestroms ein geplantes Filterstromobjekt nicht zustande kommt. In dieser Lage hätte ein clo-
se() - Aufruf an das nicht existente Filterobjekt eine NullPointerException zur Folge, und der Ein- bzw. Ausga-
bestrom bliebe eventuell offen.
2
In der Programmiersprache C# bietet die using-Anweisung eine analoge Funktionalität (siehe z. B. Baltes-Götz
2021, Abschnitt 16.2.3).
696 Kapitel 14 Ein- und Ausgabe über Datenströme
1
https://docs.oracle.com/javase/tutorial/essential/exceptions/tryResourceClose.html
2
Unter Windows ist zu beachten, dass die hier verbreiteten Verknüpfungen (mit der Dateinamenserweiterung .lnk)
keine symbolischen Links sind. Dies sind gewöhnliche Dateien, die vom Windows-Explorer speziell behandelt wer-
den. Man kann ab Windows Vista bzw. Windows Server 2008 auf einem Datenträger mit dem Dateisystem NTFS
mit dem Kommando MKLNK einen symbolischen Link erstellen, wobei administrative Rechte erforderlich sind,
z. B.:
Erfolgt nach diesem Kommando ein Schreibzugriff auf SymLink.txt, dann landen die Daten in Ausgabe.txt:
Abschnitt 14.2 Verwaltung von Dateien und Verzeichnissen 697
Das Beispiel stammt offenbar von einem Windows-Rechner, und die ersten drei Knotennamen be-
zeichnen zusammen das Heimatverzeichnis des Benutzers otto. Mit Hilfe der statischen System-
Methode getProperty() lässt sich das Heimatverzeichnis des angemeldeten Benutzers unabhängig
vom konkreten Benutzernamen und von der Plattform ansprechen, z. B.:
Path p0 = Paths.get(System.getProperty("user.home"),"Documents","java","io","ausgabe.txt");
Es ist auch erlaubt, beim get() - Aufruf einen kompletten Pfad in einem String-Objekt unterzubrin-
gen, wobei dann das plattformspezifische Trennzeichen zu verwenden ist. Unter Windows sind der
Rückwärtsschrägstrich (verdoppelt zur Unicode-Escape-Sequenz) und der gewöhnliche Schräg-
strich erlaubt, z. B.:
Path p1 = Paths.get("U:\\Eigene Dateien\\Java\\io\\ausgabe.txt");
Path p1 = Paths.get("U:/Eigene Dateien/Java/io/ausgabe.txt");
Eine das Interface Path implementierende Klasse beherrscht die folgenden Instanzmethoden:
• public Path getFileName()
Liefert das relative Path-Objekt zum Zielknoten, z. B.:
Quellcodesegment toString() - Ergebnis
p1.getFileName() ausgabe.txt
Das klappt auch, wenn der Zielknoten ein Ordner ist.
• public Path getParent()
Liefert das übergeordnete Path-Objekt, z. B.:
Quellcodesegment toString() - Ergebnis
p1.getParent() U:\Eigene Dateien\Java\io
698 Kapitel 14 Ein- und Ausgabe über Datenströme
14.2.1.2 Existenzprüfung
Mit der statischen Files-Methode exists() findet man für ein Path-Objekt heraus, ob es bereits einen
Dateisystemeintrag (Datei, Verzeichnis, symbolischer Link) mit diesem Pfad gibt, z. B.:
if (Files.exists(dir))
System.out.println(dir + " existiert bereits.");
else
if (Files.notExists(dir))
System.out.println(dir + " existiert noch nicht");
else
System.out.println(dir + " hat einen unbekannten Status.");
Als Ursache für den exist() - Rückgabewert false kommt auch ein Zugriffsproblem in Frage.
Dass zum Zeitpunkt der Abfrage kein Dateisystemeintrag mit dem fraglichen Pfad vorhanden war,
beweist die Rückgabe true der statischen Files-Methode notExists().
Wie im Java-Tutorial (Oracle 2021) zu Recht betont wird, sollte sich ein Programm anschließend
(z. B. nach dem Verstreichen von etlichen Millisekunden) nicht auf das Existenzprüfungsergebnis
verlassen, weil ein TOCTTOU-Fehler droht (Time of check to time of use).
700 Kapitel 14 Ein- und Ausgabe über Datenströme
Statt für mehrere Attribute eines Dateisystemobjekts jeweils eine zeitaufwändige Anfrage an das
Dateisystem zu richten, sollte man über die Files-Methode readAttributes() ein Informationsbün-
del mit allen Basis-, DOS- oder POSIX-Attributen eines Dateisystemobjekts anfordern, z. B.:
BasicFileAttributes attr = Files.readAttributes(file, BasicFileAttributes.class);
Durch den zweiten Parameter mit dem geforderten Typ Class<? extends BasicFileAttributes>
wird der gewünschte Rückgabetyp vereinbart. Gibt man (wie im Beispiel) das Class-Objekt zur
Schnittstelle BasicFileAttributes (im Paket java.nio.file.attribute) an, dann erhält man ein Objekt,
das elementare, von vielen Dateisystemen unterstützte Attribute kapselt. Vom Rückgabeobjekt sind
später die Attribute ohne Dateisystemzugriffe zu erfahren, z. B.:
System.out.println(" Größe in Bytes: " + attr.size());
702 Kapitel 14 Ein- und Ausgabe über Datenströme
Streng genommen kann man sich schon nach kurzer Zeit nicht mehr darauf verlassen, dass die er-
mittelten Zugriffsrechte noch bestehen.
Wie das Beispiel zeigt, wird für die übergebene Zeitangabe die Zeitzone GMT angenommen. Eine
forensische Aussagekraft sollte man dem Letztzugriffsdatum einer Datei offenbar nicht zumessen.
Neben dem Kreationsdatum können auch diverse DOS- bzw. POSIX-Dateiattribute (z. B. Hidden,
Owner) gesetzt werden (siehe Java-Tutorial, Oracle 2021).1
1
http://docs.oracle.com/javase/tutorial/essential/io/fileAttr.html
2
Das Rückgabeobjekt beherrscht aber weder das Interface Collection<T>, noch das Interface Stream<T>. Es ist also
weder eine Kollektion im Sinne von Kapitel 10, noch ein Strom im Sinne von Kapitel 12.
704 Kapitel 14 Ein- und Ausgabe über Datenströme
Im Beispiel kommt die folgende copy() - Überladung mit Path-Parametern für Quelle und Ziel zum
Einsatz:
public static Path copy(Path source, Path target, CopyOption... options)
throws IOException
Der Serienparameter vom Interface-Typ CopyOption akzeptiert eine Liste von Werten, wobei die
folgenden Konstanten der Enumerationen StandardCopyOption und LinkOption erlaubt sind:1
• StandardCopyOption.REPLACE_EXISTING
Bei einer bereits existenten Zieldatei wird das Überschreiben erlaubt. Anderenfalls wird ggf.
eine Ausnahme vom Typ FileAlreadyExistsException geworfen.
• StandardCopyOption.COPY_ATTRIBUTES
Die Attribute der Quelle sollen auf das Ziel übertragen werden, sofern dies vom Betriebs-
bzw. Dateisystem unterstützt wird.2
• LinkOption.NOFOLLOW_LINKS
Ist die Quelle ein symbolischer Link, dann wird per Voreinstellung das Verweisziel kopiert.
Mit der Option NOFOLLOW_LINKS wird stattdessen der Link kopiert, wobei unter
Windows Administratorrechte erforderlich sind.
Man kann auch Ordner kopieren, wobei allerdings die enthaltenen Dateisystemobjekte nicht einbe-
zogen werden. Ist der Zielordner bereits vorhanden, wird eine FileAlreadyExistsException gewor-
fen. Sind bei Verwendung der StandardCopyOption.REPLACE_EXISTING im Zielordner be-
reits Objekte vorhanden, dann wird eine Ausnahme vom Typ DirectoryNotEmptyException ge-
worfen.
1
Wie bei einem Serienparameter üblich, darf man die Aktualparameterliste auch komplett weglassen, sodass quasi
ein optionaler Parameter entsteht (vgl. Abschnitt 4.3.1.4.3). Die Enumerationen StandardCopyOption und
LinkOption implementieren das Interface CopyOption.
2
Unter Windows 10 mit dem Dateisystem NTFS hat die Option StandardCopyOption.COPY_ATTRIBUTES
wohl keinen Effekt:
• Elementare Attribute (z. B. Änderungsdatum, Schreibschutz) werden auf jeden Fall (auch ohne
StandardCopyOption.COPY_ATTRIBUTES) übertragen.
• Zugriffsrechte (ACLs) werden auch mit StandardCopyOption.COPY_ATTRIBUTES nicht übertragen.
3
http://docs.oracle.com/javase/tutorial/essential/io/move.html
Abschnitt 14.2 Verwaltung von Dateien und Verzeichnissen 705
Soll eine Datei verschoben werden, gibt man eine Zieldatei in einem anderen Ordner an. Im folgen-
den Beispiel wird die Quelldatei in das übergeordnete Verzeichnis verschoben und dabei auch noch
umbenannt:
Files.move(file, dir.getParent().resolve("Verschoben.dat"),
StandardCopyOption.REPLACE_EXISTING);
Das Path-Objekt zu einem (vom Wurzelknoten verschiedenen) Verzeichnis liefert über die Metho-
de getParent() das übergeordnete Verzeichnis (siehe Abschnitt 14.2.1.1). Soll beim Verschieben in
den übergeordneten Ordner der Dateiname beibehalten werden, bildet man den Zielpfad mit Hilfe
der Path-Methode getFileName():
Files.move(file, dir.getParent().resolve(file.getFileName()),
StandardCopyOption.REPLACE_EXISTING);
Im CopyOption-Serienparameter sind bei der Methode move() die beiden folgenden Konstanten
der Enumeration StandardCopyOption erlaubt:
• StandardCopyOption.REPLACE_EXISTING
Eine am Zielort vorhandene gleichnamige Datei soll überschrieben werden.
• StandardCopyOption.ATOMIC_MOVE
Die Verschiebung wird als atomare Operation deklariert, sodass entweder beide Teilaufga-
ben (Anlegen am neuen Ort, Löschen am alten Ort) ausgeführt werden, oder gar keine Än-
derung stattfindet. Ist diese Option gesetzt, werden alle anderen ignoriert, was derzeit nur
der Option REPLACE_EXISTING passieren kann. Wenn keine atomare Ausführung mög-
lich ist, wird eine Ausnahme vom Typ AtomicMoveNotSupportedException geworfen.
14.2.1.11 Löschen
Mit der statischen Files-Methode delete() lässt sich ein Dateisystemobjekt (Datei, Ordner oder
symbolischer Link) löschen:
public void delete(Path path) throws IOException
Im folgenden Beispiel werden alle Dateisystemobjekte in einem Ordner über ein Objekt der Klasse
DirectoryStream<Path> (vgl. Abschnitt 14.2.1.8) aufgesucht und gelöscht:
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir)) {
for (Path path: ds)
Files.delete(path);
} catch (IOException ioe) {
ioe.printStackTrace();
}
Damit ein Ordner gelöscht werden kann, muss er leer sein. Wird ein symbolischer Link gelöscht,
dann bleibt sein Verweisziel unangetastet.
Ist ein zu löschendes Dateisystemobjekt nicht vorhanden, wirft delete() eine NoSuchFileExcepti-
on. Soll das Programm stattdessen kommentarlos weiterarbeiten, verwendet man statt delete() die
Methode deleteIfExists().
teisystem, und das repräsentierende FileSystem-Objekt erhält man von der statischen Methode
getDefault() der Klasse FileSystems, z. B.:1
FileSystem fs = FileSystems.getDefault();
In Abhängigkeit vom Betriebssystem enthält ein Dateisystem unterschiedliche Speichereinheiten
(z. B. Partitionen oder Laufwerke), die im NIO.2 - API durch Objekte der Klasse FileStore reprä-
sentiert werden. Die zu einem Dateisystem gehörigen Speichereinheiten erhält man über die
FileSystem-Methode getFileStores() als Objekt einer Klasse, welche das Interface
Iterable<FileStore> implementiert, z. B.:
try {
for (FileStore store: fs.getFileStores())
getSpaceOfStore(store);
} catch (IOException ioe) {
ioe.printStackTrace();
}
Unter Windows resultiert eine Liste mit den Laufwerken (inkl. Netzwerk).
Mit den folgenden Methoden der Klasse FileStore erhält man Informationen über die gesamte bzw.
verfügbare Kapazität einer Speichereinheit:
• public long getTotalSpace() throws IOException
Man erhält die Gesamtkapazität in Bytes.
• public long getUsableSpace() throws IOException
Man erhält die verfügbare Kapazität in Bytes, wobei die Exaktheit der Auskunft laut API-
Dokumentation nicht garantiert ist. Mit zunehmender Zeitdistanz seit der Abfrage schwindet
die Genauigkeit der Auskunft ohnehin wegen der permanenten Dateisystemaktivitäten.
Im obigen Codesegment wird die folgende Methode zur Ausgabe von Kapazitätsdaten aufgerufen:
static void getSpaceOfStore(FileStore store) throws IOException {
final long mega = 1024*1024;
long gesamt = store.getTotalSpace()/mega;
long belegt = (store.getTotalSpace()-store.getUsableSpace())/mega;
long frei = store.getUsableSpace()/mega;
System.out.printf("%-20s %12d %12d %12d\n", store, gesamt, belegt, frei);
}
Eine typische Ausgabe (Windows-Rechner mit einer SSD, einer Festplatte, einer eingelegten DVD
und einer verbundenen Netzfreigabe):
Laufwerk Gesamt (MB) Belegt (MB) Frei (MB)
System (C:) 953368 194202 759166
DESINFECT (D:) 5711 5711 0
Daten (E:) 753865 670792 83073
SYSVOL (V:) 49907 2550 47357
1
Genaugenommen sind die Klassen FileSystem und FileStore abstrakt, und das von getDefault() gelieferte Objekt
gehört zu einer FileSystem-Ableitung, die wir nicht näher kennen müssen.
2
http://docs.oracle.com/javase/tutorial/essential/io/
Abschnitt 14.2 Verwaltung von Dateien und Verzeichnissen 707
14.2.2 Dateisystemzugriffe über die Klasse File aus dem Paket java.io
In Java 6 wird der Umgang mit Dateien und Verzeichnissen (z. B. Erstellen, auf Existenz prüfen,
Löschen, Attribute lesen und setzen) durch die Klasse File aus dem Paket java.io unterstützt. Viele
Methoden dieser Klasse werden im weiteren Verlauf des aktuellen Abschnitts anhand von Code-
fragmenten aus einem Beispielprogramm mit dem folgenden Rahmen vorgestellt:
import java.io.*;
class FileDemo {
public static void main(String[] args) {
byte[] arr = {1, 2, 3};
. . .
}
}
Um ein neues Verzeichnis anzulegen, verwendet man die Methode mkdir(). Sollen dabei ggf. auch
erforderliche Zwischenstufen automatisch angelegt werden, ist die Methode mkdirs() zu verwen-
den (siehe Beispiel).
Ausgabe:
Eigenschaften der Datei Ausgabe.dat
Vollst. Pfad: U:\Eigene Dateien\Java\FileDemo\AusDir\Ausgabe.dat
Letzte Änderung: 06.02.22 14:45
Größe in Bytes: 3
Schreiben möglich: true
Für die formatierte Ausgabe der lastModified() - Rückgabe unter Berücksichtigung der lokalen
Zeitzone sorgen ein Date- und ein DateFormat-Objekt.
Anschließend werden die Datei- oder Verzeichnisnamen mit Hilfe der File-Methode getName()
ausgegeben:
Dateisystemobjekte im akt. Verzeichnis:
.idea
Java 6.iml
out
src
Eine alternative listFiles() - Überladung liefert eine gefilterte Liste mit File-Verzeichniseinträgen,
z. B.:
files = curDir.listFiles(new FNFilter("iml"));
System.out.println("\nEinträge im aktuellen Verzeichnis mit Extension .iml:");
for (File fi : files)
System.out.println(" " + fi.getName());
Sie benötigt dazu ein Objekt aus einer Klasse, die das Interface FilenameFilter implementiert. Im
Beispiel wird dazu die Klasse FileFilter definiert:
import java.io.*;
@Override
public boolean accept(File dir, String name) {
return name.toLowerCase().endsWith("." + ext);
}
}
Um den FilenameFilter - Interface-Vertrag zu erfüllen, muss FNFilter die Methode accept()
public boolean accept(File dir, String name)
implementieren. Im Beispiel resultiert die folgende Ausgabe:
Einträge im aktuellen Verzeichnis mit Extension .java:
Java 6.iml
14.2.2.6 Umbenennen
Mit der File-Methode renameTo() lässt sich eine Datei oder ein Verzeichnis umbenennen, wobei
als Parameter ein File-Objekt mit dem neuen Namen zu übergeben ist:
File fn = new File(ordner + "/Rausgabe.txt");
if (f.renameTo(fn))
System.out.println("\nDatei " + f.getName() + " umbenannt in " + fn.getName());
else
System.out.println("Fehler beim Umbenennen der Datei " + f.getName());
Beim Umbenennen sowie beim Löschen (siehe Abschnitt 14.2.2.7) darf die betroffene Datei nicht
geöffnet sein.
14.2.2.7 Löschen
Mit der File-Methode delete() löscht man eine Datei oder einen Ordner, z. B.:
if (fn.delete())
System.out.println("Datei " + fn.getName() + " gelöscht");
else
System.out.println("Fehler beim Löschen der Datei " + fn.getName());
Abschnitt 14.3 Klassen zur Verarbeitung von Byte-Strömen 711
if (dir.delete())
System.out.println("Verzeichnis " + dir.getName() + " gelöscht");
else
System.out.println("Fehler beim Löschen des Ordners " + dir.getName());
Damit ein Verzeichnis gelöscht werden kann, muss es leer sein.
14.3.1.1 Überblick
In der folgenden Abbildung sehen Sie den für uns relevanten Teil der Klassenhierarchie zur Basis-
klasse OutputStream, wobei die Ausgabeklassen (in direktem Kontakt mit einer Datensenke) mit
einem weißen Hintergrund und die Ausgabetransformationsklassen mit einem grauen Hintergrund
dargestellt sind:
FileOutputStream
DataOutputStream
PipedOutputStream
BufferedOutputStream
OutputStream FilterOutputStream
PrintStream
ByteArrayOutputStream
DeflaterOutputStream
ObjectOutputStream
Im weiteren Verlauf des aktuellen Abschnitts werden wichtige Vertreter dieser Hierarchie behan-
delt.
Einige Klassen werden in späteren Abschnitten behandelt:
• Durch ein Tandem aus einem PipedOutputStream und einem verbundenen
PipedInputStream lässt sich ein unidirektionaler Datentransfer zwischen zwei Threads
(Ausführungsfäden) realisieren. Der erste Thread schreibt Bytes in den
PipedOutputStream und der zweite Thread liest aus dem verbundenen PipedIn-
putStream. Im Abschnitt 15.2.4.2 wird die Realisation einer Produzenten-Konsumenten -
Kooperation mit der Pipeline-Technik beschrieben.
712 Kapitel 14 Ein- und Ausgabe über Datenströme
14.3.1.2 FileOutputStream
Ein FileOutputStream-Objekt ist mit einer Datei verbunden, die vom Konstruktor im Schreibmo-
dus geöffnet und nötigenfalls automatisch erstellt wird. Die in drei Überladungen vorhandene
write() - Methode befördert die Inhalte von byte-Variablen oder -Arrays in die Ausgabedatei:
Programm
In den FileOutputStream-Konstruktoren wird die anzusprechende Datei über ein File-Objekt (sie-
he Abschnitt 14.2.2) oder über einen String festgelegt:
• public FileOutputStream(File file)
• public FileOutputStream(File file, boolean append)
• public FileOutputStream(String name)
• public FileOutputStream(String name, boolean append)
Die Konstruktoren werfen eine geprüfte Ausnahme vom Typ FileNotFoundException, wenn …
Abschnitt 14.3 Klassen zur Verarbeitung von Byte-Strömen 713
class FileCopy {
final static String QUELLE = "quelle.dat", ZIEL = "ziel.dat";
final static int BUFLEN = 1048576; // 1 Megabyte (1024*1024 Bytes) als Puffergröße
Programm
Man übergibt der Methode ein Path-Objekt mit dem Dateibezug und optionale Angaben zum Öff-
nungsmodus (vgl. Abschnitt 14.7.1):
public static OutputStream newOutputStream(Path path, OpenOption... options)
throws IOException
Wird kein OpenOption-Parameter angegeben, dann sind aus der Enumeration StandardOpen-
Option die folgenden Werte in Kraft: CREATE, TRUNCATE_EXISTING und WRITE. Folg-
lich wird eine fehlende Datei erstellt und eine vorhandene Datei zunächst entleert.
Im Vergleich zu einem FileOutputStream - Objekt bestehen folgende Vorteile:
1
Der Trick stammt von: https://stackoverflow.com/questions/7939802/how-can-i-print-to-the-same-line
Abschnitt 14.3 Klassen zur Verarbeitung von Byte-Strömen 715
14.3.1.4 DataOutputStream
Mit einem Objekt aus der Transformationsklasse DataOutputStream lassen sich die Werte primi-
tiver Datentypen sowie String-Objekte über einen OutputStream in eine binäre Datensenke beför-
dern:
Programm
Ein DataOutputStream beherrscht diverse Methoden zum Schreiben primitiver Datenwerte (z. B.
writeInt(), writeDouble()). Mit writeUTF() steht auch eine Methode zur Ausgabe von Zeichen
bereit, wobei eine modifizierte Variante der UTF-8 - Codierung (vgl. Abschnitt 14.4.1.2) zum Ein-
satz kommt. Diese Methode ist angemessen, sofern die resultierenden Zeichen später mit der
DataInputStream-Methode readUTF() wieder eingelesen werden sollen (vgl. Abschnitt 14.3.2.4).
Für universell verwendbare Textdateien ist die Klasse OutputStreamWriter aus der Writer-
Hierarchie mit einstellbarer und normkonformer Codierung weit besser geeignet (siehe Abschnitt
14.4.1.2).
Im folgenden Beispielprogramm wird ein DataOutputStream auf einen OutputStream aufgesetzt
und dann beauftragt, Daten vom Typ int, double und String zu schreiben. Das OutputStream-
Objekt wird von der statischen Files-Methode newOutputStream() geliefert, die als Parameter ein
Path-Objekt erhält, das eine Datei repräsentiert:
1
Mit getClass() befragt, liefert ein mit Files.newOutputStream() erzeugtes Objekt die Klasse
java.nio.channels.Channels$1 (OpenJDK 8 und 17 unter Windows 10).
716 Kapitel 14 Ein- und Ausgabe über Datenströme
import java.io.*;
import java.nio.file.*;
class DataOutputStreamDemo {
public static void main(String[] args) throws IOException{
Path file = Paths.get("demo.dat");
14.3.1.5 BufferedOutputStream
Zur Beschleunigung von Ein- oder Ausgaben setzt man oft Transformationsklassen ein, die durch
das Zwischenspeichern von Daten die Anzahl der (meist langsamen) Zugriffe auf Datenquellen oder
-senken reduzieren. Diese Transformationsklassen kooperieren mit Ein- bzw. Ausgabeklassen, die
in direktem Kontakt mit einer Datenquelle oder -senke stehen.
Ein BufferedOutputStream-Objekt nimmt Bytes entgegen und leitet diese in geeigneten Portionen
an einen OutputStream weiter (z. B. an einen FileOutputStream):
Programm
Das folgende Beispielprogramm schreibt in 10.000 write() - Aufrufen jeweils ein einzelnes Byte in
eine Datei, zunächst ungepuffert, dann unter Verwendung eines BufferedOutputStream-Objekts:
import java.io.*;
import java.nio.file.*;
class BufferedOutputStreamFile {
private final static int ANZAHL = 10_000;
private static Path AUSGABE = Paths.get("Ausgabe.dat");
Wegen des erheblichen Performanzvorteils sollte also ein Ausgabepuffer eingesetzt werden, wenn
zahlreiche Schreibvorgänge mit jeweils kleinem Volumen stattfinden. Das FileCopy-Beispielpro-
gramm im Abschnitt 14.3.1.2 wird hingegen kaum profitieren, weil das dortige FileOutputStream-
Objekt nur sehr große Datenblöcke zur Ausgabe erhält.
Ein BufferedOutputStream muss unbedingt vor seinem Ableben (z. B. am Ende des Programms)
per flush() entleert werden, weil sonst die zwischengelagerten Daten verfallen. Das Entleeren kann
auch über die Methode close() erfolgen, die flush() aufruft und anschließend den zugrunde liegen-
den OutputStream schließt. Durch die im Beispiel verwendete try-with-resources - Technik wird
der close() - Aufruf automatisiert (siehe Abschnitt 11.10.2).
Die von Object geerbte finalize() - Methode wird weder von FilterOutputStream noch von Buff-
eredOutputStream überschreiben, sodass beim Terminieren eines BufferedOutputStream-
1
Rechner mit Intel Core i3 550
2
Zur Netzwerkverbindung gehörte eine DSL-Strecke mit 10 MBit/s Upstream-Tempo.
718 Kapitel 14 Ein- und Ausgabe über Datenströme
Objekts per Garbage Collector kein close() - und insbesondere kein flush() - Aufruf erfolgt. Auf die
finalize() - Methode sollte man sich ohnehin generell nicht verlassen, weil ihr Aufruf nicht garan-
tiert ist (vgl. Abschnitt 14.1.5).
14.3.1.6 PrintStream
Die Transformationsklasse PrintStream dient dazu, Werte beliebigen Typs in einer für Menschen
lesbaren Form auszugeben, z. B. auf der Konsole. Während ein DataOutputStream dazu dient,
Variablen beliebigen Typs in eine Binärdatei zu schreiben, eignet sich ein PrintStream zur Ausga-
be solcher Daten in eine Textdatei. Nach Abschnitt 14.1.3 sind bei der Zeichenstromverarbeitung
allerdings die Klassen aus der später ins Java-API aufgenommenen Writer-Hierarchie zu bevorzu-
gen. Diese haben bei der Textausgabe (Datentypen String, char) den Vorteil, dass der Java-intern
verwendete Unicode in die bevorzugte Textcodierung umgesetzt werden kann (siehe Abschnitt
14.4.1.2). Trotzdem ist die Klasse PrintStream nicht überflüssig, weil z. B. der per System.out
ansprechbare Standardausgabestrom ein PrintStream-Objekt ist. Dies gilt auch für den Standard-
fehlerausgabestrom, der über die Klassenvariable System.err ansprechbar ist.1
Ein PrintStream-Objekt kann mit Hilfe seiner vielfach überladenen Methoden print() und
println() Daten von beliebigem Typ ausgeben, z. B.:
Quellcode Ausgabe
class PrintStreamConsole { Ein PrintStream kann Variablen
public static void main(String[] args) { bel. Typs verarbeiten,
PrintStreamConsole wob = new PrintStreamConsole(); z. B. die double-Zahl
System.out.println("Ein PrintStream kann Variablen " + 3.141592653589793
"beliebigen Typs verarbeiten," + oder auch das Objekt
"\nz. B. die double-Zahl\n" + " " + Math.PI + PrintStreamConsole@16f0472
"\noder auch das Objekt\n" + " " + wob);
}
}
Seit Java 5.0 (alias 1.5) ist auch die PrintStream-Methode printf() (alias format()) zur formatier-
ten Ausgabe verfügbar, die schon im Abschnitt 3.2.2 vorgestellt wurde.
Im Unterschied zu den Methoden anderer OutputStream-Ableitungen werfen die PrintStream-
Methoden keine IOException. Stattdessen setzen sie ein Fehlersignal, das mit checkError() abge-
fragt werden kann. Es wäre in der Tat sehr umständlich, jeden Aufruf der Methode
System.out.println() in einen überwachten try-Block zu setzen.
Generell kann man die PrintStream-Arbeitsweise folgendermaßen darstellen:
Programm
Daten-
Variablen anzeigegerät
von PrintStream OutputStream Bytes oder
beliebigem sonst.
Typ textuelle
Senke
1
Wird z. B. ein Ausnahmeobjekt über die Methode printStackTrace() beauftragt, die Aufrufsequenz auszugeben,
dann landet diese im Fehlerausgabestrom.
Abschnitt 14.3 Klassen zur Verarbeitung von Byte-Strömen 719
Im nächsten Beispiel ist zu sehen, wie mit Hilfe der Transformationsklasse PrintStream Werte
primitiver Datentypen in eine Textdatei geschrieben werden (über einen FileOutputStream):
FileOutputStream fos = new FileOutputStream("ps.txt");
PrintStream ps = new PrintStream(fos);
ps.println(64798 + " " + Math.PI);
In der Ausgabedatei ps.txt landen die Zeichenkettenrepräsentationen des int- und des double-
Werts:
64798 3.141592653589793
Wenn ein PrintStream-Objekt zwecks Geschwindigkeitsoptimierung in einen BufferedOut-
putStream schreibt, dann sind zwei Transformationsobjekte hintereinander geschaltet:
Programm
Daten-
Variablen Buffered- anzeigegerät
von Print- Output- oder
Output- Bytes
beliebigem Stream Stream sonst.
Stream
Typ textuelle
Senke
In dieser Situation müssen Sie unbedingt dafür sorgen, dass vor dem Terminieren des PrintStream-
Objekts der Puffer geleert wird. Dazu stehen mehrere Möglichkeiten bereit:
• Aufruf der PrintStream-Methode flush()
Dieser Aufruf wird an den angekoppelten BufferedOutputStream durchgereicht, wo die
Pufferung stattfindet. Per flush() - Aufruf kann man jederzeit dafür sorgen, dass die Senke
durch Entleeren des Zwischenspeichers auf den aktuellen Stand gebracht wird.
Das PrintStream-Objekt puffert nicht selbst, sondern leitet alle Ausgaben sofort weiter. Ist
es direkt mit einem OutputStream verbunden, dann ist kein flush() - Aufruf erforderlich,
um vor dem Ableben des PrintStream-Objekts noch gepufferte Daten in die Senke zu be-
fördern.
• Aufruf der PrintStream-Methode close()
Dabei wird auch die close() - Methode des angekoppelten BufferedOutputStream-Objekts
aufgerufen, die wiederum einen flush() - Aufruf enthält (siehe Abschnitt 14.3.1.5).
• Impliziter Aufruf der PrintStream-Methode close() durch Verwendung einer try-
Anweisung mit automatischer Ressourcenfreigabe
• PrintStream-Konstruktor mit autoFlush-Parameter wählen und diesen auf true setzen
Damit wird der Puffer in folgenden Situationen automatisch geleert:
o nach dem Schreiben eines byte-Arrays
o nach der Ausgabe eines Newline-Zeichens (\n)
o nach der Ausführung einer println() - Methode1
1
Abweichend von der API-Dokumentation
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/io/PrintStream.html
führt in Java 8 und Java 17 auch die print() - Methode beim autoFlush-Parameterwert true zum flush() - Aufruf an
einen angekoppelten puffernden Ausgabestrom.
720 Kapitel 14 Ein- und Ausgabe über Datenströme
Weil die Klasse PrintStream die von java.lang.Object geerbte finalize() - Methode nicht über-
schreibt, findet bei der Beseitigung eines PrintStream-Objekts per Garbage Collector kein close() -
oder flush() - Aufruf und damit keine Pufferentleerung statt.
Ein Beispiel für die Kombination aus einem PrintStream und einem BufferedOutputStream ist
der per System.out ansprechbare Standardausgabestrom, der analog zum folgenden Codefragment
initialisiert wird:
FileOutputStream fdout =
new FileOutputStream(FileDescriptor.out);
BufferedOutputStream bos =
new BufferedOutputStream(fdout, 128);
PrintStream ps =
new PrintStream(bos, true);
System.setOut(ps);
Mit der statischen Variablen out der Klasse FileDescriptor wird der Bezug zur Konsole hergestellt.
Im PrintStream-Konstruktor wird der autoFlush-Parameter auf den Wert true gesetzt. Über die
System-Methode setOut() kann ein selbst entworfener Strom als Standardausgabe in Betrieb ge-
nommen werden.
Bei der Textausgabe in eine Datei ist in der Regel die modernere und flexiblere Klasse PrintWriter
zu bevorzugen (siehe Abschnitt 14.4.1.5). Vermutlich werden also der per System.out ansprechbare
Standardausgabestrom und der per System.err ansprechbare Standardfehlerausgabestrom die einzi-
gen PrintStream-Objekte in Ihren Java-Programmen bleiben.
14.3.2.1 Überblick
Um Ihnen das Blättern zu ersparen, wird die schon im Abschnitt 14.1.3 gezeigte Abbildung zur In-
putStream-Hierarchie wiederholt (Eingabeklassen mit weißem Hintergrund und Eingabetransfor-
mationsklassen mit grauem Hintergrund):
FileInputStream
ChannelInputStream
DataInputStream
PipedInputStream
BufferedInputStream
InputStream FilterInputStream
InflaterInputStream
ByteArrayInputStream
PushbackInputStream
SequenceInputStream
ObjectInputStream
Im weiteren Verlauf des aktuellen Abschnitts werden wichtige Vertreter dieser Hierarchie behan-
delt.
Einige Klassen werden in späteren Abschnitten behandelt:
Abschnitt 14.3 Klassen zur Verarbeitung von Byte-Strömen 721
14.3.2.2 FileInputStream
Mit einem FileInputStream kann man Bytes aus einer Datei lesen:
Programm
Programm
ChannelInputStream
byte,
als Ergebnis von Bytes Binärdatei
byte[]
Files.newInputStream()
Man übergibt der Methode ein Path-Objekt mit dem Dateibezug und optionale Angaben zum Öff-
nungsmodus (vgl. Abschnitt 14.7.1):
public static InputStream newInputStream(Path path, OpenOption... options)
throws IOException
Wird kein OpenOption - Parameter angegeben, dann ist der Enumerationswert StandardOpen-
Option.READ in Kraft.
Im Vergleich zu einem FileInputStream - Objekt bestehen folgende Vorteile:
• Im Vergleich zum FileInputStream-Konstruktor bietet die Methode newInputStream()
differenzierte Öffnungsoptionen für die Datei (siehe Abschnitt 14.7.1).
• Im Hintergrund kommt bei den Dateizugriffen die Channel-Technik zum Einsatz, wobei ein
Geschwindigkeitsvorteil möglich, aber nicht garantiert ist.1 Bei manchen Anforderungspro-
filen kann die Channel-Technik sogar ein schlechteres Leistungsverhalten zeigen als die tra-
ditionelle Datenstromtechnik.
• Die gleichzeitige Nutzung durch mehrere Threads (Ausführungsfäden, siehe Kapitel 15) ist
erlaubt.
Ein Einsatzbeispiel für die Methode newInputStream() war schon im Abschnitt 14.1.2 zu sehen.
14.3.2.4 DataInputStream
Die Transformationsklasse DataInputStream liest Werte mit einem primitiven Datentype sowie
String-Objekte aus einem Bytestrom und ist uns zusammen mit ihrem Gegenstück DataOut-
putStream schon im Abschnitt 14.3.1.3 begegnet.
1
Mit getClass() befragt, liefert ein mit Files.newInputStream() erzeugtes Objekt die Klasse
sun.nio.ch.ChannelInputStream (OpenJDK 8 und 17 unter Windows 10).
724 Kapitel 14 Ein- und Ausgabe über Datenströme
Programm
primitive Quelle
Daten- DataInput- Input-
Bytes von
typen und Stream Stream
Bytes
String
14.4.1.1 Überblick
In der folgenden Darstellung der Writer-Hierarchie sind Ausgabeklassen (in direktem Kontakt mit
einer Senke) mit weißem Hintergrund dargestellt, Ausgabetransformationsklassen mit grauem Hin-
tergrund:
BufferedWriter
StringWriter
CharArrayWriter
PrintWriter
PipedWriter
FilterWriter
Abschnitt 14.4 Klassen zur Verarbeitung von Zeichenströmen 725
Weil die von OutputStreamWriter abgeleitete Klasse FileWriter mit einer Datei verbunden ist
und eine Transformationsfunktion besitzt, ist sie mit schraffiertem Hintergrund dargestellt.
Bei den folgenden Writer-Ableitungen beschränken wir uns auf kurze Hinweise:
• StringWriter und CharArrayWriter
Ein StringWriter schreibt in einen dynamisch wachsenden StringBuffer (siehe Abschnitt
5.2.2). Im folgenden Beispiel werden die auszugebenden Zeichen von einem PrintWriter
(siehe Abschnitt 14.4.1.5) geliefert:
Quellcode Ausgabe
import java.io.*; Zeile 1
Zeile 2
class StringWriterDemo { Zeile 3
public static void main(String[] args) { Zeile 4
StringWriter sw = new StringWriter(); Zeile 5
PrintWriter pw = new PrintWriter(sw);
for (int i = 1; i <= 5; i++)
pw.println("Zeile " + i);
System.out.println(sw.toString());
}
}
Ein CharArrayWriter schreibt in einen char-Array, der bei Bedarf automatisch mit Hilfe
der statischen Arrays-Methode copyOf() durch ein größeres Exemplar ersetzt wird.
Ein close() - Aufruf hat bei den Klassen StringWriter und CharArrayWriter keinen Ef-
fekt.
• PipedWriter
Diese Klasse ist das zeichenorientierte Analogon zu Klasse PipedOutputStream.
• FilterWriter
Diese abstrakte Basisklasse bietet sich dazu an, eigene Transformationsklassen für zeichen-
orientierte Ausgabeströme abzuleiten.
Zur Ausgabe stellt ein Writer-Objekt die folgenden write() - Überladungen zur Verfügung:
• public void write(int c) throws IOException
Die beiden niederwertigen Bytes des Parameters legen die Unicode-Nummer des
auszugebenden Zeichens fest.
• public void write(char[] cbuf ) throws IOException
Es wird ein Array mit Elementen vom Typ char komplett ausgegeben.
• public void write(char[] cbuf, int offset, int len) throws IOException
Vom char-Array im ersten Parameter werden beginnend mit dem Zeichen an der Position
offset insgesamt len Zeichen ausgegeben.
• public void write(String s) throws IOException
Es wird ein String komplett ausgegeben.
• public void write(String s, int offset, int len) throws IOException
Vom String-Objekt im ersten Parameter werden beginnend mit dem Zeichen an der Position
offset insgesamt len Zeichen ausgegeben.
Es folgt eine kleine Auswahl der insgesamt 40 in Java 17 unterstützten Codierungen (Basic En-
coding Set, enthalten im Modul java.base):1
Name für Name für java.io Beschreibung
java.nio und java.lang
US-ASCII ASCII American Standard Code for Information Interchange
Es werden nur 7 Bit verwendet. Bei den Unicode-Zeichen \u0000 bis
\u007F wird das niederwertige Byte ausgegeben, ansonsten ein Fragezei-
chen (0x3F).
ISO-8859-1 ISO8859_1 Erweiterter ASCII-Code (ISO Latin-1)
Bei den Unicode-Zeichen \u0000 bis \u00FF wird das niederwertige
Byte ausgegeben, ansonsten ein Fragezeichen (0x3F).
UTF-8 UTF8 Die Unicode-Zeichen werden durch eine variable Anzahl von Bytes
codiert. So können alle Unicode-Zeichen ausgegeben werden, ohne die
platzverschwenderische Anhäufung von Null-Bytes bei den ASCII-
Zeichen in Kauf nehmen zu müssen:
Unicode-Zeichen
Anzahl Bytes
von bis
\u0000 \u007F 1
\u0080 \u07FF 2
\u0800 \uFFFF 3
Bei den ersten 128 Unicode-Zeichen liefern die Codierungen US-ASCII,
ISO-8859-1 und UTF-8 identische Ergebnisse.
UTF-16BE UnicodeBigUnmarked Für alle Unicode-Zeichen werden 16 Bit in Big-Endian - Reihenfolge
ausgegeben: Das höherwertige Byte zuerst.
In Java ist diese Reihenfolge voreingestellt (auch bei anderen Datenty-
pen). Beim großen griechischen Delta (\u0394) wird z. B.: ausgegeben:
03 94
UTF-16LE UnicodeLittleUnmarked Für alle Unicode-Zeichen werden 16 Bit in Little-Endian - Reihenfolge
ausgegeben: Das niederwertige Byte zuerst. Beim großen griechischen
Delta (\u0394) wird z. B.: ausgegeben: 94 03
Windows-1252 Cp1252 Windows Latin-1 (ANSI)
Im Unterschied zu ISO-8859-1 werden die Codes von 0x80 bis 0x9F (in
ISO-8859-1 reserviert für Steuerzeichen) mit „höheren“ Unicode-
Zeichen belegt. Z. B. wird das Eurozeichen (Unicode: \u20AC) auf den
Code 0x80 abgebildet.
IBM850 Cp850 MS-DOS Latin-1
MS-DOS-Codepage zur Verwendung in Westeuropa
Dass die Klassen aus dem Paket java.io (z. B. OutputStreamWriter) für die Codierungen andere
Namen benutzen als die Klassen aus den Paketen java.nio.* (z. B. Files), ist bedauerlich, aber nicht
tragisch.
Bei den folgenden Überladungen des OutputStreamWriter-Konstruktors kann die gewünschte
Codierung über ihren Namen oder über ein Objekt der Klasse Charset (aus dem Paket
java.nio.charset) angegeben werden:
• public OutputStreamWriter(OutputStream out, String charsetName)
throws UnsupportedEncodingException
• public OutputStreamWriter(OutputStream out, Charset cs)
Wird im OutputStreamWriter-Konstruktor keine Codierung angegeben,
public OutputStreamWriter(OutputStream out)
1
Die Angaben stammen von der Webseite
https://docs.oracle.com/en/java/javase/17/intl/supported-encodings.html
Dort werden noch weitere unterstützte Codierungen aufgelistet.
Abschnitt 14.4 Klassen zur Verarbeitung von Zeichenströmen 727
dann entscheidet die Systemeigenschaft file.encoding. Ihr Wert kann mit der System-Methode
getProperty() ermittelt werden, z. B.:
Quellcodesegment Ausgabe
System.out.println(System.getProperty("file.encoding")); UTF-8
Die voreingestellte Codierung wird beim Start der JVM festgelegt und ist damit vom Programm
nicht zu beeinflussen.1 In der Regel wird per Voreinstellung die Codierung UTF-8 verwendet.2 Man
sollte sich aber sicherheitshalber nicht auf diese Voreinstellung verlassen.
Zur Ausgabe von Textdaten stehen die von der Basisklasse Writer geerbten (und teilweise über-
schriebenen) write() - Überladungen zur Verfügung.
Im folgenden Programm werden die oben beschriebenen Codierungen nacheinander dazu verwen-
det, um einen kurzen Text mit dem Umlaut „ä“ (\u00E4) in eine Datei zu schreiben. Dabei kommt
die von der statischen System-Methode lineSeparator() gelieferte Plattform-spezifische Zeilen-
schaltung zum Einsatz:
import java.io.*;
import java.nio.charset.Charset;
import java.nio.file.*;
class OutputStreamWriterDemo {
public static void main(String[] args) throws IOException {
String[] encodings = {"US-ASCII", "ISO-8859-1", "UTF-8",
"UTF-16BE", "UTF-16LE", "Windows-1252", "IBM850"};
Files.deleteIfExists(Paths.get("test.txt"));
for (int i = 0; i < encodings.length; i++) {
try (OutputStreamWriter osw = new OutputStreamWriter(
new FileOutputStream("test.txt", true), Charset.forName(encodings[i]))) {
osw.write(encodings[i] + " ae = ä" + System.lineSeparator());
}
}
}
}
Im FileOutputStream-Konstruktor hat der Parameter append den Wert true erhalten, damit die
Ausgaben den bisherigen Dateiinhalt nicht ersetzen, sondern erweitern.
Für das Unicode-Zeichen \u00E4 wird jeweils ausgegeben:
Codierung (Name für java.nio) Byte(s) in der Ausgabe (Hexadezimal)
US-ASCII 3F
ISO-8859-1 E4
UTF-8 C3 A4
UTF-16BE 00 E4
UTF-16LE E4 00
Windows-1252 E4
IBM850 84
Das Beispielprogramm arbeitet mit folgender Datenstromkonstruktion:
1
https://openjdk.java.net/jeps/400
https://stackoverflow.com/questions/361975/setting-the-default-java-character-encoding
2
Beobachtet mit Java 8 und 17 unter Windows 10
728 Kapitel 14 Ein- und Ausgabe über Datenströme
Programm
Brückenklasse Bytestrom
Datenstromobjekte aus der Klasse OutputStreamWriter (und auch aus der Ableitung FileWriter,
siehe Abschnitt 14.4.1.3) sammeln die per Unicode-Wandlung entstandenen Bytes zunächst in ei-
nem internen Puffer (Größe: 8192 Bytes), den ein Objekt der Klasse StreamEncoder aus dem Pa-
ket sun.nio.cs verwaltet.1 Daher muss auf jeden Fall vor dem Ableben eines OutputStreamWri-
ter-Objekts (z. B. beim Programmende) der Puffer geleert werden. Dazu stehen mehrere Möglich-
keiten bereit:
• Aufruf der Methode flush()
Dieser Aufruf wird an den eingebauten StreamEncoder durchgereicht.
• Aufruf der Methode close()
Sie sorgt dafür, dass der Puffer des eingebauten StreamEncoders vor dem Schließen geleert
wird.
• Impliziter Aufruf der Methode close() durch Verwendung einer try-Anweisung mit automa-
tischer Ressourcen-Freigabe
Weil die Klassen OutputStreamWriter und FileWriter die von java.lang.Object geerbte finali-
ze() - Methode nicht überschreiben, findet bei der Beseitigung eines Objekts aus diesen Klassen per
Garbage Collector kein close() - bzw. flush() - Aufruf und somit keine Pufferentleerung statt.
14.4.1.3 FileWriter
Die von OutputStreamWriter abgeleitete Klasse FileWriter im Paket java.io eignet sich zur Aus-
gabe von String- und char-Variablen in eine Textdatei, wenn die voreingestellte Codierung (siehe
Abschnitt 14.4.1.2) akzeptabel ist:
Programm
String, Bytes
FileWriter Textdatei
char
1
Die technischen Details wurden aus dem OpenJDK 17 - Quellcode ermittelt.
Abschnitt 14.4 Klassen zur Verarbeitung von Zeichenströmen 729
Die Klasse FileWriter wird mit einem schraffierten Hintergrund dargestellt (siehe auch die Writer-
Hierarchie im Abschnitt 14.4.1.1), weil ihre Objekte …
• mit einer Datei in Kontakt stehen (über ein Instanzobjekt aus der Klasse FileOut-
putStream), sodass sie als Ausgabestrom arbeiten können,
• den eingehenden Zeichenstrom in einen Bytestrom transformieren, sodass sie als Filterob-
jekte bezeichnet werden können.
Wichtiger als die akademische Bemerkung zur korrekten Klassifikation der Klasse FileWriter sind
ihre Konstruktoren. Das per String- oder File-Objekt bestimmte Ausgabeziel wird zum Schreiben
geöffnet, wobei der optionale zweite Parameter darüber entscheidet, ob ein vorhandener Dateian-
fang erhalten bleibt:
• public FileWriter(File file) throws IOException
• public FileWriter(File file, boolean append) throws IOException
• public FileWriter(String fileName) throws IOException
• public FileWriter(String fileName, boolean append) throws IOException
Soll ein FileWriter unter Verwendung einer per Path-Objekt identifizierten Datei instanziert wer-
den, dann bietet sich die Path-Methode toFile() an, die zu einem Path-Objekt ein korrespondieren-
des File-Objekt liefert (siehe Abschnitt 14.2.1.1).
Zur Ausgabe von Textdaten stehen die von Writer bzw. OutputStreamWriter geerbten write() -
Überladungen zur Verfügung. Das folgende Programm schreibt ein einzelnes Zeichen und eine Zei-
chenfolge jeweils in eine eigene Zeile, und trennt die beiden Zeilen durch die plattform-spezifische
Zeilenschaltung:
import java.io.*;
class FileWriterDemo {
public static void main(String[] args) throws IOException {
try (FileWriter fw = new FileWriter("fw.txt")) {
fw.write("ä");
fw.write(System.lineSeparator() + "Zeile 2");
}
}
}
Wenn die voreingestellte Codierung nicht passt, dann muss man auf die FileWriter-Bequemlichkeit
verzichten, stattdessen einen OutputStreamWriter mit passender Codierung erstellen und einen
OutputStream dahinter setzen (siehe Abschnitt 14.4.1.2).
14.4.1.4 BufferedWriter
Am Ende von Abschnitt 14.4.1.2 über die Klasse OutputStreamWriter wurde die implizite Spei-
cherung von Bytes beschrieben, die aus der Wandlung von Unicode-Zeichen entstehen. Für die ex-
plizite Pufferung von Zeichen steht in der Writer-Hierarchie die Transformationsklasse Buffe-
redWriter mit den folgenden Konstruktor-Überladungen zur Verfügung:
• public BufferedWriter(Writer out)
• public BufferedWriter(Writer out, int bufferSize)
In der zweiten Überladung kann die voreingestellte Puffergröße von 8192 Zeichen verändert wer-
den.
Abweichend vom Aufbau der OutputStream-Hierarchie ist BufferedWriter übrigens nicht von
FilterWriter abgeleitet.
730 Kapitel 14 Ein- und Ausgabe über Datenströme
Vom folgenden Programm werden mit Hilfe eines FileWriters (siehe Abschnitt 14.4.1.3) zweimal
jeweils 20.000.000 Zeilen in eine Textdatei geschrieben, zunächst ohne und dann mit zwischenge-
schaltetem BufferedWriter. Weil die erste Ausgabe unabhängig von der verwendeten Technik stets
länger dauert, findet zunächst ein „Warmlaufen“ statt:
import java.io.*;
import java.nio.file.*;
class BufferedWriterDemo {
final static int ANZAHL = 20_000_000;
Dass der Zeitgewinn durch den BufferedWriter relativ bescheiden ausfällt, liegt vermutlich an der
automatischen Byte-Pufferung durch den FileWriter:1
Benötigte Zeit ohne BufferedWriter: 3064
Benötigte Zeit mit BufferedWriter: 2081
1
Die Zeiten stammen von einem Rechner mit der Intel-CPU Core i3 (3,2 GHz).
Abschnitt 14.4 Klassen zur Verarbeitung von Zeichenströmen 731
Programm
14.4.1.5 PrintWriter
Die Transformationsklasse PrintWriter besitzt diverse print() - bzw. println() - Überladungen,
um Variablen beliebigen Typs in Textform auszugeben. Sie wurde mit Java 1.1 als Nachfolger bzw.
Ergänzung der älteren Klasse PrintStream eingeführt, die aber zumindest im Standardausga-
bestrom System.out und im Standardfehlerausgabestrom System.err weiterlebt (vgl. Abschnitt
14.3.1.6). Seit der Java-Version 5.0 (alias 1.5) beherrschen PrintWriter-Objekte auch die funkti-
onsgleichen Methoden printf() und format() zur formatierten Ausgabe. Elementare Formatie-
rungsoptionen wurde schon im Abschnitt 3.2.2 erläutert.
Bei Problemen mit dem Ausgabestrom oder mit der Formatierung werfen die PrintWriter-
Methoden keine IOException, sondern setzen ein Fehlersignal, das mit der Methode checkError()
abgefragt werden kann.
Wie die folgende Auswahl der PrintWriter-Konstruktoren zeigt, dürfen die angekoppelten Daten-
stromobjekte von den Basisklassen OutputStream oder Writer abstammen:
• public PrintWriter(OutputStream out)
• public PrintWriter(OutputStream out, boolean autoFlush)
• public PrintWriter(Writer out)
• public PrintWriter(Writer out, boolean autoFlush)
Letztlich übergibt ein PrintWriter alle Ausgabedaten als Unicode-Zeichen an einen Out-
putStreamWriter, der die Zeichen in Abhängigkeit von einer Codierung in Byte-Sequenzen über-
setzt. Diese Bytes werden auf dem Weg zur Datensenke zwischengespeichert (vgl. Abschnitt
14.4.1.2). Erhält der autoFlush-Parameter eines PrintWriter-Konstruktors den Wert true, dann
wird der Puffer bei jedem Aufruf der PrintWriter-Methoden println(), printf() oder format() ent-
leert. Dies ist bei einer Konsolenausgabe sinnvoll, sollte aber bei einer Dateiausgabe aus Perfor-
manzgründen vermieden werden.
Gibt man im PrintWriter-Konstruktor explizit einen OutputStreamWriter an, dann kann man die
Codierung festlegen:
732 Kapitel 14 Ein- und Ausgabe über Datenströme
Programm
Variablen OutputStream-
von Print- FileOutput
Writer Bytes Textdatei
beliebigem Writer Stream
(mit Kodierung)
Typ
Zeichenstrom Brückenklasse Bytestrom
Diese Möglichkeit stellt den entscheidenden Vorteil der Klasse PrintWriter gegenüber dem Vor-
gänger PrintStream dar (vgl. Abschnitt 14.4.1.2). Bei der Ausgabe von numerischen Daten in eine
Textdatei spielt die Wahlfreiheit bei der Codierung allerdings keine große Rolle, weil die hier betei-
ligten Zeichen von allen Codierungen im Wesentlichen identisch in Bytes übersetzt werden. Insge-
samt ist die Klasse PrintWriter bei der Ausgabe in Textdateien zu bevorzugen, weil sich damit alle
Aufgaben gut bewältigen lassen.
Wird ein PrintWriter auf einen Ausgabestrom aus der OutputStream-Hierarchie gesetzt, dann
nimmt der Konstruktor insgeheim einen BufferedWriter, der Zeichen zwischenspeichert (siehe
Abschnitt 14.4.1.4), und einen OutputStreamWriter mit der voreingestellten Codierung, der Bytes
zwischenspeichert (siehe Abschnitt 14.4.1.2), in Betrieb. Die folgenden PrintWriter-Konstruktoren
stammen aus dem OpenJDK 17 - Quellcode:
public PrintWriter(OutputStream out) {
this(out, false);
}
Vor dem Terminieren eines PrintWriter-Objekts müssen die Zwischenspeicher unbedingt entleert
werden, was ab Java 7 am besten mit einer try-with-resources - Anweisung geschieht:
import java.io.*;
class PrintWriterDemo {
public static void main(String[] args) throws IOException {
try (PrintWriter pw = new PrintWriter(new FileOutputStream("pw.txt"))) {
for (int i = 1; i <= 3000; i++)
pw.println(i);
}
}
}
Ein PrintWriter reicht einen (impliziten oder expliziten) close() - Aufruf an die zugrunde liegen-
den Datenströme weiter, sodass im Beispiel auch die Dateiverbindung geschlossen wird.
Abschnitt 14.4 Klassen zur Verarbeitung von Zeichenströmen 733
In der folgenden Programmvariante unterbleibt die Pufferentleerung mit dem Effekt, dass in der
Ausgabedatei ca. 1500 Zeilen fehlen:
import java.io.*;
class PrintWriterDemo {
public static void main(String[] args) throws IOException {
PrintWriter pw = new PrintWriter(new FileOutputStream("pw.txt"));
for (int i = 1; i <= 3000; i++)
pw.println(i);
}
}
Wenn die voreingestellte Codierung (siehe Abschnitt 14.4.1.2) akzeptabel ist, taugt auch ein
FileWriter (siehe Abschnitt 14.4.1.3) als Verbindungsstück zwischen einem PrintWriter und einer
Textdatei:
Programm
Variablen
von PrintWriter FileWriter Bytes Textdatei
beliebigem
Typ
Programm
Variablen BufferedWriter
von PrintWriter als Ergebnis von Bytes Textdatei
beliebigem Files.newBufferedWriter()
Typ
734 Kapitel 14 Ein- und Ausgabe über Datenströme
Man übergibt der Methode ein Path-Objekt mit dem Dateibezug, ein Charset-Objekt zur Wahl der
Codierung und optionale Angaben zum Öffnungsmodus (vgl. Abschnitt 14.7.1):
public static BufferedWriter newBufferedWriter(Path path, Charset cs,
OpenOption... options)
throws IOExecption
Wird kein OpenOption - Parameter angegeben, dann sind aus der Enumeration StandardOpen-
Option die folgenden Werte in Kraft: CREATE, TRUNCATE_EXISTING und WRITE. Folg-
lich wird eine fehlende Datei erstellt und eine vorhandene Datei zunächst entleert.
Wie ein Blick in den API-Quellcode der Klasse Files in Java 17 zeigt,
public static BufferedWriter newBufferedWriter(Path path, Charset cs,
OpenOption... options)
throws IOException {
CharsetEncoder encoder = cs.newEncoder();
Writer writer = new OutputStreamWriter(newOutputStream(path, options), encoder);
return new BufferedWriter(writer);
}
erhält man einen BufferedWriter, der seinen Zeichenstrom an einen OutputStreamWriter mit der
gewünschten Codierung weitergibt, wobei dieses Brückenklassenobjekt mit einem OutputStream
verbunden ist, den die Files-Methode newOutputStream() zum Path-Objekt mit den gewünschten
Öffnungseinstellungen liefert.
Im Vergleich zu einem traditionell durch Konstruktoraufrufe erzeugten Gespann aus BufferedWri-
ter und FileWriter bestehen folgende Vorteile:
• Vereinfachung der Syntax, weil zwei verschachtelte Konstruktoraufrufe durch einen Metho-
denaufruf ersetzt werden.
• Per Charset-Objekt kann eine Codierung gewählt werden.
• Es lassen sich detaillierte Öffnungsoptionen für die Datei angeben (siehe Abschnitt 14.7.1).
• Über das im Hintergrund per newOutputStream() erzeugte Ausgabeobjekt kommt die
Channel-Technik zum Einsatz, wobei ein Geschwindigkeitsvorteil möglich, aber nicht ga-
rantiert ist. Bei manchen Anforderungsprofilen kann die Channel-Technik sogar ein schlech-
teres Leistungsverhalten zeigen als die traditionelle Datenstromtechnik.
Zur Ausgabe in eine Textdatei mit UTF-8 - Codierung eignet sich die folgende Überladung ohne
Charset-Parameter:
public static BufferedWriter newBufferedWriter(Path path, OpenOption... options)
throws IOException
14.4.2.1 Überblick
In der folgenden Abbildung sind Eingabeklassen (in direktem Kontakt mit einer Datenquelle) mit
weißem Hintergrund dargestellt, Eingabetransformationsklassen mit grauem Hintergrund:
Abschnitt 14.4 Klassen zur Verarbeitung von Zeichenströmen 735
BufferedReader LineNumberReader
StringReader
CharArrayReader
Reader
InputStreamReader FileReader
PipedReader
FilterReader PushbackReader
Weil die Klasse FileReader mit einer Datei verbunden ist und eine Transformationsfunktion be-
sitzt, ist sie mit schraffiertem Hintergrund dargestellt.
Bei den meisten Reader-Unterklassen beschränken wir uns auf kurze Hinweise:
• LineNumberReader
Dieser gepufferte Zeicheneingabestrom erweitert seine Basisklasse BufferedReader um
Methoden zur Verwaltung von Zeilennummern.
• StringReader und CharArrayReader
Objekte dieser Klassen lesen aus einem String bzw. aus einem char-Array.
• PipedReader
Objekte dieser Klasse lesen Zeichen aus einer Pipeline, die zur Kommunikation zwischen
Threads dient.
• FilterReader
Diese abstrakte Basisklasse bietet sich dazu an, eigene Transformationsklassen für zeichen-
basierte Eingabeströme abzuleiten.
• PushbackReader
Diese Klasse bietet Methoden, um die aus einem Eingabestrom entnommenen Zeichen wie-
der zurückzustellen, was z. B. dann sinnvoll ist, wenn nach einer vorausschauenden Prüfung
die eigentliche Verarbeitung durch ein anderes Stromobjekt erfolgen soll.
Wer eine Möglichkeit zum komfortablen Einlesen von numerischen Daten aus Textdateien sucht,
sollte sich im Abschnitt 14.5 die (unmittelbar aus java.lang.Object abgeleitete) Klasse Scanner
ansehen.
Programm
Wie beim OutputStreamReader findet zur Beschleunigung der Konvertierung automatisch eine
Pufferung des Byte-Stroms statt.
class BufferedFileReader {
public static void main(String[] args) throws IOException {
List<String> als = new ArrayList<>();
try (BufferedReader br = new BufferedReader(new FileReader("fr.txt"))) {
String line;
while (true) {
line = br.readLine();
if (line != null)
als.add(line);
else
break;
}
System.out.println(als.get(als.size()-1));
}
}
}
Programm
String, Buffered-
FileReader Bytes Textdatei
char Reader
Die bei einem BufferedReader voreingestellte Puffergröße von 8192 Zeichen lässt sich per Kon-
struktorparameter ändern.
Soll ein FileReader unter Verwendung einer per Path-Objekt identifizierten Datei instanziert wer-
den, dann bietet sich die Path-Methode toFile() an, die zu einem Path-Objekt ein korrespondieren-
des File-Objekt liefert (siehe Abschnitt 14.2.1.1).
Programm
BufferedReader
String,
als Ergebnis von Bytes Textdatei
char
Files.newBufferedReader()
Man übergibt der Methode ein Path-Objekt mit dem Dateibezug und ein Charset-Objekt zur Wahl
der Codierung:
public static BufferedReader newBufferedReader(Path path, Charset cs)
throws IOException
Wie ein Blick in den OpenJDK 17 - Quellcode zeigt,
public static BufferedReader newBufferedReader(Path path, Charset cs)
throws IOException {
CharsetDecoder decoder = cs.newDecoder();
Reader reader = new InputStreamReader(newInputStream(path), decoder);
return new BufferedReader(reader);
}
erhält man einen BufferedReader, der seinen Zeichenstrom von einem InputStreamReader mit
der gewünschten Codierung bezieht, wobei dieses Brückenklassenobjekt mit einem InputStream
verbunden ist, den die Files-Methode newInputStream() zum Path-Objekt liefert.
738 Kapitel 14 Ein- und Ausgabe über Datenströme
class BufferedReaderNio2 {
public static void main(String[] args) throws IOException {
List<String> als = new ArrayList<>();
try (BufferedReader br = Files.newBufferedReader(Paths.get("inp.txt"))) {
String line;
while (true) {
line = br.readLine();
if (line != null)
als.add(line);
else
break;
}
System.out.println(als.get(als.size()-1));
}
}
}
Aufgrund der Channel-Technik ist ein Leistungsvorteil möglich, aber nicht garantiert. Bei manchen
Anforderungsprofilen zeigt die Channel-Technik sogar ein schlechteres Leistungsverhalten als die
traditionelle Datenstromtechnik.
Seit Java 5.0 (alias 1.5) erleichtert die unmittelbar von java.lang.Object abstammende Klasse
Scanner im Paket java.util das Lesen von Zahlen aus Textdateien. Ein Scanner-Objekt zerlegt den
Eingabestrom aufgrund einer frei wählbaren Trennzeichenmenge in Bestandteile, Tokens genannt,
und beherrscht diverse Methoden mit der Fähigkeit zur numerischen Interpretation (zum sogenann-
ten Parsen) beim sequentiellen Zugriff auf die Tokens, z. B.:
• public int nextInt()
public double nextDouble()
Die Methoden versuchen, das nächste Token als int- bzw. double-Wert zu interpretieren,
und werfen bei Misserfolg eine InputMismatchException.
• public BigInteger nextBigInteger()
public BigDecimal nextBigDecimal()
Die Methode versucht, das nächste Token als Ganz- bzw. Dezimalzahl zu interpretieren und
ein Objekt der Klasse BigInteger bzw. BigDecimal daraus zu erstellen. Bei Misserfolg wird
eine InputMismatchException geworfen.
Wird vom nächsten Token lediglich der Text benötigt, dann verwendet man die Scanner-Methode
next():
public String next()
Ob noch ein Token vorhanden und vom gewünschten Typ ist, kann mit einer entsprechenden Me-
thode festgestellt werden, z. B.:
• public boolean hasNext()
Die Methode prüft, ob noch ein Token vorhanden ist.
• public boolean hasNextInt()
public boolean hasNextDouble()
Die Methode prüft, ob das nächste Token als int- bzw. double-Wert interpretierbar ist.
• public boolean hasNextBigInteger ()
public boolean hasNextBigDecimal()
Die Methode prüft, ob das nächste Token als Ganz- bzw. Dezimalzahl interpretierbar ist, um
ein Objekt der Klasse BigInteger bzw. BigDecimal daraus zu erstellen.
Als Trennzeichen für die Zerlegung des Eingabestroms in Tokens gelten per Voreinstellung alle
WhiteSpace-Zeichen (z. B. Leerzeichen, Tabulator). Ob ein Zeichen zu dieser Menge gehört, lässt
sich mit der statischen Character-Methode isWhitespace() feststellen. In der API-Dokumentation
zur Klasse Character findet sich auch eine Tabelle mit allen Zeichen, die Java per Voreinstellung
als WhiteSpace-Zeichen interpretiert.1 Für eine alternative Festlegung der Trennzeichen steht die
Scanner-Methode useDelimiter() zur Verfügung.
In den diversen Scanner-Konstruktoren wird u. a. ein File-, Path- oder InputStream-Objekt als
Datenquelle akzeptiert, wobei optional auch eine Codierung (vgl. Abschnitt 14.4.1.2) angegeben
werden kann:
1
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Character.html#isWhitespace(char)
740 Kapitel 14 Ein- und Ausgabe über Datenströme
Programm
primitive
Typen,
Input- Bytes
String, Scanner Textdatei
Stream
BigInteger
BigDecimal
Das folgende Programm liest Zahlen und String-Objekte aus einer Textdatei:
import java.util.Scanner;
class ScannerFile {
public static void main(String[] args) throws java.io.IOException {
try (Scanner input = new Scanner(new java.io.FileInputStream("daten.txt"),
"Windows-1252")) {
while (input.hasNext())
if (input.hasNextInt())
System.out.println("int-Wert: " + input.nextInt());
else
if (input.hasNextDouble())
System.out.println("double-Wert: " + input.nextDouble());
else
System.out.println("Text: " + input.next());
}
}
}
Mit den Eingabedaten
4711 3,1415926
Nicht übel!
13 9,99
in einer ANSI-codierten Textdatei (vgl. Abschnitt 14.4.1.2 zur Codierung) erhält man die Ausgabe:
int-Wert: 4711
double-Wert: 3.1415926
Text: Nicht
Text: übel!
int-Wert: 13
double-Wert: 9.99
Abschnitt 14.5 Zahlen und Zeichenfolgen aus einer Textdatei lesen 741
In der Eingabedatei ist das lokalspezifische Dezimaltrennzeichen zu verwenden, bei uns also ein
Komma. Zum Lesen einer Textdatei mit dem Punkt als Dezimaltrennzeichen muss man das Kultur-
bzw. Gebietsschema anpassen, z. B.
try (Scanner input = new Scanner(
new java.io.FileInputStream("daten.txt"), "Windows-1252")) {
input.useLocale(Locale.US);
. . .
}
Wir haben den Einsatz der Klasse Scanner für die Datenerfassung via Konsole bereits im Abschnitt
3.4.1 erwogen. Das folgende Programm nimmt zwei reelle Zahlen a und b von der Standardeingabe
(einem InputStream-Objekt) entgegen und berechnet die Potenz ab mit Hilfe der statischen Math-
Methode pow():
import java.util.*;
class ScannerConsole {
public static void main(String[] args) {
double basis, exponent;
Scanner input = new Scanner(System.in);
System.out.print("Basis und Exponent Argumente (durch Leerzeichen getrennt): ");
try {
basis = input.nextDouble();
exponent = input.nextDouble();
System.out.println(basis + " hoch " + exponent +
" = " + Math.pow(basis, exponent));
} catch(InputMismatchException e) {
System.err.println("Eingabefehler");
}
}
}
Nun sind Sie im Stande, die von der Klasse Simput (siehe Abschnitt 3.4.1) zur Verfügung gestell-
ten Methoden zur Eingabe primitiver Datentypen via Tastatur komplett zu verstehen (und zu kriti-
sieren). Als Beispiel wird die Methode gint() wiedergegeben:
package de.uni_trier.zimk.util.conio;
import java.util.*;
import java.io.*;
Das IntelliJ-Projekt mit der Klasse Simput als Bestandteil des Pakets
de.uni_trier.zimk.util.conio im Modul de.uni_trier.zimk.util finden Sie im Ord-
ner:
…\BspUeb\Pakete und Module\IntelliJ\Bruchaddition
Als Eingabe für ein Scanner-Objekt kommt auch ein String in Frage, wobei der folgende Scanner-
Konstruktor zum Einsatz kommt:
public Scanner(String source)
Im folgenden Beispielprogramm erwartet die Methode extractData() eine Zeichenfolge, eine
Anzahl von zu lesenden Zahlen und einen Ersatzwert für den Fall einer fehlerhaften oder zu kurzen
Zeichenfolge. Das Ergebnis wird als double-Array abgeliefert:
Quellcode Ausgabe
import java.util.Scanner; 1.2
class Prog { 3.5
static double[] extractData(String line, int nv, double md) { -9.9
Scanner scanner = new Scanner(line); -9.9
double[] da = new double[nv];
for (int n = 0; n < nv; n++)
if (scanner.hasNextDouble())
da[n] = scanner.nextDouble();
else
da[n] = md;
return da;
}
14.6 Objektserialisierung
Wer objektorientiert programmiert, möchte natürlich auch ...
• Persistenzaufgaben objektorientiert lösen, also komplette Objekte in permanente Datenspei-
cher befördern und von dort einlesen,
• bei Netzwerktransfers zwischen Java-Programmen auf verschiedenen Rechnern komplette
Objekte auf einfache Weise übertragen.
In Java können Objekte tatsächlich meist genauso einfach wie primitive Datentypen in einen Byte-
Strom geschrieben bzw. von dort gelesen werden. Die Übersetzung eines Objektes (mit all seinen
Instanzvariablen) in einen Byte-Strom bezeichnet man recht treffend als Objektserialisierung, den
umgekehrten Vorgang als Objektdeserialisierung. Wenn Instanzvariablen auf andere Objekte zei-
gen, werden diese in die Sicherung und spätere Wiederherstellung einbezogen. Weil auch die refe-
renzierten Objekte wieder Mitgliedsobjekte haben können, ist oft ein ganzer Objektgraph beteiligt.
Ein mehrfach referenziertes Objekt wird dabei nur einmal einbezogen.
Eine häufig genutzte Anwendung der (De-)Serialisierung besteht darin, eine tiefe, den gesamten
Objektgraphen einbeziehende Kopie eines Objekt zu erstellen (siehe z. B. Krüger & Hansen 2014,
S. 881ff). Man arbeitet mit den Klassen ByteArrayOutputStream und ByteArrayInputStream,
verwendet also einen byte-Array auf dem Heap zur Zwischenspeicherung. Eine tiefe Kopie sollte
eine Klasse eigentlich durch eine passende Überschreibung der Object-Methode clone() ermögli-
chen, die jedoch in vielen Klassen fehlt.
Abschnitt 14.6 Objektserialisierung 743
In die Serialisierung werden alle Instanzvariablen (unabhängig von der Sichtbarkeit) einbezogen,
die nicht als transient deklariert sind (siehe Abschnitt 14.6.5). Statische Felder werden naheliegen-
der Weise bei der Objektserialisierung ignoriert.
Leider hat sich herausgestellt, dass die (De-)Serialisierungstechnik in Java für erhebliche Sicher-
heitsrisiken verantwortlich ist (Denial-of-Service - Angriffe, Ausführung von beliebigem Schad-
Code). Das Deserialisieren von nicht-vertrauenswürdigen Byte-Strömen ist strikt zu unterlassen.
Weitere Details zu Sicherheitsrisiken bei der Objektserialisierung folgen im Abschnitt 14.6.1.
Wer über die anschließenden Erläuterungen hinausgehende Informationen zur Serialisierung benö-
tigt, findet sie z. B. in der Java Object Serialization Specification (Oracle 2010).1
1
https://docs.oracle.com/javase/8/docs/platform/serialization/spec/serialTOC.html
2
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/io/Serializable.html
744 Kapitel 14 Ein- und Ausgabe über Datenströme
Wie gleich im Abschnitt 14.6.3 zu sehen sein wird, führt die Nutzung der Serialisierung, also die
Produktion von permanent gespeicherten Objekten dazu, dass die Weiterentwicklung einer Klasse
mit Inkompatibilitätskosten verbunden sein kann. Dazu trägt vor allem die Tatsache bei, dass auch
private Instanzvariablen in die Serialisierung einbezogen werden.
Wir demonstrieren die Serialisation mit Hilfe der folgenden Klasse Kunde:
import java.io.*;
import java.math.BigDecimal;
Über inkompatible und kompatible Änderungen der Klassendefinition informiert Oracle (2010) im
Abschnitt 5.6. Beispiele für inkompatible Änderungen sind:
• Löschen von Instanzvariablen
Wenn eine ältere Klasse beim Deserialisieren auf ein jüngeres Objekt trifft, wird eine feh-
lende Instanzvariable auf den typspezifischen Nullwert gesetzt, was eventuell zu Fehlverhal-
ten führt.
• Änderung des primitiven Datentyps einer Instanzvariablen
In diesem Fall scheitert das Deserialisieren mit einem Ausnahmefehler vom Typ
InvalidClassException.
Bei einer inkompatiblen Änderung der Klassendefinition muss die serialVersionUID aktualisiert
werden, um Fehler zu verhindern.
Beispiele für kompatible Änderungen sind:
• Ergänzung von Instanzvariablen
Wenn eine jüngere Klasse beim Deserialisieren auf ein älteres Objekt trifft, wird eine feh-
lende Instanzvariable auf den typspezifischen Nullwert gesetzt. Damit daraus kein Fehlver-
halten resultiert, kann die renovierte Klasse eine Methode namens readObject() implemen-
tieren und dort für eine passende Initialisierung sorgen (siehe Abschnitt 14.6.6).
• Änderung der Sichtbarkeit von Feldern
Eine Änderung der Sichtbarkeit von Feldern (package, private, protected, public) bereitet
bei der Deserialisierung keine Probleme.
Von Inden (2018, Abschnitt 10.3.3) wird ein Verfahren beschrieben, das die Versionskontrolle bei
der Serialisierung komplett in Eigenregie ausführt und für den Preis eines deutlich höheren Auf-
wands z. B. auch Änderungen bei den Datentypen von Instanzvariablen verkraften kann.
Insgesamt ist festzuhalten, dass sich die Serialisierbarkeit einer Klasse bei der Weiterentwicklung
durch Einschränkungen und/oder Aufwand negativ bemerkbar machen kann.
import java.io.*;
class Serialisierung {
public static void main(String[] args) throws Exception {
Kunde kunde = new Kunde("Fritz", "Orth", 1, 13,
new java.math.BigDecimal("426.89"));
System.out.println("Zu sichern:\n");
kunde.prot();
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream("test.ser"))) {
oos.writeInt(1);
oos.writeObject(kunde);
}
Selbstverständlich können auch mehrere Objekte in eine Datei gesichert werden, wobei die Reihen-
folge beim Schreiben und Lesen identisch sein muss.
Beim Schreiben eines Objekts wird auch seine Klasse samt serialVersionUID festgehalten.
Beim Lesen eines Objekts wird seine Klasse festgestellt, und die JVM benötigt den Bytecode von
allen Klassen im Objektgraphen. Ist die serialVersionUID identisch, wird das Objekt auf dem He-
ap angelegt, und die Instanzvariablen erhalten ihre rekonstruierten Werte. Dabei wird kein Kon-
struktor der serialisierbaren Klasse aufgerufen. Allerdings wird der Konstruktor der ersten nicht-
serialisierbaren Oberklasse aufgerufen (siehe Abschnitt 14.6.7).
Weil readObject() den Rückgabetyp Object hat, ist in der Regel eine explizite Typumwandlung
erforderlich.
Eine häufig verwendete, aber nicht strikt vorgeschriebene Namenserweiterung für Dateien mit se-
rialisierten Objekten ist .ser.
In der folgenden Abbildung wird die Serialisierung von Objekten vom internen Format der JVM in
eine binäre Datei skizziert:
Abschnitt 14.6 Objektserialisierung 747
Programm
ObjectOutput- FileOutput-
Objekte Bytes Binärdatei
Stream Stream
Den umgekehrten Weg bei der Deserialisierung von Objekten aus einer binären Datei in das interne
Format der JVM beschreibt die nächste Abbildung:
Programm
1
In einer Klasse können „redundante“ Felder aus Performanzgründen existieren, um wiederholte Berechnungen zu
vermeiden.
748 Kapitel 14 Ein- und Ausgabe über Datenströme
Zu sichern:
einer bestimmten Größe der verketteten Liste sogar zu einem Stackoverflow-Laufzeitfehler kom-
men kann.
Ein Beispiel für die Verwendung von writeObject() und readObject() folgt im Abschnitt 14.6.7 im
Zusammenhang mit Instanzvariablen, die von einer nicht-serialisierbaren Oberklasse geerbt wur-
den.
Um dies zu verhindern, werden in der serialisierbaren Klasse Kunde die Methoden writeObject()
und readObject() implementiert, die zunächst durch einen Aufruf der Methode
defaultWriteObject() bzw. defaultReadObject() den API-Serialisierungs-Service so weit als
möglich nutzen und dann für das Schreiben bzw. Lesen der Person-Felder sorgen:
private void writeObject(ObjectOutputStream oos) throws IOException {
oos.defaultWriteObject();
oos.writeObject(vorname);
oos.writeObject(name);
}
1
https://inside.java/2020/07/20/record-serialization/
2
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Record.html
3
Es wird der parameterfreie Konstruktor der ersten nicht-serialisierbaren Oberklasse in der Ahnenreihe aufgerufen,
um ein Objekt zu erzeugen und seine Instanzvariablen zu initialisieren. Dann werden die Felder des Objekts per Re-
flexion mit den aus dem Datenstrom entnommenen Werten versorgt (siehe Abschnitt 14.6.7).
Abschnitt 14.6 Objektserialisierung 751
1
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/io/Serializable.html
752 Kapitel 14 Ein- und Ausgabe über Datenströme
• JSON (JavaScript Object Notation) ist ein textorientiertes Format, das häufig zur Netzwer-
kübertragung von Objekten zwischen Server und Browser eingesetzt wird, aber auch zum
Speichern von Objekten in Dateien verwendet werden kann. Eine (nicht nur) in Java häufig
eingesetzte Bibliothek zur JSON - (De)serialisierung trägt den Namen Jackson.
• XML (Extensible Markup Language) war vor dem Aufkommen von JSON lange Zeit der
Standard bei der textorientierten Serialisierung, hat aber den Nachteil eines relativ hohen
Platzbedarfs.
• YAML und TOML belegen wenig Platz und sind für Menschen gut lesbar.
• Als besonders effizient gelten die für mehrere Programmiersprachen (z. B. Java, C#, Python,
C++, Ruby) verfügbaren binären Protocol Buffers der Firma Google.1
Durch die Nutzung dieser sogenannten Objekt-Mapper werden zudem die Sicherheitsprobleme der
Java-Serialisierung überwunden.
14.7 Daten lesen und schreiben über die NIO.2 - Klasse Files
Die dem NIO.2 -API zugerechnete Klasse Files im Paket java.nio beherrscht nicht nur die Dateisy-
stemverwaltung (siehe Abschnitt 14.2.1) und die Fabrikation von Datenstromobjekten mit Channel-
Technik (siehe Abschnitte 14.3.1.3, 14.3.2.3, 14.4.1.6 und 14.4.2.4) sondern auch das Lesen und
Schreiben von Daten.2 Wird beim Schreiben und Lesen von Zeichen über eine Files-Methode (siehe
Abschnitt 14.7.2) oder ein per Files erstelltes Datenstromobjekt keine Codierung angegeben, dann
kommt die Codierung UTF-8 zum Einsatz.
Außerdem kann die Klasse Files auch ....
• den MIME-Typ (Multipurpose Internet Mail Extension) einer Datei (die Art und das Format
ihres Inhalts) ermitteln (siehe Abschnitt 14.7.4).
• zu einer Textdatei einen Stream<String> im Sinn von Abschnitt 12.2 erstellen (siehe Ab-
schnitt 14.7.5).
14.7.1 Öffnungsoptionen
In der Files-Methode write() zum Schreiben in eine Datei (siehe Abschnitt 14.7.2) sowie in den
Files-Methoden zum Erstellen eines Datenstromobjekts zu einer Datei (newOutputStream(),
newInputStream(), newBufferedWriter()) können über einen Serienparameter vom Interface-Typ
OpenOption Optionen für das Öffnen der Datei festgelegt werden. Das Interface wird u. a. von der
Enumeration StandardOpenOption im Paket java.nio.file implementiert, die folgende Konstanten
(vordefinierte Objekte) für häufig benötigte Öffnungsoptionen enthält:
• READ
Mit dieser Option wird eine Datei zum Lesen geöffnet.
• WRITE
Mit dieser Option wird eine Datei zum Schreiben geöffnet.
• APPEND
Bei einer bereits existenten, zum Schreiben geöffneten Datei sorgt diese Option dafür, dass
neue Ausgaben am Ende angehängt werden, statt vorhandene Ausgaben zu überschreiben.
• TRUNCATE_EXISTING
Durch diese nur beim Schreiben erlaubte Option wird bei einer vorhandenen Datei der bis-
herige Inhalt komplett gelöscht. Lässt man beim Schreiben ab Dateianfang diese Option
weg, dann bleiben eventuell am Dateiende vorhandene Bytes stehen.
1
https://developers.google.com/protocol-buffers/
2
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/nio/file/Files.html
Abschnitt 14.7 Daten lesen und schreiben über die NIO.2 - Klasse Files 753
• CREATE
Diese Option sorgt dafür, dass eine zum Schreiben zu öffnende Datei nötigenfalls angelegt
wird.
• CREATE_NEW
Es wird eine neue Datei angelegt oder bei vorhandener Datei eine Ausnahme vom Typ File-
AlreadyExistsException geworfen.
• DELETE_ON_CLOSE
Aufgrund dieser Option wird eine Datei beim Schließen nach Möglichkeit automatisch ge-
löscht, was bei temporären Dateien sinnvoll ist.
• SPARSE
Einige Dateisysteme (z. B. NTFS unter Windows) profitieren von dem Hinweis, dass eine
Datei spärlich besetzt ist und größtenteils aus Nullbytes besteht.
Beispiel:
InputStream instr = Files.newInputStream(file, StandardOpenOption.READ);
1
http://docs.oracle.com/javase/tutorial/essential/io/file.html#common
754 Kapitel 14 Ein- und Ausgabe über Datenströme
Mit der folgenden write() - Überladung schreibt man die in einem iterierbaren Container (z.B. in
einer Kollektion vom Typ List<String>) befindlichen Zeichenfolgen (Objekte vom Typ
CharSequence) in eine Datei:
public static Path write(Path path, Iterable<? extends CharSequence > lines,
Charset cs, OpenOption... options) throws IOException
Dabei sind die Zeichencodierung und der Dateiöffnungsmodus einstellbar.
Für eine Textdatei mit UTF-8 - Codierung eignet sich die folgende Überladung ohne Charset-
Parameter:
public static Path write(Path path, Iterable<? extends CharSequence > lines,
OpenOption... options) throws IOException
class ProbeContentType {
public static void main(String[] args) throws IOException {
Path ordner = Paths.get("U:", "Eigene Dateien", "Java", "Test");
System.out.println("Inhaltstyp der Dateien im Verzeichnis " + ordner + ":\n");
try (DirectoryStream<Path> stream = Files.newDirectoryStream(ordner)) {
for (Path path: stream)
System.out.printf("%-13s %s\n", path.getFileName(),
Files.probeContentType(path));
}
}
}
wird der MIME-Type für alle Dateien in einem Verzeichnis aufgelistet:
Ausgabe.txt text/plain
Begriffe.pdf application/pdf
Java17.docx application/vnd.openxmlformats-officedocument.wordprocessingml.document
JellyFish.jpg image/jpeg
misc.xml text/xml
Wie man durch Umbenennen einer Datei verifizieren kann, orientiert sich die Methode
probeContentType() unter Windows nicht am Dateiinhalt, sondern nur an der Namenserweiterung.
Variablen
von PrintWriter BufferedWriter Bytes Textdatei
beliebigem
Typ
Beispiel:
import java.io.*;
import java.nio.file.*;
import java.nio.charset.Charset;
class DataToText {
public static void main(String[] args) throws IOException {
try (PrintWriter pw = new PrintWriter(Files.newBufferedWriter(
Paths.get("Ausgabe.txt"), Charset.forName("UTF-8")))) {
pw.println(4711);
pw.printf("%4.2f", Math.PI);
String ls = System.getProperty("line.separator");
pw.println(ls + "Nicht übel!");
}
}
}
Anderenfalls erkennt z. B. unter Windows der Standardeditor Notepad den Zeilenwechsel nicht:
Die PrintWriter-Methode println() schließt ihre Ausgabe korrekt mit der Plattform-spezifischen
Zeilenschaltung ab.
Beispiel:
import java.io.*;
import java.nio.file.*;
import java.nio.charset.Charset;
import java.util.*;
class ReadText {
public static void main(String[] args) throws IOException {
List<String> ls = new ArrayList<String>();
try (BufferedReader br = Files.newBufferedReader(Paths.get("Quelle.txt"),
Charset.forName("Windows-1252"))) {
String s;
while ((s=br.readLine()) != null)
ls.add(s);
}
for(String s : ls)
System.out.println(s);
}
}
Zum Lesen von Zeichenfolgen kommt auch die Klasse Scanner in Frage (siehe Abschnitte 14.5
und 14.8.3), die den Eingabestrom aufgrund wählbarer Trennzeichen in Bestandteile (Tokens) zer-
legen kann und ebenfalls die Wahl einer Codierung erlaubt.
prim.
Typen, Scanner Bytes Textdatei
String
Beispiel:
import java.io.IOException;
import java.nio.file.Paths;
import java.util.*;
class TokensScannen {
public static void main(String[] args) throws IOException {
try (Scanner input = new Scanner(Paths.get("Eingabe.txt"))) {
while (input.hasNext())
if (input.hasNextInt())
System.out.println("int-Wert: " + input.nextInt());
else
if (input.hasNextDouble())
System.out.println("double-Wert: " + input.nextDouble());
else
System.out.println("Text: " + input.next());
}
}
}
prim.
InputStream
Typen, Scanner Bytes Konsole
(System.in)
String
Beispiel:
Scanner input = new Scanner(System.in);
System.out.print("Ihr Alter: ");
int alter = input.nextInt();
Um die Verbindung zur Ausgabedatei per Channel-Technik herzustellen, lässt man sich von der
statischen Files-Methode newOutputStream() einen OutputStream liefern
public static OutputStream newOutputStream(Path path,
OpenOption... options)
throws IOException
und wählt dabei:
• die Ausgabedatei per Path-Objekt (NIO.2 - API, vgl. Abschnitt 14.2.1)
• Öffnungsoptionen über den Serienparameter vom Typ OpenOption (siehe Abschnitt 14.7.1)
Per Voreinstellung sind aus der Enumeration StandardOpenOption die folgenden Kon-
stanten gewählt: CREATE, TRUNCATE_EXISTING und WRITE. Folglich wird eine
fehlende Datei erstellt und eine vorhandene Datei zunächst entleert.
Beispiel:
try (DataOutputStream dos = new DataOutputStream(
Files.newOutputStream(Paths.get("Ausgabe.dat")))) {
dos.writeInt(4711);
dos.writeDouble(Math.PI);
}
Soll die Ausgabe gepuffert erfolgen, um die Anzahl der Dateizugriffe gering zu halten, dann muss
ein Filterobjekt aus der Klasse BufferedOutputStream eingesetzt werden:
760 Kapitel 14 Ein- und Ausgabe über Datenströme
prim. Buffered-
DataOutput- Output- Output- Bytes Binärdatei
Datentypen Stream Stream
Stream
Im folgenden Beispiel wird ein Puffer mit einer Kapazität von 16384 Bytes verwendet:
try (DataOutputStream dos = new DataOutputStream(
new BufferedOutputStream(
Files.newOutputStream(Paths.get("Ausgabe.dat")), 16384))) {
. . .
}
Ein Puffer muss auf jeden Fall vor dem Programmende entleert werden, was am einfachsten durch
die try - Anweisung die mit automatischer Ressourcen-Freigabe zu realisieren ist.
Um die Verbindung zur Ausgabedatei per Channel-Technik herzustellen, lässt man sich von der
statischen Files-Methode newInputStream() einen InputStream liefern
public static InputStream newInputStream(Path path,
OpenOption... options)
throws IOException
und wählt dabei:
• die Eingabedatei per Path-Objekt (NIO.2 - API, vgl. Abschnitt 14.2.1)
• Öffnungsoptionen über den Serienparameter vom Typ OpenOption (siehe Abschnitt 14.7.1)
Per Voreinstellung wird die Datei zum Lesen geöffnet (Konstante READ aus der Enumera-
tion StandardOpenOption).
Beispiel:
Path file = Paths.get("demo.dat");
try (DataInputStream dis = new DataInputStream(Files.newInputStream(file))) {
int i = dis.readInt();
double d = dis.readDouble();
}
Soll die Eingabe gepuffert erfolgen, um die Anzahl der Dateizugriffe gering zu halten, dann muss
ein Filterobjekt aus der Klasse BufferedInputStream eingesetzt werden:
Abschnitt 14.8 Empfehlungen zur Ein- und Ausgabe 761
Im folgenden Beispiel wird ein Puffer mit einer Kapazität von 16384 Bytes verwendet:
Path file = Paths.get("demo.dat");
try (DataInputStream dis = new DataInputStream(
new BufferedInputStream(
Files.newInputStream(file), 26384))) {
. . .
}
ObjectOutput- Output-
Objekte Bytes Binärdatei
Stream Stream
Beispiel:
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream("test.ser"))) {
oos.writeObject(kunde);
}
ObjectInput- Input-
Objekte Bytes Binärdatei
Stream Stream
Beispiel:
try (ObjectInputStream ois = new ObjectInputStream(
new FileInputStream("test.ser"))) {
Kunde unbekannt = (Kunde) ois.readObject();
}
Wer die Verbindung zur Aus- bzw. Eingabedatei über das NIO.2 - API (vgl. Abschnitt 14.2.1) her-
stellen möchte, lässt sich von der statischen Files-Methode newOutputStream() einen
OutputStream bzw. von der Methode newInputStream() einen InputStream liefern.
762 Kapitel 14 Ein- und Ausgabe über Datenströme
Im Abschnitt 14.6.9 werden moderne und sichere Alternativen zur binären (De)serialisierung vor-
gestellt, die zudem die Kommunikation mit anderen Programmiersprachen erlauben.
2) Die FileInputStream-Methode read() versucht, ein Byte aus der angeschlossenen Datei zu le-
sen. Warum verwendet sie den Rückgabetyp int?
3) Erstellen Sie ein Programm zur Demonstration der Ausgabepufferung. Um mitverfolgen zu kön-
nen, wie bei erschöpfter Pufferkapazität Daten weitergeleitet werden, sollten Sie als Senke die Kon-
sole verwenden.
Wie Sie aus dem Abschnitt 14.3.1.6 wissen, ist der per System.out ansprechbare PrintStream mit
aktivierter autoFlush-Option hinter einen BufferedOutputStream mit 128 Bytes Puffergröße ge-
schaltet, was insgesamt keine guten Beobachtungsmöglichkeiten bietet. Als Alternative mit besse-
ren Forschungsmöglichkeiten wird daher die folgende Ausgabestromkonstruktion vorgeschlagen:
FileOutputStream fos =
new FileOutputStream(FileDescriptor.out);
BufferedOutputStream bos =
new BufferedOutputStream(fos, 4);
Über die statische Variable out der Klasse FileDescriptor wird der Bezug zur Konsole hergestellt.
Dorthin schreibt der FileOutputStream fos, an den der BufferedOutputStream bos mit der un-
typisch kleinen Puffergröße von 4 Bytes gekoppelt ist:
BufferedOutputStream File-
byte Output- Bytes Konsole
(4 Bytes)
Stream
Wir kommen mit der BufferedOutputStream-Methode write() aus, wenn die auszugebenden
Bytes so gewählt werden, dass eine interpretierbare Bildschirmausgabe entsteht. Dies ist z. B. bei
folgendem Aufruf der Fall:
bos.write(i + 47);
Bei i = 1 wird das niederwertigste Byte der int-Zahl 48 (= 0x30) in den Ausgabestrom geschoben.
Dieses ist in jedem 8-Bit-Zeichensatz die Codierung der Null, sodass diese Ziffer auf der Konsole
erscheint. Bei i = 2 erscheint dementsprechend eine Eins usw.
Jetzt müssen Sie nur noch per „Zeitlupe“ dafür sorgen, dass man das Füllen und Entleeren des Puf-
fers mitverfolgen kann, z. B.:
Abschnitt 14.9 Übungsaufgaben zum Kapitel 14 763
Wird ein Konsolenprogramm in IntelliJ ausgeführt, dann produziert die print() - Ausgabe des Steu-
erzeichens \u0007 allerdings keinen Ton. Stattdessen erscheint in der Konsole ein Rechteck, was
zur Demonstration der Ausgabepufferung sogar recht nützlich ist, z. B.:
01234567
4) Wie kann man beim folgenden Programm den Quellcode vereinfachen und dabei auch noch die
Laufzeit erheblich reduzieren?
import java.io.*;
class AutoFlasche {
public static void main(String[] egal) throws IOException {
try (PrintWriter pw = new PrintWriter(new FileOutputStream("pw.txt"), true)) {
long time = System.currentTimeMillis();
for (int i = 1; i < 50_000; i++) {
pw.println(i);
}
System.out.println("Zeit: " + (System.currentTimeMillis()-time));
}
}
}
5) Schreiben Sie ein Programm, das den Text (hier unter Windows 10 von Notepad bei fehlerhafter
Annahme einer ANSI-Codierung angezeigt)
in der Datei
...\BspUeb\IO\MS-DOS\text.txt
einlesen und korrekt in einem JOptionPane-Meldungsfenster darstellen kann:
6) Erstellen Sie eine Klasse zur Verwaltung einer Datenmatrix bestehend aus den Messwerten von k
Merkmalen bei n Fällen. Verwenden Sie zur Aufbewahrung der Messwerte einen zweidimensiona-
len double-Array. Zu jedem Merkmal soll außerdem ein Name gespeichert werden. Objekte der
Klasse sollten Daten aus einer Textdatei nach folgendem Muster aufnehmen können:
nr temp alter gewicht
1 12,3 74,5 123,9
2 11,2 34,4 156,7
3 7,2 83,5 142,1
4 45,2 17,2 129,8
5 1,2 44,4 216,7
6 17,2 23,5 132,1
7 12,2 42,1 182,2
In der ersten Zeile stehen die Namen der Merkmale.
15 Multithreading
Wir sind längst daran gewöhnt, dass moderne Betriebssysteme mehrere Programme (Prozesse) pa-
rallel ausführen können, sodass z. B. ein längerer Ausdruck keine Zwangspause zur Folge hat. Wäh-
rend der Druckertreiber die Ausgabeseiten aufbaut, kann z. B. ein Java-Programm entwickelt oder
im Internet recherchiert werden. Weil in der Regel weniger Prozessoren bzw. virtuelle Prozessor-
kerne vorhanden sind als arbeitswillige Programme, muss das Betriebssystem die verfügbare CPU-
Leistung nach einem Zeitscheibenverfahren auf die rechenwilligen Programme verteilen. Dadurch
reduziert sich zwar die Ausführungsgeschwindigkeit jedes Programms, doch ist in den meisten An-
wendungen trotzdem ein flüssiges Arbeiten möglich.
Als Ergänzung zum gerade beschriebenen Multitasking, das ohne Zutun der Anwendungspro-
grammierer vom Betriebssystem bewerkstelligt wird, ist es oft sinnvoll oder gar unumgänglich,
auch innerhalb einer Anwendung nebenläufige Ausführungsfäden zu realisieren, wobei man hier
vom Multithreading spricht. Bei einem Internet-Browser muss man z. B. nach dem Anstoßen eines
längeren Downloads nicht untätig den Fortschrittsbalken im Download-Fenster anstarren, sondern
kann parallel mit anderen Fenstern arbeiten. Wie unter Windows die Detailsanzeige im Task-
Manager zeigt, sind z. B. bei einer typischen Verwendung des Internet-Browsers Firefox zahlreiche
Threads aktiv, wobei die Anzahl ständig schwankt:1
Bei einer GUI-Anwendung sorgt die Multithreading-Technik dafür, dass die Bedienoberfläche auch
dann noch auf Benutzereingaben reagiert, wenn im Hintergrund ein zeitaufwändiger Auftrag erle-
digt wird. Eine Server-Anwendung kann dank Multithreading mehrere Klienten simultan versorgen.
Die Multithreading-Technik kommt aber nicht nur dann in Frage, wenn eine Anwendung mehrere
Aufgaben gleichzeitig erledigen soll, damit der Prozess nicht durch eine langsame Aufgabe blo-
ckiert wird. Weil auf einem Rechner meist mehrere Prozessoren oder Prozessorkerne verfügbar
sind, sollten aufwändige Einzelaufgaben (z. B. das Rendern einer 3D-Ansicht, Virenanalyse einer
kompletten Festplatte) in Teilaufgaben zerlegt und auf mehrere CPU-Kerne verteilt werden. Weil
die CPU-Hersteller bei der Taktbeschleunigung an physikalische Grenzen gestoßen sind, konzent-
rieren sie sich seit vielen Jahren darauf, durch eine höhere Anzahl von CPU-Kernen eine Leistungs-
steigerung zu erzielen. Mittlerweile (2022) sind 4 reale Kerne zum Standard geworden, und viele
CPUs der Hersteller AMD und Intel besitzen dank der SMT-Technik (Simultaneous Multi-
Threading, bei Intel als Hyper-Threading bezeichnet) doppelt so viele logische CPU-Kerne. Multi-
Core - CPUs erhöhen den Druck auf die Software-Entwickler, per Multithreading für gut skalieren-
de Anwendungen zu sorgen, die von einer höheren Anzahl verfügbarer Kerne profitieren.
1
Mittlerweile verwenden manche Anwendungen wie z. B. der Firefox-Browser auch mehrere Prozesse, um die Stabi-
lität zu steigern. Beim Firefox zeigt das Bildschirmfoto, dass in jedem Prozess viele Threads aktiv sind.
766 Kapitel 15 Multithreading
1
https://www.oracle.com/technetwork/java/hotspotfaq-138619.html
Abschnitt 15.1 Start und Ende eines Threads 767
Wer über das aktuelle Kapitel hinaus weitere Informationen zum Multithreading in Java benötigt,
findet diese z. B. in Bloch (2018, Kap. 11) sowie in Hettel & Tran (2016).
Lager(int start) {
bilanz = start;
System.out.println("Der Laden ist offen (Bestand = " + bilanz + ")\n");
}
boolean istOffen() {
if (anz < MANZ)
return true;
else {
System.out.println("\nLieber " + Thread.currentThread().getName()+
", es ist Feierabend!");
return false;
}
}
Die für Klassen im selben Paket sichtbaren Lager-Methoden werden vom Produzenten und/oder
vom Konsumenten verwendet:
• istOffen()
Der Aufrufer erfährt, ob das Lager noch geöffnet ist.
• ergaenze()
Der Produzent erhöht mit dieser Methode den Lagerbestand.
• liefere()
Der Konsument reduziert mit dieser Methode den Lagerbestand.
Das folgende Hauptprogramm erzeugt ein Lager-Objekt mit initialem Bestand
class ProKonDemo {
public static void main(String[] args) {
Lager lager = new Lager(100);
ProThread pt = new ProThread(lager);
KonThread kt = new KonThread(lager);
pt.start();
kt.start();
}
}
und generiert dann ein ProThread- sowie ein KonThread-Objekt. Weil beide Threads mit dem
Lager-Objekt kooperieren sollen, erhalten sie als Konstruktorparameter eine entsprechende Refe-
renz.
Anschließend werden die beiden Threads vom Zustand new durch Aufruf ihrer start() - Methode in
den Zustand ready gebracht:
pt.start();
kt.start();
Von der start() - Methode eines Threads wird seine run() - Methode aufgerufen, die die im Thread
auszuführenden Anweisungen enthält. Eine aus Thread abgeleitete Klasse muss also die run() -
Methode überschreiben. Es folgt der Quellcode der Klasse ProThread:
class ProThread extends Thread {
private Lager lager;
ProThread(Lager lager) {
super("Produzent");
this.lager = lager;
}
@Override
public void run() {
while (lager.istOffen()) {
lager.ergaenze((int) (5 + Math.random()*100));
try {
Thread.sleep((int) (1000 + Math.random()*3000));
} catch(InterruptedException ie) {interrupt();}
}
}
}
In der run() - Methode der Klasse ProThread läuft eine while-Schleife so lange, bis die Lager-
Methode istOffen() den Rückgabewert false liefert.
Ein Thread im Zustand ready wartet auf die Zuteilung eines CPU-Kerns und erreicht dann den Zu-
stand running. Die JVM verwaltet die Threads in Zusammenarbeit mit dem Wirtsbetriebssystem,
Abschnitt 15.1 Start und Ende eines Threads 769
wobei ein Thread mehrfach zwischen den Zuständen ready und running wechseln kann (siehe Ab-
schnitt 15.3.3.1).
Wenn seine run() - Methode beendet ist, befindet sich ein Thread im Zustand terminated und kann
nicht erneut gestartet werden.
Es ist möglich, aber nicht empfehlenswert, einen Thread von außen mit der (mittlerweile abgewer-
teten) Methode stop() abzuwürgen (siehe Abschnitt 15.3.2.2).
Im Beispiel ergänzt der ProThread innerhalb einer while-Schleife das Lager um eine zufallsbe-
stimmte Menge. Er spricht über die per Konstruktorparameter erhaltene Referenz das Lager-
Objekt an und ruft dessen ergaenze() - Methode auf:
lager.ergaenze((int) (5 + Math.random()*100));
Anschließend legt er sich durch Aufruf der statischen Thread-Methode sleep() ein (wiederum zu-
fallsabhängiges) Weilchen zur Ruhe:
Thread.sleep((int) (1000 + Math.random()*3000));
Durch das Ausführen dieser Methode wechselt der Thread vom Zustand running zum Zustand
sleeping und konkurriert vorübergehend nicht mehr um Prozessorzeit. Schlafphasen eignen sich
wegen der unzuverlässigen, vom Wirtsbetriebssystem abhängigen Einhaltung der Zeiten übrigens
nicht für eine präzise Programmablaufsteuerung.
Weil von der Methode sleep() potentiell eine überwachte InterruptedException zu erwarten ist,
wird sie in einem try-Block ausgeführt. Zur Begründung eines sinnvollen catch-Blocks, müssen
wir etwas ausholen bzw. vorgreifen:
• Einem Thread kann durch einen Aufruf seiner interrupt() - Methode ein Unterbrechungs-
signal zugestellt werden (siehe Abschnitt 15.3.2). Ein kooperativer Thread prüft regelmäßig,
ob das Unterbrechungssignal gesetzt ist, und beendet ggf. seine run() - Methode.
• Die Methode sleep() reagiert folgendermaßen auf das Unterbrechungssignal:
o Sie hebt das Unterbrechungssignal auf!
o Sie wirft eine InterruptedException.
• Im catch-Block zur InterruptedException sollte in der Regel das Unterbrechungssignal
restauriert werden, damit auf einer höheren Ebene darauf reagiert werden kann. Im aktuellen
Zustand des Beispielprogramms hat diese Maßnahme zwar noch keine Bedeutung, doch
sollten wir uns schon jetzt an eine akzeptable Behandlung der InterruptedException ge-
wöhnen.
Zum ProThread-Konstruktor ist noch anzumerken, dass durch einen Aufruf des Superklassen-
Konstruktors ein Thread-Name festgelegt wird.
Der Konsumenten-Thread des Beispielprogramms ist weitgehend analog definiert:
class KonThread extends Thread {
private Lager lager;
KonThread(Lager lager) {
super("Konsument");
this.lager = lager;
}
770 Kapitel 15 Multithreading
@Override
public void run() {
while (lager. istOffen()) {
lager.liefere((int) (5 + Math.random()*100));
try {
Thread.sleep((int) (1000 + Math.random()*3000));
} catch(InterruptedException ie) {interrupt();}
}
}
}
Statt den Lagerbestand zu ergänzen, bezieht der Konsument in seiner run() - Methode Lieferungen.
In beiden run() - Methoden wird vor jedem Schleifendurchgang geprüft, ob das Lager noch offen
ist. Nach Dienstschluss des Lagers (im Beispiel: nach 20 Ein- oder Auslieferungen) enden beide
run() - Methoden und damit auch die zugehörigen Threads.
Auch der automatisch zur Ausführung der Startmethode des Programms kreierte Thread main ist zu
diesem Zeitpunkt bereits Geschichte. Die Aufrufe der Thread-Methode start() kehren praktisch
unmittelbar zurück, und anschließend endet mit der der main() - Methode auch der main - Thread:1
main()
pt.start();
run() kt.start();
run()
Wenn die drei Benutzer-Threads abgeschlossen sind, endet auch das Programm.2
In den beiden Ausführungsfäden Produzent bzw. Konsument führt ein ProThread- bzw. ein
KonThread-Objekt seine run() - Methode aus, wobei das Lager-Objekt wesentlich zum Einsatz
kommt:
1
Nachdem Sie unter Windows ein Java-Programm in einem Konsolenfenster gestartet haben, können Sie mit der
Tastenkombination Strg+Pause eine Liste seiner aktiven Threads anfordern.
2
Neben den bisher behandelten Benutzer-Threads sind in einem Java-Programm noch sogenannte Daemon-Threads
aktiv, die meist von der JVM gestartet werden und ein Programm nicht am Leben erhalten können (siehe Abschnitt
15.10.1).
Abschnitt 15.1 Start und Ende eines Threads 771
• In seiner Methode istOffen(), die in beiden Threads aufgerufen wird, entscheidet es auf
Anfrage, ob weitere Veränderungen des Lagers möglich sind.
• Die Methoden ergaenze() und liefere() erhöhen oder reduzieren den Lagerbestand,
aktualisieren die Anzahl der Lagerveränderungen und protokollieren jede Maßnahme.
Zur Formulierung des Protokolleintrags besorgen sich die Methoden mit der statischen
Thread-Methode currentThread() eine Referenz auf den Thread, in dem sie ausgeführt
werden, und stellen per getName() dessen Namen fest.
• Mit Hilfe der privaten Lager-Methode formZeit() erhält das Ereignisprotokoll formatier-
te Zeitangaben.
In einem typischen Ablaufprotokoll des Programms zeigen sich einige Ungereimtheiten, verursacht
durch das unkoordinierte Agieren des Produzenten- und des Konsumenten-Threads:
Der Laden ist offen (Bestand = 100)
Produzent(Lager lager) {
this.lager = lager;
}
Nun machen wir uns daran, im Produzenten-Konsumenten - Beispiel die beiden Threads so zu syn-
chronisieren, dass keine wirren Protokolleinträge und keine negativen Lagerbestände mehr auftre-
ten.
• Die traditionelle und für viele Aufgabenstellungen nach wie vor empfehlenswerte Technik
der synchronisierten Code-Bereiche wird im aktuellen Abschnitt 15.2.2 beschrieben.
• Wenn die synchronisierten Code-Bereiche nicht flexibel genug sind, kommt die im Ab-
schnitt 15.2.3 beschrieben Technik der expliziten Lock-Objekte zum Einsatz.
• Im Abschnitt 15.2.4 werden Verfahren zur automatisierten Thread-Koordination für Produ-
zenten-Konsumenten - Konstellationen beschrieben.
• Im Abschnitt 15.2.5 werden Klassen aus dem Paket java.util.concurrent zur Unterstützung
von generellen Thread-Kooperations-Szenarien vorgestellt.
Befindet sich ein Thread in einem synchronisierten Bereich, darf er andere, vom selben Monitor
geschützte Bereiche betreten, was bei verschachtelten oder rekursiven Methodenaufrufen relevant
ist.
Neben dem synchronized-Modifikator für Methoden bietet Java auch den synchronisierten Block,
wobei statt einer kompletten Methode nur eine einzelne Blockanweisung in den synchronisierten
Bereich aufgenommen und ein beliebiges Objekt als Monitor angegeben wird. Um andere Threads
möglichst wenig zu behindern, muss ein Monitor so schnell wie möglich wieder frei gegeben wer-
den. Daher kann ein möglichst klein gewählter synchronisierter Block günstiger sein als das Syn-
chronisieren einer kompletten Methode.
Obwohl in der Lager-Klassendefinition des Produzenten-Konsumenten - Beispiels der synchro-
nized-Modifikator gut geeignet ist, ersetzen wir ihn zu Demonstrationszwecken bei der Methode
ergaenze() durch einen synchronisierten Block:
void ergaenze(int add) {
synchronized (this) {
bilanz += add;
anz++;
System.out.println("Nr. " + anz + ":\t" + Thread.currentThread().getName() +
" ergänzt\t" + add + "\tum " + formZeit() + " Uhr. Stand: " + bilanz);
}
}
Nach dem Schlüsselwort synchronized ist zwischen runden Klammern ein Objekt als Monitor ex-
plizit anzugeben, während bei Verwendung des synchronized-Modifikators zu einer Instanzmetho-
de das ausführende Objekt diese Rolle automatisch übernimmt. Im Beispiel belassen wir über das
Schlüsselwort this die Monitor-Rolle beim Lageristen. Der zu einem Monitor gehörige synchroni-
sierte Bereich kann beliebig über synchronisierte Methoden und/oder Blöcke zusammengestellt
werden.
Während einer per sleep() - Methode ausgelösten Ruhephase werden im Besitz eines Threads be-
findliche Monitore nicht zurückgegeben. Daher ist die sleep() - Methode in synchronisierten Berei-
chen zu vermeiden.
778 Kapitel 15 Multithreading
Es ist zu beachten, dass durch die Synchronisierung ein geschützter Code-Bereich entsteht, nicht
aber ein geschützter Speicherbereich. Damit ein geschützter Speichbereich resultiert, müssen alle
Code-Passagen mit Zugriff auf diesen Speicherbereich in die Zone mit exklusivem Zugriff einbezo-
gen werden.1
Im Hinblick auf die Sichtbarkeit von gemeinsamen Daten für andere Threads ist von erheblicher
Relevanz, dass ...
• beim Betreten des geschützten Bereichs ein Memory Refresh stattfindet, sodass alle Daten
im lokalen Cache des Threads aktuell sind,
• beim Verlassen des geschützten Bereichs ein Memory Flush stattfindet, sodass alle Inhalte
aus dem lokalen Cache des ehemals berechtigten Threads in den allgemeinen Hauptspeicher
übertragen werden (vgl. Abschnitt 15.2.1.2 zum Java-Speichermodell).
Per Synchronisation wird also ...
• einerseits verhindert, dass zwei Threads eine synchronisierte Methode bzw. einen synchro-
nisierten Block gleichzeitig ausführen und dabei simultan auf gemeinsame Daten zugreifen,
• andererseits dafür gesorgt, dass ein Thread beim Betreten eines geschützten Bereichs alle
Daten im aktuellen Zustand sieht.
bilanz -= sub;
anz++;
System.out.println("Nr. " + anz + ":\t" + Thread.currentThread().getName() +
" entnimmt\t" + sub + "\tum " + formZeit() + " Uhr. Stand: " + bilanz);
nachfrage = false;
}
Dem wartenden Konsumenten-Thread wird der Monitor entzogen, sodass der Produzenten-Thread
freie Bahn hat, den synchronisierten Block zu betreten und das Lager aufzufüllen.
Mit Hilfe der zusätzlichen Lager-Instanzvariable nachfrage
private boolean nachfrage;
wird verhindert, dass sich der Konsumenten-Thread nach der geplanten Anzahl von Arbeitsvorgän-
gen im Wartezustand befindet und nicht mehr per notify() befreit werden kann, weil der Produzen-
1
Wie man die gemeinsame Verwendung einer einzelnen Variablen durch mehrere Threads synchronisieren kann,
wird im Abschnitt 15.2.6.2 über atomare Variablen behandelt.
Abschnitt 15.2 Threads koordinieren 779
ten-Thread bereits beendet ist. Das Ergebnis wäre ein dauerhaft blockiertes Programm. In der er-
weiterten Methode liefere() meldet sich der Konsument beim Betreten des Lagers an und beim
Verlassen wieder ab (siehe oben). In der Methode offen() wird dafür gesorgt, dass der Lagerist
weiterarbeitet, solange sich der Konsument im Lager befindet:
synchronized boolean istOffen() {
if (anz < MANZ || nachfrage)
return true;
else {
System.out.println("\nLieber " + Thread.currentThread().getName() +
", es ist Feierabend!");
return false;
}
}
Schon das einfache Produzent-Lager-Konsument - Beispiel zeigt, dass die Thread-Koordination
eine anspruchsvolle Aufgabe sein kann.
Die Object-Methoden notify() bzw. notifyAll() befördern einen Thread bzw. alle Threads vom
Zustand waiting in den Zustand ready:
• public final void notify()
Ein auf den betroffenen Monitor wartender Thread wird in den Zustand ready versetzt, so-
bald der Aufrufer den synchronisierten Bereich verlassen hat. Für die Entscheidung zwi-
schen mehreren Kandidaten ist die JVM zuständig.
• public final void notifyAll()
Alle auf den betroffenen Monitor wartenden Threads werden in den Zustand ready versetzt,
sobald der Aufrufer den synchronisierten Bereich verlassen hat. Den Monitor können diese
Threads natürlich nicht gleichzeitig erwerben, sondern nur nacheinander.
Wie wait() können auch notify() und notifyAll() nur in einem synchronisierten Bereich aufgerufen
werden, z. B.:
synchronized void ergaenze(int add) {
bilanz += add;
anz++;
System.out.println("Nr. " + anz + ":\t"+Thread.currentThread().getName() +
" ergänzt\t" + add + "\tum " + formZeit() + " Uhr. Stand: " + bilanz);
notify();
}
Nun produziert das Beispielprogramm nur noch realistische Lagerprotokolle, z. B.:
Der Laden ist offen (Bestand = 100)
gigkeit von einer Bedingung per unlock() - Aufruf freigegeben werden, was nicht unbedingt in der-
selben Methode geschehen muss, die den Lock erworben hat.
Neben der Flexibilität des Gültigkeitsbereichs bietet das Lock-Interface wichtige Optionen beim
Erwerb eines Lock-Objekts. Versucht ein Thread, einen synchronisierten Bereich zu betreten, dann
gelangt er in einen potentiell endlosen Wartezustand und ist nicht unterbrechbar.1 Dasselbe passiert
bei Verwendung der Methode lock() aus dem Interface Lock. Allerdings bietet dieses Interface über
weitere Methoden wichtige Alternativen zum endlos wartenden, nicht unterbrechbaren Lock-
Erwerb:2
• public void lockInterruptibly() throws InterruptedException
Der aufrufende Thread bewirbt sich ohne Begrenzung der maximalen Wartezeit um den ex-
pliziten Lock, ist aber (teilweise implementationsabhängig) während der Wartezeit unter-
brechbar.
• public boolean tryLock()
Der aufrufende Thread erwirbt den expliziten Lock, wenn er aktuell verfügbar ist. Die Me-
thode kehrt sofort zurück und informiert durch ihren Rückgabewert darüber, ob der Lock
erworben wurde (true) oder nicht (false). Im negativen Fall kann der Lock-Interessent sich
anderweitig beschäftigen und eventuell später sein Glück erneut versuchen.
• public boolean tryLock(long time, TimeUnit unit) throws InterruptedException
Der aufrufende Thread bewirbt sich mit einer Grenze für die maximale Wartezeit um den
expliziten Lock und ist (teilweise implementationsabhängig) während der Wartezeit unter-
brechbar. Über den Rückgabewert ist zu erfahren, ob der Lock erworben wurde (true) oder
nicht (false).
Wir haben uns bereits damit beschäftigt, wie ein Thread auf ein ihm zugestelltes Unterbrechungs-
signal reagieren sollte (vgl. Abschnitte 15.1.1 und 15.2.2.2). Mit der aktiven Rolle beim Unterbre-
chen eines Threads werden wir uns im Abschnitt 15.3.2 beschäftigen.
Neben der Flexibilität in Bezug auf den Gültigkeitsbereich und den Erwerb eines Sperrobjekts sind
mit dem Lock-Interface noch weitere Optionen verbunden, z. B.:
• Mit einem Lock-Objekt lassen sich Condition-Objekte verbinden, die es einem Thread er-
möglichen, auf das Eintreten einer bestimmten Bedingung zu warten (z. B. neue Daten in ei-
ner Warteschlange eingetroffen) oder wartende Threads über das Eintreten einer bestimmten
Bedingung zu informieren (siehe Abschnitt 15.2.3.2).
• Über die das Interface Lock implementierende Klasse ReentrantReadWriteLock kann
man einen Writer-Thread, aber beliebig viele Reader-Threads zulassen und auf diese Weise
unnötige Blockaden vermeiden.
Im folgenden Produzent-Lager-Konsument - Programm erhöht die run() - Methode des Produzen-
ten-Threads bei Bedarf einen Warenbestand, den ein parallel laufender Kunden-Thread in seiner
run() - Methode reduziert. Über ein Objekt Klasse ReentrantLock, die das Interface Lock imple-
mentiert, wird der simultane Zugriff auf den Warenbestand durch beide Threads verhindert:
1
Nachdem sich ein Thread per sleep() oder wait() in den Ruhe- oder Wartezustand begeben hat, kann ihm durch die
Thread-Methode interrupt() ein Unterbrechungssignal zugestellt werden. Das ist aber nicht möglich, während ein
Thread darauf wartet, einen synchronisierten Code-Bereich betreten zu dürfen.
2
https://javarevisited.blogspot.com/2013/03/reentrantlock-example-in-java-synchronized-difference-vs-lock.html
782 Kapitel 15 Multithreading
import java.util.concurrent.locks.ReentrantLock;
class Lager {
static ReentrantLock rl = new ReentrantLock();
static int bestand = 200;
Die Freigabe eines Lock-Objekts sollte unbedingt in der finally-Klausel einer try-Anweisung ge-
schehen, damit sie unter allen Umständen ausgeführt wird (vgl. Abschnitt 11.3.1.2). Im Beispiel
wird diese nachdrückliche Empfehlung (vgl. Goetz 2006, S. 278) umgesetzt, obwohl die Anweisun-
gen im try-Block kein nennenswertes Risiko für einen Ausnahmefehler enthalten.
Im Hinblick auf die Sichtbarkeit von gemeinsamen Daten für andere Threads ist von erheblicher
Relevanz, dass ...
• beim Erwerb eines expliziten Lock-Objekts ein Memory Refresh stattfindet, sodass alle Da-
ten im lokalen Cache des erwerbenden Threads aktuell sind,
• bei der Rückgabe eines expliziten Locks ein Memory Flush stattfindet, sodass alle Inhalte
aus dem lokalen Cache des ehemaligen Lock-Inhabers in den allgemeinen Hauptspeicher
übertragen werden (vgl. Abschnitt 15.2.1.2 zum Java-Speichermodell).
Das Beispiel demonstriert die Verwendung und vor allem die Freigabe eines expliziten Lock-
Objekts, nutzt aber nicht die höhere Flexibilität der Lock-Technik (z. B. bei einem nicht verfügba-
ren Lock-Objekt) im Vergleich zur synchronized-Technik.
1
Im Beispiel sind der Einfachheit halber das Lock-Objekt und die Condition-Objekte statisch und im gesamten
Standardpaket sichtbar definiert, sodass von der wünschenswerten Datenkapselung keine Rede sein kann.
784 Kapitel 15 Multithreading
@Override
public void run() {
System.out.println("Produzent beantragt den Lock.");
Lager.rl.lock();
try {
while (true) {
while (Lager.bestand < 1000) {
int add = (int) (100 + Math.random()*100);
Lager.bestand += add;
System.out.println(Thread.currentThread().getName() +
" ergänzt\t" + add + " Stand: " + Lager.bestand);
try {
Thread.sleep(100);
} catch(InterruptedException ie) {interrupt();}
}
Lager.filled.signal();
try {
Lager.toLow.await();
} catch(InterruptedException ie) {interrupt();}
}
} finally {
System.out.println("Produzent gibt den Lock frei.");
Lager.rl.unlock();
}
}
}
Im zweiten Condition-Objekt namens filled hinterlässt der Verbraucher-Thread einen Weckauf-
trag, wenn er sich wegen eines unzureichenden Warenangebots in den Wartezustand begibt:
if (Lager.bestand < 100)
Lager.filled.await();
Sobald der Nachfüller seine Arbeit erledigt hat, signalisiert er dies an einen Thread, der auf die
filled-Bedingung wartet:
Lager.filled.signal();
Mit der Methode signalAll() spricht man alle Threads an, die auf eine Bedingung warten.
Während die Object-Methode Methode notify() einen Thread anspricht, der unspezifisch auf den
Monitor wartet, erreicht man mit der Condition-Methode signal() einen Thread, der von der neuen
Lage profitieren kann. Wenn im Beispiel der wiedererwachte Verbraucher den Warenbestand stark
reduziert hat, signalisiert er dies an einen Thread, der auf die Bedingung toLow wartet:
if (Lager.bestand < 200)
Lager.toLow.signal();
Bekanntlich dürfen die Object-Methoden wait(), notify() und notifyAll() nur innerhalb eines syn-
chronisierten Code-Bereichs aufgerufen werden. Analog zu dieser Bedingung dürfen die Conditi-
on-Methoden await(), signal() und signalAll() nur dann aufgerufen werden, wenn der aktuelle
Thread den zum Condition-Objekt gehörigen Lock besitzt.
Wer sich für eine erfolgreiche Anwendung der Thread-Koordination über Objekte vom Typ
ReentrantLock und Condition interessiert, findet sie z. B. in der API-Klasse
ArrayBlockingQueue<E>, die im Abschnitt 15.2.4.1 vorgestellt wird. Diese Klasse zur Verwal-
tung einer Warteschlange mit fixierter Kapazität bietet die Methoden put() zum Ergänzen eines
Abschnitt 15.2 Threads koordinieren 785
neuen Eintrags sowie take() zur Entnahme eines Eintrags. Wenn put() auf eine besetzte Warte-
schlange oder take() auf eine leere Warteschlange trifft, dann warten diese Methoden mit
notFull.await() bzw. notEmpty.await() auf die Voraussetzung für eine Fortsetzung ihrer
Tätigkeit.
Das IntelliJ-Projekt mit dem Beispielprogramm befindet sich im Ordner
...\BspUeb\Multithreading\Threads koordinieren\Explizite Lock-Objekte\Condition
15.2.4.1 BlockingQueue<E>
Die das Interface BlockingQueue<E> implementierenden Kollektionsklassen wie ArrayBlocking-
Queue<E> und LinkedBlockingQueue<E> (alle Typen aus dem Paket java.util.concurrent)
funktionieren als Warteschlangen nach dem FIFO-Prinzip (First-In-First-Out) und sind thread-
sicher, dürfen also von mehreren Threads simultan genutzt werden. Außerdem sind sie in der Lage,
einen Produzenten- und einen Konsumenten-Thread automatisch zu koordinieren:
• Der Produzenten-Thread befördert mit der put() - Methode Daten in den Container. Hat ein
Container (wie z. B. ArrayBlockingQueue<E>) eine Maximalkapazität, und ist diese er-
reicht, dann wird der Produzenten-Thread in den Wartezustand versetzt und bei Bedarf für
neue Daten reaktiviert.
• Der Konsumenten-Thread holt mit der take() - Methode Daten ab. Bei fehlenden Daten wird
er in den Wartezustand versetzt und bei Verfügbarkeit von neuen Daten reaktiviert.
Erhält ein Thread im Wartezustand eine Unterbrechungsaufforderung (siehe Abschnitt 15.3.2.2),
dann werfen put() und take() eine InterruptedException.
Im Unterschied zu einer ArrayBlockingQueue<E> hat eine LinkedBlockingQueue<E> keine
Kapazitätsbeschränkung, sodass praktisch unbegrenzt viele Daten eingeliefert werden können.
Während sich die bisherige Darstellung auf einen Produzenten und einen Konsumenten beschränkt
hat, können bei Bedarf auch mehrere Threads als Datenlieferanten bzw. -konsumenten unter Ver-
mittlung einer BlockingQueue<E> tätig werden.
Zur Demonstration verwenden wir ein Beispiel mit der folgenden Startklasse:
import java.util.concurrent.*;
class BlockingQueueDemo {
public static void main(String[] args) {
BlockingQueue<Integer> depot = new LinkedBlockingQueue<>(3);
KonThread kt = new KonThread(depot);
ProThread pt = new ProThread(depot, kt);
pt.start();
kt.start();
}
}
786 Kapitel 15 Multithreading
Ein Produzent und ein Konsument agieren jeweils in einem eigenen Thread mit Zugriff auf ein De-
pot, das durch ein Objekt der Klasse LinkedBlockingQueue<Integer> nach dem Warteschlangen-
prinzip verwaltet wird.
Der Produzenten-Thread
import java.util.concurrent.BlockingQueue;
@Override
public void run() {
for(int i = 0; i < produkte.length; i++) {
System.out.println("\nDer Produzent liefert gleich.");
depot.add(produkte[i]);
try {sleep(10);} catch(InterruptedException ie) {interrupt();}
System.out.println("Kurz nach der Lieferung ist der Konsument " +
konsument.getState());
try {sleep(5000);} catch(InterruptedException ie) {interrupt();}
System.out.println("Produzent wacht auf, Konsument ist " +
konsument.getState());
}
}
}
wiederholt in seiner run() - Methode per for-Schleife die folgenden Aktionen bis zur Erschöpfung
des Vorrats an Integer-Objekten:
• Unmittelbar nach einer ankündigenden Konsolenausgabe fügt er ein Integer-Objekt in die
Warteschlange ein. Statt der oben beschriebenen, bei gefülltem Container blockierenden
Methode put() wird die Methode add() verwendet, von der keine deklarationspflichtigen
Ausnahmen zu erwarten sind. Weil der Container vom Typ BlockingQueue<Integer> kei-
ne Kapazitätsgrenze hat, sind die Methoden hier äquivalent.
• Nach einer kurzen Wartezeit von 10 Millisekunden, die dem Konsumenten-Thread genügen
sollte, um das eingetroffene Datenobjekt zu bemerken, protokolliert der Produzent mit Hilfe
der Methode getState() den Status des Konsumenten-Threads.
• Dann legt sich der Produzent 5 Sekunden schlafen, was zu einem Datenmangel für den Kon-
sumenten führt. Nach dem Aufwachen protokolliert der Produzent erneut den Status des
Konsumenten-Threads.
Der Konsumenten-Thread
import java.util.concurrent.BlockingQueue;
KonThread(BlockingQueue<Integer> dep) {
depot = dep;
}
Abschnitt 15.2 Threads koordinieren 787
@Override
public void run() {
for (int i = 0; i < 3; i++) {
try {
System.out.println("Der Konsument hat bezogen: " + depot.take());
} catch (InterruptedException ie) {Thread.currentThread().interrupt();}
for (int j = 0; j < 3_000_000; j++)
Math.random();
}
}
}
wiederholt in seiner run() - Methode per for-Schleife dreimal die folgenden Aktionen:
• Er entnimmt per take() ein Element aus der Warteschlange und protokolliert sein Verhalten.
Weil von take() (wie von wait(), vgl. Abschnitt 15.2.2.2) eine InterruptedException zu
erwarten ist, erfolgt der Aufruf in einer try-Anweisung.
• Dann simuliert der Konsument ein geschäftiges Treiben, indem er 3 Millionen Zufallszahlen
zieht.
Die Ausgaben des Programms
Der Produzent liefert gleich.
Der Konsument hat bezogen: 1
Kurz nach der Lieferung ist der Konsument RUNNABLE
Produzent wacht auf, Konsument ist WAITING
Produzenten-Thread Konsumenten-Thread
byte,
byte, byte[] PipedOutputStream Pipe PipedInputStream
byte[]
Neben der byte-orientierten Pipe-Verbindung existiert auch eine zeichenorientierte Variante mit
Objekten aus den Klassen PipedWriter und PipedReader am Ein- bzw. Ausgang.
Im nun vorzustellenden Beispielprogramm zur Produzenten-Konsumenten-Kooperation mit Pipe-
Lösung ist die Startklasse ähnlich aufgebaut wie bei der im Abschnitt 15.2.4.1 vorgestellten
BlockingQueue<E> - Lösung:
import java.io.*;
class PipedStreamDemo {
public static void main(String[] args) throws IOException {
PipedOutputStream pipedOutStream = new PipedOutputStream();
PipedInputStream pipedInStream = new PipedInputStream(pipedOutStream);
Um die beiden Pipe-Ströme zu verbinden, erhält von beiden Konstruktoren ein beliebig gewählter
per Parameter eine Referenz auf das Partnerobjekt. Alternativ könnte die connect() - Methode eines
Stroms mit dem Partnerobjekt als Parameter aufgerufen werden.
Der Lieferant ruft nach jedem Schreibzugriff auf den Ausgabestrom die Methode flush() auf:
pipedOutStream.write(produkte[i]);
pipedOutStream.flush();
Wenn der Konsumenten-Thread beim Leseversuch
pipedInStream.read();
keine Daten vorfindet, begibt er sich per wait() - Aufruf für eine Sekunde in Wartestellung.1 Der
flush() - Aufruf des Produzenten informiert über die Ankunft neuer Daten und beendet so die War-
tezeit.
Wichtige Regeln zur Verwendung der Pipe-Kommunikation zwischen Threads:2
1
Das zeigt ein Blick in den Quellcode der Methode PipedInputStream.read() im Java 17 - API:
while (in < 0) {
. . .
/* might be a writer waiting */
notifyAll();
try {
wait(1000);
} catch (InterruptedException ex) {
throw new java.io.InterruptedIOException();
}
}
2
Einige Regeln stammen von der folgenden Webseite von Daniel Ferber (besucht am 27.02.2022):
Abschnitt 15.2 Threads koordinieren 789
• Es darf nur ein Thread in die Pipe schreiben und nur ein (anderer) Thread aus der Pipe lesen.
• Auf keinen Fall darf derselbe Thread schreiben und lesen, weil der Thread dabei blockiert
werden kann (Deadlock).
• Bevor der schreibende Thread endet, muss er den PipedOutputStream schließen, z. B. über
eine try-with-resources - Anweisung. Der lesende Thread kann weiterhin auf die bereits ge-
schriebenen Daten zugreifen.
Das IntelliJ-Projekt mit dem Beispielprogramm befindet sich im Ordner
...\BspUeb\Multithreading\Threads koordinieren\PipedStreams
15.2.5.1 Semaphore
Ein Objekt der Klasse Semaphore verwaltet eine Menge von k „Passierscheinen“, die an einem
Kontrollpunkt (oder an mehreren Kontrollpunkten) benötigt werden. Ein Thread bewirbt sich mit
der Semaphore-Methode acquire() um einen Passierschein und gibt seine Berechtigung durch ei-
nen Aufruf der Semaphore-Methode release() wieder zurück. In der folgenden Semaphore-
Konstruktorüberladung
public Semaphore(int permits, boolean fair)
legt man ...
• durch den ersten Parameter fest, wie viele Threads gleichzeitig einen Passierschein besitzen
können,
• durch den zweiten Parameter fest, ob das faire FIFO-Prinzip (First-In-First-Out) garantiert
sein soll.
In einem Produzenten-Konsumenten - Programm kann man z. B. mit einem Semaphore-Objekt
dafür sorgen, dass maximal zwei Produzenten gleichzeitig anliefern dürfen:
private Semaphore sem = new Semaphore(2, true);
Alternativ oder gleichzeitig könnte man die Anzahl der simultan tätigen Konsumenten beschränken.
In der folgenden Startklasse zu einer Multiproduzenten-Variante des Beispielprogramms aus dem
Abschnitt 15.2.2.1 werden fünf Produzenten-Threads und ein Konsumenten-Thread gestartet:
class SemaphoreDemo {
public static void main(String[] args) {
Lager lager = new Lager(100);
for(int i = 1; i <= 5; i++)
new ProThread(i, lager).start();
new KonThread(lager).start();
}
}
Zwar werden fehlerhafte Bilanzwerte durch synchronisierte Code-Bereiche verhindert, doch kann
wegen der großen Anzahl von Produzenten in der Lager-Methode liefere() auf eine Absiche-
rung gegen negative Lagerbestände verzichtet werden:
https://techtavern.wordpress.com/2008/07/16/whats-this-ioexception-write-end-dead/
790 Kapitel 15 Multithreading
@Override
public void run() {
while (pl.istOffen() && !isInterrupted())
try {
pl.ergaenze(nr, (int) (5 + Math.random()*100));
Thread.sleep((int) (1000 + Math.random()*3000));
} catch(InterruptedException ie) {
interrupt();
}
}
Im catch-Block zur InterruptedException wird das Unterbrechungssignal neu gesetzt, und die
while-Schleife endet bei einer bestehenden Unterbrechungsaufforderung (siehe Abschnitt 15.3.2.2
zur angemessenen Reaktion auf eine InterruptedException).
Es folgt ein typisches Lagerprotokoll:
Der Laden ist offen (Bestand = 100)
Produzent 1 kommt
Nr. 1: Produzent 1 ergänzt 77 um 14:24:22 Uhr. Stand: 177 (Prod: [1])
Produzent 5 kommt
Nr. 2: Konsument entnimmt 177 um 14:24:22 Uhr. Stand: 0
Nr. 3: Produzent 5 ergänzt 28 um 14:24:22 Uhr. Stand: 28 (Prod: [1, 5])
Nr. 4: Produzent 1 ergänzt 77 um 14:24:22 Uhr. Stand: 105 (Prod: [1, 5])
Nr. 5: Produzent 5 ergänzt 28 um 14:24:22 Uhr. Stand: 133 (Prod: [1, 5])
Nr. 6: Produzent 1 ergänzt 77 um 14:24:23 Uhr. Stand: 210 (Prod: [1, 5])
Nr. 7: Produzent 5 ergänzt 28 um 14:24:23 Uhr. Stand: 238 (Prod: [1, 5])
Produzent 1 geht
Produzent 2 kommt
Nr. 8: Produzent 2 ergänzt 94 um 14:24:23 Uhr. Stand: 332 (Prod: [2, 5])
Produzent 5 geht
Produzent 4 kommt
Nr. 9: Produzent 4 ergänzt 27 um 14:24:23 Uhr. Stand: 359 (Prod: [2, 4])
Nr. 10: Produzent 2 ergänzt 94 um 14:24:24 Uhr. Stand: 453 (Prod: [2, 4])
Nr. 11: Produzent 4 ergänzt 27 um 14:24:24 Uhr. Stand: 480 (Prod: [2, 4])
Nr. 12: Konsument entnimmt 7 um 14:24:24 Uhr. Stand: 473
Nr. 13: Produzent 2 ergänzt 94 um 14:24:24 Uhr. Stand: 567 (Prod: [2, 4])
Nr. 14: Produzent 4 ergänzt 27 um 14:24:24 Uhr. Stand: 594 (Prod: [2, 4])
Produzent 2 geht
Produzent 3 kommt
Nr. 15: Produzent 3 ergänzt 34 um 14:24:25 Uhr. Stand: 628 (Prod: [3, 4])
Produzent 4 geht
Nr. 16: Produzent 3 ergänzt 34 um 14:24:25 Uhr. Stand: 662 (Prod: [3])
Produzent 1 kommt
Nr. 17: Produzent 1 ergänzt 23 um 14:24:26 Uhr. Stand: 685 (Prod: [1, 3])
Nr. 18: Produzent 3 ergänzt 34 um 14:24:26 Uhr. Stand: 719 (Prod: [1, 3])
Nr. 19: Produzent 1 ergänzt 23 um 14:24:26 Uhr. Stand: 742 (Prod: [1, 3])
Produzent 3 geht
Produzent 5 kommt
Nr. 20: Produzent 5 ergänzt 88 um 14:24:27 Uhr. Stand: 830 (Prod: [1, 5])
Nr. 21: Produzent 1 ergänzt 23 um 14:24:27 Uhr. Stand: 853 (Prod: [1, 5])
Nr. 22: Produzent 5 ergänzt 88 um 14:24:27 Uhr. Stand: 941 (Prod: [1, 5])
Produzent 1 geht
15.2.5.2 CountDownLatch
Objekte der Signalisierungsklasse CountdownLatch enthalten einen Zähler und starten mit einem
positiven Wert, der sich bei jedem Aufruf der Instanzmethode countDown() um 1 verringert. Hat
sich ein Thread per await() - Aufruf an das Signalisierungsobjekt in Wartestellung begeben, wird er
beim Zählerstand 0 reaktiviert.
Wir erlauben uns ein verspieltes Beispielprogramm mit einem Thread, der auf ein
CountdownLatch-Objekt wartet und nach seiner Reaktivierung einen Raketenstart simuliert:
Raketen-Thread wartet auf CountDownLatch
Countdown läuft:
10 9 8 7 6 5 4 3 2 1
Rakete startet:
.. . . . . . . . . . . . . . .
Während der Raketen-Thread wartet, setzt der Haupt-Thread durch wiederholte countDown() -
Aufrufe das Signalisierungsobjekt auf null:
import java.util.concurrent.CountDownLatch;
class CountDownLatchDemo {
static void waitAndLiftOff(CountDownLatch cdl) {
System.out.println("Raketen-Thread wartet auf CountDownLatch");
try {cdl.await();
} catch (InterruptedException ie) {Thread.currentThread().interrupt();}
System.out.println("\n\nRakete startet:");
for (int i = 1; i <= 16; i++) {
System.out.print(".");
for (int j = 0; j < i/2; j++) System.out.print(" ");
try { Thread.sleep(300);
} catch(InterruptedException ie) {Thread.currentThread().interrupt();}
}
}
15.2.5.3 CyclicBarrier
Ein Objekt der Klasse CyclicBarrier realisiert eine Barriere, an der sich mehrere Threads versam-
meln, nachdem sie jeweils eine Vorleistung erbracht haben. Die Threads warten also aufeinander.
Sind alle angekommen, werden optional die Vorleistungen zu einem Endergebnis verarbeitet.
Nachdem alle Threads angekommen sind und ggf. auch noch das Endergebnis ermittelt worden ist,
dürfen die wartenden Threads ihre Tätigkeit fortsetzten.
In einem halbwegs realistischen Beispielprogramm lassen wir N_SAMPLES Threads jeweils den
Mittelwert aus SAMPLE_SIZE (abgekürzt durch N) Pseudozufallszahlen (gleichverteilt im Intervall
[0, 1)) berechnen. Wenn alle Threads fertig sind, und dementsprechend N_SAMPLES Stichproben-
mittelwerte vorliegen, wird als Endergebnis die Standardabweichung der Stichprobenmittelwerte
berechnet. Dies ist eine Schätzung für den sogenannten Standardfehler des Mittelwerts. Weil die
Varianz einer Variablen mit einer Gleichverteilung auf dem Intervall [0, 1) bekannt ist (= 1⁄12),
lässt sich der exakte Wert des Standardfehlers berechnen:1
1⁄
𝑋2̅ = √ 12
𝑁
Folglich können wir die Qualität einer Schätzung aus N_SAMPLES Stichproben der Größe N beur-
teilen.
Das Zufallsexperiment zur empirischen Schätzung des Standardfehlers des Mittelwerts aus SAMP-
LE_SIZE Fällen wird N_EXPERIMENTS mal wiederholt, wobei die Variable ex die Nummer der
gerade ausgeführten Wiederholung enthält.
Die N_SAMPLES Threads
public static void main(String[] args) {
for (int i = 0; i < N_SAMPLES; i++)
new Thread(new Meaner(i)).start();
}
führen jeweils die run() - Methode der folgenden Klasse aus, die das Interface Runnable imple-
mentiert:
static class Meaner implements Runnable {
private int sample;
private Random zzg = new Random();
Meaner(int i) {
sample = i;
}
public void run() {
while (ex < N_EXPERIMENTS) {
double mean = 0.0;
for (int i = 0; i < SAMPLE_SIZE; i++)
mean = mean + zzg.nextDouble();
data[sample] = mean / SAMPLE_SIZE;
try {
barrier.await();
} catch (InterruptedException | BrokenBarrierException ex) {
System.out.println("Meaner " + sample + " havariert.");
System.exit(1);
}
}
}
}
1
https://www.statistik-nachhilfe.de/ratgeber/statistik/wahrscheinlichkeitsrechnung-
stochastik/wahrscheinlichkeitsverteilungen/stetige-verteilungen/stetige-gleichverteilung-rechteckverteilung
794 Kapitel 15 Multithreading
Nachdem ein Thread seinen Stichprobenmittelwert berechnet und in ein Array-Element geschrieben
hat, wartet er an der Barriere auf die anderen Threads:
barrier.await();
Das verwendete Objekt vom Typ CyclicBarrier
static final CyclicBarrier barrier = new CyclicBarrier(N_SAMPLES, barrierAction);
wird von der folgenden Konstruktor-Überladung erstellt:
public CyclicBarrier(int parties, Runnable barrierAction)
Durch den ersten Konstruktorparameter wird die Anzahl der teilnehmenden Threads festgelegt, und
durch den zweiten Konstruktorparameter wird ein Runnable-Objekt zur Endergebnisberechnung
benannt.
Im Beispiel wird von der barrierAction - Methode die erwartungstreue Schätzung der Standardab-
weichung der N_SAMPLES Stichprobenmittelwerte unter Verwendung der Verschiebungsformel
(siehe Übungsaufgabe im Abschnitt 5.1) berechnet und dann ausgegeben:
static Runnable barrierAction = new Runnable() {
@Override
public void run() {
double sum = 0.0;
double qs = 0.0;
for(int i = 0; i < N_SAMPLES; i++) {
sum += data[i];
qs += data[i] * data[i];
}
double mean = sum / N_SAMPLES;
double variance = (qs - N_SAMPLES *mean*mean) / (N_SAMPLES - 1);
double se = Math.sqrt(variance);
System.out.println("SE(" + ex++ + ") = " + se);
}
};
Nach der Beendigung der Methode run() dürfen die wartenden Threads ihre Arbeit fortsetzen und
jeweils den nächsten Mittelwert berechnen.
Aus der Stichprobengröße
static final int SAMPLE_SIZE = 10_000;
ergibt sich nach der oben angegebenen Formel ein wahrer Standardfehler von 0,002886751. In die
fünfmal durchgeführte empirische Schätzung des Standardfehlers wurden jeweils 10 Stichproben
einbezogen:
static final int N_SAMPLES = 10;
static final int N_EXPERIMENTS = 5;
Wie das folgende Ablaufprotokoll zeigt, kann der Standardfehler aus 10 Stichproben noch nicht
sehr genau geschätzt werden:
SE(0) = 0.0025556975714659674
SE(1) = 0.0023379809583384367
SE(2) = 0.0019856120048262144
SE(3) = 0.004137407692079622
SE(4) = 0.0031958711560183084
Es sollte noch eine Begründung für den Bestandteil Cyclic im Namen der aktuell behandelten Syn-
chronisierungsklasse geliefert werden. Während z. B. ein CountdownLatch-Objekt verbraucht ist,
nachdem das Schloss (engl.: latch) aufgesprungen ist, kann das Treffen an einer Barriere beliebig
oft wiederholt werden.
Abschnitt 15.2 Threads koordinieren 795
15.2.5.4 Phaser
Die mit Java 7 eingeführte Synchronisierungsklasse Phaser kann bei höherer Flexibilität die Auf-
gaben der älteren Klassen CountDownLatch und CyclicBarrier übernehmen (siehe Abschnitte
15.2.5.2 und 15.2.5.3):
• Während bei einem CyclicBarrier-Objekt die Anzahl der beteiligten Threads schon im
Konstruktor festgelegt werden muss, kann sie sich bei einem Phaser-Objekt ändern. Diese
Flexibilität ist z. B. nützlich, wenn der Verzeichnisbaum eines Dateiensystems analysiert
werden soll, wobei die Anzahl der beteiligten Threads variiert. Die Anzahl der bei einem
Phaser-Objekt registrierten Threads kann durch eine von den folgenden Methoden erhöht
werden:
public int register()
public int bulkRegister(int parties)
Ein Thread kann seine Ankuft an der Barriere melden und gleichzeitig mitteilen, an den
weiteren Phasen nicht mehr teilnehmen zu wollen:
public int arriveAndDeregister()
• Ein Phaser-Objekt ist ebenso wiederverwendbar wie ein CyclicBarrier-Objekt, wobei man
von Phasen statt von Zyklen spricht.
• Ein Thread kann seine Ankunft an der Barriere melden, um auf den nächsten Phasenwechsel
(Advance) zu warten:
public int arriveAndAwaitAdvance()
Diese Methode entspricht der CyclicBarrier-Methode await(). Allerdings wirft wait() eine
Ausnahme vom Typ InterruptedException, wenn der wartende Thread eine Terminie-
rungsaufforderung erhält (siehe Abschnitt 15.3.2.2), während arriveAndAwaitAdvance()
von dieser Aufforderung nicht tangiert wird. Um die CyclicBarrier.await() - Reaktion auf
eine Terminierungsaufforderung zu erhalten, verwendet man die Phaser-Methode
awaitAdvanceInterruptibly().
• Statt arriveAndAwaitAdvance() aufzurufen, kann ein Thread mit der Methode arrive()
seine Ankunft an der Barriere melden, ohne auf die anderen Threads und damit auf den
nächsten Phasenwechsel zu warten:
public int arrive()
Diese nicht-blockierende Methode liefert als Rückgabe entweder die Nummer der aktuellen
Phase oder eine negative Zahl, wenn der Phaser bereits terminiert wurde. Während bei der
Klasse CyclicBarrier alle an der Barriere ankommenden Threads in den Wartezustand
wechseln, erlaubt die Klasse Phaser auch eine Ankunft ohne Warten.
• Zum Phasenwechsel (Advance) kommt es, wenn alle aktuell zu erwartenden Threads einge-
troffen sind. Dann ...
o werden die wartenden Threads reaktiviert,
o wird der Phasenzähler inkrementiert,
o werden die Zählerstände für die zu erwartenden bzw. für die bereits eingetroffenen
Threads zurückgesetzt.
• Soll am Ende einer Phase eine (mit der barrierAction der Klasse CyclicBarrier vergleichba-
re) Aktion stattfinden, dann muss man eine Phaser-Ableitung definieren und die Methode
onAdvance() überschreiben:
protected boolean onAdvance(int phase, int registeredParties)
796 Kapitel 15 Multithreading
• Mit der onAdvance() - Rückgabe true lässt sich der Phaser terminieren. Diese Option wird
oft dazu benutzt, die per Phaser kontrollierten Aktivitäten unter einer Bedingung (z. B. nach
einer bestimmten Anzahl von Iterationen) zu beenden. Die Basisklassenvariante der Metho-
de onAdvance() terminiert den Phaser, wenn die Anzahl der registrierten Threads durch ei-
nen Aufruf der Methode arriveAndDeregister() auf den Wert 0 gebracht wird. Versuche
zur Registrierung bei einem terminierten Phaser bleiben ohne Effekt.
• Die Phasen sind null-basiert nummeriert, und ein Thread kann gezielt auf das Ende einer
bestimmte Phase warten:
public int awaitAdvance(int phase)
Die Methode meldet die aktuelle Phase zurück (also z. B. 1, wenn auf das Ende der Phase 0
gewartet wurde) oder signalisiert mit einer negativen Rückgabe, dass der Phaser bereits
terminiert worden ist.
• Bei der Phaser-Konstruktion kann optional ein elterliches Phaser-Objekt angegeben wer-
den, sodass sich mehrere Phaser- Objekte in eine Baumstruktur bringen lassen.
• Über die wesentlichen Betriebszustände eines Phaser-Objekts informieren die folgenden
Methoden:
o public boolean isTerminated()
o public int getRegisteredParties()
Wie viele Threads sind in der aktuellen Phase registriert?
o public int getArrivedParties(), public int getUnarrivedParties()
Wie viele von den registrierten Threads sind bereits angekommen bzw. fehlen noch.
Weitere Informationen zur Klasse Phaser finden sich u. a. bei Kreft & Langer (2012) und in der
API-Dokumentation.1
In der folgenden Variante des Beispielprogramms zur Klasse CyclicBarrier (siehe Abschnitt
15.2.5.3) wird eine anonyme Phaser-Ableitung mit Überschreibung der Methode onAdvance()
definiert, die durch den Rückgabewert true für eine Terminierung nach einer festgelegten Anzahl
von Phasen sorgt:
import java.util.*;
import java.util.concurrent.*;
class PhaserAsCyclicBarrier {
static final int SAMPLE_SIZE = 10_000;
static final int N_SAMPLES = 10;
static final int N_EXPERIMENTS = 5;
static double[] data = new double[N_SAMPLES];
1
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/concurrent/Phaser.html
Abschnitt 15.2 Threads koordinieren 797
Im Beispielprogramm stellen die Meaner-Threads ihre Tätigkeit ein, wenn das Phaser-Objekt ter-
miniert ist:
while (!phaser.isTerminated()) {...}
Weil beim Zugriff auf eine als volatile deklarierte Variable der gesamte Thread-lokale Cache-
Speicher mit dem Hauptspeicher abgeglichen wird, ist der Zugriff auf volatile-Variablen relativ
langsam (Kreft & Langer 2008a; Ziesche & Arinir 2010, S. 257).
Für eine als volatile deklarierte Variable ist also garantiert, dass bei jedem Lesezugriff der aktuelle,
von irgendeinem Thread veränderte Wert ermittelt wird. Damit eignet sich eine Variable mit dem
volatile-Modifikator dazu, einen Status oder eine Nachricht (z. B. Aufgabe erledigt, Übertragung
stoppen) über Thread-Grenzen hinweg zu signalisieren, z. B.:
private volatile boolean stopLoop;
Weitere Details zum Modifikator volatile und seiner Rolle bei der Thread-Koordination finden sich
bei Kreft & Langer (2008b).
Demgegenüber bieten die Atomic-Klassen für wichtige zusammengesetzte Operationen eine atoma-
re Behandlung, z. B. bei AtomicInteger:
• public final int incrementAndGet(), public final int getAndIncrement()
Diese Methoden leisten eine atomare Prä- bzw. Postinkrementoperation.
• public final int decrementAndGet(), public final int getAndDecrement()
Diese Methoden leisten eine atomare Prä- bzw. Postdekrementoperation.
• public final int getAndAdd(int delta), public final int addAndGet(int delta)
Man erhält den Wert der verpackten Variablen, bevor bzw. nachdem der Parameterwert ad-
diert worden ist.
Hier ist eine thread-sichere Alternative für den problematischen Code aus dem letzten Beispiel zu
sehen:
Abschnitt 15.2 Threads koordinieren 799
. . .
}
Die Methode incrementAndGet() versucht in einer do-while - Schleife, ihr Ziel zu erreichen und
verwendet dabei die Methode compareAndSet(), welche dank Hardware-Unterstützung thread-
sicher und effizient den inkrementierten Wert einträgt, wenn der bei seiner Berechnung zugrunde
gelegte alte Wert noch aktuell ist. In diesem Fall liefert compareAndSet() die Rückgabe true, und
die do-while - Schleife endet.
Mit dem Modifikator volatile wird dafür gesorgt, dass jede Änderung des im AtomicInteger-
Objekt gekapselten Werts sofort in allen Threads sichtbar ist. Im Vergleich zu einem als volatile
1
In der API-Klasse AtomicInteger aus Java 17 ist sinngemäß der beschriebene Aufbau realisiert, was aber im Quell-
code durch die Verwendung der Klasse jdk.internal.misc.Unsafe nicht unmittelbar erkennbar ist.
800 Kapitel 15 Multithreading
deklarierten Feld spart man mit einer Atomic-Klasse also keinen Aufwand, aber man erhält Metho-
den zur Realisation von atomaren Inkrement- bzw. Dekrementoperationen.
Thread2(Thread1 t1) {
this.t1 = t1;
}
@Override
public void run() {
try {
t1.join(5000);
} catch (InterruptedException ie) {
return;
}
for (int i = 0; i < 5; i++)
System.out.println("Thread 2, i = " + i);
}
}
Wie sleep(), wait() und await() reagiert auch join() bei einer interrupt() - Aufforderung an den
passiven Thread mit einer InterruptedException.
Ist der per join() angesprochene Thread bereits terminiert, hat der Aufruf keinen Effekt.
Nach dem Start der beiden Threads
Abschnitt 15.3 Direkt gestartete Threads verwalten 801
class JoinDemo {
public static void main(String[] args) {
Thread1 t1 = new Thread1();
Thread2 t2 = new Thread2(t1);
t1.start();
t2.start();
}
}
arbeiten sie nacheinander:
Thread 1, i = 0
Thread 1, i = 1
Thread 1, i = 2
Thread 1, i = 3
Thread 1, i = 4
Thread 2, i = 0
Thread 2, i = 1
Thread 2, i = 2
Thread 2, i = 3
Thread 2, i = 4
Ohne Koordination per join() resultiert hingegen eine schlecht vorhersehbare Sequenz:
Thread 1, i = 0
Thread 1, i = 1
Thread 2, i = 0
Thread 2, i = 1
Thread 2, i = 2
Thread 2, i = 3
Thread 2, i = 4
Thread 1, i = 2
Thread 1, i = 3
Thread 1, i = 4
In einer alternativen join() - Überladung kann man die maximale Wartezeit in Millisekunden ange-
ben, z. B.
t1.join(5000);
Deadlock droht.1 Im Produzent-Lager-Konsument - Beispiel spielt der Lagerbestand die Rolle des
vom Konsumenten zu beobachtenden Signals (siehe Abschnitt 15.2.2.2).
15.3.2.2 Abbrechen
Normalerweise endet ein Thread, wenn seine run() - Methode abgeschlossen ist. Es ist möglich,
aber in der Regel nicht empfehlenswert, einen Thread von außen mit der seit Java 1.2 als veraltet
bzw. herabgestuft (engl.: deprecated) annotierten Thread-Methode stop() abzuwürgen. Anders als
bei der ebenfalls herabgestuften Thread-Methode suspend() (siehe Abschnitt 15.3.2.1) besteht kei-
ne Deadlock-Gefahr, weil die vom gestoppten Thread belegten Monitore bzw. Lock-Objekte frei-
gegeben werden. Aber die abrupt unterbrochene Tätigkeit kann bearbeitete Objekte in einem inkon-
sistenten Zustand hinterlassen, sodass bei der anschließenden Verwendung dieser Objekte durch
andere Threads ein fehlerhaftes Verhalten zu befürchten ist.
Um einen Thread von außen zur Beendigung seiner Tätigkeit auffordern zu können, sollte ein ent-
sprechendes Kommunikationsverfahren in seine run() - Methode integriert werden. Man kann (ana-
log zu dem im Abschnitt 15.3.2.1 beschrieben Verfahren für das Unterbrechen eines Threads) eine
Variable als Terminierungssignal verwenden. Das anschließend beschriebene Verfahren basiert auf
derselben Idee, verwendet aber eine in die Klasse Thread integrierte Signaltechnik:
• Mit der Methode interrupt() wird einem Thread signalisiert, dass er seine Tätigkeit einstel-
len soll. Der betroffene Thread wird nicht abgebrochen, sondern sein Interrupt-Signal wird
auf den Wert true gesetzt.
• Ein gut erzogener Thread, der mit einem Interrupt-Signal rechnen muss, prüft in seiner run()
- Methode regelmäßig durch Aufruf der Thread-Methode isInterrupted(), ob er sein Wir-
ken einstellen soll. Falls ja, beendet er die run() - Methode und erreicht damit den Zustand
terminated.
• Bei einem durch die Methoden sleep(), wait() oder join() aus der Klasse Object oder durch
die Methode await() aus der Klasse Condition in den Wartezustand versetzen Thread führt
der interrupt() - Aufruf zu einer InterruptedException, und bei der Ausnahmebehandlung
wird über das weitere Vorgehen entschieden (siehe unten).
Wir greifen auf das Produzent-Lager-Konsument - Beispiel in der Variante mit synchronisierten
Methoden (siehe Abschnitt 15.2.2.1) zurück, verschaffen dem Lager-Objekt eine Referenz auf den
Konsumenten-Thread und erweitern die vom Produzenten-Thread ausgeführte Lager-Methode
ergaenze() so, dass dem Konsumenten-Thread ein Interrupt-Signal zugestellt wird, wenn nach
der aktuellen Einlieferung der Lagerbestand kleiner als 50 ist:
1
Das beschriebene Verfahren wird von Oracle auf der folgenden Webseite empfohlen:
http://docs.oracle.com/javase/8/docs/technotes/guides/concurrency/threadPrimitiveDeprecation.html
Abschnitt 15.3 Direkt gestartete Threads verwalten 803
In der while-Schleife seiner run() - Methode prüft der Konsumenten-Thread, ob sein Interrupt-
Signal gesetzt ist, und beendet ggf. (wenn auch murrend) seine Tätigkeit per return:
public void run() {
while (pl. istOffen()) {
if (isInterrupted()) {
System.out.println("Nicht sehr kundenorientiert!\n");
return;
}
pl.liefere((int) (5 + Math.random()*100));
try {
Thread.sleep((int) (1000 + Math.random()*3000));
} catch(InterruptedException ie){interrupt();}
}
}
Im folgenden Lagerverlauf ereilt den Konsumenten-Thread das Schicksal, von außen (durch den
Produzenten-Thread) das Interrupt-Signal zu erhalten:
Der Laden ist offen (Bestand = 100)
Natürlich kann das Interrupt-Signal auch durch eine im selben Thread ausgeführte Methode gesetzt
werden.
804 Kapitel 15 Multithreading
Wie bereits im Abschnitt 15.1.1 erläutert, hat ein interrupt() - Aufruf an einen Thread, der sich
durch einen blockierenden Methodenaufruf
• Methode sleep() oder join() aus der Klasse Thread
• Methode wait() aus der Klasse Object
• Methode await() aus der Klasse Condition
in den Wartezustand begeben hat, die folgenden Effekte:
• Der Thread wird sofort in den Zustand ready versetzt.
• Die für den Wartezustand verantwortliche Methode endet mit einer InterruptedException,
und das Interrupt-Signal wird aufgehoben (auf false gesetzt).
Es ist oft sinnvoll, interrupt() in der catch-Klausel der InterruptedException-Behandlung erneut
aufzurufen, um das Interrupt-Signal wieder auf true zu setzen, damit an anderer Stelle (in derselben
Methode oder in einer aufrufenden Methode) passend reagiert werden kann (siehe obige run() -
Methode des Konsumenten-Threads).
Falls sich ein per interrupt() angesprochener Thread nicht in einem Wartezustand aus den eben
genannten Gründen befindet, dann wird ein Interrupt-Signal gesetzt. Das kann auch einem Thread
passieren, der gerade auf einen Monitor oder Lock wartet, wobei das Warten nicht unterbrochen
wird. Weitere Informationen zur InterruptedException sind bei Goetz (2006) zu finden.
15.3.3 Thread-Lebensläufe
In diesem Abschnitt wird zunächst die Vergabe von Arbeitsberechtigungen für konkurrierende
Threads behandelt. Dann fassen wir unsere Kenntnisse über die verschiedenen Zustände eines
Threads und über Anlässe für Zustandswechsel zusammen.
Mit den Thread-Methoden getPriority() bzw. setPriority() lässt sich die Priorität eines Threads
feststellen bzw. ändern.
Abschnitt 15.3 Direkt gestartete Threads verwalten 805
In der Spezifikation für die virtuelle Java-Maschine wird das Verhalten des Schedulers bei der Re-
chenzeitvergabe an die Threads nicht sehr präzise beschrieben. Er muss lediglich sicherstellen, dass
die einem Thread zugeteilte Rechenzeit mit der Priorität ansteigt.
In der Regel kommt von den arbeitswilligen Threads derjenige mit der höchsten Priorität zum Zug,
jedoch kann der Scheduler Ausnahmen von dieser Regel machen, z. B. um das Verhungern (engl.
starvation) eines anderen Threads zu verhindern, der permanent auf Konkurrenten mit höherer Prio-
rität trifft. Daher darf der korrekte Ablauf eines Programms nicht davon abhängig sein, dass sich die
Rechenzeitvergabe an Threads in einem strengen Sinn an den Prioritäten orientiert.
Weil der JVM-Scheduler eng mit dem Wirtsbetriebssystem zusammenarbeiten muss, besteht bei der
Verteilung von Rechenzeit auf mehrere Threads mit gleicher Priorität keine vollständige Plattfor-
munabhängigkeit. Auf einigen Plattformen (z. B. Windows) kommt das preemtive Zeitscheiben-
verfahren zum Einsatz:
• Threads gleicher Priorität werden reihum (Round-Robin) jeweils für eine festgelegte Zeit-
spanne ausgeführt.
• Ist die Zeitscheibe eines Threads verbraucht, wird er vom Scheduler in den Zustand ready
versetzt, und der Nachfolger erhält Zugang zu einem Prozessor.
Über die Methode yield() kann ein Thread seine Zeitscheibe freiwillig abgeben und sich wieder in
die Warteschlange der rechenwilligen Threads einreihen.
new-Operator
new
start()
ready
• Schlafintervall beendet
• interrupt()
806 Kapitel 15 Multithreading
Die Thread-Methode getState() meldet als mögliche Thread-Zustände die Werte der Enumeration
Thread.State (im Paket java.lang):
• NEW
• RUNNABLE
• BLOCKED
• WAITING
• TIMED_WAITING
• TERMINATED
In der Abbildung resultiert aus der Berücksichtigung der Anlässe für Zustandswechsel eine diffe-
renziertere Darstellung. So meldet getState() z. B. den Zustand RUNNABLE, wenn ein Thread aus
der Sicht der JVM ausgeführt werden kann. Diesem Status entsprechen in der Abbildung die Zu-
stände ready und running, die von der Zuteilung der Prozessorberechtigung durch das Wirtsbe-
triebssystem abhängen.
Im Manuskript nicht behandelte Methodenüberladungen bzw. Anlässe für Zustandsübergänge feh-
len in der Abbildung. So existiert z. B. zur Object-Methode wait() eine Überladung mit Timeout-
Parameter, die einen Thread in den Zustand TIMED_WAITING im Sinn der Enumeration
Thread.State versetzt. Für die Rückkehr in den Zustand ready sorgt dann (neben notify(),
notifyAll() und interrupt()) auch der Ablauf der Wartezeit.
15.3.4 Deadlock
Wer sich beim Einsatz von Monitoren oder expliziten Lock-Objekten zur Thread-Synchronisation
ungeschickt anstellt, kann einen so genannten Deadlock (deutsch: eine Systemverklemmung) produ-
zieren, wobei sich Threads gegenseitig blockieren. Im folgenden Beispiel sind die beiden
ReentrantLock-Objekte lock1 und lock2 im Spiel (vgl. Abschnitt 15.2.3.1):
import java.util.concurrent.locks.*;
class Deadlock {
static Lock lock1 = new ReentrantLock();
static Lock lock2 = new ReentrantLock();
15.4.1 ExecutorService
Ein bequemer und (z. B. von Bloch 2018, S. 324) empfohlener Weg zum erfolgreichen Threadpool-
Einsatz führt über die statische Methode newCachedThreadPool() der Klasse Executors, z. B.:
ExecutorService es = Executors.newCachedThreadPool();
Man erhält ein Objekt aus einer Klasse, die das Interface ExecutorService implementiert und folg-
lich u. a. die Methode execute() beherrscht:
public void execute(Runnable runnable)
Die run() - Methode des Parameterobjekts vom Typ Runnable wird in einem eigenen Thread aus-
geführt, nach Möglichkeit durch Wiederverwendung eines vorhandenen Pool-Threads, z. B.:
808 Kapitel 15 Multithreading
class ProKonDemo {
public static void main(String[] args) throws InterruptedException {
Lager lager = new Lager(1000);
ProThread pt = new ProThread(lager);
pt.start();
ExecutorService es = Executors.newCachedThreadPool();
for (int i = 1; i <= 5; i++) {
es.execute(new KonThread(lager, i));
Thread.sleep(3000);
}
es.shutdown();
}
}
Ein Objekt der leicht modifizierten Konsumentenklasse beendet seine Einkaufstour nach MANZ Zu-
griffen:
Abschnitt 15.4 Aufgaben per Threadpool erledigen 809
@Override
public void run() {
while (nr++ < MANZ) {
lager.liefere((int) (5 + Math.random()*100), custID);
try {
Thread.sleep((int) (1000 + Math.random()*3000));
} catch(InterruptedException ie) {interrupt();}
}
System.out.println("\nDer Kunde " + custID + " hat keine Wünsche mehr.\n");
}
}
Wie das folgende Ablaufprotokoll zeigt, versorgt z. B. der Thread pool-1-thread-1 nacheinander
die Kunden 1, 3 und 5:
Der Laden ist offen (Bestand = 1000)
ExecutorService es = Executors.newSingleThreadExecutor();
Von der Methode newSingleThreadExecutor() erhält man einen Exekutor, der einen einzelnen
Thread für verschiedene Aufgaben verwenden kann.
Nun wird es Zeit, eine das Interface Callable<T> implementierende Beispielklasse vorzustellen:
import java.util.concurrent.Callable;
import java.util.concurrent.TimeoutException;
class CallableDemo {
public static void main(String[] args) throws InterruptedException {
DateFormat df = DateFormat.getDateTimeInstance();
ExecutorService es = Executors.newSingleThreadExecutor();
Future<Double> fd = es.submit(new RandomNumberCruncher());
while (!fd.isDone()) {
System.out.println("Warten auf call() - Ende (" + df.format(new Date()) + ")");
Thread.sleep(1_000);
}
try {
System.out.println("\nMittelwert der Zufallszahlen: " + fd.get());
} catch (Exception e) {
System.out.println("\nException beim get() - Aufruf:\n "+e);
}
es.shutdown();
}
}
Der submit() - Aufruf endet sofort und liefert im Beispiel als Rückgabe ein Objekt aus Klasse, die
das Interface Future<Double> implementiert. Über die Methode isDone() kann man sich bei die-
sem Objekt über die Fertigstellung des Auftrags informieren. Seine Methode get() liefert schließlich
die call() - Rückgabe oder leitet ggf. ein von call() geworfenes Ausnahmeobjekt weiter.
Bei einem gelungenen Aufruf (ohne TimeoutException) lieferte das Beispielprogramm die Ausga-
be:
812 Kapitel 15 Multithreading
Der vom ExecutorService verwaltete Thread (mit dem Namen pool-1-thread-1) verbleibt nach
Abschluss des call() - Aufrufs in Parkstellung und verhindert das Programmende:
"pool-1-thread-1" #10 prio=5 os_prio=0 tid=0x0000000019844000 nid=0x3638 waiting on
condition [0x0000000019e3f000]
java.lang.Thread.State: WAITING (parking)
Im Beispiel wird der überflüssig gewordene ExecutorService über seine shutdown() - Methode
gestoppt:
es.shutdown();
Beim folgenden Programmablauf hat sich die call() - Methode mit dem Werfen einer Timeout-
Exception verabschiedet:
Warten auf call() - Ende (06.03.2022 02:26:24)
Warten auf call() - Ende (06.03.2022 02:26:26)
Warten auf call() - Ende (06.03.2022 02:26:27)
Warten auf call() - Ende (06.03.2022 02:26:28)
Warten auf call() - Ende (06.03.2022 02:26:29)
import java.util.concurrent.*;
class ScheduledThreadPoolExecutorDemo {
public static void main(String[] args) throws InterruptedException {
ScheduledThreadPoolExecutor stpe = new ScheduledThreadPoolExecutor(2);
stpe.scheduleAtFixedRate(new ScheduledRunner(1, stpe), 0, 1000, TimeUnit.MILLISECONDS);
stpe.scheduleAtFixedRate(new ScheduledRunner(2, stpe), 2000, 2000, TimeUnit.MILLISECONDS);
stpe.scheduleAtFixedRate(new ScheduledRunner(3, stpe), 3000, 3000, TimeUnit.MILLISECONDS);
Thread.sleep(5000);
stpe.shutdown();
}
}
Die main() - Methode der Startklasse fordert den ScheduledThreadPoolExecutor nach fünf Se-
kunden per shutdown() - Methode auf, seine Tätigkeit einzustellen. Daraufhin werden keine neuen
Ausführungen mehr begonnen, sodass die Pool-Threads nach einiger Zeit enden. Laufende Ausfüh-
rungen werden aber noch zu Ende geführt.
Weitere Regeln für die Umsetzung der Zeitpläne:
• Die nächste Ausführung eines Auftrags wird erst dann gestartet, wenn die vorherige abge-
schlossen ist.
• Endet eine Ausführung eines Auftrags mit einer Ausnahme, dann finden keine weiteren
Ausführungen dieses Auftrags mehr statt.
• Sind alle Pool-Threads im Einsatz, dann kann sich der Start einer Ausführung verzögern.
814 Kapitel 15 Multithreading
Das Verfahren wird vom Fork-Join - Framework so weit wie möglich automatisiert, wobei ein
Threadpool zum Einsatz kommt. Anwendungsprogrammierer haben Einfluss auf zwei Stellgrößen
des Verfahrens:
Abschnitt 15.5 Datenparallelität mit Hilfe des Fork-Join - Frameworks 815
noch laufenden bzw. anstehenden Aufgaben durch einen Aufruf der ForkJoinPool-Methode
awaitQuiescence() Gelegenheit zur Fertigstellung zu geben:
public boolean awaitQuiescence(long timeout, TimeUnit unit)
Dabei kann es passieren, dass der Thread main sich an der Erledigung ausstehender Aufgaben be-
teiligt, also ob er Mitglied im Common Pool wäre (siehe Beispiel im Abschnitt 15.6.2.3).
Als Beispiel bietet sich die Berechnung der Fakultät an, weil Sie im Rahmen einer Übungsaufgabe
zum Kapitel 12 (über das funktionale Programmieren) bereits eine Multithreading-Lösung zu dieser
Aufgabe durch Reduktion eines parallelen Stroms erstellt haben (siehe auch Abschnitt 12.2.5.4.2).
Zur Parallelverarbeitung von Strömen wurde im Kapitel 12 berichtet, dass im Hintergrund das Fork-
Join - Framework zum Einsatz kommt. Nun machen wir uns daran, eine explizite Lösung mit der
Fork-Join - Technik zu erstellen.
In der von RecursiveTask<BigDecimal> abstammenden Klasse FacTask
private static class FacTask extends RecursiveTask<BigDecimal> {
private int start, ende;
private int schwelle;
@Override
protected BigDecimal compute() {
BigDecimal fac;
int umfang = ende - start;
if (umfang <= schwelle)
fac = product(start, ende);
else {
int haelfte = umfang/2;
FacTask task1 = new FacTask(start, start+haelfte, schwelle);
FacTask task2 = new FacTask(start+haelfte+1, ende, schwelle);
task2.fork();
BigDecimal erg1 = task1.compute();
BigDecimal erg2 = task2.join();
fac = erg1.multiply(erg2);
}
return fac;
}
}
ist eine (Teil)Aufgabe definiert durch (siehe Konstruktor):
• Start- und Endindex der zu bearbeitenden Teilfolge
• Schwellenwert für eine hinreichend kleine, direkt zu bearbeitende Teilfolge
Die Methode product() ist für den simplen Job zuständig, eine hinreichend kleine Aufgabe direkt
zu lösen, also das Produkt a(a+1)(a+2) ... (b-1)b für die Teilfolge von a bis b zu berechnen.
Weitaus interessanter ist die Methode compute(), die sich nach einer Umfangsbeurteilung zwischen
der Direktlösung und der Aufgabenzerlegung entscheidet. Bei einer Aufgabenzerlegung ...
Abschnitt 15.5 Datenparallelität mit Hilfe des Fork-Join - Frameworks 817
• werden zwei neue FacTask-Objekte mit einem ungefähr halbiertem Umfang gebildet.
• Dann wird dem Framework eine Teilaufgabe (task2) durch einen Aufruf der Methode
fork() zur parallelen Bearbeitung übergeben, wobei in der Regel ein anderer Pool-Thread
zum Einsatz kommt. Dieser Methodenaufruf kehrt sofort zurück.1
• Anschließend wird das erste Teilaufgabenobjekt (task1) durch einen Aufruf seiner Metho-
de compute() aufgefordert, im aktiven Thread seine Aufgabe zu erledigen. Der compute() -
Aufruf kehrt erst dann zurück, wenn die Teilaufgabe erledigt ist.
• Nach Rückkehr des compute() - Aufrufs wird das zweite Teilaufgabenobjekt (task2) durch
die Methode join() aufgefordert, sein Ergebnis abzuliefern. Liegt das Ergebnis noch nicht
vor, kümmert sich der aktive Thread um andere Teilaufgaben in seiner eigenen Warte-
schlange, statt sich schlafen zu legen. Ist die eigene Warteschlange leer, übernimmt er Teil-
aufgaben aus den Warteschlangen anderer Pool-Threads (Goetz 2007). Somit verhält sich
die ForkJoinTask<T> - Methode join() deutlich anders als die gleichnamige Methode der
Klasse Thread (vgl. Abschnitt 15.3.1).
• Sind beide Teilaufgaben abgeschlossen, dann werden die Ergebnisse zusammengefasst. Im
Beispiel sind dazu die Ergebnisse aus den beiden Teilfolgen miteinander zu multiplizieren.
Um die Berechnung der Fakultät über eine statische Methode namens factorial() bequem nutz-
bar zu machen, wird im Beispiel die Klasse FacForkJoin definiert und die Aufgabenklasse
FacTask als statische Mitgliedsklasse implementiert:
import java.math.BigDecimal;
import java.util.concurrent.*;
class FacForkJoin {
static public BigDecimal factorial(int argument, int schwelle) {
FacTask task = new FacTask(1, argument, schwelle);
ForkJoinPool pool = new ForkJoinPool();
pool.invoke(task);
pool.shutdown();
return task.join();
}
1
Die ForkJoinPool-Methode invoke(), mit der das gesamte Fork-Join - Verfahren gestartet wird (siehe unten), sollte
keinesfalls aus der compute() - Methode eines RecursiveTask<T> - oder RecursiveAction-Objekts gestartet wer-
den (Grossmann 2012).
818 Kapitel 15 Multithreading
• der ForkJoinPool mit der Methode shutdown() beauftragt, sich nach Erledigung des Auf-
trags zu beenden,
• durch einen Aufruf der ForkJoinTask<BigDecimal> - Methode join() das Ergebnis ermit-
telt und an den Aufrufer gemeldet.
Von der folgenden factorial() - Überladung wird der Schwellenwert für eine hinreichend klei-
ne, direkt zu bearbeitende Teilfolge aus der Anzahl der verfügbaren Rechenkerne abgeleitet:
static public BigDecimal factorial(int argument) {
return factorial(argument, Runtime.getRuntime().availableProcessors()*10);
}
Damit ist die Fork-Join - Technik zur Bestimmung der Fakultät leicht anzuwenden, z. B.:
import java.math.BigDecimal;
import java.util.stream.IntStream;
class ForkJoinTest {
public static void main(String[] args) {
int argument = 50_000;
long zeit;
BigDecimal ergebnis;
zeit = System.currentTimeMillis();
ergebnis = FacForkJoin.factorial(argument);
System.out.printf("\nLaufzeit mit Fork-Join in Millisekunden:%7d (Ergebnis: %e)",
(System.currentTimeMillis()-zeit), ergebnis);
}
}
Mit einer etwas erweiterten Variante des obigen Testprogramms wurden Laufzeitvergleiche zwi-
schen einer Single-Thread - Lösung und der Fork-Join - Lösung vorgenommen. Wegen stark ab-
weichender Einzelergebnisse wurde die Rechnung fünfmal wiederholt. Auf einem PC mit der Intel-
CPU Core i3 (3,2 GHz Taktfrequenz, 2 reale Kerne plus Hyperthreading) resultierten die folgenden
Laufzeiten:
Laufzeit mit Fork-Join in Millisekunden: 302 (Ergebnis: 3,347321e+213236)
Laufzeit mit Fork-Join in Millisekunden: 246 (Ergebnis: 3,347321e+213236)
Laufzeit mit Fork-Join in Millisekunden: 138 (Ergebnis: 3,347321e+213236)
Laufzeit mit Fork-Join in Millisekunden: 128 (Ergebnis: 3,347321e+213236)
Laufzeit mit Fork-Join in Millisekunden: 325 (Ergebnis: 3,347321e+213236)
import java.math.BigDecimal;
import java.util.stream.IntStream;
class FactorialByParallelStream {
public static void main(String[] args) {
int argument = 50_000;
long zeit;
BigDecimal ergebnis;
System.out.println("\n");
for (int i = 0; i < 5; i++) {
zeit = System.currentTimeMillis();
ergebnis = IntStream
.rangeClosed(1, argument)
.parallel()
.mapToObj(BigDecimal::new)
.reduce(BigDecimal.ONE, BigDecimal::multiply);
System.out.printf("\nLaufzeit mit parallelem Strom in Millisekunden:" +
"%7d (Ergebnis: %e)", System.currentTimeMillis()-zeit, ergebnis);
}
}
}
zeigt im Vergleich zu der im letzten Abschnitt vorgestellten expliziten Fork-Join-Lösung eine
enorme Vereinfachung.
Wie bei der manuellen Fork-Join - Lösung (vgl. Abschnitt 15.5.1) erhält man für wiederholte Aus-
führungen desselben Auftrags deutlich variierende Laufzeiten:
Laufzeit mit parallelem Strom in Millisekunden: 373 (Ergebnis: 3,347321e+213236)
Laufzeit mit parallelem Strom in Millisekunden: 305 (Ergebnis: 3,347321e+213236)
Laufzeit mit parallelem Strom in Millisekunden: 232 (Ergebnis: 3,347321e+213236)
Laufzeit mit parallelem Strom in Millisekunden: 544 (Ergebnis: 3,347321e+213236)
Laufzeit mit parallelem Strom in Millisekunden: 123 (Ergebnis: 3,347321e+213236)
Die Parallelstrom-Lösung kann bei der Laufzeit mit der händischen Fork-Join-Lösung durchaus
mithalten.
keiten auf möglichst einfache Weise zu ermöglichen. Neben der Schnittstelle Future<T> imple-
mentiert die Klasse CompletableFuture<T> auch die Schnittstelle CompletionStage<T>, die ca.
40 Methoden zur Verbindung von Verarbeitungsschritten zu einem komplexen, nach Möglichkeit
asynchron ausgeführten Verarbeitungsprozess vorschreibt. Durch die Definition von Folgeaufga-
ben, die von einer oder von zwei Vorgängeraufgaben abhängen kann z. B. der folgende Prozess
entstehen (CF steht für CompletableFuture):
CF51
Wir starten im Abschnitt 15.6.1 mit statischen CompletableFuture<T> - Methoden zum Erstellen
von einzelnen Aufgaben und behandeln später die vom Interface CompletionStage<T> vorge-
schriebenen Methoden zur Verbindung von Aufgaben zu einem mehr oder weniger komplexen Ver-
arbeitungsprozess.
Mit der CompletableFuture<T> - Methode isDone() lässt sich ermitteln, ob die Aufgabe bereits
beendet ist (erfolgreich, mit einer Ausnahme gescheitert oder abgebrochen):
public boolean isDone()
An die folgende runAsync() - Überladung
public static CompletableFuture<Void> runAsync(Runnable runnable, Executor executor)
wird ein für die Ausführung der Aufgabe zuständiger Threadpool übergeben, statt den Common
Pool zu verwenden, z. B.:
ExecutorService es = Executors.newCachedThreadPool();
CompletableFuture<Void> cfv = CompletableFuture.runAsync(
() -> System.out.println("Done"), es);
. . .
es.shutdown();
1
Weil wir die generische Klasse CompletableFuture<T> mit dem Typformalparameter T notiert haben, und weil
die statische CompletableFuture-Methode supplyAsync() einen eigenständigen Typformalparameter besitzt, ver-
wenden wir hier den Buchstaben U.
822 Kapitel 15 Multithreading
Quellcode Ausgabe
import java.util.concurrent.CompletableFuture; 4711
class SupplyAsync {
public static void main(String[] args) {
CompletableFuture<Integer> cfi = CompletableFuture.supplyAsync(
() -> 4711);
System.out.println(cfi.join());
}
}
}
Die main() - Methode fragt das Ergebnis der asynchron per Pool-Thread ausgeführten Aufgabe
über die join() - Methode des CompletableFuture<Integer> - Objekts ab, die nach einer erfolgrei-
chen Ausführung einen Integer-Wert liefert. Nach einer (im Beispiel schwer vorstellbaren) geschei-
terten Ausführung wirft die Methode join() eine Ausnahme:
• CompletionException
Bei der Ausführung der Supplier<U> - Methode ist eine Ausnahme aufgetreten, oder das
CompletableFuture<T> - Objekt hat einen completeExceptionally() - Aufruf erhalten.
Das CompletionException - Objekt kann per getCause() nach der zugrunde liegenden
Ausnahme befragt werden.
• CancellationException
Das CompletableFuture<T> - Objekt hat einen cancel() - Aufruf erhalten. Mit dem Abbre-
chen von Aufgaben können wir uns im Kurs aus Zeitgründen leider nicht beschäftigen.
Die Methode join() wartet (blockiert), bis die CompletableFuture<T> - Aufgabe beendet ist.
An die folgende supplyAsync() - Überladung
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier,
Executor executor)
wird ein für die Ausführung der Aufgabe zuständiger Threadpool übergeben, statt den Common
Pool zu verwenden, z. B.:
ExecutorService es = Executors.newCachedThreadPool();
CompletableFuture<Integer> cfi = CompletableFuture.supplyAsync(() -> 4711, es);
System.out.println(cfi.join());
. . .
es.shutdown();
15.6.2 Folgeaufgaben
Die Klasse CompletableFuture<T> implementiert neben dem Interface Future<T> auch das In-
terface CompletionStage<T>, das ca. 40 Instanzmethoden zur Vereinbarung von Folgeaufgaben
enthält. Weil diese Methoden ein Objekt vom Typ einer CompletableFuture<T> - Konkretisierung
als Rückgabe liefern, kann man Aufgabensequenzen definieren und dabei auch einen Fluent API -
Programmierstil verwenden (flüssiges Programmieren durch Verketten von Aufrufen). Etliche Me-
thoden machen eine Folgeaufgabe von zwei Vorgängeraufgaben abhängig, und durch die (wieder-
holte) Anwendung dieser Methoden lassen sich komplexe Abhängigkeiten zwischen Aufgaben de-
finieren.
class ThenRun {
public static void main(String[] args) {
CompletableFuture<Void> cf1 = CompletableFuture.runAsync(
() -> {
try {Thread.sleep(1000);}
catch (InterruptedException ix) {Thread.currentThread().interrupt();}
System.out.println("Start: " + Thread.currentThread().getName());
});
cf2.join();
}
}
Weil die main() - Methode per join() - Aufruf das Ende der Folgeaufgabe abwartet, erscheinen die
Kontrollausgaben beider Aufgaben:
Start: ForkJoinPool.commonPool-worker-1
Runner: ForkJoinPool.commonPool-worker-1
Erwartungsgemäß hat der Common Pool nicht nur die Startausgabe, sondern auch die Folgeaufgabe
ausgeführt.
Wenn die main() - Methode aufgrund eines Schläfchens
try {Thread.sleep(3000);}
catch (InterruptedException ix) {Thread.currentThread().interrupt();}
die Folgeaufgabe erst nach dem Ende der initialen Aufgabe startet, dann wird die Folgeaufgabe
vom main-Thread ausgeführt:
Start: ForkJoinPool.commonPool-worker-1
Runner: main
Bei Verwendung der Methode thenRun() ist es also schwer vorhersehbar, ob die Folgeaufgabe
asynchron im selben Thread wie die Vorgängeraufgabe, oder synchron im aktuellen Thread ausge-
führt wird.
Bei Verwendung der Methode thenRunAsync() wird die Folgeaufgabe unabhängig vom Verarbei-
tungsfortschritt der initialen Aufgabe auf jeden Fall asynchron ausgeführt. Ergänzt man im Ver-
gleich zur Methode thenRun() lediglich den Namensbestandteil Async, dann kümmert sich der
Common Pool auch um die Fortsetzungsaufgabe:
824 Kapitel 15 Multithreading
class ThenRunAsyn {
public static void main(String[] args) throws InterruptedException{
CompletableFuture<Void> cf1 = CompletableFuture.runAsync(() -> {
try {Thread.sleep(1000);}
catch (InterruptedException ix) {Thread.currentThread().interrupt();}
System.out.println("Starter: " + Thread.currentThread().getName());
});
Thread.sleep(3000);
cf3.join();
}
}
Es ist nicht vorhersehbar (und auch nicht relevant), ob die Folgeaufgabe vom selben Thread ausge-
führt wird wie die Starteraufgabe,
Starter: ForkJoinPool.commonPool-worker-1
Additional: ForkJoinPool.commonPool-worker-2
Runner: ForkJoinPool.commonPool-worker-1
oder vom zweiten Thread aus dem Common Pool - Team:
Starter: ForkJoinPool.commonPool-worker-1
Additional: ForkJoinPool.commonPool-worker-2
Runner: ForkJoinPool.commonPool-worker-2
Wie die Ausgabe des modifizierten Beispielprogramms zeigt, wird die Folgeaufgabe jetzt von ei-
nem speziellen Threadpool (namens pool-1) ausgeführt:
Starter: ForkJoinPool.commonPool-worker-1
Additional: ForkJoinPool.commonPool-worker-2
Runner: pool-1-thread-1
class ThenApply {
public static void main(String[] args) throws InterruptedException {
CompletableFuture<Integer> cf1 = CompletableFuture.supplyAsync(() -> {
System.out.println("First: " + Thread.currentThread().getName());
try {Thread.sleep(1000);}
catch (InterruptedException ix) {Thread.currentThread().interrupt();}
return 1;
});
Thread.sleep(3000);
System.out.println(cf2.join());
}
}
Von den Aufgaben wird jeweils der handelnde Thread dokumentiert, und wir erhalten die Bestäti-
gung, dass die erste Aufgabe von einem Thread aus dem Common Pool und die Fortsetzungsaufga-
be vom main-Thread ausgeführt wird:
First: ForkJoinPool.commonPool-worker-1
Incrementer: main
2
Verzichtet der main-Thread auf die 3-Sekunden - Pause, sodass beim Start der Fortsetzungsaufgabe
die Vorgängeraufgabe noch läuft, dann resultiert die folgende Ausgabe:
First: ForkJoinPool.commonPool-worker-1
Incrementer: ForkJoinPool.commonPool-worker-1
2
826 Kapitel 15 Multithreading
Bei Verwendung der Methode thenApply() ist es also schwer vorhersehbar, ob die Folgeaufgabe
asynchron im selben Thread wie die Vorgängeraufgabe, oder synchron im aktuellen Thread ausge-
führt wird.
Bei Verwendung der Methode thenApplyAsync() wird die Fortsetzungsaufgabe unabhängig vom
Verarbeitungsfortschritt der initialen Aufgabe auf jeden Fall asynchron ausgeführt. Ergänzt man im
Vergleich zur Methode thenApply() lediglich den Namensbestandteil Async, dann wird die Fort-
setzungsaufgabe vom Common Pool ausgeführt:
public <U> CompletableFuture<U> thenApplyAsync(
Function<? super T,? extends U> function)
Im folgenden Beispielprogramm erhält der Common Pool nach der Startaufgabe per runAsync()
noch eine Ablenkungsaufgabe, bevor per thenApplyAsync() die Fortsetzungsaufgabe gestartet
wird:
import java.util.concurrent.CompletableFuture;
class ThenApplyAsync {
public static void main(String[] args) throws InterruptedException {
CompletableFuture<Integer> cf1 = CompletableFuture.supplyAsync(() -> {
System.out.println("First: " + Thread.currentThread().getName());
try {Thread.sleep(1000);}
catch (InterruptedException ix) {Thread.currentThread().interrupt();}
return 1;
});
Thread.sleep(3000);
System.out.println(cf2.join());
}
}
Dieses für unser Thema unwesentliche Detail sorgt dafür, dass die Start- und die Fortsetzungsauf-
gabe von verschiedenen Threads aus dem Common Pool ausgeführt werden, was die folgende Aus-
gabe des Programms belegt:
First: ForkJoinPool.commonPool-worker-1
Incrementer: ForkJoinPool.commonPool-worker-2
2
Die Methode main() ermittelt mit der (blockierenden) Methode join() das Ergebnis der Fortset-
zungsaufgabe und endet dann. Damit enden auch der Thread main und das Programm, sodass die
per sleep(3000) verzögerte Ausgabe der Ablenkungsaufgabe nicht mehr erscheint, weil der
Common Pool automatisch beendet wird. Um vor dem Programmende den im Common Pool noch
laufenden bzw. anstehenden Aufgaben die (allerdings zeitlich begrenzte) Gelegenheit zur Fertigstel-
lung zu geben, kann man die ForkJoinPool-Methode awaitQuiescence() aufrufen, z. B.:
ForkJoinPool.commonPool().awaitQuiescence(3000, TimeUnit.MILLISECONDS);
Abschnitt 15.6 Aufgabenparallelität mit Hilfe der Klasse CompletableFuture<T> 827
import java.util.concurrent.*;
class ThenAcceptAsync {
public static void main(String[] args) {
CompletableFuture<Integer> cf1 = CompletableFuture.supplyAsync(() -> {
System.out.println("First: " + Thread.currentThread().getName());
try {Thread.sleep(1000);}
catch (InterruptedException ix) {Thread.currentThread().interrupt();}
return 1;
});
ForkJoinPool.commonPool().awaitQuiescence(3000, TimeUnit.MILLISECONDS);
}
}
Wegen des Aufrufs von awaitQuiescence() ist es trotz der Verwendung der Methoden
supplyAsyn() und thenAcceptAsync() nicht garantiert, dass die beiden Aufgaben tatsächlich vom
Common Pool ausgeführt werden. Es kann passieren, dass sich der main-Thread bei der Ausfüh-
rung unerledigter Aufgaben beteiligt, sodass statt der erwarteten Ausgabe (Ausführung beider Auf-
gaben durch den Common Pool):
First: ForkJoinPool.commonPool-worker-1
Incrementer: ForkJoinPool.commonPool-worker-1
2
z. B. die folgende Variante erscheint:
First: ForkJoinPool.commonPool-worker-1
Incrementer: main
2
Während die Startaufgabe von einem Pool-Thread erledigt worden ist, hat der main-Thread die
Fortsetzungsaufgabe übernommen. In der folgenden Ablaufvariante hat der main-Thread sogar
beide Aufgaben ausgeführt:
First: main
Incrementer: main
2
Selbst diese Variante kann auftreten:
First: main
Incrementer: ForkJoinPool.commonPool-worker-1
2
Die Unbestimmtheit verschwindet, wenn der main-Thread per join() - Methode auf die Beendigung
der Fortsetzungsaufgabe wartet:
cf2.join();
Diese Lösung ist im konkreten Beispiel gegenüber der Methode awaitQuiescence() zu bevorzugen,
weil im Common Pool keine weiteren Aufgaben auf die Fertigstellung warten.
An die folgende thenAcceptAsync - Überladung
public CompletableFuture<Void> thenAcceptAsync(Consumer<? super T> action,
Executor executor)
Abschnitt 15.6 Aufgabenparallelität mit Hilfe der Klasse CompletableFuture<T> 829
wird ein für die Ausführung der Aufgabe zuständiger Threadpool übergeben, statt den Common
Pool zu verwenden, z. B.:
ExecutorService es = Executors.newCachedThreadPool();
CompletableFuture<Void> cf2 = cf1.thenAcceptAsync(i -> {
System.out.println("Incrementer: " + Thread.currentThread().getName());
System.out.println(++i);
try {Thread.sleep(1000);}
catch (InterruptedException ix) {Thread.currentThread().interrupt();}
}, es);
cf2.join();
. . .
es.shutdown();
Ohne den join() - Aufruf wird im Beispielprogramm der Folgeauftrag nicht ausgeführt, weil er zum
Zeitpunkt des shutdown() - Aufrufs noch nicht vom Threadpool angenommen wurde, also keinen
Bestandsschutz genießt.
Zwischenergebnisse ein String-Objekt erstellt. Die Methode main() richtet an die Kombinations-
aufgabe einen (blockierenden) join() - Aufruf, um das Ergebnis zu erhalten.
import java.util.concurrent.CompletableFuture;
class ThenCombineAsync {
public static void main(String[] args) {
CompletableFuture<Integer> cf1 = CompletableFuture.supplyAsync(() -> {
try {Thread.sleep(1000);}
catch (InterruptedException ix) {Thread.currentThread().interrupt();}
System.out.println("First: " + Thread.currentThread().getName());
return 2;
});
System.out.println(cf3.join());
}
}
An der Durchführung der drei Aufgaben sind zwei Threads aus dem Common Pool beteiligt:
First: ForkJoinPool.commonPool-worker-1
Second: ForkJoinPool.commonPool-worker-2
Combiner: ForkJoinPool.commonPool-worker-2
2 hoch 3 = 8.0
class ApplyToEither {
public static void main(String[] args) {
CompletableFuture<String> cf1 = CompletableFuture.supplyAsync(() -> {
try {Thread.sleep(1000);}
catch (InterruptedException ix) {Thread.currentThread().interrupt();}
System.out.println("First: " + Thread.currentThread().getName());
return "Tulpen";
});
System.out.println(cf3.join());
}
}
Zur Durchführung der drei Aufgaben hat der Common Pool diesmal drei Threads eingesetzt:
First: ForkJoinPool.commonPool-worker-1
Second: ForkJoinPool.commonPool-worker-2
Combiner: ForkJoinPool.commonPool-worker-3
Heute im Angebot: Nelken
Der schnellere Blumenlieferant entscheidet über das Angebot des Tages.
An die folgende applyToEitherAsync() - Überladung
public <U> CompletableFuture<U> applyToEitherAsync(
CompletionStage<? extends T> other,
Function<? super T,U> function),
Executor executor)
wird ein für die Ausführung der Aufgabe zuständiger Threadpool übergeben, statt den Common
Pool zu verwenden, z. B.:
ExecutorService es = Executors.newCachedThreadPool();
CompletableFuture<String> cf3 = cf1.applyToEitherAsync(cf2, s -> {
try {Thread.sleep(1000);} catch (InterruptedException ix)
{Thread.currentThread().interrupt();}
System.out.println("Combiner: " + Thread.currentThread().getName());
return "Heute im Angebot: " + s;
}, es);
System.out.println(cf3.join());
. . .
es.shutdown();
Wie die Ausgabe des modifizierten Beispielprogramms zeigt, wird die kombinierende Fortset-
zungsaufgabe jetzt von einem speziellen Threadpool (namens pool-1) ausgeführt:
Second: ForkJoinPool.commonPool-worker-2
First: ForkJoinPool.commonPool-worker-1
Combiner: pool-1-thread-1
Heute im Angebot: Tulpen
15.6.4 Ausnahmebehandlung
Kommt es in einem Verarbeitungsschritt zu einer unbehandelten Ausnahme, dann werden die ab-
hängigen Verarbeitungsschritte nicht ausgeführt, d. h. die Methoden thenRun(), thenApply(),
thenAccept(), thenCombine() und applyToEither() sowie deren asynchrone Varianten werden
nicht aufgerufen. Die zur Ausnahmebehandlung konzipierten CompletableFuture<T> - Methoden
exceptionally(), handle() und whenComplete() sowie deren asynchrone Varianten werden in ei-
nem solchen Fall hingegen aufgerufen und erlauben die Integration von Fehlerbehandlungs- bzw.
Reparaturstationen in die Aufgabenverarbeitung. Die zugrundeliegenden Funktionsobjekte können
sich auf eine Protokollierung der Ausnahme beschränken oder dafür sorgen, dass eine gestörte Auf-
gabensequenz fortgesetzt werden kann.
Zunächst beschäftigen wir uns damit, was bei Verzicht auf die speziellen Fehlerbehandlungsstatio-
nen mit einer unbehandelten Ausnahme geschieht, die bei der Ausführung einer Aufgabe auftritt.
class UnbemerkteAusnahme {
public static void main(String[] args) throws InterruptedException {
CompletableFuture<Integer> cf1 = CompletableFuture.supplyAsync(() -> {
System.out.println("Starter: " + Thread.currentThread().getName());
if (true)
throw new IllegalStateException("Starter failed");
return 1;
});
ForkJoinPool.commonPool().awaitQuiescence(3000, TimeUnit.MILLISECONDS);
}
}
834 Kapitel 15 Multithreading
try {
Void r = result.get();
System.out.println("\nresult = " + r);
} catch (Exception ex) {
System.out.println("\n" + ex);
}
System.out.println("\nStarter: " + cf1);
System.out.println("Incrementer: " + cf2);
System.out.println("Result: " + result);
nachdem die Verarbeitungskette genügend Zeit zum Terminieren hatte:
• eine Abfrage mit isCompletedExceptionally(),
• einen wartenden und blockierenden Zugriff auf das Ergebnis des
CompletableFuture<Void> - Objekts result per get() -Aufruf,
• eine (implizit per toString() erstellte) Statusanzeige für alle Aufgaben.
Nun wird die in der Aufgabe cf1 aufgetretene Ausnahme sichtbar:
Starter: ForkJoinPool.commonPool-worker-1
Starter: ForkJoinPool.commonPool-worker-1
Incrementer: ForkJoinPool.commonPool-worker-1
Accepter: ForkJoinPool.commonPool-worker-1
Ergebnis = 2
result = null
class Exceptionally {
public static void main(String[] args) {
CompletableFuture<Integer> cf1 = CompletableFuture.supplyAsync(() -> {
System.out.println("Starter: " + Thread.currentThread().getName());
if (true) throw new IllegalStateException("Starter failed");
return 1;
});
836 Kapitel 15 Multithreading
result.join();
}
}
Die Ausgabe
Starter: ForkJoinPool.commonPool-worker-1
Ausnahme: java.util.concurrent.CompletionException:
java.lang.IllegalStateException: Starter failed
Reset to 0: main
Incrementer: ForkJoinPool.commonPool-worker-1
Accepter: ForkJoinPool.commonPool-worker-1
Ergebnis = 1
lässt erkennen:
• Die in der ersten Aufgabe aufgetretene Ausnahme vom Typ IllegalStateException ist in ei-
ne Ausnahme vom Typ CompletionException verpackt worden.
• Aufgrund der havarierten ersten Aufgabe ist das Endergebnis der Verarbeitungskette kleiner
als nach einem fehlerfreien Ablauf.
Im Beispielprogramm werden die Aufgaben asynchron vom Common Pool ausgeführt, der bei Be-
endigung des Programms automatisch endet, wobei laufende Aufgaben abgebrochen werden. Daher
sorgt das Programm per join() - Aufruf an den letzten Verarbeitungsschritt dafür, dass die Aufga-
ben Gelegenheit zur Fertigstellung erhalten.
Wenn ein Vorgänger des per exceptionally() angesprochenen Verarbeitungsschritts gescheitert ist,
dann erhält die apply() - Methode der exceptionally() - Rückgabe das im Vorgänger geworfene
Ausnahmeobjekt, und die Verarbeitung wird mit dem Ersatzwert wiederaufgenommen. Es kann
also passieren, dass zwischen der Unfallstelle und dem exceptionally() - Aufruf mehrere Verarbei-
tungsschritte ausgelassen werden.
Seit Java 12 beherrscht die Klasse CompletableFuture<T> zwei asynchrone exceptionally() - Va-
rianten (ohne bzw. mit Parameter für den zu verwendenden Threadpool), die als default-Methoden
in der Schnittstelle CompletionStage<T> realisiert sind.1
1
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/concurrent/CompletionStage.html
Abschnitt 15.6 Aufgabenparallelität mit Hilfe der Klasse CompletableFuture<T> 837
class Handle {
public static void main(String[] args) {
CompletableFuture<Integer> cf1 = CompletableFuture.supplyAsync(() -> {
System.out.println("Starter: " + Thread.currentThread().getName());
if (true)
throw new IllegalStateException("Starter failed");
return 1;
});
result.join();
}
}
kümmert sich die per handleAsync() erstellte Aufgabe um den Ausnahmefall und um den Normal-
fall:
838 Kapitel 15 Multithreading
• Wenn die Vorgängeraufgabe oder eine dort vorausgesetzte Aufgabe gescheitert ist, dann
wird das Problem protokolliert und ein Ersatz für den fehlenden Ergebniswert des Vorgän-
gers festgelegt. Im Beispielprogramm wird die Möglichkeit zum Wechseln des Ergebnistyps
also nicht genutzt.
• Außerdem wird die reguläre Arbeit der aktuellen Station erledigt und der aktuelle Wert in-
krementiert. Bei diesem Wert kann es sich um das Ergebnis des Vorgängers oder um den
Ersatzwert handeln.
Zur Methode handleAsync() existieren zwei Alternativen:
• Eine Überladung zu handleAsync() besitzt einen zusätzlichen Parameter für den zu verwen-
denden Threadpool.
• Bei der Methode handle() lässt sich schwer vorhersagen, ob die Ausführung synchron oder
synchron erfolgt.
Im Beispielprogramm werden die Aufgaben asynchron vom Common Pool ausgeführt, der bei Be-
endigung des Programms automatisch endet, wobei laufende Aufgaben abgebrochen werden. Daher
sorgt das Programm per join() - Aufruf an den letzten Verarbeitungsschritt dafür, dass die Aufga-
ben Gelegenheit zur Fertigstellung erhalten.
import java.util.concurrent.CompletableFuture;
class WhenCompleteAsync {
public static void main(String[] args) throws Exception {
CompletableFuture<Integer> cf1 = CompletableFuture.supplyAsync(() -> {
System.out.println("Starter: " + Thread.currentThread().getName());
if (true)
throw new IllegalStateException("Starter failed");
return 1;
});
try {
System.out.println(result.get());
} catch (Exception ex) {
System.out.println(ex.getMessage());
}
}
}
protokolliert die whenCompleteAsync() - Rückgabe die im ersten Schritt der Aufgabensequenz
geworfene Ausnahme:
Starter: ForkJoinPool.commonPool-worker-1
class java.util.concurrent.CompletionException
Cause: java.lang.IllegalStateException
Message: Starter failed
Accepter: ForkJoinPool.commonPool-worker-1
Ergebnis = null
java.lang.IllegalStateException: Starter failed
Beim anschließenden Zugriff auf das Ergebnis der Verarbeitungskette per get() - Aufruf zeigt sich,
dass die Ausnahme nicht neutralisiert worden ist.
Zur Methode whenCompleteAsync() existieren noch zwei Alternativen:
• whenComplete()
Dabei kann es zu einer synchronen oder asynchronen Ausführung kommen.
• whenCompleteAsync() mit einem Paramter für den zu verwendenden Threadpool
class AllOf {
public static void main(String[] args) throws InterruptedException {
CompletableFuture<Integer> cf1 = CompletableFuture.supplyAsync(() -> {
System.out.println("First: " + Thread.currentThread().getName());
try {Thread.sleep(1000);}
catch (InterruptedException ix) {Thread.currentThread().interrupt();}
return 1;
});
ForkJoinPool.commonPool().awaitQuiescence(3000, TimeUnit.MILLISECONDS);
}
}
Zur Protokollierung einer eventuellen Ausnahme oder/oder der Arbeitsergebnisse erhält die von
allOf() gelieferte Aufgabe einen whenComplete() - Aufruf (vgl. Abschnitt 15.6.5.1). Aus einer
störungsfreien Ausführung resultierte das folgende Protokoll:
First: ForkJoinPool.commonPool-worker-1
Second: main
Third: ForkJoinPool.commonPool-worker-2
1
2
3
Abschnitt 15.6 Aufgabenparallelität mit Hilfe der Klasse CompletableFuture<T> 841
Aufgrund des Aufrufs von awaitQuiescence() beteiligt sich der main-Thread bei der Ausführung
unerledigter Aufgaben.
Wird im Programm für eine unbehandelte Ausnahme in einer Vorgängeraufgabe gesorgt,
if (true)
throw new IllegalStateException("Third task failed");
dann erhält das Funktionsobjekt der allOf() - Rückgabe als Parameterobjekt eine Ausnahme vom
Typ CompletionException, bei der man die zugrunde liegende Ausnahme mit getCause() erfragen
kann. Nun liefert das Programm Ergebnisprotokolle wie im folgenden Beispiel:
First: main
Second: ForkJoinPool.commonPool-worker-1
Third: ForkJoinPool.commonPool-worker-2
class AnyOf {
public static void main(String[] args) {
CompletableFuture<Integer> cf1 = CompletableFuture.supplyAsync(() -> {
try {Thread.sleep(200);}
catch (InterruptedException ix) {Thread.currentThread().interrupt();}
System.out.println("First: " + Thread.currentThread().getName());
return 1;
});
Ist die erste Aufgabenterminierung erfolgreich, dann gilt auch die Kombiaufgabe als erfolgreich
beendet, und per join() - Aufruf kann das Ergebnis entnommen werden. Im Beispielprogramm sorgt
die implizit aufgerufene Methode toString() für eine sinnvolle Object-Verarbeitung:
Third: ForkJoinPool.commonPool-worker-3
Second: ForkJoinPool.commonPool-worker-2
2
Task: java.util.concurrent.CompletableFuture@7ba4f24f[Completed normally]
Wird das erste Aufgabenende durch eine Ausnahme verursacht, dann endet die Kombiaufgabe mit
einer Ausnahme vom Typ CompletionException, die mit getCause() über die zugrunde liegende
Ausnahme befragt werden kann:
Third: ForkJoinPool.commonPool-worker-3
Second: ForkJoinPool.commonPool-worker-2
Exception
Class: java.util.concurrent.CompletionException
Cause: java.lang.IllegalStateException
Message: Third task failed
Task: java.util.concurrent.CompletableFuture@7ba4f24f[Completed exceptionally:
java.util.concurrent.CompletionException: java.lang.IllegalStateException: Third task
failed]
Mit der ersten Aufgabe aus der Serie ist die Kombiaufgabe terminiert (Completed normally oder
Completed exceptionally).
1
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Collections.html
2
Während die Thread-Sicherheit der von Collections produzierten Verpackungsklassen beim Verhalten ihrer Iterato-
ren nach wie vor eingeschränkt ist, wurde sie in Java 8 deutlich verbessert durch die Erweiterung der JCF-
Schnittstellen um default-Methoden zur Unterstützung zusammengesetzter Operationen (z. B. putIfAbsent() in
Map<K,V>).
844 Kapitel 15 Multithreading
• Methoden, die den Zustand der gesamten Kollektion beurteilen (z. B. isEmpty(), size()) lie-
fern kein perfekt verlässliches Ergebnis, wenn gleichzeitig Schreibzugriffe durch andere
Threads stattfinden.
• Die Zugangsexklusivität für einen einzigen Thread, die bei einer synchronizedMap() -
Rückgabe als meist unerwünschter Effekt des solitären Sperrobjekts besteht, ist bei der
Klasse ConcurrentHashMap<K,V> nicht realisierbar.
Insgesamt ist die Klasse ConcurrentHashMap<K,V> in der Regel gegenüber einer synchronisier-
ten HashMap<K,V> - Kollektion zu bevorzugen, wenn viele Threads auf eine Abbildung zugrei-
fen.
Zwei andere populäre Klassen aus dem Paket java.util.concurrent wurden schon im Abschnitt
15.2.4.1 vorgestellt: Die Kollektionsklassen ArrayBlockingQueue<E> und LinkedBlocking-
Queue<E> realisieren thread-sichere Warteschlangen nach dem FIFO-Prinzip (First-In-First-Out).
Weitere erwähnenswerte Klassen aus dem Paket java.util.concurrrent sind:
• ConcurrentSkipListMap<K,V>
Diese Klasse realisiert eine thread-sichere Kollektion zur Verwaltung von (Schlüssel-Wert) -
Paaren mit einem geordneten Schlüsseltyp. ConcurrentSkipListMap<K,V> steht zu
ConcurrentHashMap<K,V> im selben Verhältnis wie TreeMap<K,V> zu
HashMap<K,V>. ConcurrentSkipListMap<K,V> erfüllt (wie TreeMap<K,V>) die
Schnittstelle NavigableMap<K,V> und ist außerdem performanter als die von
Collections.synchronizedSortedMap() gelieferte synchronisierte Variante von
TreeMap<K,V>. Damit eignet sich ConcurrentSkipListMap<K,V>, wenn Thread-
Sicherheit für eine Abbildung mit geordneten Elementen gefragt ist, weil die Elemente in
Sortierordnung aufgesucht werden müssen, oder weil eine Methode aus der Schnittstelle
NavigableMap<K,V> benötigt wird. Wenn viele Threads auf eine Abbildung mit geordne-
tem Schlüsseltyp zugreifen, ist ConcurrentSkipListMap<K,V> in der Regel gegenüber ei-
ner synchronisierten TreeMap<K,V> - Kollektion zu bevorzugen.
• ConcurrentSkipListSet<E>
Diese (auf ConcurrentSkipListMap<K,V> basierende) Klasse realisiert eine thread-
sichere Menge mit geordneten Elementen. Sie erfüllt (wie TreeSet<E>) die Schnittstelle
NavigableSet<E> und ist außerdem performanter als die von
Collections.synchronizedSortedSet() gelieferte synchronisierte Variante von TreeSet<E>.
Damit eignet sich ConcurrentSkipListSet<E>, wenn Thread-Sicherheit für eine Menge mit
geordneten Elementen gefragt ist, weil die Elemente in Sortierordnung aufgesucht werden
müssen, oder weil eine Methode aus der Schnittstelle NavigableSet<E> benötigt wird.
• Mengenverwaltungskollektion auf ConcurrentHashMap<K,V> - Basis
Eine Klasse namens ConcurrentHashSet<E> sucht man im Paket java.util.concurrent
vergeblich. Von der ConcurrentHashMap<K,V> - Methode newKeySet() kann man aber
ein Kollektionsobjekt mit der gewünschten Funktionalität und Thread-Sicherheit erstellen
lassen. Man erhält eine Verpackung der Klasse ConcurrentHashMap<K,Boolean>, die
das Interface Set<K> implementiert. Alle Elemente haben Boolean.TRUE als Wert, z. B.:
Set<Integer> anwesend = new ConcurrentHashMap<Integer,Boolean>().newKeySet();
Abschnitt 15.8 Threads und JavaFX 845
• CopyOnWriteArrayList<E> CopyOnWriteArraySet<E>
Diese thread-sicheren Kollektionen zur Verwaltung einer Liste bzw. einer Menge taugen
nicht generell als Alternative zur Klasse ArrayList<E> bzw. zu einer Set<E> - Implemen-
tation, können aber gegenüber einer synchronisierten Kollektion einen Performanzvorteil
bieten, wenn auf eine von mehreren Threads benutzte Kollektion meist nur lesend und nur in
seltenen Fällen auch schreibend zugegriffen wird. Ihr Vorteil besteht darin, ohne Synchroni-
sierungsaufwand für Thread-Sicherheit sorgen zu können, was folgendermaßen gelingt
(Horstmann 2015, S. 328): Jeder Iterator erhält eine Referenz auf den aktuellen Array. Vor
einer späteren Änderung wird zunächst eine Kopie angelegt, die den alten Array ersetzt.
Iteratoren im Besitz einer Referenz auf den mittlerweile veralteten Zustand erfahren also
nichts von den Veränderungen, können aber ohne Konsistenzprobleme weiterarbeiten.
Joshua Bloch, der sich als JCF-Designer ein Urteil erlauben darf, kommt beim Vergleich der syn-
chronisierten Kollektionen mit der Konkurrenz aus dem Paket java.util.concurrent zum Ergebnis
(2018, S. 326):
Concurrent collections make synchronized collections largely obsolete. For example, use
ConcurrentHashMap in preference to Collections.synchronizedMap. Simply replacing synchro-
nized maps with concurrent maps can dramatically increase the performance of concurrent appli-
cations.
Weitere Informationen zur Verwendung von Kollektionen in Programm mit Multithreading finden
sich z. B. bei Goetz (2006, Abschnitt 5.2).
1
Diese Empfehlung stammt von der Webseite:
http://www.oracle.com/technetwork/articles/javase/swingworker-137249.html
846 Kapitel 15 Multithreading
Um die Mittelwertberechnung aus dem UI-Thread herauszuhalten, wird eine vom Common Pool
asynchron auszuführende Aufgabe verwendet (vgl. Abschnitt 15.6.1.1):
Button btnTask = new Button("Mittel von " + anz + " Zufallszahlen");
btnTask.setOnAction(event -> {
lblMessage.setText("Ergebnis:");
CompletableFuture.runAsync(() -> {
randomSum = 0.0;
for (long i = 0; i < anz; i++)
randomSum += Math.random();
Platform.runLater(() ->
lblMessage.setText("Ergebnis: " +
String.format("%7.4f", randomSum / anz)));
});
});
Die Klasse CompletableFuture<T> wird hier nicht in ihrem primären Anwendungsbereich ver-
wendet, erlaubt aber im Vergleich zu alternativen Lösungen eine besonders kompakte Formulie-
rung.
Nach der Fertigstellung seiner Berechnung möchte der Pool-Thread die text-Eigenschaft der Label-
Komponente ändern, um das Ergebnis zu präsentieren. Ein direkter Zugriff
lblMessage.setText("Ergebnis: " + String.format("%7.4f", d/anz));
aus dem Pool-Thread führt allerdings zu einem Laufzeitfehler. Mit Hilfe der statischen Methode
runLater() aus der Klasse Platform kann der Pool-Thread ein Runnable-Objekt erstellen, das bal-
digst im UI-Thread ausgeführt werden soll:
Platform.runLater(() ->
lblMessage.setText("Ergebnis: " + String.format("%7.4f", d/anz)));
So gelingt die Ergebnispräsentation unter Beachtung der Singlethreading-Regel.
Das vollständige IntelliJ-Projekt mit dem Beispielprogramm ist im folgenden Ordner zu finden:
...\BspUeb\Multithreading\JavaFX\RunLater
• Worker<V>
Diese Schnittstelle schreibt Eigenschaften (im Sinn von Abschnitt 13.5.1) für einen Hinter-
grundauftrag vor (z. B. state mit dem aktuellen Status, progress mit dem anteiligen Bear-
beitungsstand, value mit dem Ergebnis). Die möglichen Statusangaben sind durch die inner-
halb von Worker<V> definierte Enumeration State festgelegt (z. B. READY, SUC-
CEEDED).
• Task<V>
Diese Klasse implementiert das Interface Worker<V> und eignet sich für eine einmalig
auszuführende Aufgabe, weil ein Task<V> - Objekt mit einem terminalen Status
(CANCELLED, SUCCEEDED oder FAILED) nicht wiederverwendet werden kann.
• Service<V>
Ein Objekt der Klasse Service<V> enthält ein Task<V> - Objekt und kann nach dem Errei-
chen eines terminalen Zustands reaktiviert werden.
• ScheduledService<V>
Die von Service<V> abgeleitete Klasse ScheduledService<V> unterstützt die automatisier-
te Wiederverwendung gemäß Einsatzplan.
Alle in der Schnittstelle Worker<V> vorgeschriebenen JavaFX-Properties sind vom ReadOnly-Typ
(vgl. Abschnitt 13.5.1.2).
Name Klasse Beschreibung
title ReadOnlyStringProperty Beschreibt die Aufgabe
Mögliche Werte der Enumeration Worker.State (siehe
ReadOnlyObjectProperty Liste unter der Tabelle):
state <Worker.State> CANCELLED, FAILED, READY, RUNNING,
SHEDULED, SUCCEEDED
Diese Eigenschaft hat genau dann den Wert true, wann
running ReadOnlyBooleanProperty
der Status SCHEDULED oder RUNNING ist.
Damit kann eine Aufgabe darüber informieren, was sie
message ReadOnlyStringProperty
gerade tut.
Werte von 0 bis Double.MAX_VALUE stehen für das
totalWork ReadOnlyDoubleProperty gesamte Arbeitsvolumen, -1 steht für einen undefinierten
Wert.
Werte von 0 bis totalWork stehen für das bereits bewäl-
workDone ReadOnlyDoubleProperty tigte Arbeitsvolumen, -1 steht für einen undefinierten
Wert.
Werte von 0 bis 1 stehen für den bereits bewältigten An-
progress ReadOnlyDoubleProperty
teil der Arbeit, -1 steht für einen undefinierten Wert.
Diese Eigenschaft enthält das Ergebnis, wenn der Auf-
value ReadOnlyObjectProperty<V>
tragsstatus SUCCEEDED erreicht ist.
Kommt es während der Auftragsbearbeitung zu einem
ReadOnlyObjectProperty Ausnahmefehler (und damit zum Status FAILED), dann
exception <Throwable>
ist das Ausnahmeobjekt über die Eigenschaft exception
abrufbar.
Anschließend sind die Werte der Enumeration Worker.State und damit die möglichen Zustände
eines Hintergrundauftrags aufgelistet:1
1
https://openjfx.cn/javadoc/17/javafx.graphics/javafx/concurrent/Worker.State.html
848 Kapitel 15 Multithreading
• READY
Der Worker ist (re)initialisiert.
• SCHEDULED
Der Worker ist zur Bearbeitung eingeplant und wartet z. B. auf einen freien Pool-Thread.
• RUNNING
Unmittelbar vor Arbeitsbeginn erreicht ein Worker den Zustand RUNNING.
• SUCCEEDED
Der Worker hat seine Arbeit erfolgreich beendet, und seine Eigenschaft value enthält ein
gültiges Ergebnis.
• CANCELLED
Der Worker wurde durch die zur Schnittstelle Worker<V> gehörige Methode cancel() ab-
gebrochen.
• FAILED
Bei der Auftragsbearbeitung ist ein Fehler aufgetreten.
Aus Zeitgründen kann im Manuskript nur die Klasse Task<V> behandelt werden. Ein gründliche
Darstellung des gesamten JavaFX-Multithreading-APIs bietet z. B. Sharan (2015, Kapitel 27).
Im Beispielprogramm werden in der überschriebenen Task<V> - Methode call() die beiden folgen-
den Task<V> - Methoden aufgerufen, um JavaFX-Properties der Aufgabe zu aktualisieren (vgl. die
Properties-Liste im Abschnitt 15.8.2):
• protected void updateMessage(String message)
Diese Methode aktualisiert die Eigenschaft message.
• protected void updateProgress(long workDone, long max)
Diese Methode aktualisiert die Eigenschaften workDone, totalWork und progress, was die
Voraussetzungen für eine Fortschrittsanzeige im UI-Thread schafft. Kommt in der call() -
Methode der Task eine Schleife zum Einsatz, sind geeignete Aktualparameter für
updateProgress() schnell gefunden.
In der Bedienoberfläche des Beispielprogramms werden die Auftrags-Properties über die Binding-
Technik von JavaFX (vgl. Abschnitt 13.5.3) mit der text-Property eines Labels bzw. mit der pro-
gress-Property eines ProgressBar-Steuerelements verbunden und so sichtbar gemacht:
private RandomNumberCruncher task;
. . .
public void start(Stage stage) {
. . .
Button btnTask = new Button("Mittel von " + anz + " Zufallszahlen");
btnTask.setOnAction(event -> {
task = new RandomNumberCruncher();
CompletableFuture.runAsync(task);
lblMessage.textProperty().bind(task.messageProperty());
progBar.progressProperty().bind(task.progressProperty());
});
. . .
}
Bei jedem Aufruf der Klickbehandlungsmethode zum oberen Befehlsschalter wird ein frisches Ob-
jekt der Klasse RandomNumberCruncher benötigt, weil ein Task<V> - Objekt mit einem termina-
len Status (CANCELLED, SUCCEEDED oder FAILED) nicht wiederverwendbar ist (vgl. Ab-
schnitt 15.8.2).
Mit Hilfe der statischen CompletableFuture<T> - Methode runAsync() wird dafür gesorgt, dass
der Common Pool die Hintergrundtätigkeit asynchron ausführt (siehe Abschnitt 15.6.1.1). Die Klas-
850 Kapitel 15 Multithreading
se Task<V> erfüllt das vom runAsync() - Parameter geforderte Interface Runnable, weil sie von
ihrer Basisklasse FutureTask<T> die geforderte Methode run() erbt, von der die Methode call()
aufgerufen wird.
Ein Task<V> - Objekt kann mit der Methode cancel() aus einem beliebigem Thread aufgefordert
werden, seine Tätigkeit zu beenden, z. B. in der Klickbehandlungsmethode zum Abbrechen-
Schalter des Beispielprogramms:
Button btnCancel = new Button("Hintergrund-Task abbrechen");
btnCancel.setOnAction(event -> {if (task != null) task.cancel();});
Weil die Inter-Thread - Kooperation in Java im Wesentlichen auf Kooperation basiert (siehe Ab-
schnitt 15.3.2), muss ein Task<V> - Objekt im Rahmen seiner call() - Methode regelmäßig prüfen,
ob das Abbruchsignal gesetzt worden ist. Dazu steht die Task<V> - Methode isCancelled() bereit.
Befindet sich ein Thread in einem blockierenden Methodenaufruf (z. B. sleep()), dann hat cancel()
eine InterruptedException zur Folge, und der Exception-Handler muss unbedingt mit Hilfe der
Methode isCancelled() prüfen, ob ein Abbruch beantragt wurde.
Um darüber informiert zu werden, dass ein bestimmter Aufgabenstatus (z. B. SUCCEEDED) ein-
getreten ist, kann man ...
• die einer Task<V> - Ableitung die zugehörige Methode überschreiben:
protected void succeeded()
Die Methode succeeded() wird aufgerufen, wenn der Auftrag den Status SUCCEEDED er-
reicht. Das Beispielprogramm aktualisiert mit dieser Technik die Task<V> - Eigenschaft
message:
@Override
protected void succeeded() {
super.succeeded();
this.updateMessage("Ergebnis: " + this.getValue());
}
• die passende Task<V> - Methode aufrufen, um einen EventHandler<WorkerStateEvent>
zu vereinbaren:
public final void setOnSucceeded(EventHandler<WorkerStateEvent> value)
Der Methode setOnSucceeded() ist ein Objekt zu übergeben, das die Schnittstelle
EventHandler<WorkerStateEvent> implementiert. Die Schnittstellenmethode handle()
wird aufgerufen, wenn der Status SUCCEEDED erreicht ist, und erhält dann ein Parame-
terobjekt vom Typ WorkerStateEvent. Der Event-Handler kann z. B. per Lambda-
Ausdruck realisiert werden und hat dann Zugriff auf die umgebende Methode und Klasse,
z. B.:
task = new RandomNumberCruncher();
task.setOnSucceeded(e -> { . . . });
Bei beiden Techniken läuft die Methode zur Behandlung des Zustandswechsels im JavaFX Applica-
tion Thread ab.
Weitere Informationen zur Klasse Task<V> liefert u. a. die OpenJFX-Dokumentation.1
Das vollständige IntelliJ-Projekt mit dem Beispielprogramm ist im folgenden Ordner zu finden:
...\BspUeb\Multithreading\JavaFX\Task
1
https://openjfx.io/javadoc/17/javafx.graphics/javafx/concurrent/Task.html
Abschnitt 15.9 Reaktive Ströme (Flow-API) 851
15.9.1 Flow-Schnittstellen
Das mit Java 9 eingeführte Flow-API hält sich strikt an die RS-Spezifikation. Durch vier statischen
Mitgliedsschnittstellen der Klasse Flow im Paket java.util.concurrent (Modul: java.base) werden
die beteiligten Komponenten und ihre Verhaltenskompetenzen syntaktisch definiert.
Eine Erfüllung der von Schnittstellen vorgeschriebenen und vom Compiler kontrollierbaren syntak-
tischen Pflichten garantiert aber noch keine sinnvolle Implementation. Auf der folgenden Webseite
hat die Organisation hinter der RS-Spezifikation die einzuhaltenden semantischen Regeln dokumen-
tiert:
https://github.com/reactive-streams/reactive-streams-jvm/blob/v1.0.4/README.md#specification
1
https://www.reactive-streams.org/
852 Kapitel 15 Multithreading
15.9.1.1 Publisher<T>
Die funktionale (genau eine abstrakte Methode enthaltende) Schnittstelle Flow.Publisher<T>
schreibt die Methode subscribe() vor, die ein potentieller Interessent für die vom Publisher angebo-
tenen Daten aufruft:
@FunctionalInterface
public static interface Publisher<T> {
public void subscribe(Subscriber<? super T> subscriber);
}
Wenn der Aufruf nicht mit einer IllegalStateException scheitert, dann ruft der Publisher die
onSubscribe() - Methode des Subscribers auf (siehe Abschnitt 15.9.1.2) und übergibt ein Objekt,
das die Schnittstelle Subscription erfüllt (siehe Abschnitt 15.9.1.3). Dieses Objekt stellt eine (1:1) -
Beziehung her zwischen dem Subscriber und dem Publisher, der potentiell mehrere Subscriber ver-
sorgen kann.
Wie im weiteren Verlauf von Abschnitt 15.9 zu sehen sein wird, hat ein Publisher<T> - Objekt
diverse Aufgaben im Flow-API, sodass die Implementation bei weitem nicht so einfach ist, wie man
angesichts der minimalistischen Schnittstelle vermuten könnte. Im Abschnitt 15.9.3 wird die kon-
krete, das Interface Publisher<T> implementierende API-Klasse SubmissionPublisher<T> vorge-
stellt.
15.9.1.2 Subscriber<T>
Ein Subscriber muss die Schnittstelle Flow.Subscriber<T> implementieren, die vier abstrakte Me-
thoden enthält:
public static interface Subscriber<T> {
public void onSubscribe(Subscription subscription);
public void onNext(T item);
public void onError(Throwable throwable);
public void onComplete();
}
Nachdem ein Subscriber die subscribe() - Methode eines Publishers erfolgreich aufgerufen hat,
erhält er einer Aufruf seiner Methode onSubscribe(), an die als Parameter ein Objekt übergeben
wird, das die Schnittstelle Subscription erfüllt (siehe Abschnitt 15.9.1.3).
Hat ein Subscriber über die Subscription-Methode request() n Elemente bestellt, dann erhält er
aufgrund dieser Anforderung durch maximal n Aufrufe seiner Methode onNext() jeweils ein Daten-
element. Man kann hier von einem push-Betrieb sprechen. Der Subscriber kann beliebig viele re-
quest() - Aufrufe vornehmen und muss dabei keineswegs die komplette Auslieferung der bisherigen
Bestellungen abwarten.
Der Publisher kann durch einen Aufruf der Subscriber-Methode onComplete() signalisieren, dass
er keine Daten mehr liefern wird, und so das Subscription-Objekt als erledigt deklarieren. Der Sub-
scriber wird also in diesem Fall nicht im gewünschten, durch request() - Aufrufe artikuliertem Um-
fang beliefert.
Durch einen Aufruf der Subscriber-Methode onError(), der ein Ausnahmeobjekt übergeben wird,
signalisiert der Publisher, dass die Subscription-Beziehung aufgrund eines nicht behebbaren Feh-
lers beendet ist.
Wann der Publisher die Methoden onComplete() und onError() aufrufen sollte bzw. muss, ist in
der RS-Spezifikation geregelt.
Abschnitt 15.9 Reaktive Ströme (Flow-API) 853
15.9.1.3 Subscription
Der vom Publisher aufgerufenen Subscriber-Methode onSubscribe() - Methode wird als Parameter
ein Objekt übergeben, das die Schnittstelle Subscription erfüllt:
public static interface Subscription {
public void request(long n);
public void cancel();
}
Das Subscription-Objekt stellt eine (1:1) - Beziehung her zwischen dem Subscriber und dem Pub-
lisher, der potentiell mehrere Subscriber versorgen kann.
Über das Subscription-Objekt kann der Subscriber ...
• durch einen Aufruf der Subscription-Methode request() die Zustellung von n weiteren
Elementen beantragen oder
• durch einen Aufruf der Subscription-Methode cancel() die Beziehung beenden, wobei aus-
stehende Daten aber eventuell noch geliefert werden.
Insbesondere kann der Subscriber über die Subscription-Methode request() den Datenzufluss so
regulieren, dass keine Überforderung mit den bereits zu Beginn des Abschnitts 15.9 erwähnten un-
erwünschten Konsequenzen auftritt (Blockierung eines Publisher-Threads, Pufferüberlauf, Aus-
nahmefehler). Die ohne blockierende Methoden auskommende Datenzuflusskontrolle durch den
Subscriber ist ein zentrales Merkmal in der RS-Spezifikation und wird als Gegendruck (engl. back
pressure) bezeichnet.
Ein Subscriber kann die Methode request() jederzeit aufrufen, um sich rechtzeitig um Nachschub
zu bemühen. Es muss insbesondere nicht warten, bis alle zuvor angeforderten Daten geliefert wor-
den sind. Der Publisher addiert neue Anforderungen eines Subscribers zu den noch unerledigten
Lieferungen.
Wie ein Subscription-Objekt eine Bestellung bzw. einen Abbruch an das Publisher<T> - Objekt
weiterreichen soll, ist in den Flow-Schnittstellen nicht geregelt. Auch an dieser Stelle zeigt sich,
dass eine Publisher<T> - Implementation keine leichte Aufgabe ist. In unseren Beispielen werden
wir die in Java 9 enthaltene Implementation SubmissionPublisher<T> verwenden und auch auf
eine eigene Subscription-Implementation verzichten.
15.9.1.4 Processor<T,R>
Im Flow-API ist auch eine Komponente vorgesehen, die als Subscriber Daten vom Typ T bezieht
und diese nach einer Transformation zum Typ R als Publisher anbietet. Das Verhalten einer solchen
Komponente wird durch die Schnittstelle Processor<T,R> beschrieben, die als Erweiterung der
beiden Schnittstellen Subscriber<T> und Publisher<R> definiert ist und keine eigenen Methoden
ergänzt:
public static interface Processor<T,R> extends Subscriber<T>, Publisher<R> {
}
Eine implementierende Klasse muss also die Methoden aus den Schnittstellen Subscriber<T> und
Publisher<R> beherrschen.
854 Kapitel 15 Multithreading
backpressure
request(n) request(n)
Publisher Processor Subscriber
onNext(e) onNext(e)
Daten
Die Datenverarbeitung durch die beteiligten Komponenten (Publisher, Processor, Subscriber) er-
folgt asynchron, und die backpressure-Steuerung ist nicht blockierend:
• Die Abnehmer steuern den Zustrom von Elementen durch request() - Aufrufe, werden also
nicht überflutet. So werden Pufferüberläufe verhindert, die je nach Programmierung zu blo-
ckierten Lieferanten-Threads, zu einem Datenverlust, zu Speichermangel oder zu einem
Ausnahmefehler führen.
• Andererseits wird ein Lieferant aber auch nicht durch einen langsamen Abnehmer blockiert.
Wenn ein Publisher z. B. die angebotenen Elemente selbst herstellt, kann er sein Lager fül-
len, obwohl gerade keine Bestellungen vorliegen.
• Wenn sich ein Abnehmer durch rechtzeitige request() - Aufrufe einen lokalen Vorrat gesi-
chert hat, kann er bei stockender Versorgung eine Zeit lang weiterarbeiten.
• Durch die Prinzipien der reaktiven Stromverarbeitung werden Puffer nicht überflüssig, aber
man kommt mit einer beschränkten Größe aus, ohne eine Überflutung befürchten zu müs-
sen.
In der Abbildung fehlen der Einfachheit halber einige Details:
• Die Vermittlungstätigkeit der Subscription-Objekte wird unterschlagen.
• Zwischen einem Lieferanten und einem Abnehmer können mehrere Processor-Objekte tätig
sein.
• Ein Lieferant kann mehrere Abnehmer beliefern (siehe Abschnitt 15.9.3).
1
Vorbild für die Abbildung: https://blog.softwaremill.com/how-not-to-use-reactive-streams-in-java-9-7a39ea9c2cb3
Abschnitt 15.9 Reaktive Ströme (Flow-API) 855
DemoSubscriber() {
subNo = ++subCum;
}
@Override
public void onSubscribe(Flow.Subscription subscr) {
System.out.println("Subscriber " + subNo +
" hat ein Subscription-Objekt erhalten (Thread: " +
Thread.currentThread().getName() + ")");
subscription = subscr;
subscription.request(1);
}
@Override
public void onNext(Integer ipar) {
System.out.println("Subscriber " + subNo + " hat erhalten: " +
ipar + " (Thread: " + Thread.currentThread().getName() + ")");
try {Thread.sleep(subNo*1000); // Rumoren von individueller Dauer
} catch (InterruptedException ie) {return;}
subscription.request(1);
}
@Override
public void onError(Throwable ex) {
System.out.println("Der Subscriber " + subNo +
" hat einen onError() - Aufruf erhalten: " + ex.getMessage());
}
@Override
public void onComplete() {
System.out.println("Der Subscriber " + subNo +
" hat einen onComplete() - Aufruf erhalten.");
}
}
Die Instanzen der Klasse erhalten eine Nummer, die in Protokolleinträgen Verwendung findet.
856 Kapitel 15 Multithreading
In onSubscribe() merkt sich ein Subscriber die erhaltene Referenz auf das Subscription-Objekt
und fordert per request() die erste Lieferung an.
In onNext() wird über die Thread-Methode sleep() ein kurzzeitige Verarbeitung von individueller
Dauer simuliert. Allzu lange sollte onNext() den Pool-Thread, in dem der Aufruf stattfindet, aber
nicht aufhalten. Anschließend wird per request() die nächste Lieferung angefordert.
In der Hauptklasse
import java.util.concurrent.*;
class PublisherDemo {
public static void main(String[] args) throws InterruptedException {
SubmissionPublisher<Integer> publisher = new SubmissionPublisher<>();
publisher.subscribe(new DemoSubscriber());
publisher.close();
System.out.println("Der Publisher ist geschlossen");
gung des main-Threads alle noch anstehenden Arbeiten auszuführen, sofern die maximal erlaubte
Zeit nicht überschritten wird.
Aus didaktischen Gründen wird der main-Thread durch einen Kurzschlaf daran gehindert, sich an
den Restarbeiten zu beteiligen. Dagegen ist eigentlich nichts einzuwenden. In den Protokollausga-
ben des Beispielprogramms soll aber dokumentiert werden, dass die Klasse
SubmissionPublisher<T> bei Verwendung des parameterfreien Konstruktors den Common
Threadpools aus dem Fork-Join - Framework verwendet:
Die Auslieferung beginnt.
Der Publisher ist geschlossen
Subscriber 1 hat ein Subscription-Objekt erhalten (Thread: ForkJoinPool.commonPool-worker-1)
Subscriber 1 hat erhalten: 1 (Thread: ForkJoinPool.commonPool-worker-1)
Subscriber 1 hat erhalten: 2 (Thread: ForkJoinPool.commonPool-worker-1)
Subscriber 1 hat erhalten: 3 (Thread: ForkJoinPool.commonPool-worker-1)
Subscriber 1 hat erhalten: 4 (Thread: ForkJoinPool.commonPool-worker-1)
Subscriber 1 hat erhalten: 5 (Thread: ForkJoinPool.commonPool-worker-1)
Der Subscriber 1 hat einen onComplete() - Aufruf erhalten.
Von den alternativen Techniken zur Vermeidung von verlorenen Lieferungen soll noch die
SubmissionPublisher-Methode estimateMaximumLag() erwähnt werden:
public int estimateMaximumLag()
Sie schätzt über alle Subscriber die maximale Anzahl von Elementen, die produziert, aber noch
nicht konsumiert worden sind. Bei einer exakt am Bestellumfang orientierten Produktion kann man
diagnostizieren, ob alle Bestellungen ausgeführt worden sind.
class PublisherDemo {
public static void main(String[] args) throws InterruptedException {
ExecutorService es = Executors.newCachedThreadPool();
SubmissionPublisher<Integer> publisher = new SubmissionPublisher<>(es, 2);
publisher.subscribe(new DemoSubscriber());
publisher.subscribe(new DemoSubscriber());
wird der von newCachedThreadPool() erstellten Threadpool aufgefordert, seine Tätigkeit geordnet
einzustellen, d. h.:
858 Kapitel 15 Multithreading
Eine mögliche Maßnahme zur Vermeidung der Blockade besteht darin, dass ein an weiteren Liefe-
rungen nicht mehr interessierter Subscriber sein Subscription-Objekt per cancel() - Aufruf termi-
niert:
if (subNo == 1)
subscription.request(1);
else
subscription.cancel();
Daraufhin erhält der abnahmewillige Subscriber alle verfügbaren Elemente:
Die Auslieferung beginnt.
Subscriber 2 hat ein Subscription-Objekt erhalten (Thread: pool-1-thread-2)
Subscriber 1 hat ein Subscription-Objekt erhalten (Thread: pool-1-thread-1)
Subscriber 2 hat erhalten: 1 (Thread: pool-1-thread-2)
Subscriber 1 hat erhalten: 1 (Thread: pool-1-thread-1)
Subscriber 1 hat erhalten: 2 (Thread: pool-1-thread-1)
Der Publisher ist geschlossen
Subscriber 1 hat erhalten: 3 (Thread: pool-1-thread-1)
Subscriber 1 hat erhalten: 4 (Thread: pool-1-thread-1)
Subscriber 1 hat erhalten: 5 (Thread: pool-1-thread-1)
Der Subscriber 1 hat einen onComplete() - Aufruf erhalten.
Abschnitt 15.9 Reaktive Ströme (Flow-API) 859
Damit ein Publisher nicht auf den cancel() - Aufruf durch einen aussteigenden Subscriber angewie-
sen ist, kann er zur Auslieferung der Elemente statt der Methode submit() die Alternative offer()
benutzen, z. B. in der folgenden Überladung:
public int offer(T item, long timeout, TimeUnit unit,
BiPredicate<Flow.Subscriber<? super T>,? super T> onDrop)
Wenn ein Element an einen Subscriber während einer timeout-Periode nicht ausgeliefert und auch
nicht in seinem Puffer gelagert werden konnte, dann wird eine Behandlungsmethode für verlorene
Elemente aufgerufen, sofern der dritte Parameter von null verschieden ist. Sie wird per Parameter
über den betroffenen Subscriber und das verlorene Element informiert. Ihre boolean-Rückgabe ent-
scheidet darüber, ob ein weiterer Auslieferungsversuch per offer() unternommen werden soll (true)
oder nicht (false). Im folgenden Beispiel wird auf die Wiederholung verzichtet, ein Protokolleintrag
vorgenommen und der betroffene Subscriber per onError() informiert:
for (int i = 1; i <= 5; i++)
publisher.offer(i, 2, TimeUnit.SECONDS, (sub, t) -> {
sub.onError(new RuntimeException("Element verloren"));
System.out.println("Element " + t + " ging verloren");
return false;
});
Wenn der Puffer lediglich zwei Elemente aufnimmt, und von zwei Subscribern einer nach der ers-
ten Lieferung weder eine neue Anforderung per request() - Aufruf vornimmt, noch per cancel() -
Aufruf sein Subscription-Objekt terminiert, dann wird der aktive Subscriber versorgt, und die we-
der auslieferbaren noch speicherbaren Elemente 4 und 5 für den passiven Subscriber werden abge-
schrieben:
Die Auslieferung beginnt.
Subscriber 2 hat ein Subscription-Objekt erhalten (Thread: pool-1-thread-2)
Subscriber 1 hat ein Subscription-Objekt erhalten (Thread: pool-1-thread-1)
Subscriber 2 hat erhalten: 1 (Thread: pool-1-thread-2)
Subscriber 1 hat erhalten: 1 (Thread: pool-1-thread-1)
Subscriber 1 hat erhalten: 2 (Thread: pool-1-thread-1)
Subscriber 1 hat erhalten: 3 (Thread: pool-1-thread-1)
Der Subscriber 2 hat einen onError() - Aufruf erhalten: Element verloren
Element 4 ging verloren
Subscriber 1 hat erhalten: 4 (Thread: pool-1-thread-1)
Subscriber 1 hat erhalten: 5 (Thread: pool-1-thread-1)
Der Subscriber 2 hat einen onError() - Aufruf erhalten: Element verloren
Element 5 ging verloren
Der Publisher ist geschlossen
Der Subscriber 1 hat einen onComplete() - Aufruf erhalten.
Außerdem erhält der passive Subscriber einen onError() - Aufruf für jedes verlorene Element. Laut
RS-Spezifikation sollte nach einem onError() - Aufruf eigentlich keine weitere Kommunikation
mehr stattfinden:1
Once a terminal state has been signaled (onError, onComplete) it is REQUIRED that no further
signals occur. The intent of this rule is to make sure that onError and onComplete are the final
states of an interaction between a Publisher and Subscriber pair.
Insofern verhält sich die Klasse SubmissionPublisher<T> nicht ganz an die RS-Spezifikation.
1
https://github.com/reactive-streams/reactive-streams-jvm/blob/v1.0.4/README.md#specification
860 Kapitel 15 Multithreading
15.10.1 Daemon-Threads
Im Zusammenhang mit dem ForkJoinPool - Threadpool sind uns die sogenannte Daemon-Threads
bereits begegnet. Sie unterscheiden sich von den sogenannten Benutzer-Threads durch ...
• eine niedrigere Priorität,
• die Unfähigkeit, ein Programm am Leben zu erhalten.
Die JVM verwendet Daemon-Threads für Arbeiten, die nur bei Verfügbarkeit von ungenutzter Re-
chenzeit ausgeführt werden sollen. Ein Beispiel ist der Garbage Collector - Thread, der obsolet ge-
wordene (nicht mehr referenzierte) Objekte abräumt.
Um das Terminieren von Daemon-Threads braucht man sich in der Regel nicht zu kümmern, denn
ein Java-Programm endet, sobald alle Benutzer-Threads ihre Tätigkeit eingestellt haben, und folg-
lich nur noch Daemon-Threads vorhanden sind. Ein Daemon-Thread muss also auf ein abruptes
Ende gefasst sein, wobei selbst ein finally-Block nicht mehr ausgeführt wird.
Mit der Thread-Methode setDaemon() lässt sich ein Benutzer-Thread dämonisieren, was vor dem
Aufruf seiner start() - Methode geschehen muss. Auf diese Weise lässt sich bei einer JavaFX-
Anwendung verhindern, dass die virtuelle Maschine nach dem Schließen der letzten Bühne (des
letzten Fensters) wegen eines Benutzer-Threads noch weiterläuft.
Ein Thread erbt den Daemon-Status des Threads, in dem er erstellt wurde. Mit der Thread-
Methode isDaemon() lässt sich feststellen, ob ein Daemon-Thread vorliegt.
15.10.2 Thread-Gruppen
Bei den in manchen Lehrbüchern behandelten Thread-Gruppen (siehe z. B. Krüger & Hansen 2014,
S. 851f) beschränken wir uns darauf, Joshua Bloch (2008, S. 288) zu zitieren:
Thread groups are obsolete.
2) Das folgende Programm startet einen Thread aus der Klasse Schnarcher, lässt ihn 3 Sekunden
lang gewähren und versucht dann, den Thread zu beenden:
class Prog {
public static void main(String[] args) throws InterruptedException {
Schnarcher st = new Schnarcher();
st.start();
System.out.println("Thread gestartet");
Thread.sleep(3000);
while(st.isAlive()) {
st.interrupt();
System.out.println("\nThread beendet!?");
Thread.sleep(1000);
}
}
}
Der Schnarcher-Thread führt in seiner run() - Methode eine while-Schleife aus, prüft bei jedem
Umlauf zunächst, ob das Interrupt-Signal gesetzt ist, und beendet sich ggf. per return. Falls keine
Einwände gegen seine weitere Tätigkeit bestehen, schreibt der Thread nach einer kurzen Wartezeit
ein Sternchen auf die Konsole:
class Schnarcher extends Thread {
@Override
public void run() {
while (true) {
if(isInterrupted())
return;
try {sleep(100);} catch (InterruptedException ie) {}
System.out.print("*");
}
}
}
Wie die Ausgabe eines Programmlaufs zeigt, bleiben die interrupt()-Aufrufe wirkungslos:
Thread gestartet
******************************
Thread beendet!?
*********
Thread beendet!?
**********
Thread beendet!?
***********
Thread beendet!?
*********
. . .
Wie ist das Verhalten zu erklären, und wie sorgt man für ein zuverlässiges Beenden des Threads?
3) Warum ist der Modifikator volatile für lokale Variablen überflüssig (und verboten)?
16 Netzwerkprogrammierung
Konform zu ihrem bereits 1982 formulierten Leitsatz The Network is the Computer hat sich die
(mittlerweile von der Firma Oracle übernommene) Firma Sun Microsystems beim Java-Design er-
folgreich darum bemüht, leistungsfähige und dabei möglichst einfach realisierbare Netzwerkan-
wendungen zu ermöglichen.
Die Java-Standardbibliothek enthält zahlreiche Klassen zur Netzwerkprogrammierung, wobei man
zwischen einem Zugriff auf Netzwerkressourcen über Standardprotokolle der Anwendungsebene
(z. B. HTTP) und einer Programmierung auf elementaren Protokollebenen (z. B. TCP/IP) mit einer
entsprechend weiterreichenden Kontrolle wählen kann. Wir beschränken uns auf einfache Anwen-
dungen und überlassen eine gründliche Behandlung der Netzwerkprogrammierung mit Java den
spezialisierten Monographien (z. B. Harold 2014).
In diesem Manuskript können insbesondere wichtige Einsatzfelder für die server-basierte Netz-
werkprogrammierung nicht behandelt werden, z. B.:
• Webdienste
Ein Webdienst bietet ein API (Application Programming Interface) an und ist zur Benut-
zung durch andere Programme konzipiert, wobei die kommunizierenden Programme ...
o meist auf unterschiedlichen Rechnern laufen,
o oft durch unterschiedliche Techniken (z. B. Programmiersprachen) realisiert sind,
o zum Datenaustausch meist das XML- oder das JSON-Format benutzen.
Eine Anwendung auf einem Rechner in einem Reisebüro kann sich z. B. vom Webdienst ei-
ner Fluglinie Daten über Verbindungen, freie Plätze, Preise etc. beschaffen. Ein Webdienst
kümmert sich nicht um die Präsentation der Daten in einer Bedienoberfläche. Man kann von
einer M2M-Kommunikation (Machine-to-Machine) sprechen. Im kommerziellen Bereich ist
die Bezeichnung B2B-Kommunikation (Business-to-Business) üblich.
Zur Realisation von Webdiensten wird meist eine von den folgenden Techniken eingesetzt:
o Das SOAP-Protokoll (Simple Object Access Protocol) verwendet maschinenlesbare
Verträge in der Web Services Description Language (WSDL).
o Die REST-Prinzipien (Representational State Transfer) beschreiben Anforderungen
an einen Webdienst. Als Protokoll wird meist HTTP verwendet.
Wenn die realisierenden Techniken SOAP bzw. REST bei der Begriffsbestimmung wegge-
lassen werden, dann kann man jedes per Netz (auf einem bestimmten Rechner, an einem be-
stimmten Port, siehe unten) erreichbare und zur M2M-Kommunikation geeignete Programm
(z.B. einen NTP-Server (Network Time Protocol), der die Uhrzeit liefert) als Webdienst be-
zeichnen.
• Webanwendungen
Webanwendungen sind für die interaktive Nutzung mit Hilfe eines Web-Browsers konzi-
piert. Es werden (oft in Kooperation mit einer Datenbankanwendung) HTML-Seiten mit an-
geforderten Daten oder Berechnungen individuell erstellt und dann zum Browser gesendet.
In der Regel wird die HTML-Syntax ergänzt durch Anweisungen in der Programmierspra-
che JavaScript, durch eine optische Gestaltung über CSS-Klassen und durch weitere Web-
techniken (z.B. SVG). Moderne Browser sowie JavaScript- und CSS-Bibliotheken ermögli-
chen die Erstellung von ergonomischen Bedienoberflächen. Man kann von einer H2M-
Kommunikation (Human-to-Machine) sprechen. Im kommerziellen Bereich ist die Bezeich-
nung B2C-Kommunikation (Business-to-Customer) üblich. Mit dem Java Servlet API, den
Java Server Pages und den Java Server Faces bietet das Java-Universum attraktive Optio-
nen zur Entwicklung von Webanwendungen.
864 Kapitel 16 Netzwerkprogrammierung
R2 E
A
F
B R1
G
C R3
H
Von der Anwendungsebene (z.B. Versandt einer E-Mail über einen SMTP-Server (Simple Mail
Transfer Protocol)) bis zur physikalischen Ebene (z.B. elektromagnetische Wellen auf einem
Ethernet-Kabel) sind zahlreiche Übersetzungen vorzunehmen bzw. Aufgaben zu lösen, jeweils un-
ter Beachtung der zugehörigen Regeln. Im nächsten Abschnitt werden die beteiligten Ebenen mit
ihren jeweiligen Protokollen behandelt, wobei wir uns auf Themen mit Relevanz für die Anwen-
dungsentwicklung konzentrieren.
6. Präsentation
Hier geht es z. B. um die Verschlüsselung oder Komprimierung von Daten. Die TCP/IP - Protokoll-
familie kümmert sich nicht darum, sondern überlässt derlei Arbeiten den Anwendungen.
1
https://de.wikipedia.org/wiki/Liste_der_standardisierten_Ports
https://de.wikipedia.org/wiki/Port_(Protokoll)
868 Kapitel 16 Netzwerkprogrammierung
Klient Serverantwort
HELO mainpc.client-dom.de 250 smtp.srv-dom.de Hello
MAIL FROM:[email protected] 250 2.1.0 Sender Ok
RCPT TO:[email protected] 250 2.1.5 Recipient Ok
DATA 354 Start mail input; end with <CRLF>.<CRLF>
From: [email protected]
To: [email protected]
Subject: Thema
Dies ist der Inhalt.
. 250 2.6.0 . . . Queued mail for delivery
QUIT 221 Bye
Der Mailempfänger lässt sich hoffentlich nicht durch die vorgegaukelte Absenderadresse täuschen:
Abschnitt 16.1 Elementare Konzepte der Netzwerktechnologie 869
Vor dem uralten „Trick“ mit einer gefälschten Mail-Absenderadresse müssen IT-Laien immer noch
gewarnt werden, weil Kriminelle mit bescheidenen IT-Kennnissen, aber einer gewissen Geschick-
lichkeit beim sogenannten Social Engineering täglich versuchen, auf diese Weise an das Geld von
Opfern heranzukommen, z. B.:
Noch häufiger als das SMTP-Protokoll kommt im Internet auf Anwendungsebene das HTTP-
Protokoll (Hypertext Transfer Protocol) für den Austausch zwischen Webserver und -Browser zum
Einsatz, heutzutage fast immer in der verschlüsselnden Variante HTTPS. Im Manuskript wird die
Bezeichnung HTTP-Protokoll verwendet, wenn eine Aussage von der Verschlüsselungstechnik un-
abhängig ist.
Ein Domänenname startet auf der rechten Seite mit dem Namen einer Top-Level - Domäne (im
Beispiel: de). Von rechts nach links folgt mindestens ein Subdomänenname. Im Beispiel sind zwei
Subdomänennamen vorhanden: www.egal. Zwischen den Namenssegmenten steht ein Punkt. Der
einleitende Subdomänenname bezeichnet oft einen konkreten Rechner.1
Die URL-Parameter dienen zur Anforderung von individuellen bzw. dynamisch erstellten Websei-
ten unter Verwendung der GET-Methode aus dem HTTP - Protokoll (siehe Abschnitt 16.2.3.2).
Durch das Zeichen & getrennt dürfen auch mehrere Parameter (als Name-Wert - Paare) angegeben
werden, z. B.:
?vorname=Kurt&nachname=Schmidt
Bei vielen Webseiten kann am Ende der Pfadangabe durch # eingeleitet noch ein seiteninternes
Sprungziel angegeben werden, z. B.:
https://www.w3.org/People/Berners-Lee/#Bio
In der deutschen Sprachpraxis wird meist über die URL zu einer Webressource gesprochen, was
auch der Übersetzungsdienstleiter http://www.leo.org/ bestätigt:
Im Manuskript wird unter Beachtung der Grammatik von dem URL (Uniform Resource Locator)
gesprochen, nach Möglichkeit aber eine geschlechtsneutrale Formulierung gewählt (z.B. der Plural
die URLs).
16.2.2.1 URL
In vielen Fällen kommt man in Java-Programmen beim Zugriff auf Internet-Ressourcen mit der
einfachen Klasse URL aus dem Paket java.net aus. Das folgende Programm fordert per URL-
Objekt die Homepage der Universität Trier an und listet die ersten 7 Zeilen des HTML-Codes auf:
1
Zu den Begriffen Domain, Subdomain und Host siehe:
• https://datatracker.ietf.org/doc/html/rfc1034
• https://datatracker.ietf.org/doc/html/rfc1035
Abschnitt 16.2 Internet-Ressourcen per HTTP-Protokoll nutzen 873
import java.net.*;
import java.io.*;
class URLDemo {
public static void main(String[] args) {
try (BufferedReader br = new BufferedReader(new InputStreamReader(
(new URL("https://www.uni-trier.de/")).openStream()))) {
String s;
for (int i = 0; i < 7; i++) {
s = br.readLine();
if (s == null)
break;
System.out.println(s);
}
} catch (MalformedURLException e) {
e.printStackTrace();
} catch (IOException e ) {
e.printStackTrace();
}
}
}
Bei Verwendung der traditionellen Java-Klassen zur Nutzung des HTTP-Protokolls kommt dieselbe
Datenstromtechnik zum Einsatz, die wir im Kapitel 14 über den seriellen Datenaustausch mit dem
lokalen Dateisystem kennengelernt haben. Wir werden daher im aktuellen Abschnitt bei den meis-
ten Beispielprogrammen neben dem Paket java.net mit den API-Klassen zur Netzwerkprogrammie-
rung auch das Paket java.io mit den Datenstromklassen importieren. Beide Pakete gehören ab Java
9 zum Modul java.base.
Wird dem URL-Konstruktor ein String-Objekt mit irregulärer Syntax übergeben, dann ist eine
MalformedURLException fällig, auf die sich ein Programm vorbereiten muss.
Die URL-Methode openStream() öffnet die Verbindung zur Ressource und gibt ein InputStream-
Objekt für den Zugriff auf die gelieferten Bytes zurück. Ein openStream() - Aufruf ist eine Abkür-
zung für die folgende Aufrufsequenz:
openConnection().getInputStream()
Die Methode openConnection() liefert eine Referenz auf das im Hintergrund tätige Objekt der
Klasse URLConnection. Im Abschnitt 16.2.2.2 werden wir diese Klasse direkt verwenden. Bei
Verbindungsproblemen wirft openStream() eine IOException, die vom Aufrufer in einer catch-
Klausel behandelt oder im Methodenkopf angekündigt werden muss.
Im Beispiel wandelt ein InputStreamReader-Objekt die angelieferten Bytes in Unicode-Zeichen.
Um zeilenweise mit der Methode readLine() zugreifen zu können, schaltet man in der Regel noch
einen BufferedReader hinter den InputStreamReader, so dass sich die folgende Pipeline ergibt:
Buffered- Input-
HTTP-
String Stream- InputStream Bytes
Reader Verbindung
Reader
An Stelle der Klasse BufferedReader kann auch die Klasse Scanner eingesetzt werden (siehe Ab-
schnitt 14.5).
Ein Programmlauf am 16.04.2022 liefert das folgende Ergebnis:
874 Kapitel 16 Netzwerkprogrammierung
<!DOCTYPE html>
<html dir="ltr" lang="de-DE">
<head>
<meta charset="utf-8">
<!--
This website is powered by TYPO3 - inspiring people to share!
Ein Netzwerkeingabestrom sollte möglichst früh geschlossen werden, um die beteiligten Ressour-
cen freizugeben. Im Beispielprogramm wird dazu die mit Java 7 eingeführte automatische Ressour-
cenfreigabe verwendet (siehe Abschnitt 11.10.2). Das Schließen eines Netzwerkeingabestroms ist
natürlich nur dann von Bedeutung, wenn das Programm anschließend aktiv bleibt.
Ob mit dem Schließen des Eingabestroms auch die Verbindung zum HTTP-Server beendet wird,
hängt von Konfigurationen auf den beiden Seiten der Verbindung ab. Zur Klärung der Frage, wel-
che TCP-Verbindungen ein Programm aktuell offen hält, eignet sich unter Windows z. B. das
Werkzeug TCPView1. Mit Hilfe dieses Programms sollen anschließend einige Beobachtungen zur
Verwendung des HTTP-Protokolls angestellt werden.
Während der kurzen Aktivität des obigen Programms zeigt TCPView 4.17, dass eine TCPv4 -
Verbindung mit der Adresse 136.199.189.15 (Port: 443) besteht:
Der HTTP-Server hält die Verbindung offen, weil er noch Daten ausliefern möchte. Nach
dem Programmende wird die Verbindung geschlossen.
1
Das von Mark Russinovich entwickelte Programm ist auf der Microsoft-Webseite
http://technet.microsoft.com/en-us/sysinternals/bb897437.aspx
kostenlos verfügbar.
Abschnitt 16.2 Internet-Ressourcen per HTTP-Protokoll nutzen 875
• Durch das Schließen des Stroms wechselt die Verbindung in den Zustand Time Wait:
In dieser Situation hat der Klient die Verbindung beendet und kann sie keinesfalls für eine
neue Anforderung an den Server nutzen. Der beteiligte lokale Port wird aber noch nicht neu
vergeben, um zu verhindern, dass verspätet eintreffende Pakete einem neuen Port-Nutzer
falsch zugestellt werden. Der Zustand Time Wait besteht je nach Einstellung zwischen 2
und 4 Minuten.
Ab der Version 1.1 des HTTP-Protokolls sind mehrfache Request/Response - Paare unter Verwen-
dung derselben TCP-Verbindung möglich, um die Performanz zu fördern und den Netzwerkverkehr
zu begrenzen (Keep Alive). Wenn ein Klientenprogramm eine Server-Antwort vollständig gelesen
oder den Netzwerkeingabestrom per close() - Aufruf geschlossen hat, dann versucht Java per Vor-
einstellung, die TCP-Verbindung in einen definierten Zustand zu bringen und für die Wiederver-
wendung bei späteren Anforderungen in einen Verbindungs-Cache aufzunehmen. Dabei spielen
auch das vom Server als Hinweis gesendete Header-Feld Keep-Alive und die Java-
Systemeigenschaft http.keepAlive eine Rolle.1 Nach Ablauf der Keep-Alive - Zeit wechselt die
TCP-Verbindung in den Zustand Time Wait.
Mit den folgenden URL-Methoden lassen sich wichtige URL-Bestandteile ermitteln:
getProtocol(), getHost(), getPort(), getPath(), getFile(), getQuery()
Mit der Klasse URL kann man nicht nur HTML-Dateien von einem HTTP-Server beziehen, son-
dern auch beliebige andere Dateien. Im folgenden Programm wird die Datei Java17.pdf mit dem
ZIMK-Manuskript zu Java 17
https://www.uni-trier.de/fileadmin/urt/doku/java/v170/Java17.pdf
vom Webserver der Universität Trier heruntergeladen:
import java.net.*;
import java.io.*;
class FileDownload {
public static void main(String[] args) {
try {
String urlString =
"https://www.uni-trier.de/fileadmin/urt/doku/java/v170/Java17.pdf";
URL url = new URL(urlString);
1
https://docs.oracle.com/javase/8/docs/technotes/guides/net/http-keepalive.html
876 Kapitel 16 Netzwerkprogrammierung
e.printStackTrace();
}
}
}
Wir benötigen nur byte-orientierte Ströme, kombinieren diese aber zur Transportbeschleunigung
jeweils mit einem Puffer (siehe Abschnitt 14.3).
Die URL-Methode getPath() liefert im Beispiel die Zeichenfolge:
/fileadmin/urt/doku/java/v170/Java17.pdf
Um eine Datei mit dem Namen Java17.pdf im aktuellen Verzeichnis des Programms anzulegen,
wird aus der Zeichenfolge ein File-Objekt erzeugt und mit getName() befragt.
16.2.2.2 URLConnection
Erhält ein Objekt der angenehm einfach verwendbaren Klasse URL den Auftrag openStream(),
dann wird hinter den Kulissen ein Objekt der Klasse URLConnection über seine Methode
getInputStream() gebeten, einen Netzwerkeingabestrom zu erstellen, der Daten vom Server be-
schaffen kann. Durch den expliziten Einsatz der Klasse URLConnection gewinnt man flexiblere
Möglichkeiten, Anforderungen zu formulieren und die Antworten eines Servers auszuwerten.
Bei Verwendung des HTTP - Protokolls kann man zur Realisation des Request/Response - Mus-
ters ...
• über Request-Header eine Anforderung näher spezifizieren. Wer z. B. an einer Ressource
nur bei einem entsprechend aktuellen Änderungsdatum interessiert ist, kann dies per If-
Modified-Since – Feld ausdrücken.
• über Response-Header Meta-Informationen über den von einem Server gelieferten Inhalt
erhalten. Im Feld Content-Type wird z. B. das Format einer Ressource beschrieben.
Die Klasse URLConnection hält Methoden bereit, um die Header-Felder zu besetzen bzw. auszu-
werten (siehe unten). Eine Liste mit allen Header-Feldern der von URLConnection unterstützten
HTTP-Version 1.1 findet sich auf der folgenden Webseite des World Wide Web Consortiums
(W3C):
http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html
Zum Erzeugen einer URLConnection steht kein öffentlicher Konstruktor zur Verfügung. Man ruft
stattdessen die openConnection() - Methode eines URL-Objekts auf.
Im folgenden Programm wird die Webseite
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified
angefordert, sofern sie seit dem 16.04.2022, 00:00:00 GMT geändert worden ist:
import java.net.*;
import java.io.*;
import java.text.*;
import java.util.*;
class URLConnectionDemo {
public static void main(String[] args) {
try {
String urlString =
"https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified";
urlConn.connect();
System.out.println("\nResponse-Header:");
System.out.println(" Content-Type:\t\t" + urlConn.getContentType());
System.out.println(" Content-Length:\t" + urlConn.getContentLength());
System.out.println(" Expiration:\t\t" + sdf.format(new Date(urlConn.getExpiration())));
System.out.println(" Last Modified:\t" +
sdf.format(new Date(urlConn.getLastModified())));
Die URL-Methode openConnection() baut noch keine Verbindung zum Server auf, sondern liefert
ein URLConnection-Objekt und schafft so die Möglichkeit, die zum URL-Objekt gehörige Anfor-
derung über Request-Header näher zu spezifizieren. Generell dient dazu die Methode
public void setRequestProperty(String key, String value)
Hier wird z. B. das If-Modified-Since – Feld gesetzt:1
urlConn.setRequestProperty("If-Modified-Since","Sat, 16 Apr 2022 02:00:00");
Einige Felder können aber auch mit speziellen URLConnection - Methoden gesetzt werden, z. B.
das Feld If-Modified-Since mit der Methode setIfModifiedSince():
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Date date = sdf.parse("2022-04-16 00:00:00");
urlConn.setIfModifiedSince(date.getTime());
Im Beispiel wird eine Klartext - Datums/Zeit - Angabe mit Hilfe der Klassen Date und
SimpleDateFormat in die von setIfModifiedSince() benötigte Anzahl von Millisekunden seit dem
1. Januar 1970 (GMT) umgewandelt.
Man kann sich übrigens nicht unbedingt darauf verlassen, dass sich ein angesprochener Server nach
der If-Modified-Since - Angabe richtet. Das RFC-Dokument 2616 zum HTTP-Protokoll 1.1 (URL:
siehe oben) enthält zu dieser Frage eher eine Empfehlung als eine Vorschrift:
c) If the variant has not been modified since a valid If-
Modified-Since date, the server SHOULD return a 304 (Not
Modified) response.
Außerdem hat das Attribut If-Modified-Since an Bedeutung verloren, weil mittlerweile sehr viele
Webseiten dynamisch erzeugt werden.
Erst durch einen Aufruf der URLConnection-Methode connect() wird die TCP-Verbindung zur
Gegenstelle tatsächlich geöffnet. Gelingt dies, können anschließend die Response-Header der
Webserver-Antwort über passende URLConnection-Methoden abgefragt werden. Das Beispielpro-
gramm hat am 16.04.2022, 22:00 Uhr die folgenden Ausgaben geliefert:
1
Statt der GMT verwendet setRequestProperty() bei uns die lokale Zeit, also im April die MESZ.
878 Kapitel 16 Netzwerkprogrammierung
Response-Header:
Content-Type: text/html; charset=utf-8
Content-Length: 126498
Expiration: 1970-01-01 01:00:00
Last Modified: 2022-04-16 03:17:39
Die URLConnection-Methoden getExpiration() und getLastModified() liefern Millisekunden seit
dem 1. Januar 1970 (GMT), die im Beispiel mit Hilfe der Klassen Date und SimpleDateFormat in
verständliche Ausgaben übersetzt werden.
Über die URLConnection-Methode getInputStream() erhält man denselben Eingabestrom mit den
angeforderten Daten, den auch die URL-Methode openStream() liefert (siehe Abschnitt 16.2.2.1).
Zu der Frage, ob man den durch die URLConnection-Methode getInputStream() erhaltenen Ein-
gabestrom schließen bzw. die involvierte Netzwerkverbindung beenden sollte, äußert sich die API-
Dokumentation zu Java 17 so:1
Invoking the close() methods on the InputStream or OutputStream of an URLConnection after a
request may free network resources associated with this instance, unless particular protocol
specifications specify different behaviours for it.
Es ist also sinnvoll, einen Netzwerkeingabestrom zu schließen, was am einfachsten über die mit
Java 7 eingeführte automatische Ressourcenfreigabe geschieht (siehe Abschnitt 11.10.2). Ob die
Netzverbindung dabei ebenfalls geschlossen oder für eine mögliche Wiederverwendung offengehal-
ten wird, hängt von Einstellungen auf den beiden Seiten der Verbindung ab. Im Zusammenhang mit
der Klasse URL, die im Hintergrund ein URLConnection-Objekt verwendet, wurde im Abschnitt
16.2.2.1 untersucht, wie sich das Schließen des Eingabestroms unter verschiedenen Bedingungen
auswirkt.
Sind Verbindungsprobleme zu befürchten, sollte vor dem connect() - Aufruf mit der URL-
Connection-Methode
public void setConnectTimeout(int timeout)
eine maximale Wartezeit festgelegt werden. Eine Überschreitung dieser Zeit wird von connect() per
SocketTimeoutException signalisiert.
16.2.2.3 HttpsURLConnection
Von den Klassen URL und URLConnection werden einige Spezifika des HTTP-Protokolls nicht
unterstützt (z.B. der Statuscode). Abhilfe schafft die aus URLConnection abgeleitete Klasse
HttpURLConnection, die u. a. die folgenden Erweiterungen bietet:
• public void setRequestMethod(String method)
Mit dem Parameterwert HEAD kann ein HTTP-Server z. B. veranlasst werden, die Methode
HEAD auszuführen und lediglich die Header-Informationen zu senden.
• public int getResponseCode()
Die Methode liefert den Statuscode einer Server-Antwort.
• public String getResponseMessage()
Die Methode liefert ggf. eine Erläuterung zum Statuscode.
• public boolean usingProxy()
Die Methode informiert darüber, ob ein Proxy-Server involviert ist.
1
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/net/URLConnection.htm
Abschnitt 16.2 Internet-Ressourcen per HTTP-Protokoll nutzen 879
1
Genaugenommen kann openConnection() kein Objekt der abstrakten Klasse HttpsURLConnection liefern. Was
man tatsächlich in Java 17 erhält, ist ein Objekt der Klasse HttpsURLConnectionImpl.
2
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/net/HttpURLConnection.html
880 Kapitel 16 Netzwerkprogrammierung
16.2.3.1 CGI-Software
Webserver beschränken sich in der Regel nicht darauf, statische HTML-Seiten und sonstige Dateien
bereitzuhalten, sondern beherrschen auch verschiedene Technologien, um HTML-Seiten dynamisch
nach Kundenwunsch zu erzeugen und an Klientenprogramme (meist WWW-Browser) auszuliefern
(z. B. mit den Ergebnissen eines Suchauftrags oder mit einer individuellen Produktkonfiguration).
Die Nutzer äußern ihre Wünsche in der Regel, indem sie per Browser eine Formularseite (mit Ein-
gabeelementen wie Textfeldern, Kontrollkästchen usw.) ausfüllen und ihre Daten zum Webserver
übertragen. Dieses Programm (z. B. Apache HTTP Server, Microsoft Internet Information Server)
analysiert und beantwortet die Formulardaten aber nicht selbst, sondern überlässt diese Arbeit ex-
ternen Anwendungen, die in unterschiedlichen Programmiersprachen erstellt werden können (z. B.
Java, C#, PHP oder Perl). Ursprünglich kooperierte ein Webserver mit einem Ergänzungsprogramm
über das sogenannte Common Gateway Interface (CGI), wobei das Ergänzungsprogramm bei jeder
Anforderung neu gestartet und nach dem Erstellen der HTML-Antwortseite wieder beendet wurde.
Längst haben sich jedoch Lösungen etabliert, die stärker mit dem Webserver verzahnt sind, perma-
nent im Speicher verbleiben und so eine bessere Leistung bieten (z. B. PHP als Apache-Modul). So
wird vermieden, dass bei jeder Anforderung ein Programm (z. B. der PHP-Interpreter) gestartet und
eventuell auch noch eine Datenbankverbindung aufwändig hergestellt werden muss. Wir werden
anschließend der Einfachheit halber alle Verfahren zur dynamischen Produktion individueller
HTML-Seiten als CGI-Lösungen bezeichnen.
Der Browser zeigt eine vom Webserver erhaltene HTML-Seite mit Formularelementen an, über die
ein Benutzer seine Wünsche artikulieren kann. Aus den Formulareinträgen erstellt der Browser eine
CGI-Anfrage, die er unter Verwendung der Methoden GET oder POST aus dem HTTP-Protokoll
an den Webserver übermittelt. Zur Erläuterung technischer Details betrachten wir ein sehr einfaches
Formular, das ein in PHP geschriebenes CGI-Skript auf einem Webserver anspricht.
In diesem Browser-Fenster
abrufbar ist und den folgenden HTML-Code mit einem Formular enthält:
<html>
<head>
<title>CGI-Demo</title>
</head>
<h1>Nenne Deinen Namen, und ich sage Dir, wie Du heißt!</h1>
<form method="get" action="cgig.php">
<table border="0" cellpadding="0" cellspacing="4">
<tr>
<td align="right">Vorname:</td>
<td><input name="vorname" type="text" size="30"></td>
</tr><tr>
<td align="right">Nachname:</td>
<td><input name="nachname" type="text" size="30"></td>
</tr>
<tr> </tr>
<tr>
<td align="right"> <input type="submit" value=" Absenden "> </td>
<td align="right"> <input type="reset" value=" Abbrechen"> </td>
</td>
</tr>
</table>
</form>
</html>
Bei der im Abschnitt 16.2.3.2 näher zu erläuternden GET-Technik, die man im form - Element
einer HTML-Seite durch die Angabe
method="get"
wählt, schickt der Browser die Formulardaten als URL-Parameter an den Webserver. Die Formu-
lardaten werden als Name-Wert - Paare am Ende der URL-Zeichenfolge hinter einem Fragezeichen
angehängt, wobei zwei Formularfelder jeweils durch ein &-Zeichen getrennt werden. Im Beispiel
mit den Feldern bzw. Parametern vorname und nachname (siehe HTML-Quellcode) resultieren
die folgenden URL-Parameter:
vorname=Kurt&nachname=Müller
Weil nach Eintreffen der Antwortseite die zugrundeliegende Anforderung in der Adresszeile des
Browsers erscheint, kann die GET-Syntax dort inspiziert werden (siehe unten).
Der Webserver gibt die URL-Parameter an das im action-Attribut der Formulardefinition angege-
bene Programm weiter. Im Beispiel handelt es sich um ein PHP-Skript, das wenig kreativ aus den
übergebenen Namen einen Gruß formuliert:1
<?php
$vorname = $_GET["vorname"];
$nachname = $_GET["nachname"];
echo "<html>\n<head><title>CGI-Demo</title>\n</head>\n";
echo "<body>\n<h1>Hallo, ".$vorname." ".$nachname."!</h1>\n</body>\n</html>";
?>
1
Obwohl die PHP-Implementierung des CGI-Beispiels für die von uns geplante Erstellung von klientenseitigen Java -
Programmen zur Anforderung von dynamischen Webseiten keine Bedeutung hat, werden für Interessierte einige De-
tails genannt: Der Webserver schreibt die URL-Parameter in eine Umgebungsvariable namens QUERY_STRING
und stellt auf analoge Weise der CGI-Software noch weitere Informationen zur Verfügung, z. B.:
QUERY_STRING="vorname=Kurt&nachname=Müller"
REMOTE_PORT="56368"
REQUEST_METHOD="GET"
Das mit der GET-Methode arbeitende PHP-Skript greift auf die URL-Parameter in der Umgebungsvariablen
QUERY_STRING über den superglobalen Array $_GET zu.
882 Kapitel 16 Netzwerkprogrammierung
In diesem PHP-Skript wird die auszugebende HTML-Seite über echo-Kommandos an die Stan-
dardausgabe geschickt, und der Webserver befördert die PHP-Produktion über das HTTP-Protokoll
zum Browser, der den empfangenen HTML-Quellcode
<html>
<head>
<title>CGI-Demo</title>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
</head>
<body>
<h1>Hallo, Kurt Müller!</h1></body>
</html>
anzeigt:
16.2.3.2 GET
Zum Versand von Formulardaten bzw. Request-Parametern an einen Webserver kennt das HTTP-
Protokoll die Methoden GET und POST, die nun in Java-Programmen realisiert werden sollen. Bei
der bereits im Abschnitt 16.2.3.1 erläuterten GET-Technik schickt der Browser die Formulardaten
als URL-Parameter an den Webserver. Daraus ergibt sich eine Längenbeschränkung, wobei die
konkreten Maximalwerte vom Server und vom Browser abhängen. Man sollte vorsichtshalber eine
URL-Gesamtlänge von 2048 Zeichen einhalten und ggf. das POST-Verfahren (siehe Abschnitt
16.2.3.3), das keine praxisrelevante Längenbeschränkung kennt, zur Übergabe von CGI-Parametern
verwenden.
Um in Java eine CGI-Software anzusprechen, die per GET mit Parametern versorgt werden möch-
te, genügt ein Objekt der angenehm einfach aufgebauten Klasse URL (siehe Abschnitt 16.2.2.1):
import java.io.*;
import java.net.*;
class GET {
public static void main(String[] args) {
try (BufferedReader in = new BufferedReader(new InputStreamReader(System.in))) {
System.out.print("Vorname: ");
String vorname = in.readLine();
System.out.print("Nachname: ");
String nachname = in.readLine();
System.out.println();
URL url = new URL("http://urtkurs.uni-trier.de/prokur/netz/cgig.php" +
"?vorname=" + URLEncoder.encode(vorname, "UTF-8") +
"&nachname=" + URLEncoder.encode(nachname, "UTF-8"));
try (BufferedReader br = new BufferedReader(
new InputStreamReader(url.openStream()))) {
String zeile;
while ((zeile = br.readLine()) != null)
System.out.println(zeile);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
Abschnitt 16.2 Internet-Ressourcen per HTTP-Protokoll nutzen 883
16.2.3.3 POST
Bei der HTTP-Methode POST, die man im form-Element einer HTML-Seite durch eine entspre-
chende method-Angabe
<form method="post" action="cgip.php">
wählt, werden die Formulardaten (wie bei der GET-Methode im Format von Name-Wert - Paaren)
mit Hilfe des WWW-Servers zur Standardeingabe der CGI-Software übertragen. Als wesentliche
Unterschiede zur GET-Methode sind zu nennen:
• Die CGI-Parameter erscheinen nicht in der Adresszeile der Antwortseite.
• Es besteht keine relevante Größenbeschränkung für die CGI-Parameter.
Was genau gemäß HTTP-Protokoll zu tun ist, braucht Java-Programmierer kaum zu interessieren,
weil die Klasse URLConnection einen Ausgabestrom zur Verfügung stellt, über den man die CGI-
Standardeingabe mit Parametern versorgen kann.
884 Kapitel 16 Netzwerkprogrammierung
In folgendem Beispielprogramm wird zunächst wie im Abschnitt 16.2.2.2 über die URL-Methode
openConnection() ein Objekt der Klasse URLConnection (genauer: HttpsURLConnectionImpl)
erzeugt. Anschließend wird dieses Objekt mit dem Methodenaufruf setDoOutput(true) darauf vor-
bereitet, dass Daten zum Server übertragen werden sollen. An den mit getOutputStream() ange-
forderten Ausgabestrom wird ein PrintWriter angekoppelt, um die URL-codierten CGI-Parameter
mit der bequemen print() - Methode „posten“ zu können:
import javax.net.ssl.HttpsURLConnection;
import java.io.*;
import java.net.*;
class POST {
public static void main(String[] args) {
try (BufferedReader in = new BufferedReader(new InputStreamReader(System.in))) {
System.out.print("Vorname: ");
String vorname = in.readLine();
System.out.print("Nachname: ");
String nachname = in.readLine();
System.out.println();
Durch eine try-with-resources - Anweisung wird für das automatische Schließen des PrintWriter-
Objekts gesorgt, sodass die Parameterdaten trotz der impliziten Pufferung auf die Reise gehen.
Das angesprochene PHP-Skript unterscheidet sich kaum von der GET-Variante: Anstelle des su-
perglobalen Arrays $_GET ist der analoge Array $_POST zu verwenden:
<?php
$vorname = $_POST["vorname"];
$nachname = $_POST["nachname"];
echo "<h1>Hallo, $vorname $nachname!</h1>";
?>
Abschnitt 16.2 Internet-Ressourcen per HTTP-Protokoll nutzen 885
• HttpClient
Ein HttpClient-Objekt beherrscht die synchrone Methode send() und die asynchrone Me-
thode sendAsync(), um unter Verwendung eines HttpRequest-Objekts und eines
HttpResponse.BodyHandler<T> - Objekts eine Anforderung an einen HTTP-Server zu
senden. Hinsichtlich der Technik der reaktiven Ströme ist ein HttpClient-Objekt ein Sub-
scriber für die Request-Inhalte (den request body) und ein Publisher für die Response-
Inhalte (den response body). Ein HttpClient-Objekt ist nach der Konfiguration unveränder-
lich und kann für mehrere Anforderungen verwendet werden.
Das HTTP/2 - API ist u. a. durch die Unterstützung der reaktiven Ströme komplex geworden. Per
Voreinstellung wird diese Technik aber automatisiert im Hintergrund eingesetzt. Die gleich folgen-
den konkreten Beispiele wirken daher vergleichsweise übersichtlich.1
Beim HTTP-Protokoll beschränken wir uns im Manuskript auf die Methoden GET und POST. Die
Klasse HttpRequest beherrscht zusätzlich auch die Methoden PUT und DELETE.2
class HttpURLConnectionDemo {
public static void main(String[] args) {
String uri = "https://www.uni-trier.de/";
try {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(uri))
.build();
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.printf("Status-Code: %d\n\n", response.statusCode());
java.util.StringTokenizer stok =
new java.util.StringTokenizer(response.body(), "\n", false);
for (int i = 0; i < 8; i++)
System.out.println(stok.nextToken());
} catch (Exception e) {
e.printStackTrace();
}
}
}
1
Die Beispiele sind von den folgenden Webseite inspiriert:
https://openjdk.java.net/groups/net/httpclient/recipes.html
2
https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html
Abschnitt 16.2 Internet-Ressourcen per HTTP-Protokoll nutzen 887
1
Im Abschnitt 16.2 haben wird den Uniform Resource Locator (URL) kennengelernt, der zur Beschreibung von In-
ternet-Ressourcen dient. Durch eine für uns momentan irrelevante Verallgemeinerung entsteht aus dem Uniform Re-
source Locator der Uniform Resource Identifier (URI).
888 Kapitel 16 Netzwerkprogrammierung
die Publisher-Rolle übernimmt, im Hintergrund mit der Technik der reaktiven Ströme zusammen
bei der Verarbeitung der nun bezogenen Daten (mit nicht-blockierendem Gegendruck).
Das Interface HttpResponse.BodySubscriber<T> erweitert das im Abschnitt 15.9 beschriebene
Interface Flow.Subscriber<List<ByteBuffer>> aus dem mit Java 9 eingeführten Flow-API:
interface BodySubscriber<T>
extends Flow.Subscriber<List<ByteBuffer>> {
CompletionStage<T> getBody();
}
Während der reaktive Strom komplett in der JVM stattfindet, sorgt das HttpClient-Objekt für eine
passende, dosierte Lieferung von Bytes durch den HTTP-Server. Wir müssen uns um diesen Über-
lastungsschutz nicht kümmern und arbeiten mit einem bequem per Fabrikmethode erstellten
HttpResponse.BodyHandler<String>, der die vom HttpClient-Objekt bezogenen Bytes ansam-
melt und in ein String-Objekt konvertiert. Natürlich ist es möglich und in speziellen Fällen ratsam,
einen eigenen HttpResponse.BodySubscriber<T> zu implementieren.1
Das von send() erstellte HttpResponse<T> - Objekt beherrscht die Methode body(), die ein Objekt
vom T liefert. Im Beispiel erhalten wir ein String-Objekt mit der angeforderten HTML-Seite:
System.out.println(response.body());
Weil diese println() - Ausgabe lang und unübersichtlich wäre, beschränkt sich das Beispielpro-
gramm mit Hilfe eines StringTokenizer-Objekts (siehe Abschnitt 5.6) auf die ersten acht Zeilen:
java.util.StringTokenizer stok =
new java.util.StringTokenizer(response.body(), "\n", false);
for (int i = 0; i < 8; i++)
System.out.println(stok.nextToken());
Außerdem wird beim HttpResponse<String> - Objekt der Statuscode angefordert:
System.out.printf("Statuscode: %d\n\n", response.statusCode());
Selbst das im aktuellen Abschnitt vorgestellte einfache Beispielprogramm taugt dazu, einen Vorteil
der Klasse HttpClient gegenüber den früheren Lösungen zu demonstrieren: Während ein Objekt
der Klasse UrlConnection mit einer konkreten Anforderung (mit einem konkreten URL) verbun-
den ist (siehe Abschnitt 16.2.2.2), kann ein Objekt der Klasse HttpClient für mehrere
Anforderungen genutzt werden.
Für die Objekte der Klassen HttpClient und HttpRequest gilt, dass Sie nach erfolgter Erstellung
unveränderlich sind, was im Zusammenhang mit dem Multithreading von Vorteil ist.
Soll der synchron angeforderte HTML-Code einer Webseite nicht in einem String-Objekt landen,
sondern in einer Textdatei, dann ist lediglich im send() - Aufruf an das HttpClient-Objekt der
zweite Parameter geeignet zu ersetzen. Es wird ein Objekt benötigt, das die Schnittstelle
HttpResponse.BodyHandler<Path> erfüllt. Man erhält es über die HttpResponse.Bodyhandlers-
Methode ofFile(), z. B.:
HttpResponse<Path> response = client.send(request,
HttpResponse.BodyHandlers.ofFile(file));
1
Das wird von Chris Hegarty in einem YouTube-Video vorgeführt:
https://www.youtube.com/watch?v=qiaC0QMLz5Y
Abschnitt 16.2 Internet-Ressourcen per HTTP-Protokoll nutzen 889
Im folgenden Beispielprogramm wird aus dem URL und dem aktuellen Datum ein aussagekräftiger
Namen für die Datei mit dem heruntergeladenen HTML-Code erstellt:1
import java.net.URI;
import java.net.http.*;
import java.nio.file.*;
import java.text.SimpleDateFormat;
import java.util.Date;
class HttpClientSyncGetFile {
public static void main(String[] args) {
String uri = "https://www.uni-trier.de/";
try {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(uri))
.build();
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
String date = simpleDateFormat.format(new Date());
Path file = Paths.get(request.uri().getHost() + "." + date + ".html");
HttpResponse<Path> response = client.send(request,
HttpResponse.BodyHandlers.ofFile(file));
System.out.println("Statuscode: " + response.statusCode());
System.out.println("Ausgabe in die Datei: " + response.body());
} catch (Exception e) {
e.printStackTrace();
}
}
}
Das von der HttpResponse<Path> - Methode body() gelieferte Path-Objekt nennt bei Befragung
durch toString() den Namen der Datei mit den herunter geladenen Daten:
Statuscode: 200
Ausgabe in die Datei: www.uni-trier.de.2022-05-24.html
Neben den eben vorgestellten HttpResponse.BodyHandler<T> - Implementation sind über
HttpResponse.BodyHandlers-Fabrikmethoden etliche weitere zu erhalten. z. B.:
• public static HttpResponse.BodyHandler<byte[]> ofByteArray()
• public static HttpResponse.BodyHandler<InputStream> ofInputStream()
• public static HttpResponse.BodyHandler<Stream<String>> ofLines()
1
Einige Lösungen stammen von Jacob Jenkov:
https://jenkov.com/tutorials/java-internationalization/simpledateformat.html
890 Kapitel 16 Netzwerkprogrammierung
class HttpClientAsyncGetString {
public static void main(String[] args) {
String uri = "https://www.uni-trier.de/";
try {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(uri))
.build();
CompletableFuture<HttpResponse<String>> cfHRS =
client.sendAsync(request, HttpResponse.BodyHandlers.ofString());
for (int i = 0; !cfHRS.isDone(); i++) {
System.out.println("i = " + i);
Thread.sleep(100);
}
String s = cfHRS.join().body();
System.out.printf("HTML-Code mit %d Zeichen erhalten.", s.length());
} catch (Exception e) {
e.printStackTrace();
}
}
}
Beim asynchronen Einsatz der Klasse HttpClient kommt die Aufgabenparallelität mit Hilfe der
Klasse CompletableFuture<T> ins Spiel, mit der wir uns im Abschnitt 15.6 ausführlich
beschäftigt haben.
Während sendAsync() von einem Pool-Thread ausgeführt wird, kann sich der Main-Thread produk-
tiv beschäftigen, was im Beispielprogramm nur angedeutet wird. Nach Ablauf einiger Warteinter-
valle von 100 Millisekunden Dauer liefert die CompletableFuture<T> - Methode isDone() den
Wert true, und der Umfang des bezogenen HTML-Codes kann protokolliert werden:
i = 0
i = 1
i = 2
. . .
i = 7
i = 8
HTML-Code mit 495857 Zeichen erhalten.
class HttpClientSyncPostString {
public static void main(String[] args) {
try (BufferedReader in = new BufferedReader(new InputStreamReader(System.in))) {
System.out.print("Vorname: ");
String vorname = in.readLine();
System.out.print("Nachname: ");
String nachname = in.readLine();
System.out.println();
1
https://openjfx.io/javadoc/17/javafx.web/javafx/scene/web/WebView.html
https://openjfx.io/javadoc/17/javafx.web/javafx/scene/web/WebEngine.html
Abschnitt 16.2 Internet-Ressourcen per HTTP-Protokoll nutzen 893
Über die Zoom-Property der Klasse WebView lässt sich der gewünschte Zoom-Faktor für die Dar-
stellung der Webseiten einstellen, z. B.:
webView.setZoom(0.80);
benötigt man neben den oben beschriebenen Objekten und Methoden der Klassen WebView und
WebEngine nur noch elementare JavaFX-Techniken (siehe Kapitel 13):
894 Kapitel 16 Netzwerkprogrammierung
import javafx.application.Application;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Insets;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.scene.text.FontSmoothingType;
import javafx.stage.Stage;
import javafx.scene.web.*;
engine.titleProperty().addListener(
(ObservableValue<? extends String> obs, String oldValue, String newValue) -> {
stage.setTitle(newValue);
});
engine.load("https://www.google.de");
Nach einem Klick auf den Befehlsschalter wird eine Methode ausgeführt, die den Inhalt des
TextField-Steuerelements als Parameter für einen Aufruf der WebEngine-Methode load() verwen-
det:
Abschnitt 16.3 IP-Adressen bzw. Host-Namen ermitteln 895
btnLoad.setOnAction(event -> {
String s = tfURL.getText();
if (!s.startsWith("https://") && !s.startsWith("http://"))
s = "https://" + s;
engine.load(s);
});
Damit auch eine vereinfachte URL-Eingabe wie z.B. www.google.de zum Erfolg führt, wird dem
Inhalt des TextField-Steuerelements nötigenfalls die Protokoll-Bezeichnung https:// voranstellt.
Die Methode load() ignoriert ungeeignete Parameterausprägungen, sodass wir keine Validierung
vornehmen müssen.
class InetAddressDemo {
public static void main(String[] args) {
try {
InetAddress lh = InetAddress.getLocalHost();
System.out.println("IP-Adresse des lokalen Rechners: " + lh.getHostAddress());
System.out.println("Host-Name des lokalen Rechners: " + lh.getHostName());
} catch (Exception e) {
e.printStackTrace();
}
}
}
Eine Beispielausgabe:
IP-Adresse des lokalen Rechners: 192.168.178.12
Host-Name des lokalen Rechners: Domino
Das nächste Beispielprogramm kann zwischen einer IPv4-Adresse und einem Host-Namen überset-
zen und bietet dabei einen zeitgemäßen Bedienkomfort:
896 Kapitel 16 Netzwerkprogrammierung
Aufgrund der JavaFX-Oberfläche ist der Quelltext deutlich länger als beim vorherigen Beispiel.
Daher wird nur der für beide Schaltflächen zuständige ActionEvent-Handler wiedergegeben:
public void findNameOrIP(ActionEvent ae) {
try {
if (ae.getSource() == cbGetIP) {
InetAddress ia = InetAddress.getByName(tfHostName.getText());
tfIP.setText(ia.getHostAddress());
} else {
byte[] ipAddr = new byte[4];
StringTokenizer st = new StringTokenizer(tfIP.getText(), ".", false);
for (int i = 0; i < 4; i++)
ipAddr[i] = (byte) Integer.parseInt(st.nextToken());
InetAddress ia = InetAddress.getByAddress(ipAddr);
tfHostName.setText(ia.getHostName());
}
} catch(Exception e) {
Alert alert = new Alert(Alert.AlertType.INFORMATION, e.getMessage());
alert.setHeaderText("Die Übersetzung ist gescheitert.");
alert.showAndWait();
}
}
Ein Objekt der Klasse StringTokenizer hilft dabei, aus einer Zeichenfolge mit einer mutmaßlichen
IPv4-Adresse den im getByAddress() - Aufruf benötigten byte-Array zu erstellen.
Den vollständigen Quellcode finden Sie im Ordner
…\BspUeb\Netzwerk\Internet-Adressen\DNS
16.4 Socket-Programmierung
Unsere bisherigen Beispielprogramme im Kapitel 16 haben hauptsächlich WWW-Inhalte von Ser-
vern bezogen und dazu API-Klassen benutzt, die ein per URL-Objekt festgelegtes Anwendungspro-
tokoll (meist HTTP) verwenden. Im aktuellen Abschnitt gewinnen wir eine erweiterte Flexibilität
durch den direkten Einsatz des TCP-Protokolls. Daraus ergibt sich z. B. die Möglichkeit, eigene
Anwendungsprotokolle zu entwickeln. Das auf der Transport- bzw. Sitzungsebene des OSI-Modells
(siehe Abschnitt 16.1.1) angesiedelte TCP-Protokoll schafft zwischen zwei (durch Portnummern
identifizierten) Anwendungen, die sich meist auf verschiedenen (durch IP-Adressen identifizierten)
Rechnern befinden, eine virtuelle, datenstrom-orientierte und gesicherte Verbindung. An beiden
Enden der Verbindung steht das von praktisch allen aktuellen Programmiersprachen unterstützte
Socket-API zur Verfügung, das in Java durch die Klasse Socket im Paket java.net realisiert wird,
das sich ab Java 9 im Modul java.base befindet.
TCP-Programmierer müssen sich nicht um IP-Pakete kümmern. Sie müssen also ...
• weder die Aufteilung einer Sendung auf mehrere Pakete unter Beachtung der maximalen
Paketgröße vornehmen,
• noch die Zustellung der Pakete anhand von Quittungen überwachen und ggf. ein verloren
gegangenes Paket erneut senden,
Abschnitt 16.4 Socket-Programmierung 897
Host 1 Host 2
Anwen- Anwen-
dung 1 Socket Socket dung 2
Technisch ist ein Vollduplex-Betrieb (also das gleichzeitige Senden beider Anwendungen) möglich,
doch schreiben die Anwendungsprotokolle in der Regel ein alternierendes Senden vor.
Ein Socket-Objekt ist im Rahmen einer Netzverbindung für die folgenden Ausgaben zuständig:
• Verbindung mit der Gegenstelle aufbauen
Das erledigt meist ein Socket-Konstruktor.
• Daten senden und empfangen
Programmierer können dieselbe Datenstromtechnik verwenden, die wir im Kapitel 14 über
die Ausgabe in Dateien bzw. über das Einlesen aus Dateien kennengelernt haben.
• Beenden einer Verbindung
Das wird per close() - Aufruf erledigt, wobei die mit einem Socket-Objekt assoziierten Da-
tenströme automatisch ebenfalls geschlossen werden.
Wir beschäftigen uns in diesem Abschnitt mit der Erstellung von Klienten- und Serveranwendun-
gen. Ein wesentlicher Unterschied zwischen den beiden Rollen besteht darin, dass ein Serverpro-
gramm mehr oder weniger permanent läuft und an einem fest vereinbarten Port auf eingehende
Verbindungswünsche wartet, während ein Klientenprogramm nur bei Bedarf aktiv wird und dabei
einen dynamisch zugewiesenen Port benutzt. Ein Serverprogramm verwendet für die Kommunika-
tion mit einem Klienten ein Objekt der Klasse Socket. Zusätzlich wird ein Objekt der Klasse
ServerSocket benötigt, das ...
• einen Port in Beschlag nimmt,
• dort auf eigehende Verbindungswünsche von Klienten wartet
• und ggf. mit Hilfe eines Socket-Objekts die Verbindung zu einem Klienten aufbaut.
16.4.1 TCP-Klient
Wir erstellen einen TCP-Klienten, der die aktuelle Tageszeit bei einem Daytime-Server erfragt. Der
Daytime-Dienst (vgl. RFC 867) eignet sich wegen des extrem einfachen Anwendungsprotokolls für
unsere Zwecke, ist aber bei längeren Paketlaufzeiten für die Zwecke der Zeitsynchronisation zu
ungenau und wurde daher durch das Network Time Protocol (NTP) ersetzt.
In diesem Abschnitt erstellen wir einen Datetime-Klienten, und das beteiligte Socket-Objekt ver-
wendet protokollgemäß nur seinen Eingabestrom. In Abschnitt 16.4.2.2 werden wir einen Daytime-
Server erstellen und dabei den Ausgabestrom eines Socket-Objekts verwenden.
898 Kapitel 16 Netzwerkprogrammierung
16.4.1.1 Socket-Konstruktorüberladungen
Für den Daytime-Klienten erzeugen wir ein Objekt aus der Klasse Socket, das mit einem Daytime-
Server Verbindung aufnehmen soll. Im Konstruktor muss neben dem Host-Namen bzw. der IP-
Adresse des Servers auch die Portnummer des Zeitansagers auftauchen. Daytime-Dienste lauschen
am TCP-Port 13, z. B.:
Socket time = new Socket("time.nist.gov", 13);
Im Beispiel wird ein Server am National Institute for Standards and Technology (NIST) angespro-
chen. Wer zum Üben keinen ansprechbaren Daytime-Server findet, sei auf den Abschnitt 16.4.2
vertröstet, wo wir einen eigenen Daytime-Server erstellen.
Durch die verwendete Konstruktorüberladung wird nicht nur ein Socket-Objekt erstellt, sondern
auch die Verbindung zur Gegenstelle aufgebaut. Der Konstruktoraufruf blockiert bis zum ...
• erfolgreichen Verbindungsaufbau
• Auftreten einer IOException, z. B. aus einem von den folgenden Gründen:
o Der Rechnername kann nicht aufgelöst werden.
o Die maximale Wartezeit ist abgelaufen.
Die voreingestellte maximale Wartezeit beträgt in Java 8 unter Windows 10 ca. 20 Sekunden
(handgestoppt). Mit der folgenden Konstruktion lässt sich für die Verbindungsaufnahme eine alter-
native Timeout-Zeit unter der Kontrolle des Programmierers einstellen:
Socket time = new Socket();
time.connect(new InetSocketAddress("time.nist.gov", 13), 1000);
Die hier verwendete parameterfreie Socket-Konstruktorüberladung kehrt sofort zurück. Für die
Verbindungsaufnahme ist die Socket-Methode connect() zuständig, die einen Timeout-Parameter
besitzt:
public void connect(SocketAddress endpoint, int timeout) throws IOException
Zur Definition des Servers kommt im Beispiel ein Objekt aus der von SocketAddress abstammen-
den Klasse InetSocketAddress zum Einsatz.
class DaytimeClient {
public static void main(String[] args) {
BufferedReader br = null;
try (Socket time = new Socket("time.nist.gov", 13)) {
System.out.println("Verbindung hergestellt (lokaler Port: " +
time.getLocalPort() + ")");
time.setSoTimeout(1000);
br = new BufferedReader(new InputStreamReader(time.getInputStream()));
String s;
while ((s = br.readLine()) != null)
if (s.length() > 0)
System.out.println("Aktuelle Zeit: " + s);
} catch (SocketTimeoutException e) {
System.out.println("Maximale Wartezeit abgelaufen");
Abschnitt 16.4 Socket-Programmierung 899
} catch (Exception e) {
System.err.println(e);
}
}
}
Weil der NIST-Server vor der Zeitansage eine leere Zeile sendet, wird zum Lesen aus dem Einga-
bestrom die folgende Schleife verwendet:
while ((s = br.readLine()) != null)
if (s.length() > 0)
System.out.println("Aktuelle Zeit: " + s);
Das Programm protokolliert den dynamisch an das Socket-Objekt vergebenen und über die Metho-
de getLocalPort() ermittelten lokalen Port und anschließend die erhaltene Uhrzeit, z. B.:
Verbindung hergestellt (lokaler Port: 53182)
Aktuelle Zeit: 59700 22-05-01 11:24:23 50 0 0 685.1 UTC(NIST) *
Zum Ausgabeformat schreibt die die Spezifikation RFC 867 nur vor, dass es von Menschen lesbar
sein muss.
16.4.2 TCP-Server
Als Gegenstück zum eben präsentierten Daytime-Klienten erstellen wir nun einen Zeitserver, der
am Port 13 lauscht und anfragenden Klienten die aktuelle Tageszeit mitteilt. Wird ein solcher Ser-
ver auf dem eigenen Rechner gestartet, dann kann er von ebenfalls lokal ausgeführten Klientenpro-
grammen unter Verwendung der sogenannten loopback-Adresse (IPv4: 127.0.0.1, IPv6:
0:0:0:0:0:0:0:1) genutzt werden.
Wird der Zugriff zugelassen, dann entstehen die folgenden eingehenden Regeln der Defender-
Firewall:
Wird der Zugriff (über den Schalter Abbrechen) verweigert, dann ist eine Nutzung des Servers
durch Klienten auf dem lokalen Rechner trotzdem möglich.
Das Fenster zur Konfiguration der Windows Defender Firewall erreicht man über:
Systemsteuerung > System und Sicherheit >
Windows Defender Firewall > Erweiterte Einstellungen
16.4.2.2 Singlethreading-Server
Nach diesen Vorbereitungen widmen wir uns wieder dem geplanten Zeitserver. Dabei ist ein Objekt
aus der Klasse ServerSocket erforderlich, das per Konstruktor erstellt und dabei an den laut RFC
867 - Spezifikation erforderlichen Port 13 gebunden wird:
ServerSocket timeServer = new ServerSocket(13);
Abschnitt 16.4 Socket-Programmierung 901
Weil der Konstruktor das ServerSocket-Objekt nicht nur erstellt, sondern auch an den Port zu bin-
den versucht, kann der Aufruf mit einem Ausnahmefehler scheitern. Wenn z. B. der Port bereits
belegt ist, dann kommt es zu einer BindException:
07.05.2022 20:30:58 Server konnte nicht gestartet werden:
java.net.BindException: Address already in use: JVM_Bind
Für jede Klientenverbindung wird ein eigenes Objekt aus der schon bekannten Klasse Socket benö-
tigt (siehe Abschnitt 16.4.1). Mit der Methode accept() beauftragen wir das ServerSocket-Objekt,
auf eingehende Verbindungswünsche zu warten und ggf. zu einem anfragenden Klienten ein So-
cket-Objekt zu liefern, das anschließend zur Kommunikation mit dem Klienten dient:
Socket client = timeServer.accept();
Der accept() - Aufruf blockiert so lange, bis eine Klientenanfrage eintrifft.
Wie vom Datetime-Protokoll gefordert, wird in den Ausgabestrom des Socket-Objekts zu einem
anfragenden Klienten die mit Hilfe der Klassen Date und DateFormat erstellte Zeitangabe ge-
schrieben (vgl. Abschnitt 16.2.2.2):
import java.io.*;
import java.net.*;
import java.util.*;
import java.text.*;
class DaytimeServer {
public static void main(String[] args) {
DateFormat df = DateFormat.getDateTimeInstance();
String zeit;
Zur Zeitanfrage lässt sich auch ein Telnet-Klient verwenden (zum Aktivieren des Telnet-Klienten in
Windows 10 siehe S. 868). Im folgenden Beispiel läuft der Telnet-Klient auf demselben Rechner
wie der Daytime-Server, sodass localhost als Rechnername verwendet werden kann:
> telnet localhost 13
Im Beispielprogramm wird über die Socket-Methode getInetAdress() ein InetAdress-Objekt zum
Klienten angefordert, um per getHostAddress() die IP-Adresse des Klienten ermitteln zu können.
Über den vom Klienten verwendeten Port informiert sich das Programm mit der Socket-Methode
getPort().
Hinter dem letzten Protokolleintrag
03.05.2022 04:41:01 Anfrage von
IP-Nummer: 0:0:0:0:0:0:0:1 (Port: 63285)
steckt ein auf dem lokalen Rechner ausgeführter Klient, der zur Kommunikation das IPv6-Protokoll
verwendet, sodass der folgende Ausdruck
client.getInetAddress().getHostAddress()
die loopback-Adresse dieser Protokollversion liefert.
Ist ein Klient versorgt, dann sollte ein Daytime-Server die Verbindung durch einen (expliziten oder
impliziten) close() - Aufruf an das beteiligte Socket-Objekt schließen.
Das Socket-Objekt taugt anschließend nicht mehr für Netzwerkzwecke. Die Socket-Methode clo-
se() sorgt auch für das Schließen des Ausgabestroms. Folglich muss ein verbundener PrintWriter
seinen Puffer rechtzeitig entleeren, was im Beispiel durch die aktivierte Autoflush-Option
PrintWriter pw = new PrintWriter(client.getOutputStream(), true);
und die Verwendung der Methode println() sichergestellt wird (vgl. Abschnitt 14.4.1.5).
Der Telnet-Klient in Windows 10 reagiert auf die Antwort des Daytime-Servers folgendermaßen:
16.4.2.3 Multithreading-Server
Wenn die Bedienung eines Klienten wie im Daytime-Beispiel nur sehr wenig Zeit in Anspruch
nimmt, dann kann ein Server mit Singlethreading-Technik sinnvoll sein. Ist jedoch der Zeitaufwand
pro Klient höher, dann kommt man an einer Multithreading-Lösung nicht vorbei, damit mehrere
Klienten simultan bedient werden können. Damit nicht für jeden Klienten zeitaufwändig ein neuer
Thread gestartet werden muss, sollte ein Threadpool zum Einsatz kommen (siehe Abschnitt 15.4),
z.B.:
ExecutorService es = Executors.newCachedThreadPool();
Wir erstellen nun einen Multithreading - Echo-Server, der gemäß der Spezifikation RFC 862 am
Port 7 lauscht und alle Sendungen eines Klienten zurückspiegelt, bis der Klient die Verbindung
beendet. Weil im Echo-Protokoll der Klient für das Beenden der Verbindung verantwortlich ist,
führt an einer Multithreading-Lösung kein Weg vorbei. Das Echo-Protokoll wurde übrigens nicht
als Übungsbeispiel für Lehrtexte entworfen, sondern zum Testen von Netzwerkverbindungen.
Abschnitt 16.4 Socket-Programmierung 903
In der main() - Methode lauert ein ServerSocket-Objekt endlos auf Verbindungswünsche. Wie im
Daytime-Beispiel aus dem Abschnitt 16.4.2.2 erzeugt die ServerSocket-Methode accept() für je-
den Klienten ein neues Socket-Objekt. Zur Versorgung des Klienten wird außerdem ein Objekt der
selbst definierten Klasse EchoTask erstellt und in einem Pool-Thread zum Einsatz gebracht:
import java.net.*;
import java.util.*;
import java.util.concurrent.*;
import java.text.*;
class EchoServer {
private static int nconn, nummer;
private static DateFormat df = DateFormat.getDateTimeInstance();
In der Instanzvariablen nconn wird die Anzahl der aktiven Verbindungen aufbewahrt. Weil die auf
nconn schreibend oder lesend zugreifenden EchoServer-Methoden in verschiedenen Threads
ablaufen können, werden sie durch Synchronisieren thread-sicher gemacht.
Ein nicht mehr erforderliches ServerSocket-Objekt sollte möglichst frühzeitig durch einen implizi-
ten oder expliziten close() - Aufruf geschlossen werden, um den belegten Port freizugeben. Das ist
kurz vor dem Programmende allerdings überflüssig, weil die von einem beendeten Programm be-
legten Ressourcen ohnehin freigegeben werden. Nach dem close() - Aufruf taugt ein ServerSocket-
Objekt nicht mehr für Netzwerkzwecke.
904 Kapitel 16 Netzwerkprogrammierung
In der run() - Methode der Klasse EchoTask findet die Kommunikation mit dem versorgten Klien-
ten über den Eingabe- und Ausgabestrom des Socket-Objekts statt. Im Konstruktor des am Ausga-
bestrom angedockten PrintWriters wird die AutoFlush-Eigenschaft auf true gesetzt, so dass jede
per println() produzierte Ausgabe sofort auf die Reise geht (vgl. Abschnitt 14.4.1.5).
import java.io.*;
import java.net.*;
Leicht von der Spezifikation RFC 862 abweichend schließt der Server das Socket-Objekts zu einem
Klienten, wenn der Klient die Verbindung beendet oder die Botschaft quit sendet.
Man benötigt zum Testen des Multithreading - Echo-Servers keine spezielle Klienten-Software,
sondern kann ihn z. B auch mit einem Telnet-Klienten ansprechen (zum Aktivieren des Telnet-
Klienten in Windows 10 siehe S. 868). Nach dem Öffnen der Verbindung steht mit dem Echo-
Server ein geduldig wiederholender Gesprächspartner bereit:
Wie die folgende Abbildung zeigt, kann der Server tatsächlich mehrere Klienten simultan versorgen
kann:
Abschnitt 16.5 Übungsaufgaben zu Kapitel 16 905
2) Erweitern Sie den im Abschnitt 16.2.5 vorgestellten Browser auf der Basis der JavaFX-Klasse
WebView um die Zoom-Funktionalität per Mausrad, sodass z. B. die folgenden Darstellungsvarian-
ten bequem einstellbar sind:
Tipps:
• Nutzen Sie die WebView-Methode setZoom().
• Vereinbaren Sie eine Mausrad-Ereignisbehandlung über die Node-Methode setOnScroll():
public final void setOnScroll(EventHandler<? super ScrollEvent> handler)
Anhang
A. Operatorentabelle
In der folgenden Tabelle sind alle im Manuskript behandelten Operatoren in absteigender Bin-
dungskraft (von oben nach unten) aufgelistet. Gruppen von Operatoren mit gleicher Bindungskraft
sind durch fette horizontale Linien begrenzt.
Operator Bedeutung
[] Array-Index
. Komponentenzugriff
! Negation
- Vorzeichenumkehr
(Typ) Typumwandlung
new Objekterzeugung
*, / Punktrechnung
% Modulo
+, - Strichrechnung
+ String-Verkettung
>, <,
Vergleichsoperatoren
>=, <=
instanceof Typprüfung
| Bitweises ODER
Operator Bedeutung
?: Konditionaloperator
= Wertzuweisung
+=, -=,
*=, /=, Wertzuweisung mit Aktualisierung
%=
Mit Ausnahme der Zuweisungsoperatoren sind alle binären Operatoren links-assoziativ. Die Zuwei-
sungsoperatoren und der Konditionaloperator sind rechts-assoziativ.
Kapitel 1 (Einleitung)
Aufgabe 1
Das Prinzip der Datenkapselung reduziert die Fehlerquote und damit den Aufwand zur Fehlersuche
und -bereinigung. Die perfektionierte Modularisierung durch die Kopplung von Eigenschaften und
zugehörigen Handlungskompetenzen in einer Klassendefinition erleichtert die …
• Kooperation von mehreren Programmierern bei großen Projekten,
• die Wiederverwendung von Software.
Aufgabe 2
1. Richtig
2. Falsch
Jedes Java-Programm muss eine Startklasse enthalten, und nur eine Startklasse benötigt eine
Methode namens main().
3. Falsch
Der vom Java-Compiler erstellte Bytecode muss vom Java-Interpreter in den Maschinen-
code der aktuellen CPU übersetzt werden.
4. Richtig
Aufgabe 4
1. Richtig
2. Falsch
3. Richtig
4. Falsch
Während der Datentyp String[] des main() - Parameters in der Tat zwingend vorgeschrie-
ben ist, kann man seinen Namen frei wählen.
Aufgabe 2
Unzulässig sind:
• 4you
Bezeichner müssen mit einem Buchstaben beginnen.
• else
Schlüsselwörter wie else sind als Bezeichner verboten.
Obwohl main kein Schlüsselwort ist, wird man mit einem derart irritierenden Bezeichner (z. B. für
eine Variable) wenig Ruhm und Sympathie gewinnen.
910 Anhang
Aufgabe 3
Das Paket java.lang der Standardbibliothek, zu der die Klassen Math und System gehören, wird
automatisch in jede Quellcodedatei importiert (vgl. Abschnitt 3.1.7).
Wie aus dem Abschnitt 3.2.1 bekannt, kann man die Formatierungszeichenfolge per Plusoperator
zusammensetzen und so auf zwei Zeilen verteilen:
class Prog {
public static void main(String[] args) {
System.out.printf("%1$-10.1f %1$-10.2f %1$-10.3f%n" +
"%1$-10.4f %1$-10.5f %1$-10.6f", Math.PI);
}
}
Um einen Zeilenwechsel zu erreichen, kann man statt der Formatspezifikation %n auch die Escape-
Sequenz \n verwenden:
class Prog {
public static void main(String[] args) {
System.out.printf("%1$-10.1f %1$-10.2f %1$-10.3f\n%1$-10.4f %1$-10.5f %1$-10.6f",Math.PI);
}
}
Aufgabe 2
Der im println() - Parameter unmittelbar auf die Zeichenkette folgende Plusoperator wird zuerst
ausgeführt. Weil sein linkes Argument eine Zeichenfolge ist, wird auch sein rechtes Argument in
eine Zeichenfolge gewandelt, um eine sinnvolle Operation zu ermöglichen, nämlich die Verkettung
von zwei Zeichenfolgen. Der Ausdruck
"3.3 + 2 = " + 3.3
wird also behandelt wie
"3.3 + 2 = " + "3.3"
und man erhält:
"3.3 + 2 = 3.3"
Anschließend arbeitet der zweite Plus-Operator analog, sodass insgesamt die Zeichenfolgen „3.3“
und „2“ nacheinander an die Zeichenfolge „3.3 + 2 = “ angehängt werden.
Abschnitt B Lösungsvorschläge zu den Übungsaufgaben 911
Durch Klammerung muss dafür gesorgt werden, dass der rechte Plusoperator zuerst ausgeführt
wird:
Quellcode Ausgabe
class Prog { 3.3 + 2 = 5.3
public static void main(String[] args) {
System.out.println("3.3 + 2 = " + (3.3 + 2));
}
}
Er trifft folglich auf zwei numerische Argumente und addiert diese. Der Ausdruck
3.3 + 2
ergibt
5.3
Anschließend bewirkt der linke Plus-Operator eine Zeichenfolgenverkettung. Der Ausdruck
"3.3 + 2 = " + 5.3
ergibt
"3.3 + 2 = 5.3"
und dieses println() - Argument landet auf der Konsole.
Aufgabe 2
char gehört zu den integralen (ganzzahligen) Datentypen. Zeichen werden über ihre Nummer im
Unicode-Zeichensatz gespeichert, das Zeichen ‚c‘ offenbar durch die Nummer 99 (im Dezimalsys-
tem).
In der folgenden Anweisung wird der char-Variablen z die Unicode - Escape-Sequenz für das Zei-
chen ‚c‘ zugewiesen:
char z = '\u0063';
Der dezimalen Zahl 99 entspricht die hexadezimale Zahl 0x63 (= 6 16 + 3).
Aufgabe 3
Die Variable i ist außerhalb des Blocks mit der Deklaration nicht sichtbar (gültig).
912 Anhang
Aufgabe 4
Lösungsvorschlag:
class Prog {
public static void main(String[] args) {
System.out.println("Dies ist ein Java-Zeichenfolgenliteral:\n \"Hallo\"");
}
}
Aufgabe 5
Die behobenen Fehler sind durch einen großen Schriftgrad gekennzeichnet:
class Prog {
public static void main(String[] args) {
float PI = 3.141593F;
double radius = 2.0;
System.out.println("Der Flächeninhalt beträgt: " + PI*radius*radius);
}
}
Aufgabe 2
Nach der Tabelle mit den Ergebnistypen der Ganzzahlarithmetik im Abschnitt 3.5.1 resultiert der
Datentyp int.
Aufgabe 3
erg1 erhält den Wert 2, denn:
• (i++ == j ? 7 : 8) hat den Wert 8, weil 2 3 ist.
• 8 % 3 ergibt 2.
erg2 erhält den Wert 0, denn:
• Der Präinkrementoperator trifft auf die bereits vom Postinkrementoperator in der vorange-
henden Zeile auf den Wert 3 erhöhte Variable i und setzt sie auf den Wert 4.
• Dies ist auch der Wert des Ausdrucks ++i, sodass die Bedingung im Konditionaloperator
erneut den Wert false hat.
• (++i == j ? 7 : 8) hat also den Wert 8, und 8 % 2 ergibt 0.
Aufgabe 4
Die Vergleichsoperatoren (>, ==) haben eine höhere Bindungskraft als die logischen Operatoren
und der Zuweisungsoperator, sodass z. B. in der folgenden Anweisung
la1 = 2 < 3 && 2 == 2 ^ 1 == 1;
auf runde Klammern verzichtet werden konnte. Besser lesbar ist aber die äquivalente Variante:
la1 = (2 < 3) && (2 == 2) ^ (1 == 1);
la1 erhält den Wert false, denn der Operator ^ wird aufgrund seiner höheren Bindungskraft vor
dem Operator && ausgeführt.
914 Anhang
la2 erhält den Wert true, weil die runden Klammern dafür sorgen, dass der Operator ^ zuletzt aus-
geführt wird.
la3 erhält den Wert false.
Aufgabe 5
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Elementare Sprachelemente\Exp
Aufgabe 6
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Elementare Sprachelemente\DM2Euro
Aufgabe 7
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Elementare Sprachelemente\UnGerade
Aufgabe 2
In der switch-Anweisung mit traditioneller Syntax wird es versäumt, per break den „Durchfall“ zu
verhindern.
Aufgabe 3
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Elementare Sprachelemente\Primzahlendiagnose\PrimitivOBC
Aufgabe 4
Das Semikolon am Ende der Zeile
while (i < 100);
wird vom Compiler als die zur while-Schleife gehörige (leere) Anweisung interpretiert, sodass
mangels i-Inkrementierung eine Endlosschleife vorliegt. Hinter der while-Schleife steht eine Blo-
ckanweisung, die nie ausgeführt wird.
Abschnitt B Lösungsvorschläge zu den Übungsaufgaben 915
Aufgabe 5
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Elementare Sprachelemente\DM2EuroS
Aufgabe 6
Lösungsvorschläge mit den beiden Algorithmus-Varianten befinden sich in den Ordnern:
...\BspUeb\Elementare Sprachelemente\GgtDiff
...\BspUeb\Elementare Sprachelemente\GgtMod
Aufgabe 7
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Elementare Sprachelemente\FloP
Bei i = 52 erhält man letztmals das mathematisch korrekte Vergleichsergebnis. Dies ist gerade die
Anzahl der Bits in der double-Mantisse (vgl. Abschnitt 3.3.6).
Die Zahl 1,0 hat nach der Norm IEE 754 die folgende normalisierte double-Darstellung (vgl. Ab-
schnitt 3.3.7.1)
(-1)0 2(1023-1023) (1+0,0)
mit den Bits:
Vorz. Exponent Mantisse
0 01111111111 0000000000000000000000000000000000000000000000000000
Das Bitmuster zum Vergleichswert:
1,0 + 2-i = (-1)0 2(1023-1023) (1 + 2-i), i = 1, …, 52
hat eine zusätzliche Eins beim i-ten Mantissen-Bit. Bei i = 52 ist diese Eins am rechten Rand ange-
kommen und letztmals vorhanden:
Vorz. Exponent Mantisse
0 01111111111 0000000000000000000000000000000000000000000000000001
Während bei binärer Gleitkommatechnik mit double-Präzision die Zahl
1,0 + 2-53 1,0 + 1,1102230246251565 10-16 1,0000000000000001110223024625157
nicht mehr von der Zahl 1,0 zu unterscheiden ist, kann die Zahl
2-53 1,1102230246251565 10-16
mit dem Bitmuster
Vorz. Exponent Mantisse
0 01111001010 0000000000000000000000000000000000000000000000000000
von der 0,0 mit dem Bitmuster
Vorz. Exponent Mantisse
0 00000000000 0000000000000000000000000000000000000000000000000000
unterschieden werden.
Erst ab 2-1023 ( 1,1125369292536007 10-308) wird erstmals die denormalisierte Darstellung benö-
tigt (mit dem festen Exponentialfaktor 2-1022)
916 Anhang
Aufgabe 2
Eine Instanzvariable mit Vollzugriff für die Methoden der eigenen Klasse und Schreibschutz ge-
genüber Methoden fremder Klassen erhält man folgendermaßen:
• Deklaration als private (Datenkapselung)
• Definition einer public-Methode zum Lesen des Werts
• Verzicht auf eine public-Methode zum Verändern des Werts
Aufgabe 3
1. Falsch
Es kann durchaus sinnvoll sein, private Methoden für den ausschließlich klasseninternen
Gebrauch zu definieren.
2. Falsch
Der Rückgabetyp spielt bei der Signatur keine Rolle (vgl. Abschnitt 4.3.4).
3. Falsch
Typkompatibilität genügt (siehe Abschnitt 4.3.2).
4. Richtig
Aufgabe 4
Bei einer Methode mit Rückgabewert muss jeder mögliche Ausführungspfad mit einer return-
Anweisung enden, die einen Rückgabewert mit kompatiblem Typ liefert. Die vorgeschlagene Me-
thode verstößt beim Aufruf mit einem von 0 verschiedenen Aktualparameterwert gegen diese Re-
gel.
Abschnitt B Lösungsvorschläge zu den Übungsaufgaben 917
Aufgabe 5
Die beiden Methoden können nicht in einer Klasse koexistieren, weil ihre Signaturen identisch sind:
• gleiche Methodennamen
• gleichlange Parameterlisten mit identische Parametertypen an allen Positionen
Dass an jeder Position die beiden typgleichen Formalparameter verschiedene Namen haben, ist für
die Signatur irrelevant.
Aufgabe 6
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Klassen und Objekte\TimeDuration
Aufgabe 7
Begriff Pos. Begriff Pos.
Definition einer Instanzmethode
7 Konstruktordefinition 3
mit Referenzrückgabe
Deklaration einer lokalen Variablen 4 Deklaration einer Klassenvariablen 2
Definition einer Instanzmethode
6 Objekterzeugung 9
mit Referenzparameter
Deklaration einer Instanzvariablen 1 Definition einer Klassenmethode 8
Methodenaufruf 5
Aufgabe 8
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Klassen und Objekte\FakulRek
Das Programm eignet sich übrigens dazu, einen Stapelüberlauf (StackOverflowError) zu provo-
zieren:
Exception in thread "main" java.lang.StackOverflowError
Allerdings hat bei der Wahl eines passend großen Arguments (z. B. 15000) die resultierende Fakul-
tät den double-Wertebereich längst in Richtung Unendlich (Infinity) verlassen.
Aufgabe 9
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Klassen und Objekte\R2Vek
918 Anhang
Aufgabe 2
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Wichtige spezielle Klassen\Arrays\Lotto
Man könnte das Problem, eine zufällige Teilmenge aus einem Array mit den Elementen 1 bis 49 zu
ziehen, in den folgenden Schritten elegant lösen:
• Bringe die Elemente des Arrays in eine zufällige Reihenfolge, wähle also eine zufällige
Permutation.
• Wähle aus der Permutation die ersten 6 Elemente.
Eine zufällige Anordnung von Array-Elementen lässt sich mit dem Fisher-Yates - Algorithmus
herstellen.1 Wird (wie im Beispiel) nur eine kleine Teilmenge aus der Permutation verwertet, ist
allerdings der Aufwand höher als bei der im Lösungsvorschlag praktizierten Suche nach unver-
brauchten Lottozahlen.
Im Java-API befindet sich keine Methode zur Erstellung einer Permutation zu einem Array. Für
eine Liste bietet die statische Methode shuffle() in der Klasse Collections hingegen genau diese
Leistung an. Dank der Konvertierungsmethode asList() aus der Klasse Arrays lässt sich mit Hilfe
der Collections-Methode shuffle() auch ein Array verwirbeln (siehe Abschnitt 10.10).
Aufgabe 3
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Wichtige spezielle Klassen\Arrays\Eratosthenes
Aufgabe 4
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Wichtige spezielle Klassen\Arrays\DataMat
1
https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle
Abschnitt B Lösungsvorschläge zu den Übungsaufgaben 919
Aufgabe 2
Der interne String-Pool wird erweitert durch die Anweisungen mit den Kommentarnummern (1)
und (5). Die Anweisungen mit den Kommentarnummern (2) und (3) erzeugen Objekte auf dem all-
gemeinen Heap. Durch die Anweisung mit der Kommentarnummer (4) findet keine Erweiterung
des String-Pools statt, weil dort bereits ein inhaltsgleiches String-Objekt vorhanden ist.
Aufgabe 3
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Wichtige spezielle Klassen\Zeichenfolgen\PerZuf
Aufgabe 4
Die Klasse StringBuilder hat die von java.lang.Object geerbte equals() - Methode nicht über-
schrieben, sodass Referenzen verglichen werden. In der Klasse String ist equals() jedoch so über-
schrieben worden, dass die referenzierten Zeichenfolgen verglichen werden.
Aufgabe 5
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Wichtige spezielle Klassen\Zeichenfolgen\StringUtil
Beim Datentyp byte ist zu beachten, dass er in Java (wie alle anderen Ganzzahltypen) vorzeichen-
behaftet ist, während z. B. die Programmiersprache C# einen vorzeichenfreien 8-Bit - Ganzzahltyp
namens byte mit Werten von 0 bis 255 besitzt.
920 Anhang
Aufgabe 2
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Wichtige spezielle Klassen\Verpackungsklassen\MaxFakul
Für die Fakultät von 170 ( 7,26ˑ10306) wird noch ein regulärer double-Wert ermittelt, während die
Berechnung der Fakultät von 171 ( 1,24ˑ10309) zum Wert Double.POSITIVE_INFINITY führt.
Aufgabe 3
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Wichtige spezielle Klassen\Verpackungsklassen\Mint
Aufgabe 2
Eine Komponente (Instanzvariable) einer Record-Klasse darf einen Referenztyp haben, und die
Objekte der zugehörigen Klasse müssen keinesfalls unveränderlich sein. Folglich lässt sich eine per
Zugriffsmethode ausgelieferte Objektreferenz dazu verwenden, ein Member-Objekt zu verändern
(vgl. Abschnitt 5.5.2.3).
Aufgabe 2
1. Falsch
Es wird empfohlen, die Namen von exportierten Paketen mit dem Modulnamen beginnen zu
lassen. Das ist aber nicht vorgeschrieben und z. B. beim API-Paket java.lang (ab Java 9 im
Modul java.base) aus Kompatibilitätsgründen nicht realisiert.
2. Richtig
3. Richtig
4. Falsch
Nur die automatischen Module (siehe Abschnitt 6.2.9.1) können das unbenannte Modul (mit
den via Klassenpfad erreichbaren class-Dateien) sehen.
Aufgabe 2
In der Klasse Figur haben xpos und ypos den voreingestellten Zugriffsschutz (package). Weil
Kreis und Figur nicht zum selben Paket gehören, hat die Kreis-Klasse keinen direkten Zugriff.
Soll dieser Zugriff möglich sein, müssen xpos und ypos in der Figur-Definition die Schutzstufe
protected (oder public) erhalten.
Aufgabe 3
1. Richtig
2. Falsch
3. Falsch
4. Falsch
5. Richtig
Aufgabe 4
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Vererbung und Polymorphie\Abstand
Aufgabe 5
Es muss nur die Basisklasse neu übersetzt werden. Eine abgeleitete Klasse enthält keinen Bytecode
für geerbte Methoden, was man z. B. mit dem JDK-Werkzeug javap.exe überprüfen kann. Um den
Bytecode einer Klasse in lesbarer Form anzeigen zu lassen, gibt man beim Aufruf die Option -c und
den Klassennamen an, z. B.:
>javap -c Kreis
922 Anhang
Aufgabe 2
Weil sl vom parametrisierten Typ ArrayList<String> ist, fügt der Compiler die folgende Casting-
Operation ein:
System.out.println((String)sl.get(0));
Diese scheitert, weil ein Integer-Objekt in sl eingeschmuggelt worden ist. Das konnte passieren,
weil im ersten Parameter der Methode addElement() der ArrayList-Rohtyp verwendet wird.
Aufgabe 3
Einer Referenzvariablen vom Rohtyp (z. B. ArrayList) kann ein Objekt mit einem beliebigen pa-
rametrisierten Typ zugewiesen werden (z. B. ArrayList<String>). Anschließend kann der Compi-
ler nicht verhindern, dass über die Rohtyp-Referenz ein Element mit abweichendem Typ eingefügt
und somit das Kollektionsobjekt beschädigt wird.
Zeigt eine Referenzvariable auf eine Parametrisierung mit dem Elementtyp Object, verhindert der
Compiler das beschriebene Problem.
Kapitel 9 (Interfaces)
Aufgabe 1
1. Falsch
2. Richtig
3. Falsch
Das im Abschnitt 9.2 vorgeführt API-Interface Serializable enthält keine Methoden. Es ist
ein sogenanntes Marker-Interface.
4. Richtig
5. Richtig
Aufgabe 2
Sie finden einen Lösungsvorschlag (als IntelliJ-Projekt) im Verzeichnis:
...\BspUeb\Interfaces\Bruch implements Comparable
Abschnitt B Lösungsvorschläge zu den Übungsaufgaben 923
Aufgabe 3
Beim Aufruf der folgenden Methode
static <T extends Basis & SayOne & SayTwo> void moin(T x) {
int max = x.getRep();
for (int i = 0; i < max; i++) {
x.sayOne();
x.sayTwo();
System.out.println();
}
}
muss der Typ des Aktualparameters die Klasse Basis in seiner Ahnenreihe haben. Außerdem muss
er die Schnittstellen SayOne und SayTo erfüllen. Den vollständigen Quellcode finden Sie im Ord-
ner
...\BspUeb\Interfaces\MultiBound
Aufgabe 2
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Java Collections Framework\Abbildungen\StringUtil
Aufgabe 3
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Java Collections Framework\Mengen\Mengenlehre
Aufgabe 4
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Java Collections Framework\Abbildungen\DataMat
Die Lösung mit Hilfe einer JCF-Abbildungsklasse ist erfreulich einfach, aber in der aktuellen Form
nur für kleine Datenmengen geeignet, weil durch die Verwendung von unveränderlichen Verpa-
ckungsobjekten jeder Zählvorgang zu einer Objektkreation führt. Dieses Problem ist allerdings
durch die Verwendung einer selbst definierten, veränderlichen Verpackungsklasse leicht zu lösen.
924 Anhang
Kapitel 11 (Ausnahmebehandlung)
Aufgabe 1
1. Falsch
Bei der Ausnahmeklasse RuntimeException ist die Vorbereitung (z. B. per try-catch-
finally - Anweisung) freiwillig. Bleibt jedoch eine geworfene Ausnahme dieser Klasse un-
behandelt, wird das Programm (genauer: der betroffene Thread) von der JVM beendet.
2. Richtig
3. Falsch
Ist ein finally-Block vorhanden, wird dieser auch nach einem störungsfreien try-Block aus-
geführt, bevor es hinter der try-catch-finally - Anweisung weitergeht.
4. Falsch
In einem catch- oder finally-Block ist selbstverständlich auch eine try-catch-finally-
Anweisung erlaubt.
5. Richtig
Man kann auch bei Verzicht auf einen catch-Block per finally-Block dafür sorgen, dass im
Ausnahmefall vor dem Verlassen der Methode noch bestimmte Anweisungen ausgeführt
werden.
Aufgabe 2
Lösungsvorschlag:
try-catch-finally -
Anweisung
catch-Block
Ausnahmeklassenliste
Ausnahmeklasse
Aufgabe 3
Lösungsvorschlag:
class Prog {
public static void main(String[] args) {
Object o = null;
System.out.println(o.toString());
}
}
Abschnitt B Lösungsvorschläge zu den Übungsaufgaben 925
Aufgabe 4
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Ausnahmebehandlung\DuaLog\IllegalArgumentException
Aufgabe 2
Bei der Erstellung eines Objekts auf der Basis eines Lambda-Ausdrucks muss der Compiler das zu
implementierende funktionale Interface kennen, um per Typinferenz die erforderlichen Prüfungen
und Einstellungen vornehmen zu können. Anschließend kann die Adresse des per Lambda-
Ausdruck realisierten Objekts durchaus in einer Referenzvariablen vom Typ Object abgelegt wer-
den, z. B.:
Predicate<String> ps = s -> s.length() >= 5;
Object obj = ps;
Aufgabe 3
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Funktionales Programmieren\Ströme\Fakultät
Der Lösungsvorschlag liefert z. B. zum Argument 1.000.000 bei Verwendung eines parallelen
Stroms nach wenigen Sekunden eine Lösung und arbeitet dabei parallel mit mehreren CPU-Kernen,
was an der Nutzung von mehr als 25% CPU-Zeit auf einem Rechner mit 4 virtuellen Kernen zu
erkennen ist:
Die Fakultät von 1.000.000 ist eine Zahl von astronomischer Größenordnung:
926 Anhang
Aufgabe 2
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\JavaFX\Properties und Bindings\SliderLabelSync
Aufgabe 3
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\JavaFX\DM-Euro - Konverter
Aufgabe 2
Bei der read() - Rückgabe stehen Werte von 0 bis 255 am Ende eines erfolgreichen Leseversuchs.
Das erreichte Dateiende signalisiert read() durch den Rückgabewert -1. Durch die Wahl des Typs
int kann der Rückgabewert Nutzdaten oder eine Fehlerinformation transportieren (vgl. Abschnitt
11.4). Weil es kein außergewöhnliches Ereignis darstellt, beim Lesen einer Datei irgendwann auf
deren Ende zu stoßen, informiert die Methode read() in diesem Fall über den Kombirückgabewert.
Bei unerwarteten Problemen wirft read() hingegen eine IOException.
Aufgabe 3
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\IO\OutputStream\BufferedOutputStream\Konsole
Aufgabe 4
Im PrintWriter-Konstruktor ist die autoFlush-Option eingeschaltet, was bei einer Dateiausgabe in
der Regel keinen Nutzen bringt. So hat jeder println() - Aufruf einen zeitaufwändigen Dateizugriff
zur Folge, und die voreingestellte Pufferung durch den eingebundenen OutputStreamWriter (vgl.
Abschnitte 14.4.1.2 und 14.4.1.5) bleibt wirkungslos. Für das Programm ist der folgende
PrintWriter-Konstruktoraufruf besser geeignet:
try (PrintWriter pw = new PrintWriter(new FileOutputStream("pw.txt"))) {
. . .
}
Der Zeitaufwand reduziert sich um ca. 90%.
Aufgabe 5
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\IO\MS-DOS
Aufgabe 6
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\IO\Datenmatrix
Kapitel 15 (Multithreading)
Aufgabe 1
1. Falsch
2. Richtig
Werden im UI-Thread länger laufende Methoden ausgeführt, reagiert die Bedienoberfläche
zäh.
3. Richtig
4. Falsch
Daher sollte sich ein Thread niemals in einem synchronisierten Bereich zur Ruhe begeben.
Aufgabe 2
Der interrupt() - Aufruf zum Setzen des Unterbrechungssignals trifft fast immer auf einen schla-
fenden Schnarcher-Thread. In dieser Situation wirft die Methode sleep() eine Interrupted-
Exception und löscht das Unterbrechungssignal wieder. Damit die run() - Methode plangemäß
reagieren kann, muss in der Regel bei der Ausnahmebehandlung interrupt() erneut aufgerufen
werden, um des Interrupt-Signal zu restaurieren:
928 Anhang
Aufgabe 3
Weil jeder Thread seinen eigenen Stapelspeicher für Methodenaufrufe besitzt, sind bei lokalen Va-
riablen konkurrierende Zugriffe durch mehrere Threads unmöglich.
Abschnitt 16 (Netzwerkprogrammierung)
Aufgabe 1
1. Falsch
Server benötigen eine feste Port-Nummer, die den Klienten schon vor der Verbindungsauf-
nahme bekannt sein muss.
2. Richtig
3. Falsch
Das HTTP-Protokoll gehört zur Schicht 7 (Anwendung).
4. Richtig
Es ist aber ein Socket-Konstruktor mit Timeout-Parameter für die Verbindungsaufnahme
vorhanden.
Aufgabe 2
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Netzwerk\Internet-Ressourcen nutzen\WebView
Im Vergleich zum Programm aus dem Abschnitt 16.2.5 ist lediglich in der Methode start() die fol-
gende Vereinbarung einer Mausrad-Ereignisbehandlung zu ergänzen:
webView.setOnScroll(event -> {
if (event.getDeltaY() > 100)
webView.setZoom(webView.getZoom() * 1.1);
else
webView.setZoom(webView.getZoom() * 0.9);
});
Die ScrollEvent-Methode getDeltaY() liefert bei einer beliebigen Mausraddrehung nach vorne den
Wert 120,0 und bei einer beliebigen Mausraddrehung nach hinten den Wert -120,0.
Literatur
Baltes, S. & Diehl, S. (2014). Sketches and Diagrams in Practice. Paper presented at the Interna-
tional Symposium on the Foundations of Software Engineering (November 2014, Hong
Kong).
Baltes-Götz, B. & Götz, J. (2016). Einführung in das Programmieren mit Java 8. Online-
Dokument: https://www.uni-trier.de/fileadmin/urt/doku/java/v80/java8.pdf
Baltes-Götz, B. & Götz, J. (2018). Einführung in das Programmieren mit Java 9. Online-
Dokument: https://www.uni-trier.de/index.php?id=22790
Baltes-Götz, B. & Götz, J. (2020). Einführung in das Programmieren mit Java 13. Online-
Dokument: https://www.uni-trier.de/index.php?id=22787
Baltes-Götz, B. (2018). Einführung in die Entwicklung von Apps für Android 8. Online-Dokument:
https://www.uni-trier.de/index.php?id=60390
Baltes-Götz, B. (2021). Einführung in das Programmieren mit C# 9.0. Online-Dokument:
https://www.uni-trier.de/index.php?id=22777
Balzert, H. (2011). Lehrbuch der Objektmodellierung: Analyse und Entwurf mit der UML 2. Hei-
delberg: Spektrum.
Bateman, A., Buckley, A., Gibbons, J. & Reinhold, M. (2017). JEP 261: Module System. Online-
Dokument: https://openjdk.java.net/jeps/261
Bloch, J. (2008). Effective Java (2nd. ed.). Upper Saddle River, NJ: Addison-Wesley.
Bloch, J. (2018). Effective Java (3rd. ed.). Upper Saddle River, NJ: Addison-Wesley.
Booch, G. et al. (2007). Object-Oriented Analysis and Design with Applications (3rd ed.). Boston,
MA: Addison-Wesley.
Bracha, G. (2004). Generics in the Java Programming Language. Online-Dokument:
http://www.cs.rice.edu/~cork/312/Readings/GenericsTutorial.pdf
Eck, D.J. (2021). Introduction to Programming Using Java. Online-Dokument:
https://math.hws.edu/javanotes/
Epple, A. (2015). JavaFX 8. Heidelberg: dpunkt-Verlag.
Evans, B.J. & Flanagan, D. (2015). Java in a Nutshell (5th ed.). Beijing: O’Reilly.
Flanagan, D. (2005). Java in a Nutshell (5th ed.). Sebastopol, CA: O’Reilly.
Goetz, B. (2003). Concurrent Collection Classes. Online-Dokument:
https://www.ibm.com/developerworks/library/j-jtp07233/index.html
Goetz, B., Peierls, T., Bloch, J., Bowbeer, J., Holmes, D. & Lea, D. (2006). Java Concurrency in
Practice. Addison-Wesley. Upper Saddle River, NJ: Addison-Wesley.
Goetz, B. (2007). Java Theory and Practice. Stick a Fork in it, Part 1. Online-Dokument:
http://public.dhe.ibm.com/software/dw/java/j-jtp11137-pdf.pdf
Goll, J., Weiß, C. & Rothländer, P. (2000). Java als erste Programmiersprache. Stuttgart: Teubner.
Goll, J. & Heinisch, C. (2016). Java als erste Programmiersprache (8. Aufl.). Wiesbaden: Springer
Vieweg
Goetz, B. (2012). Translation of Lambda Expressions. Online-Dokument:
http://cr.openjdk.java.net/~briangoetz/lambda/lambda-translation.html
Gosling, J., Joy, B., Steele, G., Bracha, G., Buckley, A., Smith, D. & Bierman, G. (2021). The Java
Language Specification. Java SE 17 Edition (Ausgabe 2021-08-09). Online-Dokument:
https://docs.oracle.com/javase/specs/jls/se17/jls17.pdf
930 Literatur
Grammes, R., & Schaal, K. (2015). Datenströme asynchron verarbeiten mit Reactive Streams.
JAVASPEKTRUM, 5, 44-48.
Grammes, R., Lehmann, M. & Schaal, K. (2017). Java 9 bringt das neue Modulsystem Jigsaw.
Online-Dokument: https://www.informatik-
aktuell.de/entwicklung/programmiersprachen/java-9-das-neue-modulsystem-jigsaw-
tutorial.html
Grossmann, D. (2012). Beginner's Introduction to Java's ForkJoin Framework. Online-Dokument:
http://www.cs.washington.edu/homes/djg/teachingMaterials/grossmanSPAC_forkJoinFrame
work.html
Gupta, M. (2021). Java 17 and IntelliJ IDEA. Blog-Beitrag:
https://blog.jetbrains.com/idea/2021/09/java-17-and-intellij-idea/
Harold, E.R. (2014). Java Network Programming (4th ed.), Sebastopol, CA: O’Reilly.
Hettel, J. & Tran, M.T. (2016). Nebenläufige Programmierung mit Java: Konzepte und Program-
miermodelle für Multicore-Systeme. Heidelberg: dpunkt-Verlag.
Hommel, S. (2014). Oracle JvaFX. Implementing JavaFX Best Practices. Online-Dokument:
http://docs.oracle.com/javafx/2/best_practices/jfxpub-best_practices.pdf
Horstmann, C.S. (2014a). Java SE 8 for the Really Impationt. Upper Saddle River, NJ: Addison
Wesley.
Horstmann, C.S. (2014b). Lambda Expressions in Java 8. Online-Dokument:
http://www.drdobbs.com/jvm/lambda-expressions-in-java-8/240166764?pgno=1
Horstmann, C.S. (2015). Core Java for the Impationt. Upper Saddle River, NJ: Addison Wesley.
Horstmann, C.S. & Cornell, G. (2002). Core Java. Volume II – Advanved Features. Palo Alto, CA:
Sun Microsystems Press.
Inden, M. (2015). Java 8. Die Neuerungen (2. Aufl.). Heidelberg: dpunkt-Verlag.
Inden, M. (2018a). Der Weg zum Java-Profi (4. Aufl.). Heidelberg: dpunkt-Verlag.
Inden, M. (2018b). Java 11 – eine Einführung: HTTP/2-API. Online-Dokument:
https://entwickler.de/java/java-11-eine-einfuhrung-http2-api/
Kegel, H. & Steimann, F. (2008). Systematically Refactoring Inheritance to Delegation in JAVA. In:
Schäfer, W., Dwyer, M.B. & Gruhn, V. (eds.) 30th International Conference on Software
Engineering (ICSE), S. 431-440. Leipzig.
Kreft, K & Langer, A. (2008a). Java Memory Model: Überblick. Online-Dokument:
http://www.angelikalanger.com/Articles/EffectiveJava/38.JMM-Overview/38.JMM-
Overview.html
Kreft, K & Langer, A. (2008b). Regeln für die Verwendung von volatile. Online-Dokument:
http://www.angelikalanger.com/Articles/EffectiveJava/42.JMM-volatileIdioms/42.JMM-
volatileIdioms.html
Kreft, K. & Langer, A. (2012). Java 7. Thread-Synchronisation mit Hilfe des Phasers. Online-
Dokument:
http://www.angelikalanger.com/Articles/EffectiveJava/63.Java7.Phaser/63.Java7.Phaser.html
Kreft, K & Langer, A. (2014). Java 8. Default-Methoden und statische Methoden in Interfaces. On-
line-Dokument:
http://www.angelikalanger.com/Articles/EffectiveJava/72.Java8.DefaultMethods/72.Java8.De
faultMethods.html
Literatur 931
Urma, R.-G. (2014). Processing Data with Java SE 8 Streams, Part 1. Online-Dokument:
http://www.oracle.com/technetwork/articles/java/ma14-java-se-8-streams-2177646.html
Vorontsov, M. (2014). Java Performance Tuning Guide. String.intern in Java 6, 7 and 8 – string
pooling. Webseite: http://java-performance.info/string-intern-in-java-6-7-8/
Weaver, J. (2014). Pro JavaFX 8: A Definitive Guide to Building Desktop, Mobile, and Embedded
Java Clients. New York: Apress.
Ziesche, P. & Arinir, D. (2010). Java: Nebenläufige und verteilte Programmierung (2. Aufl.).
Herdecke: W3L-Verlag.
Index
& Annotation ................................................. 429
& Annotationen.............................................. 468
Bei beschränkt. Typformalparametern ...435 Annotationselemente ................................. 469
Bitweises UND ......................................139 Anonyme Klassen ...................... 238, 260, 572
@ ANSI .......................................................... 726
@FXML .....................................................638 Anweisungen ............................................. 156
A Anweisungen benennen ............................. 184
Abhängigkeiten ..........................................650 Anweisungsblock ....................................... 156
Ablaufsteuerung .........................................157 anyMatch() ................................................. 607
Abschluss ...................................................581 anyOf() ....................................................... 841
abstract .......................................................407 Anzeigeeinstellungen ................................. 666
Abstract Windowing Toolkit .....................615 API ................................................... 3, 26, 386
AbstractSet<E> ..........................................498 append()
Abstrakte StringBuilder.......................................... 304
Klasse .....................................................408 Application ................................................ 621
Methode .................................................407 apply()
Abstraktion .....................................................1 Function<T,U> ...................................... 825
accept() ...............................................710, 901 applyAsInt() ............................................... 591
ACK-Bit .....................................................870 applyToEither().......................................... 831
acos() ..........................................................281 applyToEitherAsync() ............................... 831
add() Äquivalenzrelation ..................................... 486
Collection<E> ........................................483 Archivdateien ............................................. 347
List<E> ..................................................488 Arithmetische Operatoren .......................... 128
ListIterator<E> .......................................498 Arithmetischer Ausdruck ........................... 128
Queue<E> ..............................................522 Array .......................................................... 283
Set<E>....................................................499 mehrdimensionaler ................................ 291
addAll() ArrayBlockingQueue<E> .................. 785, 844
Collection<E> ........................................483 ArrayDeque<E>......................................... 523
List<E> ..................................................488 ArrayIndex¬Out¬Of¬BoundsException ... 287
Set<E>....................................................499 ArrayIndexOutOfBoundsException .......... 530
addAndGet()...............................................798 ArrayList ............................................ 308, 417
add-exports .................................................385 ArrayList<E> ............................................. 492
addFirst() Arrays ................ 283, 288, 325, 448, 524, 918
Deque<E> ..............................................522 Klasse ..................................................... 325
addLast() ArrayStoreException ......................... 286, 423
Deque<E> ..............................................523 ART ............................................................. 28
addListener() ..............................................647 ASCII ......................................................... 726
Aggregatoperationen ..................................587 asList() ............................................... 524, 918
Aggregatormodul .......................................361 Arrays .................................................... 481
Aktualisierungsoperatoren .........................143 Assembler .................................................... 23
Aktualparameter .................................222, 223 assert .......................................................... 550
Alan Kay ....................................................196 AssertionError............................................ 550
Algorithmen ...................................................8 Assoziative Funktion ................................. 600
alignment ............................................670, 671 Assoziativität ............................................. 145
allMatch() ...................................................607 Assoziativität von Operatoren ................... 145
ALL-MODULE-PATH ..............................385 Atomar ....................................................... 774
allOf() .........................................................839 Atomare Variablen..................................... 798
Amazon ........................................................37 AtomicInteger .................................... 329, 798
AnchorPane ................................................668 Aufgabenparallelität .................. 766, 814, 819
Android ........................................................28 Aufzählungen ............................................. 313
934 Index
umbenennen oder verschieben ab Java 7 Durchschnitt von zwei Mengen ................. 499
............................................................704 Dynamisches Binden ................................. 406
Datei-Öffnungsoptionen.............................752 E
Dateisystem ................................................705 Eager Execution
Datenkapselung ..................................196, 213 Binding<T> ........................................... 651
Datenpaket .................................................865 Echo-Server ............................................... 902
Datenparallelität .........................766, 814, 819 Eclipse.......................................................... 68
Datenströme ...............................................689 EduTools ...................................................... 46
Datentyp .......................................................96 Eingabefokus ............................................. 677
Datentypen Eingeschachtelte Klassen........................... 255
primitive .................................................102 Eingeschachtelte Schnittstellen ................. 459
Daytime-Server ..........................................897 Einschränkende Konvertierung ................. 140
Deadlock ....................................................806 Einstellige Operatoren ............................... 127
Debug .........................................................226 else-Klausel................................................ 158
decrementAndGet() ....................................798 emptyList()
Default Button ............................................677 Collections ............................................. 524
default package ..........................................335 emptyMap()
Defensive Kopien .......................................323 Collections ............................................. 524
DeflaterOutputStream ................................712 emptySet()
Deklarative Programmierung .....................469 Collections ............................................. 524
Delegation ..................................................411 encode() ..................................................... 883
delete() .......................................................705 Encodings .................................................. 725
File .........................................................710 Endlosschleifen .......................................... 181
Files ........................................................705 entry point .................................................. 352
StringBuilder ..........................................304 entrySet()
deleteIfExists() ...........................................705 Map<K,V> ............................................. 514
Denormalisierte Entwurfsmuster .......................................... 449
Gleitkommadarstellung ..........................106 Enumerationen ........................................... 313
Dependencies .............................................650 equals() .............................................. 486, 498
Deprecated .........................................469, 473 Integer .................................................... 310
descendingIterator() String...................................................... 298
NavigableSet<E> ...................................510 Eratosthenes ............................................... 325
design pattern .............................................449 Ereignisbehandler ...................................... 662
Dezimale Gleitkommadarstellung..............106 Ereignisfilter .............................................. 661
Dezimaltrennzeichen ..................................741 Error ........................................................... 548
Differenz von zwei Mengen .......................499 ERRORLEVEL ......................................... 531
distinct() .............................................592, 595 Ersetzbarkeitsregel ..................................... 409
DNS ............................................................895 Erweiternde Typanpassung ................ 128, 139
Documented ...............................................474 Erweiterte for-Schleife............................... 178
Dokumentationskommentar .................87, 473 Escape-Sequenzen ..................................... 117
Domain Name System ...............................895 estimateMaximumLag() ............................ 857
Domänenname ...........................................872 Euklidischer Algorithmus ...................... 9, 193
Doppelt verkettete Liste .............................493 event filter .................................................. 661
do-Schleife .................................................180 event handler .............................................. 662
double .................................................102, 133 exceptionally() ........................................... 835
Double ................................................153, 447 Exception-Handler ..................................... 534
doubles().....................................................591 Exceptions.................................................. 529
dropWhile() ................................................596 execute() .................................................... 807
DRY-Prinzip ..............................................577 Executors ........................................... 807, 810
Dualer Logarithmus ...................................567 ExecutorService ......................... 807, 810, 815
Duke ...........................................................675 Exhaustivität ...................................... 170, 172
Durchfall ....................................................164 exists()........................................................ 699
Index 937
N O
Namen ..........................................................89 Object
von Klassen ............................................205 hashCode() ............................................. 502
von Methoden ........................................217 ObjectInputStream ..................................... 745
Namensparameter.......................................218 ObjectOutputStream .................................. 745
NaN ............................................153, 313, 567 Objekte ....................................................... 232
NavigableMap<K,V> ................................519 Objektgraph ............................................... 742
NavigableSet<E> ...............................506, 509 Objekt-Mapper ........................................... 752
Nebeneffekt ................................127, 129, 137 Objektorientierung ....................................... 29
Nebeneffekt-Produzenten...................599, 600 Objektserialisierung ................................... 742
Negation .....................................................136 Observable ................................................. 647
NEGATIVE_INFINITY observableArrayList() ................................ 654
Double ....................................................313 ObservableList<E> .................................... 654
Netzwerk ....................................................864 ObservableList<String> ............................. 626
Netzwerkprogrammierung .........................863 ObservableValue<T>................................. 647
newBufferedReader() .................737, 754, 757 of() ............................................................. 590
newBufferedWriter() ..................733, 754, 756 List<E> .................................................. 490
newBuilder() ..............................................887 Map<K,V> ............................................. 515
newCachedThreadPool() ............................807 Set .......................................................... 507
newCondition() ..........................................783 Set<E> ................................................... 499
newDirectoryStream() ................................703 ofFile() ....................................................... 888
newHttpClient() .........................................887 Öffnungsoptionen ...................................... 752
newInputStream().......................723, 754, 760 ofString().................................................... 887
new-Operator .....................................233, 235 ojdkbuild ................................................ 13, 37
newOutputStream() ....................714, 754, 759 Oktalsystem ............................................... 115
next() ..........................................................739 onChanged() .............................................. 655
Iterator<E> .............................................497 onNext()
nextBigDecimal() .......................................739 Subscriber .............................................. 852
nextBigInteger() .........................................739 open (JPMS) .............................................. 364
nextDouble() ..............................................739 Open-Closed - Prinzip ....................... 200, 482
nextInt() ..............................................289, 739 openConnection() ...................................... 876
Random ....................................................90 OpenJDK ..................................................... 29
Scanner ...................................................120 OpenJFX ...................................... 71, 613, 616
NIO.2 - API ................................................696 OpenOption................................................ 752
NoClassDefFoundError .............................549 opens (JPMS) ............................................. 364
Node ...........................................................624 openStream()...................................... 873, 876
noneMatch() ...............................................607 Operationen
non-sealed ..................................................413 intermediäre ........................................... 592
Normalisierte terminale ................................................ 593
Gleitkommandarstellung ........................105 zustandsbehaftete ................................... 593
normalize() .................................................699 zustandslose ........................................... 593
Path.........................................................699 Operatoren ................................................. 126
NoSuchElementException .........................523 Arithmetische ......................................... 128
notExists() ..................................................699 bitorientierte ........................................... 138
notify() ...............................................779, 801 logische .................................................. 136
notifyAll() ..................................................779 vergleichende ......................................... 131
now() ..........................................................651 Optional<T> ...................................... 546, 601
null .............................................119, 210, 232 OptionalInt ......................................... 579, 601
NullPointerException .........................210, 567 ordinal() ..................................................... 316
Nulltyp .......................................................119 Ordner
NumberFormatException ...................311, 532 anlegen ................................................... 700
anlegen in Java 6 .................................... 707
944 Index